diff --git a/src/app/admin-panel/page.tsx b/src/app/admin-panel/page.tsx index ab6ba77..87e1bb2 100644 --- a/src/app/admin-panel/page.tsx +++ b/src/app/admin-panel/page.tsx @@ -159,6 +159,7 @@ function UserManagement() { const [searchDept, setSearchDept] = useState(""); const [searchType, setSearchType] = useState(""); const [data, setData] = useState[]>([]); + const [selectedRows, setSelectedRows] = useState[]>([]); const columns: GridColumn[] = [ { title: "부서명", field: "DEPT_NAME", width: 120 }, @@ -189,7 +190,29 @@ function UserManagement() { }; const handleDelete = async () => { - Swal.fire({ icon: "warning", title: "삭제할 사용자를 선택하세요." }); + if (selectedRows.length === 0) { + Swal.fire({ icon: "warning", title: "삭제할 사용자를 선택하세요." }); + return; + } + const r = await Swal.fire({ + icon: "warning", title: `${selectedRows.length}명 비활성화`, + text: "선택한 사용자를 비활성 상태로 전환합니다.", + showCancelButton: true, confirmButtonText: "삭제", cancelButtonText: "취소", + confirmButtonColor: "#dc2626", + }); + if (!r.isConfirmed) return; + const userIds = selectedRows.map((row) => String(row.USER_ID)).filter(Boolean); + const res = await fetch("/api/admin/users/delete", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userIds }), + }); + const j = await res.json(); + if (j.success) { + Swal.fire({ icon: "success", title: `${j.count}명 비활성화 완료`, timer: 1200, showConfirmButton: false }); + setSelectedRows([]); fetchData(); + } else { + Swal.fire({ icon: "error", title: "오류", text: j.message }); + } }; return ( @@ -217,7 +240,7 @@ function UserManagement() { - + ); } @@ -1028,6 +1051,18 @@ function DeptManagement() {
+ {editForm.actionType !== "regist" && ( + + )}
diff --git a/src/app/api/admin/dept/delete/route.ts b/src/app/api/admin/dept/delete/route.ts new file mode 100644 index 0000000..59c5128 --- /dev/null +++ b/src/app/api/admin/dept/delete/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +export async function POST(req: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { deptCode, deptCodes } = await req.json() as { deptCode?: string; deptCodes?: string[] }; + const targets = deptCodes && deptCodes.length > 0 ? deptCodes : (deptCode ? [deptCode] : []); + if (targets.length === 0) { + return NextResponse.json({ success: false, message: "삭제할 부서를 선택하세요." }, { status: 400 }); + } + + // 자식 부서 검사 + const placeholders = targets.map((_, i) => `$${i + 1}`).join(","); + const child = await queryOne<{ cnt: string }>( + `SELECT COUNT(*) AS cnt FROM dept_info WHERE parent_dept_code IN (${placeholders}) AND COALESCE(status,'active') != 'inActive'`, + targets + ); + if (Number(child?.cnt || 0) > 0) { + return NextResponse.json({ success: false, message: `하위 부서 ${child!.cnt}개가 있어 삭제할 수 없습니다.` }, { status: 400 }); + } + + // 사용자 검사 + const userCnt = await queryOne<{ cnt: string }>( + `SELECT COUNT(*) AS cnt FROM user_info WHERE dept_code IN (${placeholders}) AND COALESCE(status,'active') != 'inActive'`, + targets + ); + if (Number(userCnt?.cnt || 0) > 0) { + return NextResponse.json({ success: false, message: `소속 사용자 ${userCnt!.cnt}명이 있어 삭제할 수 없습니다.` }, { status: 400 }); + } + + await execute(`UPDATE dept_info SET status='inActive' WHERE dept_code IN (${placeholders})`, targets); + return NextResponse.json({ success: true, count: targets.length }); +} diff --git a/src/app/api/admin/menus/delete/route.ts b/src/app/api/admin/menus/delete/route.ts index c06d52d..41de23a 100644 --- a/src/app/api/admin/menus/delete/route.ts +++ b/src/app/api/admin/menus/delete/route.ts @@ -14,7 +14,6 @@ export async function POST(request: NextRequest) { await client.query("BEGIN"); const target = Number(objid); - // 자손 모두 수집 (재귀) const descendants = await client.query<{ objid: number }>( `WITH RECURSIVE tree AS ( SELECT objid FROM menu_info WHERE objid = $1 @@ -24,23 +23,20 @@ export async function POST(request: NextRequest) { SELECT objid FROM tree`, [target] ); - const ids = descendants.rows.map((r) => r.objid); const childCount = ids.length - 1; - // cascade 미지정인데 하위 있으면 거부 if (childCount > 0 && !cascade) { await client.query("ROLLBACK"); return NextResponse.json({ success: false, message: `하위 메뉴 ${childCount}개가 있습니다. 함께 삭제하려면 다시 시도하세요.`, - childCount, - needsCascade: true, + childCount, needsCascade: true, }); } - // 권한-메뉴 매핑 정리 후 메뉴 삭제 - await client.query(`DELETE FROM rel_menu_auth WHERE menu_obj_id = ANY($1::bigint[])`, [ids]); + // rel_menu_auth 의 정확한 컬럼은 menu_objid (언더스코어 없음) + await client.query(`DELETE FROM rel_menu_auth WHERE menu_objid = ANY($1::bigint[])`, [ids]); const r = await client.query(`DELETE FROM menu_info WHERE objid = ANY($1::bigint[])`, [ids]); await client.query("COMMIT"); return NextResponse.json({ success: true, deleted: r.rowCount, ids }); diff --git a/src/app/api/admin/users/delete/route.ts b/src/app/api/admin/users/delete/route.ts new file mode 100644 index 0000000..34d80d1 --- /dev/null +++ b/src/app/api/admin/users/delete/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +export async function POST(req: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { ids, userIds } = await req.json() as { ids?: string[]; userIds?: string[] }; + const targets = (userIds || ids || []).filter(Boolean); + if (targets.length === 0) { + return NextResponse.json({ success: false, message: "삭제할 사용자를 선택하세요." }, { status: 400 }); + } + // soft delete (status='inActive') — 안전. 데이터 보존 + const placeholders = targets.map((_, i) => `$${i + 1}`).join(","); + await execute(`UPDATE user_info SET status='inActive' WHERE user_id IN (${placeholders})`, targets); + return NextResponse.json({ success: true, count: targets.length }); +} diff --git a/src/app/api/approval/approve/route.ts b/src/app/api/approval/approve/route.ts new file mode 100644 index 0000000..26efefa --- /dev/null +++ b/src/app/api/approval/approve/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { updateTargetStatus } from "@/lib/approval-target"; + +// 결재 승인 (approval.xml: setInboxtaskResult + getNextApprovalObjId + +// setNextInboxtaskStatus + getNotCompleteInboxtaskCnt + completeRoute + completeApproval 대응) +// 원본 ApprovalService.setApprovalResult + completeRouteInfo +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const inboxObjId = body.inboxObjId; + if (!inboxObjId) return NextResponse.json({ success: false, message: "inboxObjId 필요" }); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1. 현재 결재자 검증 + const cur = await client.query( + `SELECT IT.*, A.target_objid, A.target_type, A.objid AS approval_objid, R.objid AS route_objid + FROM inboxtask IT + JOIN approval A ON A.objid = IT.approval_objid + LEFT JOIN route R ON R.objid = IT.route_objid + WHERE IT.objid = $1::numeric AND IT.target_user_id = $2 AND IT.status = 'ready'`, + [inboxObjId, user.userId] + ); + if (cur.rows.length === 0) { + await client.query("ROLLBACK"); + return NextResponse.json({ success: false, message: "결재 권한이 없거나 이미 처리된 건입니다." }); + } + const task = cur.rows[0]; + + // setInboxtaskResult — RESULT='Y' (원본), STATUS='complete' + await client.query( + `UPDATE inboxtask SET result = 'Y', status = 'complete', proc_date = now(), result_message = $1, + sign = $2, sign_width = $3, sign_height = $4 + WHERE objid = $5::numeric`, + [body.message || "", body.sign || null, body.sign_width || null, body.sign_height || null, inboxObjId] + ); + + // 2. 다음 차례의 normal 결재자가 있는지 — APPROVAL_TYPE='normal' + seq+1 + // getNextApprovalObjId 대응 + const next = await client.query( + `SELECT objid FROM inboxtask + WHERE UPPER(approval_type) = 'NORMAL' + AND approval_objid = $1::numeric AND route_objid = $2::numeric + AND seq::numeric = $3::numeric + 1`, + [task.approval_objid, task.route_objid, task.seq] + ); + if ((next.rowCount ?? 0) > 0) { + // 다음 결재자 ready로 변경 (setNextInboxtaskStatus) + await client.query( + `UPDATE inboxtask SET status = 'ready', regdate = now() WHERE objid = $1::numeric`, + [next.rows[0].objid] + ); + } + + // 3. route별 남은 결재자(ready/standby) 없으면 완료 처리 + // getNotCompleteInboxtaskCnt 대응 + const remaining = await client.query( + `SELECT COUNT(*)::int AS cnt FROM inboxtask + WHERE route_objid = $1::numeric AND status IN ('ready', 'standby')`, + [task.route_objid] + ); + if (remaining.rows[0].cnt === 0) { + // completeRoute + completeApproval + await client.query( + `UPDATE route SET status = 'complete' WHERE objid = $1::numeric`, + [task.route_objid] + ); + await client.query( + `UPDATE approval SET status = 'complete', complete_date = now() WHERE objid = $1::numeric`, + [task.approval_objid] + ); + // 대상 모듈 approvalComplete (XxxApprovalStatus) + await updateTargetStatus(client, task.target_type, String(task.target_objid), "approvalComplete", user.userId); + } + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "결재 승인되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("Approve:", e); + return NextResponse.json({ success: false, message: "승인 중 오류가 발생했습니다." }, { status: 500 }); + } finally { client.release(); } +} diff --git a/src/app/api/approval/count/route.ts b/src/app/api/approval/count/route.ts new file mode 100644 index 0000000..dbdafaa --- /dev/null +++ b/src/app/api/approval/count/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// approval/getApprovalCnt.do 대응 +export async function GET() { + const user = await getSession(); + if (!user) return NextResponse.json({ count: 0 }); + + try { + // 원본 getApprovalCnt: INBOXTASK + APPROVAL + ROUTE JOIN, + // SYSTEM_TYPE='PLM' + INBOX.STATUS='READY' + ROUTE.STATUS='inProcess' + const result = await queryOne<{ CNT: string }>( + `SELECT COUNT(1) AS "CNT" + FROM inboxtask INBOX, approval APP, route ROU + WHERE INBOX.target_user_id = $1 + AND INBOX.approval_objid = APP.objid + AND INBOX.route_objid = ROU.objid + AND APP.system_type = 'PLM' + AND UPPER(INBOX.status) = 'READY' + AND ROU.status = 'inProcess'`, + [user.userId] + ); + return NextResponse.json({ count: Number(result?.CNT || 0) }); + } catch { + return NextResponse.json({ count: 0 }); + } +} diff --git a/src/app/api/approval/detail/route.ts b/src/app/api/approval/detail/route.ts new file mode 100644 index 0000000..84c4ac5 --- /dev/null +++ b/src/app/api/approval/detail/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne, queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 결재 상세 + 결재선 (getApprovalLine 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + // 원본 getRouteInfo 대응 — APPROVAL-NNNN/ROUTE-NNNN 번호, 이메일, 부서, reject 제외 + const info = await queryOne( + `SELECT A.objid::text AS "OBJID", A.target_objid AS "TARGET_OBJID", + A.target_type AS "TARGET_TYPE", + A.approval_seq AS "APPROVAL_SEQ", + 'APPROVAL-' || LPAD(A.approval_seq::text, 4, '0') AS "APPROVAL_NO", + A.status AS "STATUS", + R.objid::text AS "ROUTE_OBJID", + R.route_seq AS "ROUTE_SEQ", + 'ROUTE-' || LPAD(R.route_seq::text, 4, '0') AS "ROUTE_NO", + R.approval_title AS "TITLE", R.approval_desc AS "DESCRIPTION", + R.writer AS "WRITER", + COALESCE((SELECT email FROM user_info WHERE user_id = R.writer LIMIT 1), '') AS "EMAIL", + COALESCE((SELECT user_name FROM user_info WHERE user_id = R.writer LIMIT 1), R.writer) AS "WRITER_NAME", + COALESCE((SELECT dept_name FROM user_info WHERE user_id = R.writer LIMIT 1), '') AS "DEPT_NAME", + TO_CHAR(A.regdate, 'YYYY-MM-DD HH24:MI') AS "REGDATE", + TO_CHAR(A.complete_date, 'YYYY-MM-DD HH24:MI') AS "COMPLETE_DATE" + FROM approval A + LEFT JOIN route R ON R.approval_objid = A.objid AND R.status != 'reject' + WHERE A.objid::text = $1 + ORDER BY R.regdate DESC LIMIT 1`, [objId] + ); + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + + // 결재선 (INBOXTASK) + const line = await queryRows( + `SELECT IT.objid::text AS "OBJID", IT.seq AS "SEQ", + IT.target_user_id AS "USER_ID", + COALESCE((SELECT user_name FROM user_info WHERE user_id = IT.target_user_id LIMIT 1), IT.target_user_id) AS "USER_NAME", + COALESCE((SELECT dept_name FROM user_info WHERE user_id = IT.target_user_id LIMIT 1), '') AS "DEPT_NAME", + IT.approval_type AS "APPROVAL_TYPE", + IT.status AS "STATUS", + CASE IT.status + WHEN 'ready' THEN '대기중' + WHEN 'standby' THEN '대기' + WHEN 'complete' THEN '승인' + WHEN 'reject' THEN '반려' + WHEN 'cancel' THEN '취소' + ELSE IT.status END AS "STATUS_NAME", + TO_CHAR(IT.proc_date, 'YYYY-MM-DD HH24:MI') AS "PROC_DATE", + IT.result_message AS "RESULT_MESSAGE" + FROM inboxtask IT + WHERE IT.approval_objid::text = $1 + ORDER BY IT.seq::numeric`, [objId] + ); + + // 현재 사용자가 결재자인지 확인 + const myTask = line.find((l: Record) => + l.USER_ID === user.userId && (l.STATUS === "ready") + ); + + return NextResponse.json({ + success: true, data: info, LINE: line, + canApprove: !!myTask, myInboxObjId: myTask?.OBJID || null, + }); +} diff --git a/src/app/api/approval/line-history/route.ts b/src/app/api/approval/line-history/route.ts new file mode 100644 index 0000000..58a3e4c --- /dev/null +++ b/src/app/api/approval/line-history/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 동일 target_objid 에 대한 결재번호/이력 목록 (approvalDetail.jsp 의 routeNo 드롭다운) +// 상신 → 반려 → 재상신 흐름 시 이전 라우트들을 보존하므로 여러 건 존재 가능 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { targetObjId, approvalObjId } = await request.json(); + if (!targetObjId && !approvalObjId) { + return NextResponse.json({ success: false, message: "targetObjId 또는 approvalObjId 필요" }); + } + + // targetObjId 가 있으면 같은 target 의 모든 route 이력 + // approvalObjId 만 있으면 같은 approval 의 모든 route 이력 + const sql = targetObjId + ? `SELECT R.objid::text AS "ROUTE_OBJID", + A.objid::text AS "APPROVAL_OBJID", + 'APPROVAL-' || LPAD(A.approval_seq::text, 4, '0') AS "APPROVAL_NO", + 'ROUTE-' || LPAD(R.route_seq::text, 4, '0') AS "ROUTE_NO", + R.status AS "STATUS", + R.approval_title AS "TITLE", + TO_CHAR(R.regdate, 'YYYY-MM-DD HH24:MI') AS "REGDATE" + FROM approval A + JOIN route R ON R.approval_objid = A.objid + WHERE A.target_objid = $1::numeric + ORDER BY R.regdate DESC` + : `SELECT R.objid::text AS "ROUTE_OBJID", + A.objid::text AS "APPROVAL_OBJID", + 'APPROVAL-' || LPAD(A.approval_seq::text, 4, '0') AS "APPROVAL_NO", + 'ROUTE-' || LPAD(R.route_seq::text, 4, '0') AS "ROUTE_NO", + R.status AS "STATUS", + R.approval_title AS "TITLE", + TO_CHAR(R.regdate, 'YYYY-MM-DD HH24:MI') AS "REGDATE" + FROM approval A + JOIN route R ON R.approval_objid = A.objid + WHERE A.objid = $1::numeric + ORDER BY R.regdate DESC`; + + const rows = await queryRows(sql, [String(targetObjId || approvalObjId)]); + return NextResponse.json({ RESULTLIST: rows }); +} diff --git a/src/app/api/approval/reject/route.ts b/src/app/api/approval/reject/route.ts new file mode 100644 index 0000000..62386b7 --- /dev/null +++ b/src/app/api/approval/reject/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { updateTargetStatus } from "@/lib/approval-target"; + +// 결재 반려 (approval.xml: setInboxtaskResult + cancelInboxtask + +// updateRouteStatus + changeApprovalStatus 대응) +// 원본 ApprovalService.rejectRouteInfo +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const inboxObjId = body.inboxObjId; + if (!inboxObjId) return NextResponse.json({ success: false, message: "inboxObjId 필요" }); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const cur = await client.query( + `SELECT IT.*, A.target_objid, A.target_type + FROM inboxtask IT + JOIN approval A ON A.objid = IT.approval_objid + WHERE IT.objid = $1::numeric AND IT.target_user_id = $2 AND IT.status = 'ready'`, + [inboxObjId, user.userId] + ); + if (cur.rows.length === 0) { + await client.query("ROLLBACK"); + return NextResponse.json({ success: false, message: "결재 권한이 없거나 이미 처리된 건입니다." }); + } + const task = cur.rows[0]; + + // setInboxtaskResult — RESULT='N'(반려) + await client.query( + `UPDATE inboxtask SET result = 'N', status = 'reject', proc_date = now(), result_message = $1, + sign = $2, sign_width = $3, sign_height = $4 + WHERE objid = $5::numeric`, + [body.message || "", body.sign || null, body.sign_width || null, body.sign_height || null, inboxObjId] + ); + + // cancelInboxtask — 해당 route의 reject/complete 아닌 건을 cancel + await client.query( + `UPDATE inboxtask SET status = 'cancel' + WHERE status NOT IN ('complete', 'reject') AND route_objid = $1::numeric`, + [task.route_objid] + ); + + // updateRouteStatus — reject + await client.query( + `UPDATE route SET status = 'reject' WHERE objid = $1::numeric`, + [task.route_objid] + ); + // changeApprovalStatus — reject + await client.query( + `UPDATE approval SET status = 'reject', complete_date = now() WHERE objid = $1::numeric`, + [task.approval_objid] + ); + + // 대상 모듈 상태 반려 (XxxApprovalStatus) + await updateTargetStatus(client, task.target_type, String(task.target_objid), "reject", user.userId); + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "결재 반려되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("Reject:", e); + return NextResponse.json({ success: false, message: "반려 중 오류가 발생했습니다." }, { status: 500 }); + } finally { client.release(); } +} diff --git a/src/app/api/approval/request/route.ts b/src/app/api/approval/request/route.ts new file mode 100644 index 0000000..0a4fd34 --- /dev/null +++ b/src/app/api/approval/request/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; +import { updateTargetStatus } from "@/lib/approval-target"; + +// 결재상신 (approval.xml: insertApprovalInfo + createRouteInfo + createInboxTaskInfo 대응) +// 원본 ApprovalService.createApprovalInfo() 로직 포팅 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + if (!body.target_objid || !body.target_type) { + return NextResponse.json({ success: false, message: "결재 대상과 유형이 필요합니다." }); + } + const approvers: { user_id: string; approval_type?: string }[] = body.approvers || []; + if (approvers.length === 0) { + return NextResponse.json({ success: false, message: "결재자를 지정하세요." }); + } + + const systemType = body.system_type || "PLM"; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1. APPROVAL 생성/재사용 — APPROVAL_SEQ 자동증가 (SELECT MAX+1) + let approvalObjId: string; + const existing = await client.query( + `SELECT objid::text AS objid FROM approval + WHERE target_objid = $1::numeric AND system_type = $2 AND status != 'complete' + ORDER BY regdate DESC LIMIT 1`, + [body.target_objid, systemType] + ); + if (existing.rows.length > 0) { + approvalObjId = existing.rows[0].objid; + await client.query( + `UPDATE approval SET status = 'inProcess' WHERE objid = $1::numeric`, + [approvalObjId] + ); + } else { + approvalObjId = createObjectId(); + await client.query( + `INSERT INTO approval (objid, target_objid, target_type, approval_seq, system_type, regdate, status) + VALUES ($1::numeric, $2::numeric, $3, + (SELECT COALESCE(MAX(approval_seq::numeric) + 1, 1) FROM approval), + $4, now(), 'inProcess')`, + [approvalObjId, body.target_objid, body.target_type, systemType] + ); + } + + // 2. ROUTE 생성 — ROUTE_SEQ 자동증가 + const routeObjId = createObjectId(); + await client.query( + `INSERT INTO route (objid, target_objid, approval_objid, route_seq, + approval_title, approval_desc, system_type, writer, regdate, status) + VALUES ($1::numeric, $2::numeric, $3::numeric, + (SELECT COALESCE(MAX(route_seq::numeric) + 1, 1) FROM route), + $4, $5, $6, $7, now(), 'inProcess')`, + [routeObjId, body.target_objid, approvalObjId, + body.approval_title || "결재 요청", body.approval_desc || "", systemType, user.userId] + ); + + // 3. INBOXTASK — normal 순차(1st=ready, 나머지=standby), help/ref=즉시 ready + for (let i = 0; i < approvers.length; i++) { + const a = approvers[i]; + const approvalType = a.approval_type || "normal"; + const isFirstNormal = approvalType === "normal" && i === 0; + const isHelpOrRef = approvalType === "help" || approvalType === "ref"; + const status = (isFirstNormal || isHelpOrRef) ? "ready" : "standby"; + await client.query( + `INSERT INTO inboxtask (objid, seq, approval_type, target_objid, approval_objid, route_objid, + target_user_id, regdate, status) + VALUES ($1::numeric, $2, $3, $4::numeric, $5::numeric, $6::numeric, $7, now(), $8)`, + [createObjectId(), i + 1, approvalType, body.target_objid, + approvalObjId, routeObjId, a.user_id, status] + ); + } + + // 4. 대상 모듈 상태 업데이트 (XxxApprovalStatus 쿼리 대응) + await updateTargetStatus(client, body.target_type, body.target_objid, "approvalRequest", user.userId); + + await client.query("COMMIT"); + return NextResponse.json({ + success: true, approvalObjId, routeObjId, message: "결재상신되었습니다.", + }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("Approval request:", e); + return NextResponse.json({ success: false, message: "결재상신 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/approval/route.ts b/src/app/api/approval/route.ts new file mode 100644 index 0000000..36e540f --- /dev/null +++ b/src/app/api/approval/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 결재함 목록 (selectApprovalList 대응) +// status: PENDING(ready/standby) | APPROVED(complete) | REJECTED(reject) | "" (전체) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const systemType = body.system_type || "PLM"; + const conditions: string[] = [ + "A.objid = R.approval_objid", + "A.system_type = $1", + "IT.approval_objid = A.objid", + "IT.route_objid = R.objid", + ]; + const params: unknown[] = [systemType]; + let idx = 2; + + // 원본: target_user_id = connectUserId (내 결재건만, 관리자는 전체) + if (!user.isAdmin) { + conditions.push(`IT.target_user_id = $${idx++}`); + params.push(user.userId); + } + + // 원본: status 필터 + if (body.status === "PENDING") { + conditions.push(`UPPER(IT.status) = 'READY'`); + conditions.push(`R.status = 'inProcess'`); + } else if (body.status === "APPROVED") { + conditions.push(`UPPER(A.status) = 'COMPLETE'`); + } else if (body.status === "REJECTED") { + conditions.push(`UPPER(A.status) = 'REJECT'`); + } + + if (body.title) { + conditions.push(`UPPER(R.approval_title) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.title); + } + if (body.writer_name) { + conditions.push(`UPPER((SELECT user_name FROM user_info WHERE user_id = R.writer)) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.writer_name); + } + if (body.from_date) { + conditions.push(`R.regdate >= $${idx++}::date`); + params.push(body.from_date); + } + if (body.to_date) { + conditions.push(`R.regdate <= $${idx++}::date`); + params.push(body.to_date); + } + + // 원본 selectApprovalList와 동일한 컬럼 구조 + APPROVAL-NNNN / ROUTE-NNNN 번호 + const rows = await queryRows( + `SELECT + A.objid::text AS "OBJID", + A.objid::text AS "APPROVAL_OBJID", + A.target_objid AS "TARGET_OBJID", + A.target_type AS "TARGET_TYPE", + A.approval_seq AS "APPROVAL_SEQ", + 'APPROVAL-' || LPAD(A.approval_seq::text, 4, '0') AS "APPROVAL_NO", + A.status AS "APPROVAL_STATUS", + TO_CHAR(A.regdate, 'YYYY-MM-DD') AS "APPROVAL_REGDATE", + R.objid::text AS "ROUTE_OBJID", + R.route_seq AS "ROUTE_SEQ", + 'ROUTE-' || LPAD(R.route_seq::text, 4, '0') AS "ROUTE_NO", + R.approval_title AS "TITLE", + R.approval_desc AS "DESCRIPTION", + R.writer AS "WRITER", + COALESCE((SELECT dept_name FROM user_info WHERE user_id = R.writer LIMIT 1), '') AS "DEPT_NAME", + COALESCE((SELECT user_name FROM user_info WHERE user_id = R.writer LIMIT 1), R.writer) AS "WRITER_NAME", + TO_CHAR(R.regdate, 'YYYY-MM-DD') AS "REGDATE", + R.status AS "STATUS", + CASE + WHEN UPPER(A.status) = 'COMPLETE' THEN '결재완료' + WHEN UPPER(A.status) = 'REJECT' THEN '반려' + WHEN UPPER(IT.status) = 'READY' THEN '미결재' + WHEN UPPER(IT.status) = 'STANDBY' THEN '대기' + ELSE A.status END AS "STATUS_NAME", + IT.objid::text AS "INBOX_OBJID", + IT.seq AS "SEQ", + IT.approval_type AS "APPROVAL_TYPE", + IT.status AS "INBOX_STATUS", + IT.target_user_id AS "TARGET_USER_ID", + CASE A.target_type + WHEN 'PURCHASE_ORDER' THEN '발주' + WHEN 'EXPENSE_APPLY' THEN '경비' + WHEN 'MATERIAL_APPLY' THEN '자재' + WHEN 'AS_MNG' THEN 'CS/AS' + WHEN 'CSM' THEN '고객서비스' + WHEN 'ISSUE_RELEASE' THEN '이슈' + WHEN 'EO_MNG' THEN '설변(EO)' + WHEN 'ECR_MNG' THEN '변경요청(ECR)' + WHEN 'SALES_REQUEST' THEN '영업요청' + ELSE A.target_type END AS "TYPE_NAME" + FROM approval A, route R, inboxtask IT + WHERE ${conditions.join(" AND ")} + ORDER BY R.regdate DESC +`, + params + ); + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index ffe9101..bdb88ff 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -15,27 +15,32 @@ export async function POST(request: NextRequest) { ); } + // 이메일 형태이면 MOMO 사용자 우선 시도, 그 외에는 FITO 우선 시도 const looksLikeEmail = /@/.test(userId); if (looksLikeEmail) { const momo = await verifyMomoCredentials(userId, password); if (momo.success && momo.user) { - await createSession(momoToSessionUser(momo.user)); - return NextResponse.json({ success: true, redirectTo: "/m/dashboard" }); + const sessionUser: User = momoToSessionUser(momo.user); + await createSession(sessionUser); + return NextResponse.json({ success: true, user: sessionUser, redirectTo: "/m/dashboard" }); } + // MOMO 실패 시 FITO 폴백 시도 (관리자 마이그레이션 케이스) } const fito = await verifyCredentials(userId, password); if (fito.success && fito.user) { await createSession(fito.user); - return NextResponse.json({ success: true, redirectTo: "/dashboard" }); + return NextResponse.json({ success: true, user: fito.user, redirectTo: "/dashboard" }); } + // FITO 도 실패하면 MOMO를 한 번 더 시도 (이메일 형태가 아니지만 MOMO 계정인 경우) if (!looksLikeEmail) { const momo = await verifyMomoCredentials(userId, password); if (momo.success && momo.user) { - await createSession(momoToSessionUser(momo.user)); - return NextResponse.json({ success: true, redirectTo: "/m/dashboard" }); + const sessionUser: User = momoToSessionUser(momo.user); + await createSession(sessionUser); + return NextResponse.json({ success: true, user: sessionUser, redirectTo: "/m/dashboard" }); } } @@ -46,15 +51,33 @@ export async function POST(request: NextRequest) { } function momoToSessionUser(u: { - objid: string; email: string; companyName: string; phone: string; - role: "USER" | "ADMIN"; isAdmin: boolean; + objid: string; + email: string; + companyName: string; + phone: string; + role: "USER" | "ADMIN"; + isAdmin: boolean; }): User { return { - sabun: "", userId: u.email, userName: u.companyName, userNameEng: "", userNameCn: "", - deptCode: "", deptName: "", positionCode: "", positionName: "", email: u.email, - tel: "", cellPhone: u.phone, userType: "MOMO", + sabun: "", + userId: u.email, + userName: u.companyName, + userNameEng: "", + userNameCn: "", + deptCode: "", + deptName: "", + positionCode: "", + positionName: "", + email: u.email, + tel: "", + cellPhone: u.phone, + userType: "MOMO", userTypeName: u.role === "ADMIN" ? "관리자" : "거래처", - authName: u.role, partnerCd: "", isAdmin: u.isAdmin, - role: u.role, objid: u.objid, companyName: u.companyName, + authName: u.role, + partnerCd: "", + isAdmin: u.isAdmin, + role: u.role, + objid: u.objid, + companyName: u.companyName, }; } diff --git a/src/app/api/cost-mgmt/route.ts b/src/app/api/cost-mgmt/route.ts new file mode 100644 index 0000000..ce873e4 --- /dev/null +++ b/src/app/api/cost-mgmt/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원가관리 메인 (expenseDashBoardGrid 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["CM.contract_result = '0000964'"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(TO_DATE(CM.contract_date, 'YYYY-MM-DD'), 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`CM.contract_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + + const where = conditions.join(" AND "); + + const rows = await queryRows( + `SELECT CM.objid::text AS "OBJID", + CM.contract_no AS "PROJECT_NO", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = CM.product LIMIT 1), '') AS "PRODUCT_NAME", + -- 목표원가 (input_cost_goal) + COALESCE((SELECT (COALESCE(NULLIF(material_cost_goal,'')::numeric,0) + + COALESCE(NULLIF(labor_cost_goal,'')::numeric,0) + + COALESCE(NULLIF(expense_cost_goal,'')::numeric,0)) + FROM input_cost_goal WHERE contract_objid = CM.objid::text LIMIT 1), 0) AS "TARGET_COST", + -- 실적원가 = 재료비 + 노무비 + 경비 + (COALESCE((SELECT SUM(COALESCE(NULLIF(POP.supply_unit_price,'')::numeric,0)) + FROM purchase_order_part POP + JOIN purchase_order_master POM ON POM.objid = POP.purchase_order_master_objid + WHERE POM.contract_mgmt_objid = CM.objid AND POM.status = 'approvalComplete'), 0) + + COALESCE((SELECT SUM(COALESCE(NULLIF(amount_payment,'')::numeric,0)) + FROM expense_master WHERE project_mgmt_objid = CM.objid::text), 0) + ) AS "ACTUAL_COST", + TO_CHAR(CM.regdate, 'YYYY-MM-DD') AS "REGDATE" + FROM contract_mgmt CM + WHERE ${where} + ORDER BY CM.regdate DESC +`, + params + ); + + // JS에서 차이/달성율 계산 + const result = rows.map((r: Record) => { + const target = Number(r.TARGET_COST || 0); + const actual = Number(r.ACTUAL_COST || 0); + return { + ...r, + DIFF_COST: target - actual, + ACHIEVE_RATE: target > 0 ? Math.round(actual / target * 100 * 10) / 10 : 0, + }; + }); + + return NextResponse.json({ RESULTLIST: result, TOTAL_CNT: result.length }); +} diff --git a/src/app/api/cost/expense/route.ts b/src/app/api/cost/expense/route.ts new file mode 100644 index 0000000..d5b9edc --- /dev/null +++ b/src/app/api/cost/expense/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 경비관리 (expenseDashBoardGrid 원본 기반) +// 주 테이블: project_mgmt. SETTLE_AMOUNT = CARD_USED + CASH_USED - PAYMENT +// exp_status_cd별: 0001548 조립, 0001549 셋업, 0001629 외주(Turn-key) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(T.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`T.project_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.customer_objid) { + conditions.push(`T.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.pm_user_id) { + conditions.push(`T.pm_user_id = $${idx++}`); + params.push(body.pm_user_id); + } + + const where = conditions.join(" AND "); + + const sql = ` + WITH settle AS ( + SELECT + M.project_mgmt_objid, + M.exp_status_cd, + SUM(COALESCE(NULLIF(D.card_used, '')::numeric, 0) + + COALESCE(NULLIF(D.cash_used, '')::numeric, 0) + - COALESCE(NULLIF(D.payment, '')::numeric, 0)) AS settle_amount + FROM expense_master M + LEFT JOIN expense_detail D ON M.expense_master_objid = D.expense_master_objid + GROUP BY M.project_mgmt_objid, M.exp_status_cd + ) + SELECT T.objid::text AS "OBJID", + T.project_no AS "PROJECT_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = T.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + T.project_name AS "PROJECT_NAME", + T.req_del_date AS "REQ_DEL_DATE", + T.setup AS "SETUP", + COALESCE((SELECT user_name FROM user_info WHERE user_id = T.pm_user_id LIMIT 1), '') AS "PM_USER_NAME", + COALESCE((SELECT NULLIF(expense_cost_goal,'')::numeric FROM input_cost_goal WHERE contract_objid = T.objid::text LIMIT 1), 0) AS "EXPENSE_COST_GOAL", + COALESCE((SELECT settle_amount FROM settle WHERE project_mgmt_objid = T.objid::text AND exp_status_cd = '0001548'), 0) AS "SETTLE_AMOUNT_ASSEMBLE", + COALESCE((SELECT settle_amount FROM settle WHERE project_mgmt_objid = T.objid::text AND exp_status_cd = '0001549'), 0) AS "SETTLE_AMOUNT_SETUP", + COALESCE((SELECT settle_amount FROM settle WHERE project_mgmt_objid = T.objid::text AND exp_status_cd = '0001629'), 0) AS "SETTLE_AMOUNT_CS", + COALESCE((SELECT SUM(settle_amount) FROM settle WHERE project_mgmt_objid = T.objid::text), 0) AS "TOTAL_SETTLE_AMOUNT" + FROM project_mgmt T + WHERE ${where} + ORDER BY SUBSTRING(T.project_no, POSITION('-' IN T.project_no)+1) DESC + `; + + const rows = await queryRows(sql, params); + + const result = rows.map((r: Record) => { + const goal = Number(r.EXPENSE_COST_GOAL || 0); + const total = Number(r.TOTAL_SETTLE_AMOUNT || 0); + return { + ...r, + INPUT_RATE: goal > 0 ? Math.round((total / goal) * 1000) / 10 : 0, + }; + }); + + return NextResponse.json({ RESULTLIST: result, TOTAL_CNT: result.length }); +} diff --git a/src/app/api/cost/expense/save/route.ts b/src/app/api/cost/expense/save/route.ts new file mode 100644 index 0000000..3a8dd8f --- /dev/null +++ b/src/app/api/cost/expense/save/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 경비신청 저장 (saveExpense 대응, 단순화) +// body: { actionType, expense_master_objid?, project_mgmt_objid, bus_title, bns_start_date, bns_end_date, +// exp_area_cd, exp_status_cd, bus_content, reason, amount_payment, status } +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const isNew = body.actionType === "regist" || !body.expense_master_objid; + const objId = isNew ? createObjectId() : String(body.expense_master_objid); + const projectMgmtObjid = String(body.project_mgmt_objid || "").trim() || "0"; + + try { + // expense_master의 많은 컬럼이 'null::character varying' 기본값이라 + // 명시적으로 빈 문자열 전달 필요 + await execute( + `INSERT INTO expense_master ( + expense_master_objid, project_mgmt_objid, expense_id, + bus_title, bns_start_date, bns_end_date, + exp_status_cd, exp_company_cd, exp_area_cd, + vehicel_used, bus_users_id, bus_content, reason, instructions, + amount_payment, reg_user_id, reg_date, exp_sort_cd, status + ) VALUES ($1,$2,'',$3,$4,$5,$6,'',$7,'',$8,$9,$10,'',$11,$12,TO_CHAR(NOW(),'YYYY-MM-DD'),'',$13) + ON CONFLICT (expense_master_objid) DO UPDATE SET + project_mgmt_objid = EXCLUDED.project_mgmt_objid, + bus_title = EXCLUDED.bus_title, + bns_start_date = EXCLUDED.bns_start_date, + bns_end_date = EXCLUDED.bns_end_date, + exp_status_cd = EXCLUDED.exp_status_cd, + exp_area_cd = EXCLUDED.exp_area_cd, + bus_content = EXCLUDED.bus_content, + reason = EXCLUDED.reason, + amount_payment = EXCLUDED.amount_payment`, + [ + objId, projectMgmtObjid, + body.bus_title || "", body.bns_start_date || "", body.bns_end_date || "", + body.exp_status_cd || "", body.exp_area_cd || "", + body.bus_users_id || user.userId, body.bus_content || "", body.reason || "", + body.amount_payment || "", user.userId, body.status || "created", + ] + ); + return NextResponse.json({ success: true, objId, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (error) { + console.error("Expense save:", error); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/cost/goal/detail/route.ts b/src/app/api/cost/goal/detail/route.ts new file mode 100644 index 0000000..c23c689 --- /dev/null +++ b/src/app/api/cost/goal/detail/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 목표가 단건 조회 + 프로젝트 정보 +// body: { contract_objid } — 실제는 project_mgmt.objid (DB 컬럼명이 오해의 소지 있음) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const pmObjid = String(body.contract_objid || ""); + if (!pmObjid) return NextResponse.json({ success: true, data: null }); + + const row = await queryOne( + `SELECT PM.objid::text AS "OBJID", + PM.project_no AS "PROJECT_NO", + PM.project_name AS "PROJECT_NAME", + COALESCE(NULLIF(G.material_cost_goal, ''), '0') AS "MATERIAL_COST_GOAL", + COALESCE(NULLIF(G.labor_cost_goal, ''), '0') AS "LABOR_COST_GOAL", + COALESCE(NULLIF(G.expense_cost_goal, ''), '0') AS "EXPENSE_COST_GOAL" + FROM project_mgmt PM + LEFT JOIN input_cost_goal G ON G.contract_objid = PM.objid::text + WHERE PM.objid::text = $1`, + [pmObjid] + ); + return NextResponse.json({ success: true, data: row || null }); +} diff --git a/src/app/api/cost/goal/save/route.ts b/src/app/api/cost/goal/save/route.ts new file mode 100644 index 0000000..84be76b --- /dev/null +++ b/src/app/api/cost/goal/save/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 목표가 저장 (procurStandMgmt.saveCostGoalInfo 대응) +// body: { contract_objid, material_cost_goal, labor_cost_goal, expense_cost_goal } +// input_cost_goal 테이블에 contract_objid 유니크 제약 없으므로 수동 upsert +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const contractObjid = String(body.contract_objid || "").trim(); + if (!contractObjid) { + return NextResponse.json({ success: false, message: "프로젝트가 선택되지 않았습니다." }, { status: 400 }); + } + + // 콤마 제거 + const strip = (v: unknown) => String(v ?? "").replace(/,/g, ""); + const material = strip(body.material_cost_goal) || "0"; + const labor = strip(body.labor_cost_goal) || "0"; + const expense = strip(body.expense_cost_goal) || "0"; + + try { + const existing = await queryOne<{ OBJID: string }>( + `SELECT objid::text AS "OBJID" FROM input_cost_goal WHERE contract_objid = $1 LIMIT 1`, + [contractObjid] + ); + if (existing) { + await execute( + `UPDATE input_cost_goal + SET material_cost_goal = $1, + labor_cost_goal = $2, + expense_cost_goal = $3, + writer = $4, + regdate = NOW() + WHERE objid::text = $5`, + [material, labor, expense, user.userId, existing.OBJID] + ); + return NextResponse.json({ success: true, message: "수정되었습니다." }); + } + await execute( + `INSERT INTO input_cost_goal + (objid, contract_objid, material_cost_goal, labor_cost_goal, expense_cost_goal, writer, regdate) + VALUES ($1, $2, $3, $4, $5, $6, NOW())`, + [createObjectId(), contractObjid, material, labor, expense, user.userId] + ); + return NextResponse.json({ success: true, message: "등록되었습니다." }); + } catch (error) { + console.error("Cost goal save:", error); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/cost/labor/route.ts b/src/app/api/cost/labor/route.ts new file mode 100644 index 0000000..58933ce --- /dev/null +++ b/src/app/api/cost/labor/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 노무비관리 (costTotaltGridList의 LC 노무비 계산 부분 포팅) +// LABOR_COST_ACTUAL = LABOR_DESIGN_COST + LABOR_ASSEMBLY_COST +// 설계: PMS_WBS_TASK (design_act_end - design_act_start) × 300000 +// 조립 insourcing: WORK_DIARY dept DPT005/013/023 work_hour/8 × 250000 +// 조립 outsourcing: WORK_DIARY sourcing_type='outsourcing' work_hour/8 × 350000 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(T.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`T.project_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.customer_objid) { + conditions.push(`T.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.product) { + conditions.push(`T.product = $${idx++}`); + params.push(body.product); + } + if (body.pm_user_id) { + conditions.push(`T.pm_user_id = $${idx++}`); + params.push(body.pm_user_id); + } + + const where = conditions.join(" AND "); + + const sql = ` + WITH + labor_design AS ( + SELECT contract_objid, + (MAX(TO_DATE(NULLIF(design_act_end,''),'YYYY-MM-DD')) + - MIN(TO_DATE(NULLIF(design_act_start,''),'YYYY-MM-DD'))) * 300000 AS cost + FROM pms_wbs_task + WHERE contract_objid IS NOT NULL + AND NULLIF(design_act_start,'') IS NOT NULL + AND NULLIF(design_act_end,'') IS NOT NULL + GROUP BY contract_objid + ), + labor_in AS ( + SELECT wd.contract_objid, + SUM(COALESCE(NULLIF(wd.work_hour,''),'0')::numeric) AS hour + FROM work_diary wd + JOIN user_info ui ON ui.user_id = wd.worker_id + WHERE wd.status = 'complete' + AND wd.contract_objid IS NOT NULL AND wd.contract_objid <> '' + AND ui.dept_code IN ('DPT005','DPT023','DPT013') + GROUP BY wd.contract_objid + ), + labor_out AS ( + SELECT contract_objid, + SUM(COALESCE(NULLIF(work_hour,''),'0')::numeric) AS hour + FROM work_diary + WHERE status = 'complete' + AND sourcing_type = 'outsourcing' + AND contract_objid IS NOT NULL AND contract_objid <> '' + GROUP BY contract_objid + ) + SELECT T.objid::text AS "OBJID", + T.project_no AS "PROJECT_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = T.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + T.project_name AS "PROJECT_NAME", + T.req_del_date AS "REQ_DEL_DATE", + T.setup AS "SETUP", + COALESCE((SELECT user_name FROM user_info WHERE user_id = T.pm_user_id LIMIT 1), '') AS "PM_USER_NAME", + COALESCE(NULLIF(ICG.labor_cost_goal,'')::numeric, 0) AS "LABOR_COST_GOAL", + (COALESCE(LBD.cost, 0) + + COALESCE(LI.hour, 0) / 8 * 250000 + + COALESCE(LO.hour, 0) / 8 * 350000) AS "LABOR_COST_ACTUAL", + (COALESCE(LI.hour, 0) + COALESCE(LO.hour, 0)) AS "LABOR_HOURS" + FROM project_mgmt T + LEFT JOIN input_cost_goal ICG ON ICG.contract_objid = T.objid::text + LEFT JOIN labor_design LBD ON LBD.contract_objid = T.objid::text + LEFT JOIN labor_in LI ON LI.contract_objid = T.objid::text + LEFT JOIN labor_out LO ON LO.contract_objid = T.objid::text + WHERE ${where} + ORDER BY SUBSTRING(T.project_no, POSITION('-' IN T.project_no)+1) DESC + `; + + const rows = await queryRows(sql, params); + + const result = rows.map((r: Record) => { + const goal = Number(r.LABOR_COST_GOAL || 0); + const actual = Number(r.LABOR_COST_ACTUAL || 0); + return { + ...r, + LABOR_INPUT_RATE: goal > 0 ? Math.round((actual / goal) * 1000) / 10 : 0, + }; + }); + + return NextResponse.json({ RESULTLIST: result, TOTAL_CNT: result.length }); +} diff --git a/src/app/api/cost/material/route.ts b/src/app/api/cost/material/route.ts new file mode 100644 index 0000000..b29831c --- /dev/null +++ b/src/app/api/cost/material/route.ts @@ -0,0 +1,130 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 재료비관리 (materialCostTotaltGridList 원본 1:1 포팅) +// ALL_TOTAL_PRICE = BOM + 재발주(0001408) + 장납기 +// NEW_TOTAL_PRICE = 발주(0001407) +// ALL_TOTAL_PRICE_RE = 재발주(0001408) +// MATERIAL_COST_GOAL_RATE = ALL_TOTAL_PRICE × 100 / MATERIAL_COST_GOAL +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(T.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`T.project_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.customer_objid) { + conditions.push(`T.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.product) { + conditions.push(`T.product = $${idx++}`); + params.push(body.product); + } + if (body.pm_user_id) { + conditions.push(`T.pm_user_id = $${idx++}`); + params.push(body.pm_user_id); + } + + const where = conditions.join(" AND "); + + const sql = ` + WITH RECURSIVE view_bom AS ( + SELECT pbr.contract_objid, a.bom_report_objid, a.child_objid, + 1 AS lev, ARRAY[a.child_objid::text] AS path, false AS cycle, + COALESCE(NULLIF(a.qty,''),'0')::numeric AS aggregate_qty + FROM part_bom_report pbr + JOIN bom_part_qty a ON pbr.objid = a.bom_report_objid + WHERE (a.parent_objid IS NULL OR a.parent_objid = '') + UNION ALL + SELECT v.contract_objid, b.bom_report_objid, b.child_objid, + v.lev + 1, v.path || b.child_objid::text, + b.parent_objid = ANY(v.path), + v.aggregate_qty * COALESCE(NULLIF(b.qty,''),'0')::numeric + FROM bom_part_qty b + JOIN view_bom v ON b.parent_objid = v.child_objid AND v.bom_report_objid = b.bom_report_objid + WHERE NOT v.cycle + ), + bom_material AS ( + SELECT pbr.contract_objid, + SUM((COALESCE(NULLIF(sp.price,''),'0')::numeric + + COALESCE(NULLIF(sp.price1,''),'0')::numeric + + COALESCE(NULLIF(sp.price2,''),'0')::numeric + + COALESCE(NULLIF(sp.price3,''),'0')::numeric + + COALESCE(NULLIF(sp.price4,''),'0')::numeric) * v.aggregate_qty) AS total + FROM part_bom_report pbr + JOIN sales_bom_report sb ON sb.parent_objid = pbr.objid + JOIN sales_bom_report_part sp ON sp.parent_objid = pbr.objid + JOIN view_bom v ON v.child_objid = sp.bom_part_qty_objid + GROUP BY pbr.contract_objid + ), + new_order AS ( + SELECT contract_mgmt_objid, + SUM(COALESCE(NULLIF(total_supply_unit_price,'')::numeric, 0)) AS total + FROM purchase_order_master + WHERE status = 'approvalComplete' AND order_type_cd = '0001407' + AND contract_mgmt_objid IS NOT NULL + GROUP BY contract_mgmt_objid + ), + re_order AS ( + SELECT contract_mgmt_objid, + SUM(COALESCE(NULLIF(total_supply_unit_price,'')::numeric, 0)) AS total + FROM purchase_order_master + WHERE status = 'approvalComplete' AND order_type_cd = '0001408' + AND contract_mgmt_objid IS NOT NULL + GROUP BY contract_mgmt_objid + ), + long_delivery AS ( + SELECT li.contract_objid, + SUM(NULLIF(li.input_qty,'')::numeric * NULLIF(l.price,'')::numeric) AS total + FROM sales_long_delivery_input li + JOIN sales_long_delivery l ON l.objid = li.parent_objid + WHERE NULLIF(li.input_qty,'') IS NOT NULL + AND NULLIF(l.price,'') IS NOT NULL + GROUP BY li.contract_objid + ) + SELECT T.objid::text AS "OBJID", + T.project_no AS "PROJECT_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = T.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + T.project_name AS "PROJECT_NAME", + T.req_del_date AS "REQ_DEL_DATE", + T.setup AS "SETUP", + COALESCE((SELECT user_name FROM user_info WHERE user_id = T.pm_user_id LIMIT 1), '') AS "PM_USER_NAME", + COALESCE(NULLIF(ICG.material_cost_goal,'')::numeric, 0) AS "MATERIAL_COST_GOAL", + COALESCE(BM.total, 0) + COALESCE(RO.total, 0) + COALESCE(LD.total, 0) AS "ALL_TOTAL_PRICE", + COALESCE(NO_.total, 0) AS "NEW_TOTAL_PRICE", + COALESCE(RO.total, 0) AS "ALL_TOTAL_PRICE_RE" + FROM project_mgmt T + LEFT JOIN input_cost_goal ICG ON ICG.contract_objid = T.objid::text + LEFT JOIN bom_material BM ON BM.contract_objid = T.objid::text + LEFT JOIN new_order NO_ ON NO_.contract_mgmt_objid = T.objid::text + LEFT JOIN re_order RO ON RO.contract_mgmt_objid = T.objid::text + LEFT JOIN long_delivery LD ON LD.contract_objid = T.objid::text + WHERE ${where} + ORDER BY SUBSTRING(T.project_no, POSITION('-' IN T.project_no)+1) DESC + `; + + const rows = await queryRows(sql, params); + + const result = rows.map((r: Record) => { + const goal = Number(r.MATERIAL_COST_GOAL || 0); + const actual = Number(r.ALL_TOTAL_PRICE || 0); + return { + ...r, + MATERIAL_COST_GOAL_RATE: goal > 0 ? Math.round((actual / goal) * 1000) / 10 : 0, + }; + }); + + return NextResponse.json({ RESULTLIST: result, TOTAL_CNT: result.length }); +} diff --git a/src/app/api/cost/status/route.ts b/src/app/api/cost/status/route.ts new file mode 100644 index 0000000..daed562 --- /dev/null +++ b/src/app/api/cost/status/route.ts @@ -0,0 +1,199 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 투입원가현황 (costTotaltGridList 원본 1:1 포팅) +// 주 테이블: project_mgmt +// 재료비 실적 = BOM(SALES_BOM_REPORT_PART price × 누적수량) + 재발주(0001408) + 장납기 +// 노무비 실적 = 설계(PMS_WBS_TASK 기간×300K) + 조립(WORK_DIARY DPT005/013/023 work_hour/8*250K + sourcing_type='outsourcing' /8*350K) +// 경비 실적 = EXPENSE_DETAIL CARD+CASH-PAYMENT +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(T.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`T.project_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.customer_objid) { + conditions.push(`T.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.product) { + conditions.push(`T.product = $${idx++}`); + params.push(body.product); + } + if (body.pm_user_id) { + conditions.push(`T.pm_user_id = $${idx++}`); + params.push(body.pm_user_id); + } + + const where = conditions.join(" AND "); + + const sql = ` + WITH RECURSIVE view_bom AS ( + SELECT pbr.contract_objid, + a.bom_report_objid, + a.child_objid, + 1 AS lev, + ARRAY[a.child_objid::text] AS path, + false AS cycle, + COALESCE(NULLIF(a.qty,''),'0')::numeric AS aggregate_qty + FROM part_bom_report pbr + JOIN bom_part_qty a ON pbr.objid = a.bom_report_objid + WHERE (a.parent_objid IS NULL OR a.parent_objid = '') + UNION ALL + SELECT v.contract_objid, + b.bom_report_objid, + b.child_objid, + v.lev + 1, + v.path || b.child_objid::text, + b.parent_objid = ANY(v.path), + v.aggregate_qty * COALESCE(NULLIF(b.qty,''),'0')::numeric + FROM bom_part_qty b + JOIN view_bom v ON b.parent_objid = v.child_objid + AND v.bom_report_objid = b.bom_report_objid + WHERE NOT v.cycle + ), + bom_material AS ( + SELECT pbr.contract_objid, + SUM((COALESCE(NULLIF(sp.price,''),'0')::numeric + + COALESCE(NULLIF(sp.price1,''),'0')::numeric + + COALESCE(NULLIF(sp.price2,''),'0')::numeric + + COALESCE(NULLIF(sp.price3,''),'0')::numeric + + COALESCE(NULLIF(sp.price4,''),'0')::numeric) * v.aggregate_qty) AS all_total_price + FROM part_bom_report pbr + JOIN sales_bom_report sb ON sb.parent_objid = pbr.objid + JOIN sales_bom_report_part sp ON sp.parent_objid = pbr.objid + JOIN view_bom v ON v.child_objid = sp.bom_part_qty_objid + GROUP BY pbr.contract_objid + ), + re_order AS ( + SELECT contract_mgmt_objid, + SUM(COALESCE(NULLIF(total_supply_unit_price,'')::numeric, 0)) AS total + FROM purchase_order_master + WHERE status = 'approvalComplete' AND order_type_cd = '0001408' + AND contract_mgmt_objid IS NOT NULL + GROUP BY contract_mgmt_objid + ), + long_delivery AS ( + SELECT li.contract_objid, + SUM(NULLIF(li.input_qty,'')::numeric * NULLIF(l.price,'')::numeric) AS total + FROM sales_long_delivery_input li + JOIN sales_long_delivery l ON l.objid = li.parent_objid + WHERE NULLIF(li.input_qty,'') IS NOT NULL + AND NULLIF(l.price,'') IS NOT NULL + GROUP BY li.contract_objid + ), + labor_design AS ( + SELECT contract_objid, + (MAX(TO_DATE(NULLIF(design_act_end,''),'YYYY-MM-DD')) + - MIN(TO_DATE(NULLIF(design_act_start,''),'YYYY-MM-DD'))) * 300000 AS cost + FROM pms_wbs_task + WHERE contract_objid IS NOT NULL + AND NULLIF(design_act_start,'') IS NOT NULL + AND NULLIF(design_act_end,'') IS NOT NULL + GROUP BY contract_objid + ), + labor_in AS ( + SELECT wd.contract_objid, + SUM(COALESCE(NULLIF(wd.work_hour,''),'0')::numeric) AS hour + FROM work_diary wd + JOIN user_info ui ON ui.user_id = wd.worker_id + WHERE wd.status = 'complete' + AND wd.contract_objid IS NOT NULL AND wd.contract_objid <> '' + AND ui.dept_code IN ('DPT005','DPT023','DPT013') + GROUP BY wd.contract_objid + ), + labor_out AS ( + SELECT contract_objid, + SUM(COALESCE(NULLIF(work_hour,''),'0')::numeric) AS hour + FROM work_diary + WHERE status = 'complete' + AND sourcing_type = 'outsourcing' + AND contract_objid IS NOT NULL AND contract_objid <> '' + GROUP BY contract_objid + ), + expense_settle AS ( + SELECT M.project_mgmt_objid, + SUM(COALESCE(NULLIF(D.card_used,'')::numeric, 0) + + COALESCE(NULLIF(D.cash_used,'')::numeric, 0) + - COALESCE(NULLIF(D.payment,'')::numeric, 0)) AS amount + FROM expense_master M + LEFT JOIN expense_detail D ON M.expense_master_objid = D.expense_master_objid + GROUP BY M.project_mgmt_objid + ) + SELECT T.objid::text AS "OBJID", + T.project_no AS "PROJECT_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = T.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + T.project_name AS "PROJECT_NAME", + T.req_del_date AS "REQ_DEL_DATE", + T.setup AS "SETUP", + COALESCE((SELECT user_name FROM user_info WHERE user_id = T.pm_user_id LIMIT 1), '') AS "PM_USER_NAME", + COALESCE(NULLIF(T.contract_price,'')::numeric, 0) AS "CONTRACT_PRICE", + COALESCE(NULLIF(ICG.material_cost_goal,'')::numeric, 0) AS "MATERIAL_COST_GOAL", + COALESCE(NULLIF(ICG.labor_cost_goal,'')::numeric, 0) AS "LABOR_COST_GOAL", + COALESCE(NULLIF(ICG.expense_cost_goal,'')::numeric, 0) AS "EXPENSE_COST_GOAL", + (COALESCE(NULLIF(ICG.material_cost_goal,'')::numeric, 0) + + COALESCE(NULLIF(ICG.labor_cost_goal,'')::numeric, 0) + + COALESCE(NULLIF(ICG.expense_cost_goal,'')::numeric, 0)) AS "TOTAL_COST_GOAL", + (COALESCE(BM.all_total_price, 0) + + COALESCE(RO.total, 0) + + COALESCE(LD.total, 0)) AS "ACCRUAL_MATERIAL_COST", + (COALESCE(LBD.cost, 0) + + COALESCE(LI.hour, 0) / 8 * 250000 + + COALESCE(LO.hour, 0) / 8 * 350000) AS "LABOR_COST_ACTUAL", + COALESCE(ES.amount, 0) AS "ACCRUAL_EXPENSE" + FROM project_mgmt T + LEFT JOIN input_cost_goal ICG ON ICG.contract_objid = T.objid::text + LEFT JOIN bom_material BM ON BM.contract_objid = T.objid::text + LEFT JOIN re_order RO ON RO.contract_mgmt_objid = T.objid::text + LEFT JOIN long_delivery LD ON LD.contract_objid = T.objid::text + LEFT JOIN labor_design LBD ON LBD.contract_objid = T.objid::text + LEFT JOIN labor_in LI ON LI.contract_objid = T.objid::text + LEFT JOIN labor_out LO ON LO.contract_objid = T.objid::text + LEFT JOIN expense_settle ES ON ES.project_mgmt_objid = T.objid::text + WHERE ${where} + ORDER BY SUBSTRING(T.project_no, POSITION('-' IN T.project_no)+1) DESC + `; + + const rows = await queryRows(sql, params); + + // 원본 공식 그대로: + // TOTAL_COST_ACTUAL = 재료비실적 + 노무비실적 + 경비실적 + // TOTAL_INPUT_RATE = TOTAL_ACTUAL / TOTAL_GOAL × 100 + // MC_RATE = 재료비실적 / TOTAL_GOAL × 100 (실제 원본 SQL: 실투입재료 / 목표합계) + // MATERIAL_COST_GOAL_RATE = 재료비실적 / CONTRACT_PRICE × 100 + // LABOR_INPUT_RATE = 노무비실적 / 노무비목표 × 100 + // EXPENSE_RATE = 경비실적 / 경비목표 × 100 + const result = rows.map((r: Record) => { + const tg = Number(r.TOTAL_COST_GOAL || 0); + const lg = Number(r.LABOR_COST_GOAL || 0); + const eg = Number(r.EXPENSE_COST_GOAL || 0); + const ma = Number(r.ACCRUAL_MATERIAL_COST || 0); + const la = Number(r.LABOR_COST_ACTUAL || 0); + const ea = Number(r.ACCRUAL_EXPENSE || 0); + const ta = ma + la + ea; + const cp = Number(r.CONTRACT_PRICE || 0); + return { + ...r, + TOTAL_COST_ACTUAL: ta, + TOTAL_INPUT_RATE: tg > 0 ? Math.round((ta / tg) * 1000) / 10 : 0, + MC_RATE: tg > 0 ? Math.round((ma / tg) * 1000) / 10 : 0, + MATERIAL_COST_GOAL_RATE: cp > 0 ? Math.round((ma / cp) * 1000) / 10 : 0, + LABOR_INPUT_RATE: lg > 0 ? Math.round((la / lg) * 1000) / 10 : 0, + EXPENSE_RATE: eg > 0 ? Math.round((ea / eg) * 1000) / 10 : 0, + }; + }); + + return NextResponse.json({ RESULTLIST: result, TOTAL_CNT: result.length }); +} diff --git a/src/app/api/cs/chart/route.ts b/src/app/api/cs/chart/route.ts new file mode 100644 index 0000000..022f89d --- /dev/null +++ b/src/app/api/cs/chart/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// CS 차트 데이터 (getAsTotalList_CS / getASDashboardList 대응) +// 년도/제품별 CS 건수 집계 + 유/무상 구분 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`A.year = $${idx++}`); + params.push(body.year); + } + if (body.product_code) { + conditions.push(`A.product_code = $${idx++}`); + params.push(body.product_code); + } + + const where = conditions.join(" AND "); + + // 제품별 CS 건수 + 유/무상 구분 + const rows = await queryRows( + `SELECT + COALESCE(A.product_name, (SELECT code_name FROM comm_code WHERE code_id = A.product_code LIMIT 1), '미분류') AS "PRODUCT_NAME", + COUNT(*) AS "TOTAL_CNT", + COUNT(CASE WHEN A.paid_free = '유상' OR A.warranty_code = '0000157' THEN 1 END) AS "PAID_CNT", + COUNT(CASE WHEN A.paid_free = '무상' OR A.warranty_code = '0000158' THEN 1 END) AS "FREE_CNT", + COUNT(CASE WHEN A.status_cd = 'AS_STATUS_03' OR A.status_cd = '0000102' THEN 1 END) AS "COMPLETE_CNT", + COUNT(CASE WHEN A.status_cd IS NULL OR (A.status_cd != 'AS_STATUS_03' AND A.status_cd != '0000102') THEN 1 END) AS "INCOMPLETE_CNT", + -- 월별 건수 (차트용) + COUNT(CASE WHEN EXTRACT(MONTH FROM A.reg_date) = 1 THEN 1 END) AS "M01", + COUNT(CASE WHEN EXTRACT(MONTH FROM A.reg_date) = 2 THEN 1 END) AS "M02", + COUNT(CASE WHEN EXTRACT(MONTH FROM A.reg_date) = 3 THEN 1 END) AS "M03", + COUNT(CASE WHEN EXTRACT(MONTH FROM A.reg_date) = 4 THEN 1 END) AS "M04", + COUNT(CASE WHEN EXTRACT(MONTH FROM A.reg_date) = 5 THEN 1 END) AS "M05", + COUNT(CASE WHEN EXTRACT(MONTH FROM A.reg_date) = 6 THEN 1 END) AS "M06", + COUNT(CASE WHEN EXTRACT(MONTH FROM A.reg_date) = 7 THEN 1 END) AS "M07", + COUNT(CASE WHEN EXTRACT(MONTH FROM A.reg_date) = 8 THEN 1 END) AS "M08", + COUNT(CASE WHEN EXTRACT(MONTH FROM A.reg_date) = 9 THEN 1 END) AS "M09", + COUNT(CASE WHEN EXTRACT(MONTH FROM A.reg_date) = 10 THEN 1 END) AS "M10", + COUNT(CASE WHEN EXTRACT(MONTH FROM A.reg_date) = 11 THEN 1 END) AS "M11", + COUNT(CASE WHEN EXTRACT(MONTH FROM A.reg_date) = 12 THEN 1 END) AS "M12" + FROM as_mng A + WHERE ${where} + GROUP BY A.product_code, A.product_name + ORDER BY "TOTAL_CNT" DESC`, + params + ); + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/cs/manage/approval/route.ts b/src/app/api/cs/manage/approval/route.ts new file mode 100644 index 0000000..b0d7eb5 --- /dev/null +++ b/src/app/api/cs/manage/approval/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// CS 결재상신 처리 — status_cd='approvalRequest' 업데이트 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objIds } = await request.json(); + if (!objIds?.length) return NextResponse.json({ success: false, message: "상신할 항목을 선택하세요." }); + try { + const ph = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + await execute( + `UPDATE as_mng SET status_cd = 'approvalRequest' WHERE objid IN (${ph})`, + objIds + ); + return NextResponse.json({ success: true, message: `${objIds.length}건 결재상신되었습니다.` }); + } catch (error) { + console.error("CS approval:", error); + return NextResponse.json({ success: false, message: "상신 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/cs/manage/delete/route.ts b/src/app/api/cs/manage/delete/route.ts new file mode 100644 index 0000000..3b32917 --- /dev/null +++ b/src/app/api/cs/manage/delete/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// CS관리 삭제 (asDelete 대응) — CSM + 부품/작업시간 + 첨부파일 일괄 삭제 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objIds } = await request.json(); + if (!objIds?.length) { + return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }); + } + + try { + const ph = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + await execute(`DELETE FROM customer_service_part WHERE parent_objid IN (${ph})`, objIds); + await execute(`DELETE FROM customer_service_workingtime WHERE parent_objid IN (${ph})`, objIds); + await execute(`DELETE FROM attach_file_info WHERE target_objid IN (${ph})`, objIds); + await execute(`DELETE FROM customer_service_mgmt WHERE objid IN (${ph})`, objIds); + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (error) { + console.error("CS delete:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/cs/manage/detail/route.ts b/src/app/api/cs/manage/detail/route.ts new file mode 100644 index 0000000..2a723ba --- /dev/null +++ b/src/app/api/cs/manage/detail/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne, queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// CS 단건 조회 (getCSMInfo + getCSPList + getCSWList 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + const info = await queryOne( + `SELECT CSM.objid::text AS "OBJID", + CSM.service_no AS "SERVICE_NO", + CSM.product AS "PRODUCT", + (SELECT code_name FROM comm_code WHERE code_id = CSM.product) AS "PRODUCT_NAME", + CSM.contract_objid AS "CONTRACT_OBJID", + CSM.cs_category AS "CS_CATEGORY", + (SELECT code_name FROM comm_code WHERE code_id = CSM.cs_category) AS "CATEGORY_NAME", + CSM.warranty AS "WARRANTY", + (SELECT code_name FROM comm_code WHERE code_id = CSM.warranty) AS "WARRANTY_NAME", + CSM.manager_id AS "MANAGER_ID", + (SELECT user_name FROM user_info WHERE user_id = CSM.manager_id) AS "MANAGER_NAME", + CSM.act_date AS "ACT_DATE", + CSM.category_h AS "CATEGORY_H", + CSM.category_m AS "CATEGORY_M", + CSM.category_l AS "CATEGORY_L", + (SELECT code_name FROM comm_code WHERE code_id = CSM.category_h) AS "CATEGORY_H_NAME", + CSM.title AS "TITLE", + CSM.before_contents AS "BEFORE_CONTENTS", + CSM.after_contents AS "AFTER_CONTENTS", + CSM.writer AS "WRITER", + CSM.status AS "STATUS", + (SELECT code_name FROM comm_code WHERE code_id = CSM.status) AS "STATUS_NAME", + TO_CHAR(CSM.regdate, 'YYYY-MM-DD') AS "REC_DT", + CM.project_no AS "PROJECT_NO", + CM.customer_objid AS "CUSTOMER_OBJID", + (SELECT supply_name FROM supply_mng WHERE objid::text = CM.customer_objid) AS "CUSTOMER_NAME", + CM.setup AS "SETUP", + RM.release_date AS "RELEASE_DATE", + APPR.APPR_STATUS AS "APPR_STATUS", + APPR.APPR_STATUS_NAME AS "APPR_STATUS_NAME", + APPR.APPROVAL_OBJID AS "APPROVAL_OBJID", + APPR.ROUTE_OBJID AS "ROUTE_OBJID" + FROM customer_service_mgmt CSM + LEFT JOIN project_mgmt CM ON CSM.contract_objid = CM.objid + LEFT JOIN release_mgmt RM ON CM.objid = RM.parent_objid + LEFT JOIN ( + SELECT B.objid AS ROUTE_OBJID, + B.status AS APPR_STATUS, + CASE UPPER(B.status) + WHEN 'INPROCESS' THEN '결재중' + WHEN 'COMPLETE' THEN '결재완료' + WHEN 'REJECT' THEN '반려' + ELSE '' END AS APPR_STATUS_NAME, + A.objid AS APPROVAL_OBJID, + A.target_objid + FROM approval A, + (SELECT T1.* FROM + (SELECT target_objid, MAX(route_seq) AS route_seq FROM route GROUP BY target_objid) T, + route T1 + WHERE T.target_objid = T1.target_objid AND T.route_seq = T1.route_seq) B + WHERE A.objid = B.approval_objid AND A.target_type = 'CSM' + ) APPR ON CSM.objid::numeric = APPR.target_objid + WHERE CSM.objid = $1`, + [objId] + ); + + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + + const parts = await queryRows( + `SELECT objid::text AS "OBJID", parent_objid AS "PARENT_OBJID", + part_no AS "PART_NO", part_name AS "PART_NAME", spec AS "SPEC", + qty AS "QTY", cur_qty AS "CUR_QTY", price AS "PRICE", sup_price AS "SUP_PRICE" + FROM customer_service_part WHERE parent_objid = $1 ORDER BY objid`, + [objId] + ); + + const works = await queryRows( + `SELECT objid::text AS "OBJID", parent_objid AS "PARENT_OBJID", + supply_objid AS "SUPPLY_OBJID", form_date AS "FORM_DATE", to_date AS "TO_DATE", + work_day AS "WORK_DAY", work_person AS "WORK_PERSON", work_day_m AS "WORK_DAY_M", + labor_cost AS "LABOR_COST", expenses AS "EXPENSES" + FROM customer_service_workingtime WHERE parent_objid = $1 ORDER BY objid`, + [objId] + ); + + return NextResponse.json({ success: true, data: info, parts, works }); +} diff --git a/src/app/api/cs/manage/route.ts b/src/app/api/cs/manage/route.ts new file mode 100644 index 0000000..7521119 --- /dev/null +++ b/src/app/api/cs/manage/route.ts @@ -0,0 +1,140 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// CS관리 - 등록 및 조치 목록 (asMngList_CS.jsp + getECRList_CS 대응) +// FITO: /as/asMngGridList.do → CUSTOMER_SERVICE_MGMT + PROJECT_MGMT + RELEASE_MGMT + CUSTOMER_SERVICE_WORKINGTIME + APPROVAL/ROUTE +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.Year || body.year) { + conditions.push(`TO_CHAR(CSM.regdate,'YYYY') = $${idx++}`); + params.push(String(body.Year || body.year)); + } + if (body.product_cd) { + conditions.push(`CSM.product = $${idx++}`); + params.push(body.product_cd); + } + if (body.project_no) { + conditions.push(`CSM.contract_objid = $${idx++}`); + params.push(body.project_no); + } + if (body.project_nos && Array.isArray(body.project_nos) && body.project_nos.length > 0) { + const placeholders = body.project_nos.map(() => `$${idx++}`).join(","); + conditions.push(`CSM.contract_objid IN (${placeholders})`); + params.push(...body.project_nos); + } + if (body.warranty) { + conditions.push(`CSM.warranty = $${idx++}`); + params.push(body.warranty); + } + if (body.category_h) { + conditions.push(`CSM.category_h = $${idx++}`); + params.push(body.category_h); + } + if (body.rec_start_date) { + conditions.push(`TO_DATE(TO_CHAR(CSM.regdate,'YYYY-MM-DD'),'YYYY-MM-DD') >= TO_DATE($${idx++},'YYYY-MM-DD')`); + params.push(body.rec_start_date); + } + if (body.rec_end_date) { + conditions.push(`TO_DATE(TO_CHAR(CSM.regdate,'YYYY-MM-DD'),'YYYY-MM-DD') <= TO_DATE($${idx++},'YYYY-MM-DD')`); + params.push(body.rec_end_date); + } + if (body.manager_id) { + conditions.push(`CSM.manager_id = $${idx++}`); + params.push(body.manager_id); + } + if (body.act_start_date) { + conditions.push(`TO_DATE(CSM.act_date,'YYYY-MM-DD') >= TO_DATE($${idx++},'YYYY-MM-DD')`); + params.push(body.act_start_date); + } + if (body.act_end_date) { + conditions.push(`TO_DATE(CSM.act_date,'YYYY-MM-DD') <= TO_DATE($${idx++},'YYYY-MM-DD')`); + params.push(body.act_end_date); + } + if (body.appr_status) { + conditions.push(`APPR.APPR_STATUS = $${idx++}`); + params.push(body.appr_status); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT + CSM.objid::text AS "OBJID", + CSM.service_no AS "SERVICE_NO", + CSM.product AS "PRODUCT", + (SELECT code_name FROM comm_code WHERE code_id = CSM.product) AS "PRODUCT_NAME", + CSM.contract_objid AS "CONTRACT_OBJID", + CSM.cs_category AS "CS_CATEGORY", + (SELECT code_name FROM comm_code WHERE code_id = CSM.cs_category) AS "CATEGORY_NAME", + CSM.warranty AS "WARRANTY", + (SELECT code_name FROM comm_code WHERE code_id = CSM.warranty) AS "WARRANTY_NAME", + CSM.manager_id AS "MANAGER_ID", + (SELECT user_name FROM user_info WHERE user_id = CSM.manager_id) AS "MANAGER_NAME", + CSM.act_date AS "ACT_DATE", + CSM.category_h AS "CATEGORY_H", + CSM.category_m AS "CATEGORY_M", + CSM.category_l AS "CATEGORY_L", + (SELECT code_name FROM comm_code WHERE code_id = CSM.category_h) AS "CATEGORY_H_NAME", + (SELECT code_name FROM comm_code WHERE code_id = CSM.category_m) AS "CATEGORY_M_NAME", + (SELECT code_name FROM comm_code WHERE code_id = CSM.category_l) AS "CATEGORY_L_NAME", + CSM.title AS "TITLE", + CSM.writer AS "WRITER", + CSM.status AS "STATUS", + (SELECT code_name FROM comm_code WHERE code_id = CSM.status) AS "STATUS_NAME", + TO_CHAR(CSM.regdate,'YYYY-MM-DD') AS "REC_DT", + CM.project_no AS "PROJECT_NO", + CM.setup AS "SETUP", + CM.customer_objid AS "CUSTOMER_OBJID", + (SELECT supply_name FROM supply_mng WHERE objid::text = CM.customer_objid) AS "CUSTOMER_NAME", + RM.release_date AS "RELEASE_DATE", + CSW.PLAN_COST AS "PLAN_COST", + (SELECT COUNT(1) FROM attach_file_info WHERE target_objid = CSM.objid AND doc_type='AS_DOC_01' AND UPPER(status) = 'ACTIVE') AS "CU03_CNT", + APPR.APPROVAL_OBJID AS "APPROVAL_OBJID", + APPR.ROUTE_OBJID AS "ROUTE_OBJID", + APPR.APPR_STATUS AS "APPR_STATUS", + APPR.APPR_STATUS_NAME AS "APPR_STATUS_NAME" + FROM customer_service_mgmt AS CSM + LEFT OUTER JOIN project_mgmt AS CM ON CSM.contract_objid = CM.objid + LEFT OUTER JOIN release_mgmt AS RM ON CM.objid = RM.parent_objid + LEFT OUTER JOIN ( + SELECT parent_objid, + SUM(COALESCE(NULLIF(labor_cost,''),'0')::numeric + COALESCE(NULLIF(expenses,''),'0')::numeric) AS PLAN_COST + FROM customer_service_workingtime + GROUP BY parent_objid + ) AS CSW ON CSM.objid = CSW.parent_objid + LEFT OUTER JOIN ( + SELECT B.objid AS ROUTE_OBJID, + B.status AS APPR_STATUS, + CASE UPPER(B.status) + WHEN 'INPROCESS' THEN '결재중' + WHEN 'COMPLETE' THEN '결재완료' + WHEN 'REJECT' THEN '반려' + ELSE '' END AS APPR_STATUS_NAME, + A.objid AS APPROVAL_OBJID, + A.target_objid, + B.route_seq + FROM approval A, + (SELECT T1.* + FROM (SELECT target_objid, MAX(route_seq) AS route_seq + FROM route GROUP BY target_objid) T, + route T1 + WHERE T.target_objid = T1.target_objid + AND T.route_seq = T1.route_seq) B + WHERE A.objid = B.approval_objid + AND A.target_type = 'CSM' + ) AS APPR ON CSM.objid::numeric = APPR.target_objid + WHERE ${where} + ORDER BY CSM.regdate DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/cs/manage/save/route.ts b/src/app/api/cs/manage/save/route.ts new file mode 100644 index 0000000..aa8100a --- /dev/null +++ b/src/app/api/cs/manage/save/route.ts @@ -0,0 +1,151 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// CS관리 저장 (saveas.do → mergeCSM + deleteCSP/mergeCSP + deleteCSW/mergeCSW) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + let objId = String(body.objId || "").trim(); + const isNew = !objId; + if (isNew) objId = createObjectId(); + + // SERVICE_NO 자동 채번 (신규일 때만) + let serviceNo = String(body.service_no || "").trim(); + if (!serviceNo) { + const r = await client.query( + `SELECT 'CSM'||TO_CHAR(NOW(),'yy')::VARCHAR||'-'||LPAD(nextval('seq_as_no')::VARCHAR,4,'0') AS no` + ); + serviceNo = r.rows[0].no; + } + + const status = body.status && body.status !== "" ? body.status : "0000100"; + + // MERGE CSM + await client.query( + `INSERT INTO customer_service_mgmt ( + objid, service_no, product, contract_objid, cs_category, warranty, + manager_id, act_date, category_h, category_m, category_l, title, + before_contents, after_contents, writer, regdate, status, + total_sup_price, total_work_day, total_work_person, total_work_day_m, + total_labor_cost, total_expenses + ) VALUES ($1,$2,CASE WHEN $3='' THEN NULL ELSE $3 END,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,now(),$16,$17,$18,$19,$20,$21,$22) + ON CONFLICT (objid) DO UPDATE SET + service_no=EXCLUDED.service_no, + product=EXCLUDED.product, + contract_objid=EXCLUDED.contract_objid, + cs_category=EXCLUDED.cs_category, + warranty=EXCLUDED.warranty, + manager_id=EXCLUDED.manager_id, + act_date=EXCLUDED.act_date, + category_h=EXCLUDED.category_h, + category_m=EXCLUDED.category_m, + category_l=EXCLUDED.category_l, + title=EXCLUDED.title, + before_contents=EXCLUDED.before_contents, + after_contents=EXCLUDED.after_contents, + writer=EXCLUDED.writer, + regdate=now(), + status=EXCLUDED.status, + total_sup_price=EXCLUDED.total_sup_price, + total_work_day=EXCLUDED.total_work_day, + total_work_person=EXCLUDED.total_work_person, + total_work_day_m=EXCLUDED.total_work_day_m, + total_labor_cost=EXCLUDED.total_labor_cost, + total_expenses=EXCLUDED.total_expenses`, + [ + objId, + serviceNo, + body.product || "", + body.contract_objid || "", + body.cs_category || "", + body.warranty || "", + body.manager_id || "", + body.act_date || "", + body.category_h || "", + body.category_m || "", + body.category_l || "", + body.title || "", + body.before_contents || "", + body.after_contents || "", + body.writer || user.userId, + status, + String(body.TOTAL_SUP_PRICE || body.total_sup_price || "0").replace(/,/g, ""), + String(body.TOTAL_WORK_DAY || body.total_work_day || "0").replace(/,/g, ""), + String(body.TOTAL_WORK_PERSON || body.total_work_person || "0").replace(/,/g, ""), + String(body.TOTAL_WORK_DAY_M || body.total_work_day_m || "0").replace(/,/g, ""), + String(body.TOTAL_LABOR_COST || body.total_labor_cost || "0").replace(/,/g, ""), + String(body.TOTAL_EXPENSES || body.total_expenses || "0").replace(/,/g, ""), + ] + ); + + // CSP: 전체 삭제 후 재삽입 + await client.query(`DELETE FROM customer_service_part WHERE parent_objid = $1`, [objId]); + const parts: Array> = Array.isArray(body.parts) ? body.parts : []; + for (const p of parts) { + const partObjId = String(p.OBJID || "").trim() || createObjectId(); + await client.query( + `INSERT INTO customer_service_part + (objid, parent_objid, part_no, part_name, spec, qty, cur_qty, price, sup_price) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [ + partObjId, + objId, + String(p.PART_NO || ""), + String(p.PART_NAME || ""), + String(p.SPEC || ""), + String(p.QTY || "0").replace(/,/g, ""), + String(p.CUR_QTY || "0").replace(/,/g, ""), + String(p.PRICE || "0").replace(/,/g, ""), + String(p.SUP_PRICE || "0").replace(/,/g, ""), + ] + ); + } + + // CSW: 전체 삭제 후 재삽입 + await client.query(`DELETE FROM customer_service_workingtime WHERE parent_objid = $1`, [objId]); + const works: Array> = Array.isArray(body.works) ? body.works : []; + for (const w of works) { + const workObjId = String(w.OBJID || "").trim() || createObjectId(); + await client.query( + `INSERT INTO customer_service_workingtime + (objid, parent_objid, supply_objid, form_date, to_date, work_day, work_person, work_day_m, labor_cost, expenses) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + [ + workObjId, + objId, + String(w.SUPPLY_OBJID || ""), + String(w.FORM_DATE || ""), + String(w.TO_DATE || ""), + String(w.WORK_DAY || "0").replace(/,/g, ""), + String(w.WORK_PERSON || "0").replace(/,/g, ""), + String(w.WORK_DAY_M || "0").replace(/,/g, ""), + String(w.LABOR_COST || "0").replace(/,/g, ""), + String(w.EXPENSES || "0").replace(/,/g, ""), + ] + ); + } + + await client.query("COMMIT"); + return NextResponse.json({ + success: true, + message: isNew ? "등록되었습니다." : "수정되었습니다.", + objId, + serviceNo, + }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("CS save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/cs/route.ts b/src/app/api/cs/route.ts new file mode 100644 index 0000000..38ec02d --- /dev/null +++ b/src/app/api/cs/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// CS 메인 목록 (as_mng 기반 — customerServiceList.jsp 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`A.year = $${idx++}`); + params.push(body.year); + } + if (body.customer_name) { + conditions.push(`COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = A.custcd LIMIT 1), A.company_name) LIKE '%' || $${idx++} || '%'`); + params.push(body.customer_name); + } + + const where = conditions.join(" AND "); + + const rows = await queryRows( + `SELECT A.objid::text AS "OBJID", + A.as_no AS "CS_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = A.custcd LIMIT 1), A.company_name) AS "CUSTOMER_NAME", + COALESCE(A.product_name, (SELECT code_name FROM comm_code WHERE code_id = A.product_code LIMIT 1)) AS "PRODUCT_NAME", + A.release_date AS "RECEIPT_DATE", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = A.rec_type LIMIT 1), '') AS "CS_TYPE_NAME", + A.problem_contents AS "DESCRIPTION", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = A.status_cd LIMIT 1), A.status_cd) AS "STATUS_NAME", + COALESCE((SELECT user_name FROM user_info WHERE user_id = A.writer LIMIT 1), '') AS "CHARGER_NAME", + A.plan_date AS "COMPLETE_DATE" + FROM as_mng A + WHERE ${where} + ORDER BY A.reg_date DESC NULLS LAST, A.as_no DESC +`, + params + ); + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/cs/status/route.ts b/src/app/api/cs/status/route.ts new file mode 100644 index 0000000..aeb4226 --- /dev/null +++ b/src/app/api/cs/status/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// CS관리 현황 (asList_CS.jsp + getASDashboardList + getHeaderList 대응) +// 제품×프로젝트 기준으로 유상/무상 건수·비용을 집계하고, CATEGORY_H 유형별 카운트를 동적 컬럼으로 추가 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + + // 1) 동적 컬럼 헤더 (0000975 '조치내역서유형' 하위 재귀, LEV>1) + const parentCode = body.category_root_code || "0000975"; + const columnList = await queryRows( + `WITH RECURSIVE COM AS ( + SELECT parent_code_id, code_id, code_name AS name, code_cd, status, id, ext_val, + 'COL_'||code_id AS col_name, 1 AS lev + FROM comm_code + WHERE UPPER(status)='ACTIVE' AND parent_code_id = $1 + UNION ALL + SELECT cc.parent_code_id, cc.code_id, cc.code_name AS name, cc.code_cd, cc.status, cc.id, cc.ext_val, + 'COL_'||cc.code_id AS col_name, com.lev+1 + FROM comm_code cc JOIN COM ON COM.code_id = cc.parent_code_id + ) + SELECT + ROW_NUMBER() OVER (PARTITION BY parent_code_id ORDER BY code_id) AS "GROUP_SEQ", + (SELECT COUNT(1) FROM comm_code WHERE parent_code_id = C.parent_code_id) AS "GROUP_CNT", + (SELECT code_name FROM comm_code WHERE code_id = C.parent_code_id) AS "GROUP_NAME", + C.parent_code_id AS "PARENT_CODE_ID", + C.code_id AS "CODE_ID", + C.name AS "NAME", + C.col_name AS "COL_NAME" + FROM COM C + WHERE lev > 1 + ORDER BY C.parent_code_id, C.code_id`, + [parentCode] + ); + + // 2) 집계 데이터 (getASDashboardList) — 동적 컬럼 SUM 추가 + const conditions: string[] = ["CSM.status = '0000102'"]; // 결재완료 + const params: unknown[] = []; + let idx = 1; + + if (body.Year || body.year) { + conditions.push(`TO_CHAR(TO_DATE(CSM.act_date,'YYYY-MM-DD'),'YYYY') = $${idx++}`); + params.push(String(body.Year || body.year)); + } + if (body.product_cd) { + conditions.push(`CSM.product = $${idx++}`); + params.push(body.product_cd); + } + if (body.project_no) { + conditions.push(`CSM.contract_objid = $${idx++}`); + params.push(body.project_no); + } + if (body.project_nos && Array.isArray(body.project_nos) && body.project_nos.length > 0) { + const ph = body.project_nos.map(() => `$${idx++}`).join(","); + conditions.push(`CSM.contract_objid IN (${ph})`); + params.push(...body.project_nos); + } + if (body.warranty) { + conditions.push(`CSM.warranty = $${idx++}`); + params.push(body.warranty); + } + if (body.cs_category) { + conditions.push(`CSM.cs_category = $${idx++}`); + params.push(body.cs_category); + } + if (body.category_h) { + conditions.push(`CSM.category_h = $${idx++}`); + params.push(body.category_h); + } + + // 동적 컬럼 SUM 생성 — COL_NAME 은 영숫자+언더스코어만이므로 직접 삽입 안전 (CODE_ID도 서버 데이터) + const dynSum = columnList + .map((c) => { + const codeId = String(c.CODE_ID).replace(/[^A-Za-z0-9_-]/g, ""); + const col = String(c.COL_NAME).replace(/[^A-Za-z0-9_]/g, ""); + return `,SUM(CASE WHEN T.category_h = '${codeId}' THEN 1 ELSE 0 END) AS "${col}"`; + }) + .join("\n"); + + const rows = await queryRows( + `SELECT + T.product AS "PRODUCT", + (SELECT code_name FROM comm_code WHERE code_id = T.product) AS "PRODUCT_NAME", + T.project_no AS "PROJECT_NO", + SUM(CASE WHEN T.warranty = '0000157' THEN 1 ELSE 0 END) AS "WARRANTY1", + SUM(CASE WHEN T.warranty = '0000157' THEN T.cost ELSE 0 END) AS "COST1", + SUM(CASE WHEN T.warranty = '0000158' THEN 1 ELSE 0 END) AS "WARRANTY2", + SUM(CASE WHEN T.warranty = '0000158' THEN T.cost ELSE 0 END) AS "COST2" + ${dynSum} + FROM ( + SELECT CSM.product, + TRIM(CM.project_no) AS project_no, + CSM.warranty, + CSM.act_date, + CSM.category_h, + (COALESCE(NULLIF(CSM.total_labor_cost,''),'0')::numeric + + COALESCE(NULLIF(CSM.total_expenses,''),'0')::numeric) AS cost + FROM customer_service_mgmt AS CSM + LEFT OUTER JOIN project_mgmt AS CM ON CSM.contract_objid = CM.objid + WHERE ${conditions.join(" AND ")} + ) AS T + GROUP BY T.product, T.project_no + ORDER BY T.project_no DESC`, + params + ); + + return NextResponse.json({ + RESULTLIST: rows, + TOTAL_CNT: rows.length, + COLUMN_LIST: columnList, + }); +} diff --git a/src/app/api/dashboard/route.ts b/src/app/api/dashboard/route.ts new file mode 100644 index 0000000..8939f9c --- /dev/null +++ b/src/app/api/dashboard/route.ts @@ -0,0 +1,299 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 대시보드 (project.dashBoardMain.do + dashboard.getmainDash_* 대응) +// 데이터 소스: project_mgmt + contract_mgmt + planning_issue + pms_wbs_task + release_mgmt +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const year = String(body.year || new Date().getFullYear()); + + try { + // 1) 프로젝트 상태별 카운트 (원본 contractMgmt.projectCount 5상태 이식) + // 전체 = 계획미수립 + 진행중 + 지연 + 종료 (상호 배타적) + // 계획미수립: PLAN_START/END 자체가 없음 (act이 아니라 plan 기준!) + const projectStats = await queryOne>( + `WITH pm_base AS ( + SELECT PM.objid::text AS objid, + -- 셋업 완료율 (100%면 종료) + (SELECT CASE + WHEN COUNT(CASE WHEN COALESCE(parent_objid,'') != '' THEN 1 END) = 0 THEN 0 + ELSE ROUND((COUNT(CASE WHEN COALESCE(setup_act_end,'') != '' AND COALESCE(parent_objid,'') != '' THEN 1 END)::float + / COUNT(CASE WHEN COALESCE(parent_objid,'') != '' THEN 1 END) * 100)::numeric)::int + END + FROM setup_wbs_task WHERE contract_objid = PM.objid::text) AS setup_rate, + -- WBS 계획(PLAN_START/END) 존재 여부 + (SELECT COUNT(*) FROM pms_wbs_task WHERE contract_objid = PM.objid::text AND ( + (COALESCE(design_plan_start,'') != '' AND COALESCE(design_plan_end,'') != '') + OR (COALESCE(purchase_plan_start,'') != '' AND COALESCE(purchase_plan_end,'') != '') + OR (COALESCE(produce_plan_start,'') != '' AND COALESCE(produce_plan_end,'') != '') + )) AS wbs_plan_cnt, + -- 셋업 계획 존재 여부 + (SELECT COUNT(*) FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(setup_plan_start,'') != '' AND COALESCE(setup_plan_end,'') != '') AS setup_plan_cnt, + -- 지연 task 존재 여부 + (EXISTS (SELECT 1 FROM pms_wbs_task WHERE contract_objid = PM.objid::text AND ( + (COALESCE(design_plan_end,'') != '' AND TO_DATE(design_plan_end,'YYYY-MM-DD') < CURRENT_DATE AND COALESCE(design_act_end,'') = '') + OR (COALESCE(purchase_plan_end,'') != '' AND TO_DATE(purchase_plan_end,'YYYY-MM-DD') < CURRENT_DATE AND COALESCE(purchase_act_end,'') = '') + OR (COALESCE(produce_plan_end,'') != '' AND TO_DATE(produce_plan_end,'YYYY-MM-DD') < CURRENT_DATE AND COALESCE(produce_act_end,'') = '') + )) OR EXISTS (SELECT 1 FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(setup_plan_end,'') != '' AND TO_DATE(setup_plan_end,'YYYY-MM-DD') < CURRENT_DATE AND COALESCE(setup_act_end,'') = '') + ) AS has_delay + FROM project_mgmt PM + INNER JOIN contract_mgmt CM ON CM.objid = PM.contract_objid + WHERE CM.contract_result = '0000964' + -- 원본 Year 필터: 해당 연도가 WBS PLAN 범위 내에 포함되는 프로젝트 + AND $1 BETWEEN ( + SELECT LEAST( + MIN(TO_CHAR(TO_DATE(NULLIF(purchase_plan_start,''),'YYYY-MM-DD'),'YYYY')), + MIN(TO_CHAR(TO_DATE(NULLIF(produce_plan_start,''),'YYYY-MM-DD'),'YYYY')), + MIN(TO_CHAR(TO_DATE(NULLIF(design_plan_start,''),'YYYY-MM-DD'),'YYYY')), + (SELECT MIN(TO_CHAR(TO_DATE(NULLIF(setup_plan_start,''),'YYYY-MM-DD'),'YYYY')) + FROM setup_wbs_task S WHERE S.contract_objid = PM.objid::text) + ) + FROM pms_wbs_task O WHERE O.contract_objid = PM.objid::text) + AND ( + SELECT GREATEST( + MAX(TO_CHAR(TO_DATE(NULLIF(purchase_plan_end,''),'YYYY-MM-DD'),'YYYY')), + MAX(TO_CHAR(TO_DATE(NULLIF(produce_plan_end,''),'YYYY-MM-DD'),'YYYY')), + MAX(TO_CHAR(TO_DATE(NULLIF(design_plan_end,''),'YYYY-MM-DD'),'YYYY')), + (SELECT MAX(TO_CHAR(TO_DATE(NULLIF(setup_plan_end,''),'YYYY-MM-DD'),'YYYY')) + FROM setup_wbs_task S WHERE S.contract_objid = PM.objid::text) + ) + FROM pms_wbs_task O WHERE O.contract_objid = PM.objid::text) + ), + pm_cat AS ( + SELECT objid, + CASE + WHEN setup_rate >= 100 THEN '종료' + WHEN wbs_plan_cnt = 0 AND setup_plan_cnt = 0 THEN '계획미수립' + WHEN has_delay THEN '지연' + ELSE '진행중' + END AS cat + FROM pm_base + ) + SELECT + (SELECT COUNT(*) FROM pm_cat)::int AS "CNT_TOTAL", + (SELECT COUNT(*) FROM pm_cat WHERE cat = '계획미수립')::int AS "CNT_NOPLAN", + (SELECT COUNT(*) FROM pm_cat WHERE cat = '진행중')::int AS "CNT_ING", + (SELECT COUNT(*) FROM pm_cat WHERE cat = '지연')::int AS "CNT_DELAY", + (SELECT COUNT(*) FROM pm_cat WHERE cat = '종료')::int AS "CNT_END", + -- 이슈/비용 집계는 전체 기준 + COALESCE((SELECT COUNT(*) FROM planning_issue WHERE status = 'release' AND project_objid IN (SELECT objid FROM pm_cat)), 0)::int AS "ISSUE_TOTAL", + COALESCE((SELECT COUNT(*) FROM planning_issue WHERE status = 'release' AND project_objid IN (SELECT objid FROM pm_cat) + AND (COALESCE(design_result,'') = '' OR COALESCE(design_date,'') = '')), 0)::int AS "ISSUE_MISS" + `, + [year], + ) || {}; + + // 2) 제품별 수주현황 - pie chart (contractMgmt.getContractCNTByProduct 대응) + const productDist = await queryRows( + `SELECT + CM.product AS "CODE", + (SELECT code_name FROM comm_code WHERE code_id = CM.product LIMIT 1) AS "NAME", + COUNT(*)::int AS "CNT" + FROM contract_mgmt CM + WHERE SUBSTR(COALESCE(CM.contract_date,''), 1, 4) = $1 + AND CM.contract_result = '0000964' + AND CM.product IS NOT NULL AND CM.product != '' + GROUP BY CM.product + ORDER BY COUNT(*) DESC`, + [year], + ); + + // 3) 고객사별 수주현황 - pie chart (contractMgmt.getContractCNTBySupply 대응) + const supplyDist = await queryRows( + `SELECT + CM.customer_objid AS "CODE", + (SELECT supply_name FROM supply_mng WHERE objid::text = CM.customer_objid LIMIT 1) AS "NAME", + COUNT(*)::int AS "CNT" + FROM contract_mgmt CM + WHERE SUBSTR(COALESCE(CM.contract_date,''), 1, 4) = $1 + AND CM.contract_result = '0000964' + AND CM.customer_objid IS NOT NULL AND CM.customer_objid != '' + GROUP BY CM.customer_objid + ORDER BY COUNT(*) DESC`, + [year], + ); + + // 3) 월별 계약금액 (년도별 매출현황) + const monthlyContract = await queryRows( + `SELECT + SUBSTR(contract_date, 6, 2)::int AS "MONTH", + COALESCE(SUM(COALESCE(NULLIF(contract_price,'')::numeric, 0)), 0) AS "AMOUNT" + FROM contract_mgmt + WHERE SUBSTR(COALESCE(contract_date,''), 1, 4) = $1 + AND contract_result = '0000964' + AND SUBSTR(COALESCE(contract_date,''), 6, 2) ~ '^[0-9]{2}$' + GROUP BY SUBSTR(contract_date, 6, 2) + ORDER BY SUBSTR(contract_date, 6, 2)`, + [year], + ); + + // 4) 주요 프로젝트 리스트 + 진척율 + const projectList = await queryRows( + `SELECT + PM.objid::text AS "OBJID", + PM.project_no AS "PROJECT_NO", + PM.project_name AS "PROJECT_NAME", + (SELECT code_name FROM comm_code WHERE code_id = PM.category_cd LIMIT 1) AS "CATEGORY_NAME", + (SELECT supply_name FROM supply_mng WHERE objid::text = PM.customer_objid LIMIT 1) AS "CUSTOMER_NAME", + (SELECT code_name FROM comm_code WHERE code_id = PM.product LIMIT 1) AS "PRODUCT_NAME", + (SELECT code_name FROM comm_code WHERE code_id = PM.manufacture_plant LIMIT 1) AS "MANUFACTURE_PLANT_NAME", + PM.contract_del_date AS "CONTRACT_DEL_DATE", + PM.req_del_date AS "REQ_DEL_DATE", + COALESCE(NULLIF(PM.contract_price_currency,'')::numeric, 0) AS "CONTRACT_PRICE", + CASE WHEN (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) = 0 THEN 0 + ELSE ROUND( + ((SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text AND COALESCE(design_act_end,'') != '') + +(SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text AND COALESCE(purchase_act_end,'') != '') + +(SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text AND COALESCE(produce_act_end,'') != ''))::numeric + / ((SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) * 3)::numeric * 100, 1) + END AS "TOTAL_RATE", + -- 이슈: 발생/조치/미결 + (SELECT COUNT(1) FROM planning_issue WHERE project_objid = PM.objid::text AND status = 'release') AS "ISSUE_CNT", + (SELECT COUNT(1) FROM planning_issue WHERE project_objid = PM.objid::text AND status = 'release' + AND COALESCE(design_result,'') != '' AND COALESCE(design_date,'') != '') AS "ISSUE_DONE_CNT", + (SELECT COUNT(1) FROM planning_issue WHERE project_objid = PM.objid::text AND status = 'release') + - (SELECT COUNT(1) FROM planning_issue WHERE project_objid = PM.objid::text AND status = 'release' + AND COALESCE(design_result,'') != '' AND COALESCE(design_date,'') != '') AS "ISSUE_MISS_CNT", + -- 투입원가 항목별 (원본 costMgmt.costTotaltGridList 이식, 노무비 실적은 WORK_DIARY 복잡도로 0 처리) + COALESCE((SELECT material_cost_goal::numeric FROM input_cost_goal WHERE contract_objid::text = PM.objid::text LIMIT 1), 0) AS "MATERIAL_COST_GOAL", + COALESCE((SELECT labor_cost_goal::numeric FROM input_cost_goal WHERE contract_objid::text = PM.objid::text LIMIT 1), 0) AS "LABOR_COST_GOAL", + COALESCE((SELECT expense_cost_goal::numeric FROM input_cost_goal WHERE contract_objid::text = PM.objid::text LIMIT 1), 0) AS "EXPENSE_COST_GOAL", + -- 재료비 실적 = purchase_order_master(approvalComplete) total_supply_unit_price 합 + COALESCE((SELECT SUM(COALESCE(NULLIF(total_supply_unit_price,'')::numeric, 0)) + FROM purchase_order_master + WHERE contract_mgmt_objid = PM.objid AND status = 'approvalComplete'), 0) AS "ACCRUAL_MATERIAL_COST", + -- 노무비 실적 (WORK_DIARY 기반 계산 복잡 — 추후 보강) + 0 AS "LABOR_COST_ACTUAL", + -- 경비 실적 = expense_master + expense_detail SETTLE_AMOUNT 합 + COALESCE((SELECT SUM(settle) + FROM (SELECT (SUM(COALESCE(ED.card_used,'0')::numeric) + SUM(COALESCE(ED.cash_used,'0')::numeric) - SUM(COALESCE(ED.payment,'0')::numeric)) AS settle + FROM expense_master EM + LEFT OUTER JOIN expense_detail ED ON ED.expense_master_objid = EM.expense_master_objid + WHERE EM.project_mgmt_objid = PM.objid::text + GROUP BY EM.expense_master_objid) s), 0) AS "ACCRUAL_EXPENSE", + RM.release_date AS "RELEASE_DATE", + -- 셋업 완료일 (setup_wbs_task 중 마지막 act_end) + (SELECT MAX(setup_act_end) FROM setup_wbs_task + WHERE contract_objid = PM.objid::text AND COALESCE(parent_objid,'') != '' + AND COALESCE(setup_act_end,'') != '') AS "SETUP_DONE_DATE", + -- 셋업 진척율 + COALESCE((SELECT CASE + WHEN COUNT(CASE WHEN COALESCE(parent_objid,'') != '' THEN 1 END) = 0 THEN 0 + ELSE ROUND((COUNT(CASE WHEN COALESCE(setup_act_end,'') != '' AND COALESCE(parent_objid,'') != '' THEN 1 END)::float + / COUNT(CASE WHEN COALESCE(parent_objid,'') != '' THEN 1 END) * 100)::numeric, 1) END + FROM setup_wbs_task WHERE contract_objid = PM.objid::text), 0) AS "SETUP_RATE", + -- 5상태 분류 (원본 projectCount 로직, 계획미수립 = PLAN_START/END 없음) + CASE + WHEN COALESCE((SELECT CASE + WHEN COUNT(CASE WHEN COALESCE(parent_objid,'') != '' THEN 1 END) = 0 THEN 0 + ELSE ROUND((COUNT(CASE WHEN COALESCE(setup_act_end,'') != '' AND COALESCE(parent_objid,'') != '' THEN 1 END)::float + / COUNT(CASE WHEN COALESCE(parent_objid,'') != '' THEN 1 END) * 100)::numeric)::int END + FROM setup_wbs_task WHERE contract_objid = PM.objid::text), 0) >= 100 THEN '종료' + WHEN (SELECT COUNT(*) FROM pms_wbs_task WHERE contract_objid = PM.objid::text AND ( + (COALESCE(design_plan_start,'') != '' AND COALESCE(design_plan_end,'') != '') + OR (COALESCE(purchase_plan_start,'') != '' AND COALESCE(purchase_plan_end,'') != '') + OR (COALESCE(produce_plan_start,'') != '' AND COALESCE(produce_plan_end,'') != '') + )) = 0 + AND (SELECT COUNT(*) FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(setup_plan_start,'') != '' AND COALESCE(setup_plan_end,'') != '') = 0 THEN '계획미수립' + WHEN (EXISTS (SELECT 1 FROM pms_wbs_task WHERE contract_objid = PM.objid::text AND ( + (COALESCE(design_plan_end,'') != '' AND TO_DATE(design_plan_end,'YYYY-MM-DD') < CURRENT_DATE AND COALESCE(design_act_end,'') = '') + OR (COALESCE(purchase_plan_end,'') != '' AND TO_DATE(purchase_plan_end,'YYYY-MM-DD') < CURRENT_DATE AND COALESCE(purchase_act_end,'') = '') + OR (COALESCE(produce_plan_end,'') != '' AND TO_DATE(produce_plan_end,'YYYY-MM-DD') < CURRENT_DATE AND COALESCE(produce_act_end,'') = '') + )) OR EXISTS (SELECT 1 FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(setup_plan_end,'') != '' AND TO_DATE(setup_plan_end,'YYYY-MM-DD') < CURRENT_DATE AND COALESCE(setup_act_end,'') = '')) + THEN '지연' + ELSE '진행중' + END AS "STATUS_TITLE" + FROM project_mgmt PM + INNER JOIN contract_mgmt CM ON CM.objid = PM.contract_objid + LEFT OUTER JOIN release_mgmt RM ON RM.parent_objid = PM.objid::text + WHERE CM.contract_result = '0000964' + AND $1 BETWEEN ( + SELECT LEAST( + MIN(TO_CHAR(TO_DATE(NULLIF(purchase_plan_start,''),'YYYY-MM-DD'),'YYYY')), + MIN(TO_CHAR(TO_DATE(NULLIF(produce_plan_start,''),'YYYY-MM-DD'),'YYYY')), + MIN(TO_CHAR(TO_DATE(NULLIF(design_plan_start,''),'YYYY-MM-DD'),'YYYY')), + (SELECT MIN(TO_CHAR(TO_DATE(NULLIF(setup_plan_start,''),'YYYY-MM-DD'),'YYYY')) + FROM setup_wbs_task S WHERE S.contract_objid = PM.objid::text) + ) + FROM pms_wbs_task O WHERE O.contract_objid = PM.objid::text) + AND ( + SELECT GREATEST( + MAX(TO_CHAR(TO_DATE(NULLIF(purchase_plan_end,''),'YYYY-MM-DD'),'YYYY')), + MAX(TO_CHAR(TO_DATE(NULLIF(produce_plan_end,''),'YYYY-MM-DD'),'YYYY')), + MAX(TO_CHAR(TO_DATE(NULLIF(design_plan_end,''),'YYYY-MM-DD'),'YYYY')), + (SELECT MAX(TO_CHAR(TO_DATE(NULLIF(setup_plan_end,''),'YYYY-MM-DD'),'YYYY')) + FROM setup_wbs_task S WHERE S.contract_objid = PM.objid::text) + ) + FROM pms_wbs_task O WHERE O.contract_objid = PM.objid::text) + ORDER BY PM.regdate DESC +`, + [year], + ); + + // 5) 3년치 영업현황 (getYearGoalInfo 이식): 수주건수 국내/해외/비율, 계약금액(억원), 영업목표, 달성율 + const years = [Number(year), Number(year) - 1, Number(year) - 2].map(String); + const yearGoalInfo = await Promise.all( + years.map(async (y) => { + const row = await queryOne>( + `WITH W_CM AS ( + SELECT area_cd, + CASE WHEN COALESCE(NULLIF(contract_price,''),'0') != '0' THEN contract_price + WHEN contract_currency = '0001566' AND COALESCE(NULLIF(contract_price_currency,''),'0') != '0' THEN contract_price_currency + ELSE COALESCE(NULLIF(contract_price,''),'0') + END AS price + FROM contract_mgmt + WHERE contract_result = '0000964' + AND TO_CHAR(TO_DATE(contract_date, 'YYYY-MM-DD'), 'YYYY') = $1 + ) + SELECT + $1 AS "YEAR", + (SELECT COUNT(*) FROM contract_mgmt WHERE TO_CHAR(TO_DATE(contract_date,'YYYY-MM-DD'),'YYYY') = $1) AS "CONTRACT_CNT_YEAR_ALL", + (SELECT COUNT(*) FROM W_CM)::int AS "CONTRACT_CNT_YEAR", + (SELECT COUNT(*) FROM W_CM WHERE area_cd = '0001220')::int AS "CONTRACT_CNT_YEAR_IN", + (SELECT COUNT(*) FROM W_CM WHERE area_cd = '0001221')::int AS "CONTRACT_CNT_YEAR_OUT", + COALESCE((SELECT (SUM(COALESCE(NULLIF(price,'')::numeric, 0))) / 100000000 FROM W_CM)::numeric(18,1), 0) AS "CONTRACT_COST_YEAR", + COALESCE((SELECT price::numeric FROM pms_pjt_year_goal WHERE year = $1 LIMIT 1), 0) AS "PRICE", + (SELECT objid::text FROM pms_pjt_year_goal WHERE year = $1 LIMIT 1) AS "YEAR_GOAL_OBJID"`, + [y], + ); + if (!row) return { YEAR: y }; + const total = Number(row.CONTRACT_CNT_YEAR_ALL || 0); + const ok = Number(row.CONTRACT_CNT_YEAR || 0); + const cost = Number(row.CONTRACT_COST_YEAR || 0); + const goal = Number(row.PRICE || 0); + return { + ...row, + CONTRACT_CNT_YEAR_RATE: total > 0 ? Math.round((ok / total) * 1000) / 10 : 0, + GOAL_RATE: goal > 0 ? Math.round((cost / goal) * 1000) / 10 : 0, + }; + }) + ); + + return NextResponse.json({ + projectStats: projectStats || {}, + productDist, + supplyDist, + monthlyContract, + projectList, + yearGoalInfo, + }); + } catch (error) { + console.error("Dashboard error:", error); + return NextResponse.json({ + projectStats: {}, + productDist: [], + supplyDist: [], + monthlyContract: [], + projectList: [], + yearGoalInfo: [], + }); + } +} diff --git a/src/app/api/delivery/acceptance/detail/route.ts b/src/app/api/delivery/acceptance/detail/route.ts new file mode 100644 index 0000000..eeee050 --- /dev/null +++ b/src/app/api/delivery/acceptance/detail/route.ts @@ -0,0 +1,151 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne, queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 입고결과등록 팝업 상세 조회 +// 원본: +// - purchaseOrderService.getPurchaseOrderMasterInfo → info +// - purchaseOrder.getPurchaseOrderDeliveryTargetPartList → partList (발주파트+기입고/미입고) +// - supplyChainMgmt.arrivalResultList → arrivalList (차수별 arrival_plan) +// - purchaseOrder.selectPurchaseOrderMasterList → multiMasterList (동시발주 슬레이브) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId가 필요합니다." }); + + // 1. 발주 마스터 정보 (readonly 표시용) + const info = await queryOne( + `SELECT POM.objid::text AS "OBJID", + POM.purchase_order_no AS "PURCHASE_ORDER_NO", + POM.purchase_order_no_org AS "PURCHASE_ORDER_NO_ORG", + POM.title AS "TITLE", + POM.delivery_date AS "DELIVERY_DATE", + TO_CHAR(POM.regdate, 'YYYY-MM-DD') AS "REGDATE", + POM.type AS "TYPE", + (SELECT code_name FROM comm_code WHERE code_id = POM.type LIMIT 1) AS "TYPE_NAME", + POM.order_type_cd AS "ORDER_TYPE_CD", + (SELECT code_name FROM comm_code WHERE code_id = POM.order_type_cd LIMIT 1) AS "ORDER_TYPE_CD_NAME", + POM.delivery_place AS "DELIVERY_PLACE", + (SELECT code_name FROM comm_code WHERE code_id = POM.delivery_place LIMIT 1) AS "DELIVERY_PLACE_NAME", + POM.inspect_method AS "INSPECT_METHOD", + (SELECT code_name FROM comm_code WHERE code_id = POM.inspect_method LIMIT 1) AS "INSPECT_METHOD_NAME", + POM.payment_terms AS "PAYMENT_TERMS", + (SELECT code_name FROM comm_code WHERE code_id = POM.payment_terms LIMIT 1) AS "PAYMENT_TERMS_NAME", + POM.total_price_txt AS "TOTAL_PRICE_TXT", + POM.remark AS "REMARK", + POM.status AS "STATUS", + COALESCE(POM.multi_yn, '') AS "MULTI_YN", + COALESCE(POM.multi_master_yn, '') AS "MULTI_MASTER_YN", + POM.multi_master_objid AS "MULTI_MASTER_OBJID", + POM.bom_report_objid AS "BOM_REPORT_OBJID", + POM.contract_mgmt_objid AS "CONTRACT_MGMT_OBJID", + COALESCE(CM.project_no, CM.contract_no) AS "PROJECT_NO", + CM.customer_project_name AS "CUSTOMER_PROJECT_NAME", + POM.unit_code AS "UNIT_CODE", + (SELECT COALESCE(O.unit_no,'') || '-' || COALESCE(O.task_name,'') FROM pms_wbs_task O WHERE O.objid = POM.unit_code LIMIT 1) AS "UNIT_NAME", + POM.partner_objid AS "PARTNER_OBJID", + (SELECT supply_name FROM admin_supply_mng WHERE objid::text = POM.partner_objid LIMIT 1) AS "PARTNER_NAME", + (SELECT bus_reg_no FROM admin_supply_mng WHERE objid::text = POM.partner_objid LIMIT 1) AS "SUPPLY_BUS_NO", + (SELECT supply_address FROM admin_supply_mng WHERE objid::text = POM.partner_objid LIMIT 1) AS "SUPPLY_ADDR", + POM.supply_user_name AS "SUPPLY_USER_NAME", + POM.supply_user_hp AS "SUPPLY_USER_HP", + POM.supply_user_tel AS "SUPPLY_USER_TEL", + POM.supply_user_fax AS "SUPPLY_USER_FAX", + POM.supply_user_email AS "SUPPLY_USER_EMAIL", + POM.sales_mng_user_id AS "SALES_MNG_USER_ID", + (SELECT user_name FROM user_info WHERE user_id = POM.sales_mng_user_id LIMIT 1) AS "SALES_MNG_USER", + (SELECT cell_phone FROM user_info WHERE user_id = POM.sales_mng_user_id LIMIT 1) AS "SALES_MNG_USER_CELL_PHONE" + FROM purchase_order_master POM + LEFT JOIN project_mgmt CM ON POM.contract_mgmt_objid = CM.objid + WHERE POM.objid::text = $1`, + [String(objId)], + ); + if (!info) { + return NextResponse.json({ success: false, message: "발주서를 찾을 수 없습니다." }); + } + + // 2. 파트 목록 + 기입고/미입고 수량 + const partList = await queryRows( + `SELECT + POP.objid::text AS "ORDER_PART_OBJID", + POP.part_objid AS "PART_OBJID", + POP.ld_part_objid AS "LD_PART_OBJID", + CASE WHEN POM.type IN ('0001070','0001069') THEN COALESCE(PM.part_no, POP.part_no) ELSE POP.part_no END AS "PART_NO", + POP.part_name AS "PART_NAME", + COALESCE(PM.material, '') AS "MATERIAL", + POP.spec AS "SPEC", + POP.maker AS "MAKER", + (SELECT code_name FROM comm_code WHERE code_id = POP.unit LIMIT 1) AS "UNIT_TITLE", + POP.order_qty AS "ORDER_QTY", + POP.real_order_qty AS "REAL_ORDER_QTY", + POM.delivery_date AS "POM_DELIVERY_DATE", + COALESCE(AP.total_delivery_qty, '0') AS "TOTAL_DELIVERY_QTY", + (COALESCE(POP.real_order_qty,'0')::numeric - COALESCE(AP.total_delivery_qty, '0')::numeric) AS "NON_ARRIVAL_QTY" + FROM purchase_order_master POM + INNER JOIN purchase_order_part POP ON POM.objid::text = POP.purchase_order_master_objid + LEFT JOIN part_mng PM ON POP.part_objid = PM.objid::text + LEFT JOIN ( + SELECT order_part_objid, + SUM(COALESCE(receipt_qty,'0')::numeric)::text AS total_delivery_qty + FROM arrival_plan + WHERE parent_objid = $1 + GROUP BY order_part_objid + ) AP ON AP.order_part_objid = POP.objid::text + WHERE POM.objid::text = $1 + ORDER BY PM.part_no NULLS LAST, POP.regdate DESC`, + [String(objId)], + ); + + // 3. 기존 차수별 입고계획 (arrival_plan) + const arrivalList = await queryRows( + `SELECT + AP.objid::text AS "OBJID", + AP.part_objid AS "PART_OBJID", + AP.order_part_objid AS "ORDER_PART_OBJID", + AP.arrival_qty AS "ARRIVAL_QTY", + AP.arrival_plan_date AS "ARRIVAL_PLAN_DATE", + AP.group_seq AS "GROUP_SEQ", + AP.seq AS "SEQ", + AP.receipt_qty AS "RECEIPT_QTY", + AP.receipt_date AS "RECEIPT_DATE", + AP.location AS "LOCATION", + AP.sub_location AS "SUB_LOCATION", + AP.error_qty AS "ERROR_QTY", + AP.error_reason AS "ERROR_REASON", + AP.attribution AS "ATTRIBUTION", + AP.inventory_status AS "INVENTORY_STATUS", + IM.objid::text AS "INVOICE_OBJID" + FROM arrival_plan AP + LEFT JOIN invoice_mgmt IM ON IM.parent_objid::text = AP.parent_objid AND IM.group_seq = AP.group_seq + WHERE AP.parent_objid = $1 + ORDER BY NULLIF(AP.seq,'')::numeric NULLS LAST, AP.objid`, + [String(objId)], + ); + + // 4. 동시발주 슬레이브 리스트 (현재 건이 마스터인 경우) + let multiMasterList: Record[] = []; + if (String(info.MULTI_MASTER_YN) === "Y") { + multiMasterList = await queryRows( + `SELECT POM.objid::text AS "OBJID", + POM.contract_mgmt_objid::text AS "CONTRACT_MGMT_OBJID", + POM.unit_code AS "UNIT_CODE", + (SELECT project_no FROM project_mgmt CNT WHERE CNT.objid = POM.contract_mgmt_objid LIMIT 1) AS "CONTRACT_NO", + POM.delivery_date AS "DELIVERY_PLAN_DATE", + (SELECT SUM(COALESCE(real_order_qty,'0')::numeric)::text FROM purchase_order_part WHERE purchase_order_master_objid = POM.objid::text) AS "DELIVERY_PLAN_QTY" + FROM purchase_order_master POM + WHERE POM.multi_master_objid = $1 + ORDER BY "CONTRACT_NO" DESC`, + [String(objId)], + ); + } + + return NextResponse.json({ + success: true, + info, + partList, + arrivalList, + multiMasterList, + }); +} diff --git a/src/app/api/delivery/acceptance/route.ts b/src/app/api/delivery/acceptance/route.ts new file mode 100644 index 0000000..59c5436 --- /dev/null +++ b/src/app/api/delivery/acceptance/route.ts @@ -0,0 +1,205 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: purchaseOrder.xml#deliveryMngList_new +// 입고관리 > 입고결과등록 목록 +// - 결재완료 발주서만 (status = 'approvalComplete') +// - 동시발주 마스터 + 일반 건만 (슬레이브 제외) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + + const conditions: string[] = [ + "POM.status = 'approvalComplete'", + "(COALESCE(POM.multi_master_yn,'') = 'Y' OR (COALESCE(POM.multi_master_yn,'') != 'Y' AND COALESCE(POM.multi_yn,'') != 'Y'))", + ]; + const params: unknown[] = []; + let idx = 1; + + // Year: 최종입고일(MAX receipt_date) 기준 — 원본과 동일 + if (body.year) { + conditions.push( + `TO_CHAR(TO_DATE(S1.cur_delivery_date, 'YYYY-MM-DD'), 'YYYY') = $${idx++}`, + ); + params.push(body.year); + } + if (body.customer_cd) { + conditions.push(`CM.customer_objid::text = $${idx++}`); + params.push(body.customer_cd); + } + if (body.customer_project_name) { + conditions.push( + `TRIM(UPPER(CM.customer_project_name)) LIKE '%' || TRIM(UPPER($${idx++})) || '%'`, + ); + params.push(body.customer_project_name); + } + // project_nos: 복수 objid (array 또는 CSV 문자열) + const projectNos: string[] = Array.isArray(body.project_nos) + ? body.project_nos.filter(Boolean).map(String) + : body.project_nos + ? String(body.project_nos).split(",").filter(Boolean) + : []; + if (projectNos.length > 0) { + const placeholders = projectNos.map(() => `$${idx++}`).join(", "); + conditions.push(`CM.objid::text IN (${placeholders})`); + params.push(...projectNos); + } + if (body.unit_code) { + conditions.push(`POM.unit_code = $${idx++}`); + params.push(body.unit_code); + } + if (body.po_client_id) { + conditions.push(`POM.po_client_id = $${idx++}`); + params.push(body.po_client_id); + } + if (body.purchase_order_no) { + conditions.push( + `TRIM(UPPER(POM.purchase_order_no)) LIKE '%' || TRIM(UPPER($${idx++})) || '%'`, + ); + params.push(body.purchase_order_no); + } + if (body.type) { + conditions.push(`POM.type = $${idx++}`); + params.push(body.type); + } + if (body.delivery_start_date) { + conditions.push( + `TO_DATE(POM.delivery_date, 'YYYY-MM-DD') >= TO_DATE($${idx++}, 'YYYY-MM-DD')`, + ); + params.push(body.delivery_start_date); + } + if (body.delivery_end_date) { + conditions.push( + `TO_DATE(POM.delivery_date, 'YYYY-MM-DD') <= TO_DATE($${idx++}, 'YYYY-MM-DD')`, + ); + params.push(body.delivery_end_date); + } + if (body.partner_objid) { + conditions.push(`POM.partner_objid = $${idx++}`); + params.push(body.partner_objid); + } + // sales_mng_user_ids: 복수 user_id + const userIds: string[] = Array.isArray(body.sales_mng_user_ids) + ? body.sales_mng_user_ids.filter(Boolean).map(String) + : body.sales_mng_user_ids + ? String(body.sales_mng_user_ids).split(",").filter(Boolean) + : []; + if (userIds.length > 0) { + const placeholders = userIds.map(() => `$${idx++}`).join(", "); + conditions.push(`POM.sales_mng_user_id IN (${placeholders})`); + params.push(...userIds); + } + if (body.reg_start_date) { + conditions.push(`TO_CHAR(POM.regdate, 'YYYY-MM-DD') >= $${idx++}`); + params.push(body.reg_start_date); + } + if (body.reg_end_date) { + conditions.push(`TO_CHAR(POM.regdate, 'YYYY-MM-DD') <= $${idx++}`); + params.push(body.reg_end_date); + } + if (body.delivery_status) { + conditions.push( + `(CASE WHEN 0 >= (COALESCE((SELECT SUM(COALESCE(O.real_order_qty,'0')::numeric) FROM purchase_order_part O WHERE POM.objid::text = O.purchase_order_master_objid), 0) - COALESCE(S1.total_delivery_qty, 0)) THEN '입고완료' + WHEN TO_CHAR(NOW(), 'YYYY-MM-DD') > POM.delivery_date THEN '지연' + ELSE '입고중' END) = $${idx++}`, + ); + params.push(body.delivery_status); + } + if (body.SEARCH_PART_NO) { + conditions.push( + `EXISTS (SELECT 1 FROM purchase_order_part POP WHERE POP.purchase_order_master_objid = POM.objid::text + AND TRIM(UPPER(COALESCE(POP.part_no,''))) LIKE '%' || TRIM(UPPER($${idx++})) || '%')`, + ); + params.push(body.SEARCH_PART_NO); + } + if (body.SEARCH_PART_NAME) { + conditions.push( + `EXISTS (SELECT 1 FROM purchase_order_part POP WHERE POP.purchase_order_master_objid = POM.objid::text + AND TRIM(UPPER(COALESCE(POP.part_name,''))) LIKE '%' || TRIM(UPPER($${idx++})) || '%')`, + ); + params.push(body.SEARCH_PART_NAME); + } + if (body.SEARCH_PART_SPEC) { + conditions.push( + `EXISTS (SELECT 1 FROM purchase_order_part POP WHERE POP.purchase_order_master_objid = POM.objid::text + AND TRIM(UPPER(COALESCE(POP.spec,''))) LIKE '%' || TRIM(UPPER($${idx++})) || '%')`, + ); + params.push(body.SEARCH_PART_SPEC); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT POM.objid::text AS "OBJID", + TO_CHAR(POM.regdate, 'YYYY') AS "POM_YEAR", + (SELECT supply_name FROM supply_mng SM WHERE SM.objid::text = CM.customer_objid LIMIT 1) AS "CUSTOMER_NAME", + CM.customer_project_name AS "CUSTOMER_PROJECT_NAME", + (SELECT COALESCE(O.unit_no,'') || '-' || COALESCE(O.task_name,'') FROM pms_wbs_task O WHERE O.objid = POM.unit_code LIMIT 1) AS "UNIT_NAME", + COALESCE(CM.project_no, CM.contract_no) AS "PROJECT_NO", + POM.purchase_order_no AS "PURCHASE_ORDER_NO", + POM.title AS "TITLE", + POM.delivery_place AS "DELIVERY_PLACE", + (SELECT code_name FROM comm_code WHERE code_id = POM.delivery_place LIMIT 1) AS "DELIVERY_PLACE_NAME", + POM.inspect_method AS "INSPECT_METHOD", + (SELECT code_name FROM comm_code WHERE code_id = POM.inspect_method LIMIT 1) AS "INSPECT_METHOD_NAME", + POM.payment_terms AS "PAYMENT_TERMS", + (SELECT code_name FROM comm_code WHERE code_id = POM.payment_terms LIMIT 1) AS "PAYMENT_TERMS_NAME", + POM.delivery_date AS "DELIVERY_DATE", + POM.partner_objid AS "PARTNER_OBJID", + (SELECT supply_name FROM admin_supply_mng WHERE objid::text = POM.partner_objid LIMIT 1) AS "PARTNER_NAME", + POM.sales_mng_user_id AS "SALES_MNG_USER_ID", + (SELECT user_name FROM user_info WHERE user_id = POM.sales_mng_user_id LIMIT 1) AS "SALES_MNG_USER_NAME", + TO_CHAR(POM.regdate, 'YYYY-MM-DD') AS "REGDATE", + POM.total_price AS "TOTAL_PRICE", + POM.discount_price AS "DISCOUNT_PRICE", + POM.total_supply_unit_price AS "TOTAL_SUPPLY_UNIT_PRICE", + POM.nego_rate AS "NEGO_RATE", + COALESCE(POM.multi_master_yn,'') AS "MULTI_MASTER_YN", + COALESCE(POM.multi_yn,'') AS "MULTI_YN", + CASE WHEN COALESCE(POM.multi_master_yn,'') = 'Y' THEN '' ELSE COALESCE(POM.multi_yn,'') END AS "MULTI_YN_MAKED", + COALESCE((SELECT SUM(COALESCE(O.real_order_qty,'0')::numeric) FROM purchase_order_part O WHERE POM.objid::text = O.purchase_order_master_objid), 0) AS "TOTAL_PO_QTY", + S1.cur_delivery_date AS "CUR_DELIVERY_DATE", + COALESCE(S1.total_delivery_qty, 0) AS "TOTAL_DELIVERY_QTY", + (COALESCE((SELECT SUM(COALESCE(O.real_order_qty,'0')::numeric) FROM purchase_order_part O WHERE POM.objid::text = O.purchase_order_master_objid), 0) + - COALESCE(S1.total_delivery_qty, 0)) AS "NON_DELIVERY_QTY", + (CASE + WHEN 0 >= (COALESCE((SELECT SUM(COALESCE(O.real_order_qty,'0')::numeric) FROM purchase_order_part O WHERE POM.objid::text = O.purchase_order_master_objid), 0) - COALESCE(S1.total_delivery_qty, 0)) + THEN '입고완료' + WHEN TO_CHAR(NOW(), 'YYYY-MM-DD') > POM.delivery_date THEN '지연' + ELSE '입고중' + END) AS "DELIVERY_STATUS", + POM.type AS "TYPE", + (SELECT code_name FROM comm_code WHERE code_id = POM.type LIMIT 1) AS "TYPE_NAME", + POM.order_type_cd AS "ORDER_TYPE_CD", + (SELECT code_name FROM comm_code WHERE code_id = POM.order_type_cd LIMIT 1) AS "ORDER_TYPE_CD_NAME", + (SELECT UI.user_name FROM arrival_plan AP JOIN user_info UI ON UI.user_id = AP.receiver_id + WHERE AP.parent_objid = POM.objid::text AND AP.receipt_date IS NOT NULL AND AP.receipt_date <> '' + ORDER BY AP.receipt_date DESC LIMIT 1) AS "CUR_RECEIVER_NAME" + FROM purchase_order_master POM + LEFT OUTER JOIN ( + SELECT POP.purchase_order_master_objid, + MAX(DH.receipt_date) AS cur_delivery_date, + SUM(COALESCE(DH.receipt_qty,'0')::numeric) AS total_delivery_qty + FROM purchase_order_part POP + LEFT OUTER JOIN arrival_plan DH ON POP.objid::text = DH.order_part_objid + GROUP BY POP.purchase_order_master_objid + ) S1 ON POM.objid::text = S1.purchase_order_master_objid + LEFT OUTER JOIN project_mgmt CM ON POM.contract_mgmt_objid = CM.objid + WHERE ${where} + ORDER BY POM.regdate DESC + `; + + try { + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); + } catch (e) { + console.error("delivery/acceptance:", e); + return NextResponse.json( + { success: false, message: "조회 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/delivery/acceptance/save/route.ts b/src/app/api/delivery/acceptance/save/route.ts new file mode 100644 index 0000000..bfe9c99 --- /dev/null +++ b/src/app/api/delivery/acceptance/save/route.ts @@ -0,0 +1,374 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { PoolClient } from "pg"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 입고결과 저장 (원본: purchaseOrder/saveDeliveryInfo.do → supplyChainMgmt.saveDeliveryInfo) +// 처리 순서: +// 1) 파트×차수 행마다 ARRIVAL_PLAN UPSERT +// 2) RECEIPT_QTY > RECEIPT_INV_QTY(기입고) 인 경우에만 inventory 반영 +// - ARRIVAL_PLAN.INVENTORY_STATUS='Y' 로 마킹 +// - INVENTORY_MGMT(contract_objid, unit, part_objid) UPSERT +// - 단일발주: INVENTORY_MGMT_IN 1행 +// - 동시발주(MULTI_YN='Y'): 마스터부터 슬레이브 순으로 프로젝트별 ORDER_QTY 만큼 분배 +// +// Request body 형식: +// { +// ORDER_OBJID / PARENT_OBJID : 발주 마스터 objId (필수) +// TYPE : POM.type +// MULTI_YN : 'Y' | 'N' +// CONTRACT_MGMT_OBJID : 프로젝트 objId +// UNIT_CODE : 유닛 +// // 파트 정보 (DETAIL_GROUP 번호로 그룹핑) +// parts: [{ ORDER_PART_OBJID, PART_OBJID, LD_PART_OBJID, ORDER_QTY, REAL_ORDER_QTY }] +// // 차수별 입고 행들 (파트 수 × 차수 수) +// items: [{ +// OBJID?, GROUP_SEQ, SEQ, DETAIL_GROUP, // DETAIL_GROUP = 파트 index (1-based) +// ARRIVAL_QTY, ARRIVAL_PLAN_DATE, +// RECEIPT_QTY, RECEIPT_INV_QTY, RECEIPT_DATE, +// LOCATION, SUB_LOCATION, +// INVENTORY_STATUS, +// }] +// } +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + + // 구(舊) 호환 (단건 입고): body.items 없을 때 body.objId 단건 처리 + const legacyItems: Record[] = Array.isArray(body.items) ? body.items : []; + if (legacyItems.length === 0 && !body.parts && body.objId) { + return legacySingleSave(body, user.userId); + } + + const orderObjId: string = String(body.ORDER_OBJID ?? body.PARENT_OBJID ?? body.objId ?? ""); + const parentObjId: string = String(body.PARENT_OBJID ?? body.ORDER_OBJID ?? orderObjId); + const contractMgmtObjId: string = String(body.CONTRACT_MGMT_OBJID ?? ""); + const unitCode: string = String(body.UNIT_CODE ?? ""); + const multiYn: string = String(body.MULTI_YN ?? ""); + const parts: Record[] = Array.isArray(body.parts) ? body.parts : []; + const items: Record[] = Array.isArray(body.items) ? body.items : []; + + if (!orderObjId) { + return NextResponse.json( + { success: false, message: "발주 objId가 없습니다." }, + { status: 400 }, + ); + } + if (items.length === 0) { + return NextResponse.json( + { success: false, message: "저장할 입고 데이터가 없습니다." }, + { status: 400 }, + ); + } + + const partByIdx = new Map>(); + parts.forEach((p, i) => partByIdx.set(i + 1, p)); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + for (const item of items) { + const arrivalObjId = String(item.OBJID ?? "") || createObjectId(); + const detailGroup = Number(item.DETAIL_GROUP ?? 0) || 1; + const part = partByIdx.get(detailGroup); + const orderPartObjId = String(part?.ORDER_PART_OBJID ?? ""); + const partObjId = String(part?.PART_OBJID ?? ""); + const ldPartObjId = String(part?.LD_PART_OBJID ?? ""); + const realOrderQty = Number(part?.REAL_ORDER_QTY ?? 0) || 0; + const orderQty = Number(part?.ORDER_QTY ?? realOrderQty) || realOrderQty; + + const receiptQty = Number(item.RECEIPT_QTY ?? 0) || 0; + const receiptInvQty = Number(item.RECEIPT_INV_QTY ?? 0) || 0; + const arrivalQty = Number(item.ARRIVAL_QTY ?? 0) || 0; + const receiptDate = String(item.RECEIPT_DATE ?? ""); + const arrivalPlanDate = String(item.ARRIVAL_PLAN_DATE ?? ""); + const location = String(item.LOCATION ?? ""); + const subLocation = String(item.SUB_LOCATION ?? ""); + const groupSeq = String(item.GROUP_SEQ ?? "1"); + const seq = String(item.SEQ ?? "1"); + + // 1) ARRIVAL_PLAN UPSERT + await client.query( + `INSERT INTO arrival_plan ( + objid, parent_objid, order_part_objid, part_objid, + receipt_qty, receipt_date, location, sub_location, + writer, receiver_id, group_seq, seq, arrival_qty, arrival_plan_date + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9,$10,$11,$12,$13) + ON CONFLICT (objid) DO UPDATE SET + receipt_qty = EXCLUDED.receipt_qty, + receipt_date = EXCLUDED.receipt_date, + location = EXCLUDED.location, + sub_location = EXCLUDED.sub_location, + arrival_qty = EXCLUDED.arrival_qty, + arrival_plan_date = EXCLUDED.arrival_plan_date, + receiver_id = EXCLUDED.receiver_id`, + [ + arrivalObjId, parentObjId, orderPartObjId, partObjId, + String(receiptQty), receiptDate, location, subLocation, + user.userId, groupSeq, seq, String(arrivalQty), arrivalPlanDate, + ], + ); + + // 2) 실제 신규 입고가 없으면(receiptQty=0 또는 기입고와 동일) 재고 반영 skip + const deltaQty = receiptQty - receiptInvQty; + if (receiptQty <= 0 || deltaQty <= 0) continue; + + // INVENTORY_STATUS='Y' + await client.query( + `UPDATE arrival_plan SET inventory_status = 'Y', receiver_id = $1 WHERE objid = $2`, + [user.userId, arrivalObjId], + ); + + // 3) 재고 반영 — 마스터(본인 프로젝트)부터 처리 + let remainingQty = deltaQty; + + // 마스터 inventory_mgmt 행 확보(없으면 생성) + const masterInvParent = await ensureInventoryMgmt(client, { + contractObjId: contractMgmtObjId, + unit: unitCode, + partObjId, + orderObjId, + ldPartObjId, + receiptQty: String(deltaQty), + location, + subLocation, + writer: user.userId, + }); + + if (multiYn !== "Y") { + // 단일발주: 마스터에 전부 기록 + await insertInventoryIn(client, { + parentObjId: masterInvParent, + receiptQty: String(deltaQty), + location, subLocation, + writer: user.userId, + contractMgmtObjId, + poMasterObjId: orderObjId, + poSubObjId: orderObjId, + }); + continue; + } + + // 동시발주: 마스터부터 min(ORDER_QTY - 기입고, remaining) 분배 + // 마스터 기존 입고수량 (동일 purchase_order_master_objid + contract + parent) + const masterExistingQty = await getExistingInventoryInQty(client, { + poMasterObjId: orderObjId, + contractMgmtObjId, + parentObjId: masterInvParent, + }); + const masterInsertable = Math.max(0, orderQty - masterExistingQty); + const masterInsert = Math.min(masterInsertable, remainingQty); + if (masterInsert > 0) { + await insertInventoryIn(client, { + parentObjId: masterInvParent, + receiptQty: String(masterInsert), + location, subLocation, + writer: user.userId, + contractMgmtObjId, + poMasterObjId: orderObjId, + poSubObjId: orderObjId, + }); + remainingQty -= masterInsert; + } + + if (remainingQty <= 0) continue; + + // 슬레이브들 순차 분배 + const slaves = await client.query( + `SELECT objid::text AS objid, + contract_mgmt_objid::text AS contract_mgmt_objid, + unit_code + FROM purchase_order_master + WHERE multi_master_objid = $1 + ORDER BY (SELECT project_no FROM project_mgmt CNT WHERE CNT.objid = purchase_order_master.contract_mgmt_objid LIMIT 1)`, + [orderObjId], + ); + + for (const slave of slaves.rows as Array<{ objid: string; contract_mgmt_objid: string; unit_code: string }>) { + if (remainingQty <= 0) break; + + const slaveContractObjId = slave.contract_mgmt_objid ?? ""; + const slaveUnit = slave.unit_code ?? ""; + const slavePoObjId = slave.objid; + + // 슬레이브 inventory_mgmt 확보 + const slaveInvParent = await ensureInventoryMgmt(client, { + contractObjId: slaveContractObjId, + unit: slaveUnit, + partObjId, + orderObjId, + ldPartObjId, + receiptQty: String(remainingQty), + location, + subLocation, + writer: user.userId, + }); + + const slaveExistingQty = await getExistingInventoryInQty(client, { + poMasterObjId: orderObjId, + contractMgmtObjId: slaveContractObjId, + parentObjId: slaveInvParent, + }); + const slaveInsertable = Math.max(0, orderQty - slaveExistingQty); + const slaveInsert = Math.min(slaveInsertable, remainingQty); + if (slaveInsert <= 0) continue; + + await insertInventoryIn(client, { + parentObjId: slaveInvParent, + receiptQty: String(slaveInsert), + location, subLocation, + writer: user.userId, + contractMgmtObjId: slaveContractObjId, + poMasterObjId: orderObjId, + poSubObjId: slavePoObjId, + }); + remainingQty -= slaveInsert; + } + } + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("delivery/acceptance/save:", e); + return NextResponse.json( + { success: false, message: "저장 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } finally { + client.release(); + } +} + +async function ensureInventoryMgmt( + client: PoolClient, + p: { + contractObjId: string; unit: string; partObjId: string; + orderObjId: string; ldPartObjId: string; + receiptQty: string; location: string; subLocation: string; writer: string; + }, +): Promise { + if (!p.partObjId) return ""; + const found = await client.query( + `SELECT objid::text AS objid FROM inventory_mgmt + WHERE contract_objid = $1 AND unit = $2 AND part_objid = $3 LIMIT 1`, + [p.contractObjId, p.unit, p.partObjId], + ); + if (found.rows[0]?.objid) return String(found.rows[0].objid); + + const newObjId = createObjectId(); + await client.query( + `INSERT INTO inventory_mgmt ( + objid, contract_objid, unit, part_objid, + cls_cd, qty, location, sub_location, + reg_date, price, writer + ) + VALUES ($1,$2,$3,$4,'0001205',$5,$6,$7, + TO_CHAR(NOW(),'YYYY-MM-DD'), + (SELECT partner_price FROM purchase_order_part + WHERE purchase_order_master_objid = $8 AND part_objid = $4 LIMIT 1), + $9) + ON CONFLICT (contract_objid, unit, part_objid) DO NOTHING`, + [ + newObjId, p.contractObjId, p.unit, p.partObjId, + p.receiptQty, p.location, p.subLocation, + p.orderObjId, p.writer, + ], + ); + // 경합 시 기존 값 재조회 + const again = await client.query( + `SELECT objid::text AS objid FROM inventory_mgmt + WHERE contract_objid = $1 AND unit = $2 AND part_objid = $3 LIMIT 1`, + [p.contractObjId, p.unit, p.partObjId], + ); + return String(again.rows[0]?.objid ?? newObjId); +} + +async function getExistingInventoryInQty( + client: PoolClient, + p: { poMasterObjId: string; contractMgmtObjId: string; parentObjId: string }, +): Promise { + const r = await client.query( + `SELECT SUM(CASE WHEN receipt_qty IS NULL OR receipt_qty = '' THEN 0 ELSE receipt_qty::numeric END) AS qty + FROM inventory_mgmt_in + WHERE purchase_order_master_objid = $1 + AND contract_mgmt_objid = $2 + AND parent_objid = $3`, + [p.poMasterObjId, p.contractMgmtObjId, p.parentObjId], + ); + return Number(r.rows[0]?.qty ?? 0) || 0; +} + +async function insertInventoryIn( + client: PoolClient, + p: { + parentObjId: string; receiptQty: string; + location: string; subLocation: string; writer: string; + contractMgmtObjId: string; poMasterObjId: string; poSubObjId: string; + }, +) { + await client.query( + `INSERT INTO inventory_mgmt_in ( + objid, parent_objid, receipt_qty, location, sub_location, + writer, regdate, contract_mgmt_objid, + purchase_order_master_objid, purchase_order_sub_objid + ) VALUES ($1,$2,$3,$4,$5,$6,NOW(),$7,$8,$9)`, + [ + createObjectId(), p.parentObjId, p.receiptQty, p.location, p.subLocation, + p.writer, p.contractMgmtObjId, p.poMasterObjId, p.poSubObjId, + ], + ); +} + +// 구(舊) 단건 호환 — 기존 arrival-plan 팝업이 호출하는 경로 유지 +async function legacySingleSave(body: Record, writer: string) { + const objId = String(body.objId ?? "") || createObjectId(); + const parentObjId = String(body.purchase_order_master_objid ?? body.parent_objid ?? ""); + const orderPartObjId = String(body.purchase_order_part_objid ?? body.order_part_objid ?? ""); + const partObjId = String(body.part_objid ?? ""); + const receiptQty = String(body.receipt_qty ?? "0"); + const receiptDate = String(body.receipt_date ?? ""); + const arrivalQty = String(body.arrival_qty ?? receiptQty); + const arrivalPlanDate = String(body.arrival_plan_date ?? receiptDate); + const location = String(body.location ?? ""); + const subLocation = String(body.sub_location ?? ""); + const groupSeq = String(body.group_seq ?? "1"); + const seq = String(body.seq ?? "1"); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query( + `INSERT INTO arrival_plan ( + objid, parent_objid, order_part_objid, part_objid, + receipt_qty, receipt_date, location, sub_location, + writer, receiver_id, group_seq, seq, arrival_qty, arrival_plan_date + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9,$10,$11,$12,$13) + ON CONFLICT (objid) DO UPDATE SET + receipt_qty=EXCLUDED.receipt_qty, receipt_date=EXCLUDED.receipt_date, + location=EXCLUDED.location, sub_location=EXCLUDED.sub_location, + arrival_qty=EXCLUDED.arrival_qty, arrival_plan_date=EXCLUDED.arrival_plan_date, + receiver_id=EXCLUDED.receiver_id`, + [ + objId, parentObjId, orderPartObjId, partObjId, + receiptQty, receiptDate, location, subLocation, + writer, groupSeq, seq, arrivalQty, arrivalPlanDate, + ], + ); + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("delivery/acceptance/save legacy:", e); + return NextResponse.json( + { success: false, message: "저장 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } finally { + client.release(); + } +} diff --git a/src/app/api/delivery/defect/detail/route.ts b/src/app/api/delivery/defect/detail/route.ts new file mode 100644 index 0000000..545cb8d --- /dev/null +++ b/src/app/api/delivery/defect/detail/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + const info = await queryOne( + `SELECT AP.objid::text AS "OBJID", + POM.purchase_order_no AS "PURCHASE_ORDER_NO", + POP.part_no AS "PART_NO", POP.part_name AS "PART_NAME", + AP.error_qty AS "ERROR_QTY", AP.error_reason AS "ERROR_REASON", + AP.attribution AS "ATTRIBUTION", AP.receipt_qty AS "RECEIPT_QTY", + AP.receipt_date AS "RECEIPT_DATE", AP.re_arrival_plan_date AS "RE_ARRIVAL_PLAN_DATE", + AP.group_seq AS "GROUP_SEQ", AP.seq AS "SEQ" + FROM arrival_plan AP + JOIN purchase_order_part POP ON POP.objid::text = AP.order_part_objid + JOIN purchase_order_master POM ON POM.objid = POP.purchase_order_master_objid + WHERE AP.objid::text = $1`, [objId] + ); + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return NextResponse.json({ success: true, data: info }); +} diff --git a/src/app/api/delivery/defect/route.ts b/src/app/api/delivery/defect/route.ts new file mode 100644 index 0000000..e903b46 --- /dev/null +++ b/src/app/api/delivery/defect/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 입고관리 > 부적합리스트 목록 조회 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + + // arrival_plan의 error_qty > 0 인 건이 부적합 (invalidMgmtGridList 대응) + const conditions: string[] = ["AP.error_qty IS NOT NULL", "AP.error_qty != ''", "AP.error_qty != '0'"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(CM.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.partner_objid) { + conditions.push(`POM.partner_objid = $${idx++}`); + params.push(body.partner_objid); + } + if (body.project_no) { + conditions.push(`CM.contract_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.defect_type || body.error_reason) { + conditions.push(`AP.error_reason = $${idx++}`); + params.push(body.defect_type || body.error_reason); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT AP.objid::text AS "OBJID", + POM.purchase_order_no AS "PURCHASE_ORDER_NO", + CM.contract_no AS "PROJECT_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = CM.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') AS "PARTNER_NAME", + POP.part_no AS "PART_NO", + POP.part_name AS "PART_NAME", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = AP.error_reason LIMIT 1), '') AS "DEFECT_TYPE_NAME", + AP.error_qty AS "DEFECT_QTY", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = AP.attribution LIMIT 1), '') AS "ATTRIBUTION_NAME", + AP.receipt_date AS "REG_DATE", + COALESCE((SELECT user_name FROM user_info WHERE user_id = AP.receiver_id LIMIT 1), '') AS "REG_USER_NAME", + AP.re_arrival_plan_date AS "RE_ARRIVAL_PLAN_DATE", + CASE WHEN COALESCE(AP.assembly_status, '') != '' THEN '처리완료' ELSE '미처리' END AS "STATUS_NAME", + AP.group_seq AS "GROUP_SEQ", + AP.seq AS "SEQ" + FROM arrival_plan AP + JOIN purchase_order_part POP ON POP.objid::text = AP.order_part_objid + JOIN purchase_order_master POM ON POM.objid = POP.purchase_order_master_objid + LEFT JOIN contract_mgmt CM ON CM.objid = POM.contract_mgmt_objid + WHERE ${where} + ORDER BY AP.group_seq, AP.seq + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/delivery/defect/save/route.ts b/src/app/api/delivery/defect/save/route.ts new file mode 100644 index 0000000..cc20ed4 --- /dev/null +++ b/src/app/api/delivery/defect/save/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 부적합 등록/수정 (supplyChainMgmt.saveDeliveryInvalidInfo 대응) +// ARRIVAL_PLAN의 error_qty, error_reason, attribution 업데이트 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + if (!body.objId) { + return NextResponse.json({ success: false, message: "대상 항목을 선택하세요." }); + } + + try { + await execute( + `UPDATE arrival_plan SET + error_qty = $1, + error_reason = $2, + attribution = $3 + WHERE objid = $4`, + [ + body.error_qty || body.defect_qty || "", + body.error_reason || body.defect_reason || "", + body.attribution || "", + body.objId, + ] + ); + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + console.error("Defect save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/delivery/price/route.ts b/src/app/api/delivery/price/route.ts new file mode 100644 index 0000000..c631b6f --- /dev/null +++ b/src/app/api/delivery/price/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 입고관리 > 단가관리 목록 조회 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`EXTRACT(YEAR FROM POM.regdate) = $${idx++}`); + params.push(body.year); + } + if (body.partner_objid) { + conditions.push(`POM.partner_objid = $${idx++}`); + params.push(body.partner_objid); + } + if (body.part_no) { + conditions.push(`POP.part_no LIKE '%' || $${idx++} || '%'`); + params.push(body.part_no); + } + if (body.part_name) { + conditions.push(`POP.part_name LIKE '%' || $${idx++} || '%'`); + params.push(body.part_name); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT POP.objid::text AS "OBJID", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') AS "PARTNER_NAME", + POP.part_no AS "PART_NO", + POP.part_name AS "PART_NAME", + POP.spec AS "SPEC", + POP.material AS "MATERIAL", + POP.unit AS "UNIT_NAME", + COALESCE(POP.partner_price, '0') AS "UNIT_PRICE", + POM.delivery_date AS "START_DATE", + '' AS "END_DATE", + TO_CHAR(POM.regdate, 'YYYY-MM-DD') AS "REG_DATE", + POP.remark AS "REMARK" + FROM purchase_order_part POP + JOIN purchase_order_master POM ON POM.objid = POP.purchase_order_master_objid + WHERE ${where} + ORDER BY POM.regdate DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/delivery/status/route.ts b/src/app/api/delivery/status/route.ts new file mode 100644 index 0000000..875875c --- /dev/null +++ b/src/app/api/delivery/status/route.ts @@ -0,0 +1,194 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: purchaseOrder.xml#deliveryMngStatus +// 입고관리 > 현황 — 프로젝트 BOM 1개(part_bom_report) 단위 집계 리포트 +// 4개 그룹: 프로젝트정보 / 발주내역 / 입고현황 / 수입검사결과(불량현황) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = [ + "SBR.parent_objid = PBR.objid", + "PBR.contract_objid = CM.objid", + ]; + const params: unknown[] = []; + let idx = 1; + + if (body.Year) { + conditions.push(`TO_CHAR(CM.regdate, 'YYYY') = $${idx++}`); + params.push(body.Year); + } + if (body.customer_objid) { + conditions.push(`CM.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + + // project_nos (array or CSV) + const projectNos: string[] = Array.isArray(body.project_nos) + ? body.project_nos.filter(Boolean).map(String) + : body.project_nos + ? String(body.project_nos).split(",").filter(Boolean) + : []; + if (projectNos.length > 0) { + const placeholders = projectNos.map(() => `$${idx++}`).join(", "); + conditions.push(`CM.objid::text IN (${placeholders})`); + params.push(...projectNos); + } + + if (body.unit_code) { + conditions.push(`PBR.unit_code = $${idx++}`); + params.push(body.unit_code); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT T.*, + CASE WHEN "TOTAL_PO_PART_CNT"::numeric = 0 THEN 0 + WHEN "TOTAL_BOM_PART_CNT"::numeric = 0 THEN 0 + ELSE ROUND("TOTAL_PO_PART_CNT"::numeric / "TOTAL_BOM_PART_CNT"::numeric * 100, 1) + END AS "PO_RATE", + CASE WHEN "TOTAL_BOM_PART_CNT"::numeric - "TOTAL_PO_PART_CNT"::numeric < 0 THEN 0 + ELSE "TOTAL_BOM_PART_CNT"::numeric - "TOTAL_PO_PART_CNT"::numeric + END AS "NON_PO_PART_CNT", + "TOTAL_DELIVERY_QTY"::numeric AS "DELIVERY_QTY", + ("TOTAL_PO_QTY"::numeric - "TOTAL_DELIVERY_QTY"::numeric) AS "NON_DELIVERY_QTY", + CASE WHEN "TOTAL_DEFECT_QTY"::numeric = 0 THEN 0 + WHEN "TOTAL_PO_QTY"::numeric = 0 THEN 0 + ELSE ROUND("TOTAL_DEFECT_QTY"::numeric / "TOTAL_PO_QTY"::numeric * 100, 1) + END AS "DELIVERY_RATE" + FROM ( + SELECT CM.objid::text AS "OBJID", + SBR.objid::text AS "SALES_OBJID", + PBR.objid::text AS "BOM_REPORT_OBJID", + TO_CHAR(CM.regdate, 'YYYY') AS "CM_YEAR", + (SELECT supply_name FROM supply_mng SM WHERE SM.objid::text = CM.customer_objid LIMIT 1) AS "CUSTOMER_NAME", + CM.customer_project_name AS "CUSTOMER_PROJECT_NAME", + COALESCE(CM.project_no, CM.contract_no) AS "PROJECT_NO", + (SELECT COALESCE(O.unit_no,'') || '-' || COALESCE(O.task_name,'') FROM pms_wbs_task O WHERE O.objid = PBR.unit_code LIMIT 1) AS "UNIT_PART_NAME", + + -- BOM 부품개수 + (SELECT COUNT(DISTINCT CASE WHEN PM.part_type IN (SELECT code_id FROM comm_code WHERE parent_code_id = '0000062') THEN BPQ.part_no ELSE NULL END) + FROM bom_part_qty BPQ + JOIN part_mng PM ON BPQ.part_no = PM.objid::text + WHERE BPQ.bom_report_objid = PBR.objid::text + AND BPQ.status IN ('beforeEdit','editing','deleting','deploy') + ) AS "TOTAL_BOM_PART_CNT", + + -- BOM 총수량 (재귀 CTE) — LEV>2 & IS_LEAF + (WITH RECURSIVE V(bom_report_objid, child_objid, qty, lev, path, is_leaf, aggregate_qty) AS ( + SELECT A.bom_report_objid, A.child_objid, A.qty, 1, + ARRAY[A.child_objid::text], FALSE, + COALESCE(NULLIF(A.qty,''),'0')::numeric + FROM bom_part_qty A + WHERE A.bom_report_objid = PBR.objid::text + AND (A.parent_objid IS NULL OR A.parent_objid = '') + UNION ALL + SELECT B.bom_report_objid, B.child_objid, B.qty, V.lev + 1, + V.path || B.child_objid::text, + B.parent_objid = ANY(V.path), + V.aggregate_qty * COALESCE(NULLIF(B.qty,''),'0')::numeric + FROM bom_part_qty B + JOIN V ON B.parent_objid = V.child_objid AND B.bom_report_objid = V.bom_report_objid + WHERE NOT (B.parent_objid = ANY(V.path)) + ) + SELECT SUM(V.aggregate_qty) FROM V + WHERE V.lev > 2 + AND NOT EXISTS (SELECT 1 FROM bom_part_qty C WHERE C.parent_objid = V.child_objid AND C.bom_report_objid = V.bom_report_objid) + ) AS "TOTAL_BOM_PART_QTY_SUM", + + -- 발주품 개수 (발주서 기준) + (SELECT COUNT(DISTINCT PO.part_no) + FROM bom_part_qty Q + JOIN purchase_order_part PO ON COALESCE(NULLIF(Q.last_part_objid,''), Q.part_no) = PO.part_objid + JOIN purchase_order_master POM ON POM.objid::text = PO.purchase_order_master_objid + JOIN part_mng P ON P.objid::text = PO.part_objid + WHERE POM.bom_report_objid = PBR.objid::text + AND Q.bom_report_objid = PBR.objid::text + AND Q.status IN ('beforeEdit','editing','deleting','deploy') + AND POM.status = 'approvalComplete' + AND COALESCE(P.part_type,'') != '' + ) AS "TOTAL_PO_PART_CNT", + + -- 발주수량 총계 + (SELECT SUM(COALESCE(NULLIF(POP.order_qty,''),'0')::numeric) + FROM purchase_order_master POM + JOIN purchase_order_part POP ON POM.objid::text = POP.purchase_order_master_objid + WHERE POM.bom_report_objid = PBR.objid::text + AND POM.status = 'approvalComplete' + AND POM.contract_mgmt_objid = CM.objid + ) AS "TOTAL_PO_QTY", + + -- 발주수량 신규 (order_type_cd='0001407') + (SELECT SUM(COALESCE(NULLIF(POP.order_qty,''),'0')::numeric) + FROM purchase_order_master POM + JOIN purchase_order_part POP ON POM.objid::text = POP.purchase_order_master_objid + WHERE POM.bom_report_objid = PBR.objid::text + AND POM.order_type_cd = '0001407' + AND POM.status = 'approvalComplete' + AND POM.contract_mgmt_objid = CM.objid + ) AS "TOTAL_PO_NEW_QTY", + + -- 발주수량 재발주 (order_type_cd='0001408') + (SELECT SUM(COALESCE(NULLIF(POP.order_qty,''),'0')::numeric) + FROM purchase_order_master POM + JOIN purchase_order_part POP ON POM.objid::text = POP.purchase_order_master_objid + WHERE POM.bom_report_objid = PBR.objid::text + AND POM.order_type_cd = '0001408' + AND POM.status = 'approvalComplete' + AND POM.contract_mgmt_objid = CM.objid + ) AS "TOTAL_PO_RE_QTY", + + -- 입고수량 (inventory_mgmt_in 기준) + (SELECT SUM(COALESCE(NULLIF(DH.receipt_qty,''),'0')::numeric) + FROM inventory_mgmt_in DH + JOIN purchase_order_master POM ON POM.objid::text = DH.purchase_order_sub_objid + WHERE CM.objid::text = DH.contract_mgmt_objid + AND POM.bom_report_objid = PBR.objid::text + AND POM.status = 'approvalComplete' + AND POM.contract_mgmt_objid = CM.objid + ) AS "TOTAL_DELIVERY_QTY", + + -- 부적합 집계 + COALESCE(S1.total_defect_qty, 0) AS "TOTAL_DEFECT_QTY", + COALESCE(S1.defect_qty_1, 0) AS "DEFECT_QTY_1", + COALESCE(S1.defect_qty_2, 0) AS "DEFECT_QTY_2", + COALESCE(S1.defect_qty_3, 0) AS "DEFECT_QTY_3", + COALESCE(S1.defect_qty_4, 0) AS "DEFECT_QTY_4", + COALESCE(S1.total_defect_price, 0) AS "TOTAL_DEFECT_PRICE" + FROM sales_bom_report SBR, + part_bom_report PBR + LEFT OUTER JOIN ( + SELECT POM.bom_report_objid, + SUM(COALESCE(NULLIF(DH.error_qty,''),'0')::numeric) AS total_defect_qty, + SUM(CASE WHEN DH.error_reason = '0001114' THEN COALESCE(NULLIF(DH.error_qty,''),'0')::numeric ELSE NULL END) AS defect_qty_1, + SUM(CASE WHEN DH.error_reason = '0001115' THEN COALESCE(NULLIF(DH.error_qty,''),'0')::numeric ELSE NULL END) AS defect_qty_2, + SUM(CASE WHEN DH.error_reason = '0001116' THEN COALESCE(NULLIF(DH.error_qty,''),'0')::numeric ELSE NULL END) AS defect_qty_3, + SUM(CASE WHEN DH.error_reason = '0001117' THEN COALESCE(NULLIF(DH.error_qty,''),'0')::numeric ELSE NULL END) AS defect_qty_4, + SUM(COALESCE(NULLIF(POP.supply_unit_price,''),'0')::numeric * COALESCE(NULLIF(DH.error_qty,''),'0')::numeric) AS total_defect_price + FROM purchase_order_master POM + JOIN purchase_order_part POP ON POM.objid::text = POP.purchase_order_master_objid + JOIN arrival_plan DH ON POM.objid::text = DH.parent_objid AND POP.part_objid = DH.part_objid + WHERE POM.status = 'approvalComplete' + GROUP BY POM.bom_report_objid + ) S1 ON PBR.objid::text = S1.bom_report_objid, + project_mgmt CM + WHERE ${where} + ORDER BY "PROJECT_NO" DESC, "UNIT_PART_NAME" + ) T + `; + + try { + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); + } catch (e) { + console.error("delivery/status:", e); + return NextResponse.json( + { success: false, message: "조회 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/fund/expense-form/detail/route.ts b/src/app/api/fund/expense-form/detail/route.ts new file mode 100644 index 0000000..9631784 --- /dev/null +++ b/src/app/api/fund/expense-form/detail/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne, queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + const info = await queryOne( + `SELECT EM.expense_master_objid::text AS "OBJID", EM.expense_id AS "EXPENSE_ID", + EM.project_mgmt_objid AS "PROJECT_MGMT_OBJID", EM.bns_start_date AS "BNS_START_DATE", + EM.bns_end_date AS "BNS_END_DATE", EM.exp_status_cd AS "EXP_STATUS_CD", + EM.exp_sort_cd AS "EXP_SORT_CD", EM.exp_area_cd AS "EXP_AREA_CD", + EM.bus_title AS "BUS_TITLE", EM.bus_content AS "BUS_CONTENT", + EM.vehicel_used AS "VEHICEL_USED", EM.amount_payment AS "AMOUNT_PAYMENT", + EM.status AS "STATUS", + COALESCE((SELECT user_name FROM user_info WHERE user_id = EM.reg_user_id LIMIT 1), '') AS "REG_USER_NAME", + CM.contract_no AS "PROJECT_NO", CM.project_name AS "PROJECT_NAME" + FROM expense_master EM + LEFT JOIN contract_mgmt CM ON CM.objid::text = EM.project_mgmt_objid + WHERE EM.expense_master_objid::text = $1`, [objId] + ); + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + + const details = await queryRows( + `SELECT ED.expense_detail_objid::text AS "OBJID", + ED.exp_sort_cd AS "EXP_SORT_CD", ED.exp_subm_cd AS "EXP_SUBM_CD", + ED.exp_subd_cd AS "EXP_SUBD_CD", + ED.card_used AS "CARD_USED", ED.cash_used AS "CASH_USED", ED.payment AS "PAYMENT" + FROM expense_detail ED WHERE ED.expense_master_objid::text = $1`, [objId] + ); + + return NextResponse.json({ success: true, data: info, DETAILS: details }); +} diff --git a/src/app/api/fund/expense-form/route.ts b/src/app/api/fund/expense-form/route.ts new file mode 100644 index 0000000..058b574 --- /dev/null +++ b/src/app/api/fund/expense-form/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = []; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(EM.reg_date, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.writer_name) { + conditions.push(`(SELECT user_name FROM user_info WHERE user_id = EM.reg_user_id LIMIT 1) LIKE '%' || $${idx++} || '%'`); + params.push(body.writer_name); + } else if (body.reg_user_id) { + conditions.push(`EM.reg_user_id = $${idx++}`); + params.push(body.reg_user_id); + } + if (body.status_code || body.exp_status_cd) { + conditions.push(`EM.exp_status_cd = $${idx++}`); + params.push(body.status_code || body.exp_status_cd); + } + if (body.expense_date_from || body.bns_start_date) { + conditions.push(`EM.bns_start_date >= $${idx++}`); + params.push(body.expense_date_from || body.bns_start_date); + } + if (body.expense_date_to || body.bns_end_date) { + conditions.push(`EM.bns_end_date <= $${idx++}`); + params.push(body.expense_date_to || body.bns_end_date); + } + + const whereClause = conditions.length > 0 ? "AND " + conditions.join(" AND ") : ""; + + const sql = ` + SELECT EM.expense_master_objid::text AS "OBJID", EM.expense_id AS "EXPENSE_ID", + CM.contract_no AS "PROJECT_NO", CM.project_name AS "PROJECT_NAME", + COALESCE((SELECT user_name FROM user_info WHERE user_id = EM.reg_user_id LIMIT 1), '') AS "REG_USER_NAME", + EM.bns_start_date AS "BNS_START_DATE", + EM.bns_end_date AS "BNS_END_DATE", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = EM.exp_status_cd LIMIT 1), '') AS "EXP_STATUS_NAME", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = EM.exp_area_cd LIMIT 1), '') AS "EXP_AREA_NAME", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = EM.exp_sort_cd LIMIT 1), '') AS "EXP_SORT_NAME", + EM.bus_title AS "BUS_TITLE", EM.bus_content AS "BUS_CONTENT", + EM.vehicel_used AS "VEHICEL_USED", + COALESCE(EM.amount_payment::numeric, 0) AS "AMOUNT_PAYMENT", + EM.expense_id AS "EXPENSE_FORM_NO", + EM.bns_start_date AS "EXPENSE_DATE", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = EM.exp_sort_cd LIMIT 1), '') AS "EXPENSE_TYPE_NAME", + COALESCE(EM.amount_payment::numeric, 0) AS "EXPENSE_AMOUNT", + EM.bus_title AS "EXPENSE_PLACE", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = EM.status LIMIT 1), EM.status) AS "STATUS_NAME", + COALESCE((SELECT user_name FROM user_info WHERE user_id = EM.reg_user_id LIMIT 1), '') AS "WRITER_NAME", + EM.status AS "STATUS", + EM.reg_date AS "REG_DATE", + COALESCE((SELECT SUM(COALESCE(ED.payment,'0')::numeric) FROM expense_detail ED WHERE ED.expense_master_objid = EM.expense_master_objid), 0) AS "DETAIL_TOTAL" + FROM expense_master EM + LEFT JOIN contract_mgmt CM ON CM.objid::text = EM.project_mgmt_objid + WHERE 1=1 ${whereClause} + ORDER BY EM.reg_date DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/fund/expense-form/save/route.ts b/src/app/api/fund/expense-form/save/route.ts new file mode 100644 index 0000000..8520726 --- /dev/null +++ b/src/app/api/fund/expense-form/save/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 경비신청서 저장 (mergeExpenseMaster 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const body = await request.json(); + const isNew = !body.objId || body.actionType === "regist"; + const objId = isNew ? createObjectId() : body.objId; + + const client = await pool.connect(); + try { + // 신규시 expense_id 자동 생성: EC-YYYY-NNN + let expenseId = body.expense_id || ""; + if (isNew && !expenseId) { + const year = new Date().getFullYear(); + const r = await client.query( + `SELECT COALESCE(MAX(SUBSTR(expense_id, 9)::numeric), 0)::int + 1 AS seq + FROM expense_master WHERE expense_id LIKE $1`, [`EC-${year}-%`] + ); + const seq = r.rows[0]?.seq || 1; + expenseId = `EC-${year}-${String(seq).padStart(3, "0")}`; + } + + await client.query( + `INSERT INTO expense_master ( + expense_master_objid, expense_id, project_mgmt_objid, + bns_start_date, bns_end_date, exp_status_cd, exp_sort_cd, + exp_area_cd, bus_title, bus_content, vehicel_used, amount_payment, + reg_user_id, reg_date, status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, now(), $14) + ON CONFLICT (expense_master_objid) DO UPDATE SET + project_mgmt_objid=EXCLUDED.project_mgmt_objid, + bns_start_date=EXCLUDED.bns_start_date, bns_end_date=EXCLUDED.bns_end_date, + exp_status_cd=EXCLUDED.exp_status_cd, exp_sort_cd=EXCLUDED.exp_sort_cd, + exp_area_cd=EXCLUDED.exp_area_cd, bus_title=EXCLUDED.bus_title, + bus_content=EXCLUDED.bus_content, vehicel_used=EXCLUDED.vehicel_used, + amount_payment=EXCLUDED.amount_payment, status=EXCLUDED.status`, + [objId, expenseId, body.project_mgmt_objid || "", + body.bns_start_date || null, body.bns_end_date || null, + body.exp_status_cd || "", body.exp_sort_cd || "", + body.exp_area_cd || "", body.bus_title || "", body.bus_content || "", + body.vehicel_used || "", body.amount_payment || "0", + user.userId, body.status || "created"] + ); + return NextResponse.json({ success: true, objId, expenseId, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (e) { + console.error("Expense save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { client.release(); } +} diff --git a/src/app/api/fund/invoice/detail/route.ts b/src/app/api/fund/invoice/detail/route.ts new file mode 100644 index 0000000..6eaeae7 --- /dev/null +++ b/src/app/api/fund/invoice/detail/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + const info = await queryOne( + `SELECT IM.objid::text AS "OBJID", IM.parent_objid AS "PARENT_OBJID", + IM.price_sum AS "PRICE_SUM", IM.issuance_date AS "ISSUANCE_DATE", + IM.status AS "STATUS", IM.group_seq AS "GROUP_SEQ", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = CM.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + CM.contract_no AS "PROJECT_NO", CM.project_name AS "PROJECT_NAME", + COALESCE((SELECT user_name FROM user_info WHERE user_id = IM.writer LIMIT 1), '') AS "WRITER_NAME" + FROM invoice_mgmt IM + LEFT JOIN contract_mgmt CM ON CM.objid::text = IM.parent_objid + WHERE IM.objid::text = $1`, [objId] + ); + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return NextResponse.json({ success: true, data: info }); +} diff --git a/src/app/api/fund/invoice/route.ts b/src/app/api/fund/invoice/route.ts new file mode 100644 index 0000000..e68e006 --- /dev/null +++ b/src/app/api/fund/invoice/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = []; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(IM.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.customer_name) { + conditions.push(`COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = CM.customer_objid LIMIT 1), '') LIKE '%' || $${idx++} || '%'`); + params.push(body.customer_name); + } + if (body.invoice_date_from) { + conditions.push(`IM.issuance_date >= $${idx++}`); + params.push(body.invoice_date_from); + } + if (body.invoice_date_to) { + conditions.push(`IM.issuance_date <= $${idx++}`); + params.push(body.invoice_date_to); + } + + const whereClause = conditions.length > 0 ? "AND " + conditions.join(" AND ") : ""; + + const sql = ` + SELECT IM.objid::text AS "OBJID", + IM.objid::text AS "INVOICE_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = CM.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + CM.contract_no AS "PROJECT_NO", CM.project_name AS "PROJECT_NAME", + IM.issuance_date AS "INVOICE_DATE", + COALESCE(IM.price_sum::numeric, 0) AS "SUPPLY_AMOUNT", + ROUND(COALESCE(IM.price_sum::numeric, 0) * 0.1) AS "TAX_AMOUNT", + ROUND(COALESCE(IM.price_sum::numeric, 0) * 1.1) AS "TOTAL_AMOUNT", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = IM.status LIMIT 1), IM.status) AS "STATUS_NAME", + COALESCE((SELECT user_name FROM user_info WHERE user_id = IM.writer LIMIT 1), '') AS "WRITER_NAME" + FROM invoice_mgmt IM + LEFT JOIN contract_mgmt CM ON CM.objid::text = IM.parent_objid + WHERE 1=1 ${whereClause} + ORDER BY IM.regdate DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/fund/invoice/save/route.ts b/src/app/api/fund/invoice/save/route.ts new file mode 100644 index 0000000..a76d85c --- /dev/null +++ b/src/app/api/fund/invoice/save/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 거래명세서 저장 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const body = await request.json(); + const isNew = !body.objId || body.actionType === "regist"; + const objId = isNew ? createObjectId() : body.objId; + + const client = await pool.connect(); + try { + await client.query( + `INSERT INTO invoice_mgmt (objid, parent_objid, group_seq, price_sum, issuance_date, status, regdate, writer) + VALUES ($1, $2, $3, $4, $5, $6, now(), $7) + ON CONFLICT (objid) DO UPDATE SET + parent_objid=EXCLUDED.parent_objid, + group_seq=EXCLUDED.group_seq, + price_sum=EXCLUDED.price_sum, + issuance_date=EXCLUDED.issuance_date, + status=EXCLUDED.status`, + [objId, body.parent_objid || "", body.group_seq || 1, + body.price_sum || body.supply_amount || "0", + body.issuance_date || null, body.status || "created", user.userId] + ); + return NextResponse.json({ success: true, objId, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (e) { + console.error("Invoice save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { client.release(); } +} diff --git a/src/app/api/inventory/fund/route.ts b/src/app/api/inventory/fund/route.ts new file mode 100644 index 0000000..0371935 --- /dev/null +++ b/src/app/api/inventory/fund/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 자재관리 > 자금관리 (공급업체별 발주/입고 금액 집계) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["POM.status = 'approvalComplete'"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(POM.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.month) { + conditions.push(`TO_CHAR(POM.regdate, 'MM') = $${idx++}`); + params.push(body.month); + } + if (body.partner_objid) { + conditions.push(`POM.partner_objid = $${idx++}`); + params.push(body.partner_objid); + } + + const where = conditions.join(" AND "); + + const rows = await queryRows( + `SELECT POM.partner_objid AS "PARTNER_OBJID", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') AS "PARTNER_NAME", + -- 발주금액 + SUM(COALESCE(NULLIF(POM.total_supply_price,'')::numeric, 0)) AS "ORDER_AMOUNT", + -- 입고금액 (입고된 파트의 공급가 합산) + COALESCE(SUM((SELECT SUM(COALESCE(NULLIF(AP2.receipt_qty,'')::numeric, 0) * COALESCE(NULLIF(POP2.partner_price,'')::numeric, 0)) + FROM purchase_order_part POP2 + LEFT JOIN arrival_plan AP2 ON AP2.order_part_objid = POP2.objid::text + WHERE POP2.purchase_order_master_objid = POM.objid)), 0) AS "DELIVERY_AMOUNT", + -- 지급 관련 (placeholder — 별도 지급 테이블 확인 필요) + 0 AS "PAYMENT_PLAN_AMOUNT", + 0 AS "PAYMENT_DONE_AMOUNT", + SUM(COALESCE(NULLIF(POM.total_supply_price,'')::numeric, 0)) AS "UNPAID_AMOUNT", + POM.delivery_date AS "PAYMENT_PLAN_DATE", + '' AS "PAYMENT_STATUS_NAME", + '' AS "REMARK" + FROM purchase_order_master POM + WHERE ${where} + GROUP BY POM.partner_objid, POM.delivery_date + ORDER BY "PARTNER_NAME" +`, + params + ); + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/inventory/input/save/route.ts b/src/app/api/inventory/input/save/route.ts new file mode 100644 index 0000000..37487cd --- /dev/null +++ b/src/app/api/inventory/input/save/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const body = await request.json(); + const objId = createObjectId(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + // Insert input record + await client.query( + `INSERT INTO inventory_mgmt_in (objid, parent_objid, receipt_qty, location, writer, regdate) + VALUES ($1, $2, $3, $4, $5, now())`, + [objId, body.parent_objid || "", body.receipt_qty || "", body.location || "", user.userId] + ); + // Update parent inventory_mgmt input_qty and input_date + await client.query( + `UPDATE inventory_mgmt SET + input_qty = COALESCE(input_qty, '0')::numeric + COALESCE($1, '0')::numeric || '', + input_date = now()::text + WHERE objid = $2`, + [body.receipt_qty || "0", body.parent_objid || ""] + ); + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "입고 등록되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("Inventory input save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }); + } finally { client.release(); } +} diff --git a/src/app/api/inventory/list/delete/route.ts b/src/app/api/inventory/list/delete/route.ts new file mode 100644 index 0000000..65a6011 --- /dev/null +++ b/src/app/api/inventory/list/delete/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 자재 삭제 (deleteInventoryMng 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objIds } = await request.json(); + if (!objIds?.length) return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }); + try { + const ph = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + await execute(`DELETE FROM inventory_mgmt_out WHERE parent_objid IN (${ph})`, objIds); + await execute(`DELETE FROM inventory_mgmt_in WHERE parent_objid IN (${ph})`, objIds); + await execute(`DELETE FROM inventory_mgmt_history WHERE parent_objid IN (${ph})`, objIds); + await execute(`DELETE FROM inventory_mgmt WHERE objid IN (${ph})`, objIds); + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (error) { + console.error("Inventory delete:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/inventory/list/route.ts b/src/app/api/inventory/list/route.ts new file mode 100644 index 0000000..05b1a3d --- /dev/null +++ b/src/app/api/inventory/list/route.ts @@ -0,0 +1,175 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 자재관리 > 자재리스트 (inventoryMng.getInventoryMngGridList 대응) +// 원본: /Users/jhj/FITO/src/com/pms/mapper/inventoryMng.xml (line 790) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + // project_nos: comma-delimited (다중 선택) + const projectNos = String(body.project_nos || "").trim(); + if (projectNos) { + const arr = projectNos.split(",").map((s) => s.trim()).filter(Boolean); + if (arr.length) { + const placeholders = arr.map(() => `$${idx++}`).join(","); + conditions.push(`IM.contract_objid IN (${placeholders})`); + params.push(...arr); + } + } + if (body.unit_code) { + conditions.push(`IM.unit = $${idx++}`); + params.push(body.unit_code); + } + if (body.part_no) { + conditions.push(`UPPER(P.part_no) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.part_no); + } + if (body.part_name) { + conditions.push(`UPPER(P.part_name) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.part_name); + } + if (body.spec) { + conditions.push(`UPPER(P.spec) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.spec); + } + if (body.part_type) { + conditions.push(`P.part_type = $${idx++}`); + params.push(body.part_type); + } + if (body.cls_cd) { + conditions.push(`IM.cls_cd = $${idx++}`); + params.push(body.cls_cd); + } + if (body.cau_cd) { + conditions.push(`IM.cau_cd = $${idx++}`); + params.push(body.cau_cd); + } + if (body.location) { + conditions.push(`IM.location = $${idx++}`); + params.push(body.location); + } + if (body.writer) { + conditions.push(`IM.writer = $${idx++}`); + params.push(body.writer); + } + + const where = conditions.join(" AND "); + + const rows = await queryRows( + `SELECT + IM.OBJID::text AS "OBJID", + IM.PART_OBJID AS "PART_OBJID", + IM.CONTRACT_OBJID AS "CONTRACT_OBJID", + IM.UNIT AS "UNIT", + IM.CLS_CD AS "CLS_CD", + IM.CAU_CD AS "CAU_CD", + IM.QTY AS "QTY", + IM.LOCATION AS "LOCATION", + IM.REG_DATE AS "REG_DATE", + IM.PRICE AS "PRICE", + IM.WRITER AS "WRITER", + IM.PART_NO AS "PART_NO", + IM.PART_NAME AS "PART_NAME", + IM.SPEC AS "SPEC", + IM.MAKER AS "MAKER", + IM.PART_TYPE_NAME AS "PART_TYPE_NAME", + IM.PART_TYPE AS "PART_TYPE", + IM.MATERIAL AS "MATERIAL", + IM.WRITER_NAME AS "WRITER_NAME", + (SELECT project_no FROM project_mgmt O WHERE O.objid = IM.CONTRACT_OBJID) AS "PROJECT_NO", + ( + COALESCE(( + SELECT SUM(CASE WHEN (O.receipt_qty IS NULL OR O.receipt_qty = '') + THEN 0 + ELSE (O.receipt_qty::numeric - COALESCE(O.move_qty::numeric, 0)) + END) + FROM inventory_mgmt_in O WHERE O.parent_objid = IM.OBJID + ), 0) + - COALESCE(( + SELECT SUM(CASE WHEN (O.request_qty IS NULL OR O.request_qty = '') + THEN 0 + ELSE O.request_qty::numeric + END) + FROM inventory_mgmt_out O WHERE O.parent_objid = IM.OBJID + ), 0) + ) AS "USE_CNT", + ( + COALESCE(( + SELECT SUM(CASE WHEN (O.receipt_qty IS NULL OR O.receipt_qty = '') + THEN 0 + ELSE (O.receipt_qty::numeric - COALESCE(O.move_qty::numeric, 0)) + END) + FROM inventory_mgmt_in O, inventory_mgmt S_IM + WHERE S_IM.part_objid = IM.PART_OBJID + AND O.parent_objid = S_IM.objid + ), 0) + - COALESCE(( + SELECT SUM(CASE WHEN (O.request_qty IS NULL OR O.request_qty = '') + THEN 0 + ELSE O.request_qty::numeric + END) + FROM inventory_mgmt_out O, inventory_mgmt S_IM + WHERE S_IM.part_objid = IM.PART_OBJID + AND O.parent_objid = S_IM.objid + ), 0) + ) AS "USE_CNT_ALL", + COALESCE(( + SELECT SUM(CASE WHEN (O.out_qty IS NULL OR O.out_qty = '') + THEN 0 + ELSE O.out_qty::numeric + END) + FROM inventory_mgmt_out O WHERE O.parent_objid = IM.OBJID + ), 0) AS "REQUEST_QTY", + (SELECT O.unit_no || '-' || O.task_name FROM pms_wbs_task O WHERE O.objid = IM.UNIT) AS "UNIT_NAME", + ( + SELECT ARRAY_TO_STRING(ARRAY_AGG(DISTINCT (SELECT code_name FROM comm_code CC WHERE CC.code_id = IMI.location)), ',') + FROM inventory_mgmt_in IMI WHERE IMI.parent_objid = IM.OBJID + ) AS "LOCATION_NAME", + ( + SELECT MAX(O.remark) FROM inventory_mgmt_out_master O + WHERE O.objid IN ( + SELECT T1.inventory_request_master_objid FROM inventory_mgmt_out T1 + WHERE T1.parent_objid = IM.OBJID ORDER BY T1.regdate DESC LIMIT 1 + ) + ) AS "REMARK", + (SELECT code_name FROM comm_code WHERE code_id = IM.CLS_CD) AS "CLS_CD_NAME", + (SELECT code_name FROM comm_code WHERE code_id = IM.CAU_CD) AS "CAU_CD_NAME" + FROM ( + SELECT + IM.objid AS OBJID, + IM.part_objid AS PART_OBJID, + P.part_no AS PART_NO, + P.part_name AS PART_NAME, + P.spec AS SPEC, + P.maker AS MAKER, + (SELECT code_name FROM comm_code WHERE code_id = P.part_type) AS PART_TYPE_NAME, + P.part_type AS PART_TYPE, + P.material AS MATERIAL, + IM.cls_cd AS CLS_CD, + IM.cau_cd AS CAU_CD, + IM.qty AS QTY, + IM.location AS LOCATION, + IM.reg_date AS REG_DATE, + IM.price AS PRICE, + IM.writer AS WRITER, + (SELECT user_name FROM user_info WHERE user_id = IM.writer) AS WRITER_NAME, + IM.contract_objid AS CONTRACT_OBJID, + IM.unit AS UNIT + FROM inventory_mgmt IM + INNER JOIN part_mng P ON P.objid::varchar = IM.part_objid + WHERE ${where} + ) IM + ORDER BY IM.REG_DATE DESC NULLS LAST +`, + params, + ); + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/inventory/list/save/route.ts b/src/app/api/inventory/list/save/route.ts new file mode 100644 index 0000000..9436235 --- /dev/null +++ b/src/app/api/inventory/list/save/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId, getDate } from "@/lib/utils"; + +// 자재등록 (saveinventoryForm 대응) +// 원본 쿼리: inventoryMng.xml line 595 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const objId = body.objId || body.objid || createObjectId(); + + try { + // inventory_mgmt PK = (contract_objid, unit, part_objid) 복합키 + await execute( + `INSERT INTO inventory_mgmt + (objid, contract_objid, unit, part_objid, cls_cd, cau_cd, qty, + location, sub_location, reg_date, price, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (contract_objid, unit, part_objid) DO UPDATE SET + cls_cd = EXCLUDED.cls_cd, + cau_cd = EXCLUDED.cau_cd, + qty = EXCLUDED.qty, + location = EXCLUDED.location, + sub_location = EXCLUDED.sub_location, + price = EXCLUDED.price, + writer = EXCLUDED.writer`, + [ + objId, + body.contract_objid || body.project_no || "", + body.unit || "", + body.part_objid || body.part_no || "", + body.cls_cd || "", + body.cau_cd || "", + body.qty || "0", + body.location || "", + body.sub_location || "", + body.reg_date || getDate(), + body.price || "0", + user.userId, + ], + ); + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + console.error("inventory list save:", e); + return NextResponse.json({ success: false, message: "오류가 발생하였습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/inventory/move/save/route.ts b/src/app/api/inventory/move/save/route.ts new file mode 100644 index 0000000..5f6668c --- /dev/null +++ b/src/app/api/inventory/move/save/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 자재이동 저장 (saveInventoryMove 대응) +// 원본: InventoryMngController.saveInventoryMove → mergeInventoryMoveInfo + saveInventoryMoveInInfo +// 각 라인별로: +// 1) 기존 inventory_mgmt_in 행의 move_objid=신규OBJID, move_qty=이동수량 업데이트 +// 2) 신규 inventory_mgmt_in 행 INSERT (이동된 재고, move_date/move_user 포함) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const lines = Array.isArray(body.lines) ? body.lines : []; + if (lines.length === 0) { + return NextResponse.json({ success: false, message: "이동수량을 입력해주세요." }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + for (const line of lines) { + const moveObjId = createObjectId(); + + // 1. 원본 inventory_mgmt_in 행의 move_objid/move_qty 갱신 + await client.query( + `UPDATE inventory_mgmt_in + SET move_objid = $1::varchar, + move_qty = $2 + WHERE objid = $3`, + [moveObjId, line.MOVE_QTY, line.IN_OBJID], + ); + + // 2. 신규 inventory_mgmt_in 행 INSERT (같은 parent_objid 아래 이동처리된 행) + // $1은 새 inventory_mgmt_in.objid + 원본 행 검색용 move_objid로 두 번 사용 → 명시 cast + await client.query( + `INSERT INTO inventory_mgmt_in ( + objid, parent_objid, receipt_qty, location, sub_location, + writer, regdate, purchase_order_master_objid, contract_mgmt_objid, + purchase_order_sub_objid, move_date, move_user + ) + SELECT $1::varchar, parent_objid, $2, $3, $4, $5, now(), + purchase_order_master_objid, contract_mgmt_objid, purchase_order_sub_objid, + $6, $7 + FROM inventory_mgmt_in + WHERE move_objid = $8::varchar`, + [ + moveObjId, + line.MOVE_QTY, + line.LOCATION || "", + line.SUB_LOCATION || "", + user.userId, + line.MOVE_DATE || "", + line.MOVE_USER || "", + moveObjId, + ], + ); + } + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("inventory move save:", e); + return NextResponse.json( + { success: false, message: "오류가 발생하였습니다." }, + { status: 500 }, + ); + } finally { + client.release(); + } +} diff --git a/src/app/api/inventory/request/accept/route.ts b/src/app/api/inventory/request/accept/route.ts new file mode 100644 index 0000000..7975b11 --- /dev/null +++ b/src/app/api/inventory/request/accept/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { getDate } from "@/lib/utils"; + +// 자재불출 완료 (acceptInventoryRequestInfo 대응) +// 원본: InventoryMngController.acceptInventoryRequestInfo, inventoryMng.xml line 2191, 2200, 2073 +// 1) transfer(인계) 라인별 OUT_QTY/OUT_DATE/ACQ_USER 업데이트 (lines 파라미터 활용) +// 2) mergeAcceptInventoryRequestInfo: master의 outstatus='complete', request_date 업데이트 +// 3) mergeAcceptInventoryRequestPartInfo: 모든 OUT 라인의 out_date 일괄 업데이트 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const masterObjId = String(body.INVENTORY_REQUEST_MASTER_OBJID || ""); + if (!masterObjId) { + return NextResponse.json({ success: false, message: "마스터 OBJID 누락" }, { status: 400 }); + } + const lines: Array> = Array.isArray(body.lines) ? body.lines : []; + const requestDate = String(body.REQUEST_DATE || getDate()); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1. 라인별 OUT_QTY/OUT_DATE/ACQ_USER 업데이트 + for (const line of lines) { + await client.query( + `INSERT INTO inventory_mgmt_out + (objid, parent_objid, request_qty, writer, regdate, + inventory_request_master_objid) + VALUES ($1, $2, $3, $4, NOW(), $5) + ON CONFLICT (objid) DO UPDATE SET + out_qty = $6, + out_date = $7, + acq_user = $8, + writer = $4`, + [ + String(line.OBJID || ""), + String(line.PARENT_OBJID || ""), + String(line.REQUEST_QTY || "0"), + user.userId, + masterObjId, + String(line.OUT_QTY || "0"), + String(line.OUT_DATE || requestDate), + String(line.ACQ_USER || ""), + ], + ); + } + + // 2. 마스터 불출완료 상태 + await client.query( + `UPDATE inventory_mgmt_out_master + SET outstatus = $1, + request_date = $2 + WHERE objid = $3`, + ["complete", requestDate, masterObjId], + ); + + // 3. 상세 out_date 일괄 + await client.query( + `UPDATE inventory_mgmt_out + SET out_date = $1 + WHERE inventory_request_master_objid = $2`, + [requestDate, masterObjId], + ); + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "불출완료되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("inventory request accept:", e); + return NextResponse.json( + { success: false, message: "오류가 발생하였습니다." }, + { status: 500 }, + ); + } finally { + client.release(); + } +} diff --git a/src/app/api/inventory/request/delete/route.ts b/src/app/api/inventory/request/delete/route.ts new file mode 100644 index 0000000..8f38c5f --- /dev/null +++ b/src/app/api/inventory/request/delete/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 불출의뢰서 삭제 (inventory_mgmt_out_master + inventory_mgmt_out) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objIds } = await request.json(); + if (!objIds?.length) return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }); + try { + const ph = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + await execute(`DELETE FROM inventory_mgmt_out WHERE inventory_request_master_objid IN (${ph})`, objIds); + await execute(`DELETE FROM inventory_mgmt_out_master WHERE objid IN (${ph})`, objIds); + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (error) { + console.error("Inventory request delete:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/inventory/request/design-qty/route.ts b/src/app/api/inventory/request/design-qty/route.ts new file mode 100644 index 0000000..e8a7041 --- /dev/null +++ b/src/app/api/inventory/request/design-qty/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 설계수량 조회 (inventoryMng.getPartQTYInfoList 대응) +// 원본: inventoryMng.xml line 1739 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json().catch(() => ({})); + const partObjId = String(body.part_objid || body.PART_OBJID || ""); + const projectObjId = String(body.project_objid || body.PROJECT_OBJID || ""); + const unitCode = String(body.unit_code || body.UNIT_CODE || ""); + if (!partObjId || !projectObjId || !unitCode) { + return NextResponse.json({ RESULTLIST: [] }); + } + + const rows = await queryRows( + `SELECT + T.*, + (COALESCE(NULLIF(T."DESIGN_QTY1", '')::numeric, 0) * COALESCE(T."QTY1", 0) + * COALESCE(NULLIF(T."QTY3", '')::numeric, 1)) AS "DESIGN_QTY" + FROM ( + SELECT + BPQ.bom_report_objid AS "BOM_REPORT_OBJID", + BPQ.qty AS "DESIGN_QTY1", + (SELECT MAX(COALESCE(NULLIF(BPQ.qty, '')::numeric, 0)) + FROM bom_part_qty BPQ + WHERE BPQ.bom_report_objid = PBR.objid AND COALESCE(BPQ.parent_part_no, '') = '') AS "QTY1", + (SELECT SUM(COALESCE(NULLIF(BPQ3.qty, '')::numeric, 0))::text + FROM bom_part_qty BPQ3 + WHERE BPQ3.bom_report_objid = PBR.objid AND BPQ3.child_objid = BPQ.parent_objid) AS "QTY3", + PM.objid AS "PART_OBJID", + PM.part_no AS "PART_NO", + PM.part_name AS "PART_NAME", + PBR.contract_objid AS "CONTRACT_OBJID", + PBR.unit_code AS "UNIT_CODE", + COALESCE((SELECT SUM(CASE WHEN (out_qty IS NULL OR out_qty = '') THEN 0 + ELSE out_qty::numeric END) + FROM inventory_mgmt_out O, inventory_mgmt S_IM + WHERE S_IM.part_objid = PM.objid + AND O.parent_objid = S_IM.objid + AND O.contract_mgmt_objid = PBR.contract_objid + AND O.unit = PBR.unit_code), 0) AS "OUT_QTY" + FROM bom_part_qty BPQ + LEFT OUTER JOIN part_mng PM ON PM.objid = BPQ.part_no + LEFT OUTER JOIN part_bom_report PBR ON PBR.objid = BPQ.bom_report_objid + WHERE BPQ.last_part_objid = $1 + AND PBR.contract_objid = $2 + AND PBR.unit_code = $3 + AND BPQ.status = 'deploy' + ) T +`, + [partObjId, projectObjId, unitCode], + ); + + return NextResponse.json({ RESULTLIST: rows }); +} diff --git a/src/app/api/inventory/request/detail/route.ts b/src/app/api/inventory/request/detail/route.ts new file mode 100644 index 0000000..7b61708 --- /dev/null +++ b/src/app/api/inventory/request/detail/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 불출의뢰서 상세 (inventoryMng.getInventoryRequestMasterInfo + getInventoryMngRequestDetailList 대응) +// 원본: inventoryMng.xml line 1946, 2110 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const masterObjId = String(body.INVENTORY_REQUEST_MASTER_OBJID || body.objId || ""); + if (!masterObjId) { + return NextResponse.json({ success: false, message: "OBJID 누락" }, { status: 400 }); + } + + const master = await queryOne( + `SELECT + IM.objid::text AS "OBJID", + IM.parent_objid AS "PARENT_OBJID", + IM.inventory_out_no AS "INVENTORY_OUT_NO", + IM.request_date AS "REQUEST_DATE", + IM.request_id AS "REQUEST_ID", + IM.reception_status AS "RECEPTION_STATUS", + IM.reception_id AS "RECEPTION_ID", + IM.reception_date AS "RECEPTION_DATE", + IM.outstatus AS "OUTSTATUS", + IM.writer AS "WRITER", + IM.regdate AS "REGDATE", + IM.remark AS "REMARK", + IM.contract_mgmt_objid AS "CONTRACT_MGMT_OBJID", + IM.sign AS "SIGN", + (SELECT project_no FROM project_mgmt P WHERE P.objid = IM.contract_mgmt_objid) AS "PROJECT_NO" + FROM inventory_mgmt_out_master IM + WHERE IM.objid = $1`, + [masterObjId], + ); + + const details = await queryRows( + `SELECT + IMO.objid::text AS "OBJID", + IMO.parent_objid AS "PARENT_OBJID", + IMO.request_qty AS "REQUEST_QTY", + IMO.out_qty AS "OUT_QTY", + IMO.out_date AS "OUT_DATE", + IMO.writer AS "WRITER", + IMO.acq_user AS "ACQ_USER", + IMO.inventory_request_master_objid AS "INVENTORY_REQUEST_MASTER_OBJID", + IMO.sign AS "SIGN_ROW", + IMO.contract_mgmt_objid AS "CONTRACT_MGMT_OBJID", + IMO.unit AS "UNIT", + (SELECT project_no FROM project_mgmt PM WHERE PM.objid = IMO.contract_mgmt_objid) AS "PROJECT_NO", + (SELECT O.unit_no || '-' || O.task_name FROM pms_wbs_task O WHERE O.objid = IMO.unit) AS "UNIT_NAME", + (SELECT part_no FROM part_mng O WHERE O.objid::varchar = IM.part_objid) AS "PART_NO", + (SELECT part_name FROM part_mng O WHERE O.objid::varchar = IM.part_objid) AS "PART_NAME", + (SELECT spec FROM part_mng O WHERE O.objid::varchar = IM.part_objid) AS "SPEC", + (SELECT maker FROM part_mng O WHERE O.objid::varchar = IM.part_objid) AS "MAKER", + (SELECT (SELECT code_name FROM comm_code CC WHERE CC.code_id = part_type) + FROM part_mng O WHERE O.objid::varchar = IM.part_objid) AS "PART_TYPE_NAME", + (SELECT material FROM part_mng O WHERE O.objid::varchar = IM.part_objid) AS "MATERIAL", + (SELECT code_name FROM comm_code O + WHERE O.code_id = COALESCE(NULLIF(IMI.location, ''), IM.location)) AS "LOCATION_NAME", + (SELECT code_name FROM comm_code O + WHERE O.code_id = COALESCE(NULLIF(IMI.sub_location, ''), IM.sub_location)) AS "SUB_LOCATION_NAME", + (SELECT sign FROM inventory_mgmt_out_master O WHERE O.objid = IMO.inventory_request_master_objid) AS "SIGN", + (SELECT user_name FROM user_info UI + WHERE UI.user_id = ( + SELECT writer FROM inventory_mgmt_in IMI2 + WHERE IMI2.parent_objid = IMO.parent_objid + ORDER BY regdate DESC LIMIT 1 + )) AS "RECEIVER_NAME", + (SELECT user_name FROM user_info UI WHERE UI.user_id = IMO.acq_user) AS "ACQ_USER_NAME", + IMI.objid AS "IN_OBJID" + FROM inventory_mgmt_out IMO + LEFT OUTER JOIN inventory_mgmt IM ON IM.objid = IMO.parent_objid + LEFT OUTER JOIN inventory_mgmt_in IMI + ON IMO.objid = ANY(STRING_TO_ARRAY(IMI.out_objid, ',')) + WHERE IMO.inventory_request_master_objid = $1 + ORDER BY IMO.regdate`, + [masterObjId], + ); + + return NextResponse.json({ success: true, master, details }); +} diff --git a/src/app/api/inventory/request/history/route.ts b/src/app/api/inventory/request/history/route.ts new file mode 100644 index 0000000..9e3fc65 --- /dev/null +++ b/src/app/api/inventory/request/history/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 입출고 History 팝업 (getInventoryRequestHistoryList 대응) +// 원본: inventoryMng.xml line 2142 +// 입고(inventory_mgmt_in, move_date 없음) + 출고(inventory_mgmt_out) + 이동(inventory_mgmt_in, move_date 있음) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const objId = String(body.objId || body.OBJID || ""); + if (!objId) return NextResponse.json({ RESULTLIST: [] }); + + const rows = await queryRows( + `SELECT + (SELECT MAX(parent_objid) FROM arrival_plan O + WHERE O.part_objid = (SELECT part_objid FROM inventory_mgmt O1 WHERE O1.objid = T.parent_objid)) AS "OBJID", + (SELECT part_no FROM part_mng O + WHERE O.objid = (SELECT part_objid FROM inventory_mgmt O1 WHERE O1.objid = T.parent_objid)) AS "PART_NO", + (SELECT part_name FROM part_mng O + WHERE O.objid = (SELECT part_objid FROM inventory_mgmt O1 WHERE O1.objid = T.parent_objid)) AS "PART_NAME", + '입고' AS "GUBUN", + T.receipt_qty AS "RECEIPT_QTY", + (SELECT code_name FROM comm_code WHERE code_id = T.location) AS "LOCATION_NAME", + (SELECT code_name FROM comm_code WHERE code_id = T.sub_location) AS "SUB_LOCATION_NAME", + (SELECT project_no FROM project_mgmt PM WHERE PM.objid = T.contract_mgmt_objid) AS "PROJECT_NO" + FROM inventory_mgmt_in T + WHERE T.parent_objid = $1 + AND (T.move_date IS NULL OR T.move_date = '') + UNION ALL + SELECT + T2.objid AS "OBJID", + (SELECT part_no FROM part_mng O + WHERE O.objid = (SELECT part_objid FROM inventory_mgmt O1 WHERE O1.objid = T.parent_objid)) AS "PART_NO", + (SELECT part_name FROM part_mng O + WHERE O.objid = (SELECT part_objid FROM inventory_mgmt O1 WHERE O1.objid = T.parent_objid)) AS "PART_NAME", + '출고' AS "GUBUN", + T.out_qty AS "RECEIPT_QTY", + (SELECT code_name FROM comm_code WHERE code_id = T1.location) AS "LOCATION_NAME", + (SELECT code_name FROM comm_code WHERE code_id = T1.sub_location) AS "SUB_LOCATION_NAME", + (SELECT project_no FROM project_mgmt PM WHERE PM.objid = T.contract_mgmt_objid) AS "PROJECT_NO" + FROM inventory_mgmt_out T + LEFT JOIN inventory_mgmt T1 ON T.parent_objid = T1.objid + LEFT JOIN inventory_mgmt_out_master T2 ON T.inventory_request_master_objid = T2.objid + WHERE T.parent_objid = $1 + UNION ALL + SELECT + (SELECT MAX(parent_objid) FROM arrival_plan O + WHERE O.part_objid = (SELECT part_objid FROM inventory_mgmt O1 WHERE O1.objid = T.parent_objid)) AS "OBJID", + (SELECT part_no FROM part_mng O + WHERE O.objid = (SELECT part_objid FROM inventory_mgmt O1 WHERE O1.objid = T.parent_objid)) AS "PART_NO", + (SELECT part_name FROM part_mng O + WHERE O.objid = (SELECT part_objid FROM inventory_mgmt O1 WHERE O1.objid = T.parent_objid)) AS "PART_NAME", + '이동' AS "GUBUN", + T.receipt_qty AS "RECEIPT_QTY", + (SELECT code_name FROM comm_code WHERE code_id = T.location) AS "LOCATION_NAME", + (SELECT code_name FROM comm_code WHERE code_id = T.sub_location) AS "SUB_LOCATION_NAME", + (SELECT project_no FROM project_mgmt PM WHERE PM.objid = T.contract_mgmt_objid) AS "PROJECT_NO" + FROM inventory_mgmt_in T + WHERE T.parent_objid = $1 + AND T.move_date IS NOT NULL + AND T.move_date != '' +`, + [objId], + ); + + return NextResponse.json({ RESULTLIST: rows }); +} diff --git a/src/app/api/inventory/request/parts/route.ts b/src/app/api/inventory/request/parts/route.ts new file mode 100644 index 0000000..462d030 --- /dev/null +++ b/src/app/api/inventory/request/parts/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 불출의뢰/자재이동 팝업에서 선택된 inventory_mgmt OBJID들의 상세정보 +// 원본: inventoryMng.getInventoryMngRequestList (inventoryMng.xml line 1665) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json().catch(() => ({})); + const checkArrRaw = body.checkArr; + const arr: string[] = Array.isArray(checkArrRaw) + ? checkArrRaw.map(String) + : String(checkArrRaw || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (arr.length === 0) { + return NextResponse.json({ RESULTLIST: [] }); + } + + const placeholders = arr.map((_, i) => `$${i + 1}`).join(","); + + const rows = await queryRows( + `SELECT + TBL.*, + (COALESCE(TBL."DESIGN_QTY1", 0) * COALESCE(TBL."QTY1", 0) + * COALESCE(NULLIF(TBL."QTY3", '')::numeric, 1)) AS "DESIGN_QTY" + FROM ( + SELECT + IM.objid::text AS "OBJID", + IM.part_objid AS "PART_OBJID", + (SELECT project_no FROM project_mgmt O WHERE O.objid = IM.contract_objid) AS "PROJECT_NO", + CASE WHEN IMI.receipt_qty IS NULL OR IMI.receipt_qty = '' THEN 0 + ELSE (IMI.receipt_qty::numeric - COALESCE(NULLIF(IMI.out_qty, '')::numeric, 0) - COALESCE(NULLIF(IMI.move_qty, '')::numeric, 0)) + END AS "USE_CNT", + (SELECT SUM(CASE WHEN (receipt_qty IS NULL OR receipt_qty = '') THEN 0 ELSE receipt_qty::numeric END) + FROM inventory_mgmt_in O WHERE O.parent_objid = IM.objid) AS "QTY", + (SELECT O.unit_no || '-' || O.task_name FROM pms_wbs_task O WHERE O.objid = IM.unit) AS "UNIT_NAME", + (SELECT part_no FROM part_mng O WHERE O.objid::varchar = IM.part_objid) AS "PART_NO", + (SELECT part_name FROM part_mng O WHERE O.objid::varchar = IM.part_objid) AS "PART_NAME", + (SELECT spec FROM part_mng O WHERE O.objid::varchar = IM.part_objid) AS "SPEC", + (SELECT maker FROM part_mng O WHERE O.objid::varchar = IM.part_objid) AS "MAKER", + (SELECT (SELECT code_name FROM comm_code CC WHERE CC.code_id = part_type) + FROM part_mng O WHERE O.objid::varchar = IM.part_objid) AS "PART_TYPE_NAME", + (SELECT part_type FROM part_mng O WHERE O.objid::varchar = IM.part_objid) AS "PART_TYPE", + (SELECT code_name FROM comm_code O WHERE O.code_id = IM.cls_cd) AS "CLS_CD_NAME", + IM.cls_cd AS "CLS_CD", + (SELECT code_name FROM comm_code O WHERE O.code_id = IM.cau_cd) AS "CAU_CD_NAME", + IM.cau_cd AS "CAU_CD", + (SELECT material FROM part_mng O WHERE O.objid::varchar = IM.part_objid) AS "MATERIAL", + IMI.location AS "LOCATION", + (SELECT code_name FROM comm_code O WHERE O.code_id = IMI.location) AS "LOCATION_NAME", + IMI.sub_location AS "SUB_LOCATION", + (SELECT code_name FROM comm_code O WHERE O.code_id = IMI.sub_location) AS "SUB_LOCATION_NAME", + IM.reg_date AS "REG_DATE", + IM.price AS "PRICE", + (SELECT user_name FROM user_info O WHERE O.user_id = IM.writer) AS "WRITER_NAME", + IM.contract_objid AS "CONTRACT_OBJID", + IM.unit AS "UNIT", + IMI.objid AS "IN_OBJID", + PBR.objid AS "BOM_REPORT_OBJID", + (SELECT SUM(COALESCE(NULLIF(BPQ.qty, '')::numeric, 0)) + FROM bom_part_qty BPQ + WHERE BPQ.bom_report_objid = PBR.objid + AND BPQ.last_part_objid = IM.part_objid + AND BPQ.status IN ('beforeEdit', 'editing', 'deleting', 'deploy')) AS "DESIGN_QTY1", + (SELECT MAX(COALESCE(NULLIF(BPQ.qty, '')::numeric, 0)) + FROM bom_part_qty BPQ + WHERE BPQ.bom_report_objid = PBR.objid + AND COALESCE(BPQ.parent_part_no, '') = '' + AND BPQ.status IN ('beforeEdit', 'editing', 'deleting', 'deploy')) AS "QTY1", + (SELECT SUM(COALESCE(NULLIF(BPQ3.qty, '')::numeric, 0))::text + FROM bom_part_qty BPQ3 + WHERE BPQ3.bom_report_objid = PBR.objid + AND BPQ3.child_objid = ( + SELECT parent_objid FROM bom_part_qty BPQ + WHERE BPQ.bom_report_objid = PBR.objid + AND BPQ.last_part_objid = IM.part_objid + AND BPQ.status IN ('beforeEdit', 'editing', 'deleting', 'deploy') + LIMIT 1 + )) AS "QTY3", + (SELECT SUM(COALESCE(NULLIF(pre_booking_qty, '')::numeric, 0)) + FROM sales_bom_report_part SBRP + WHERE SBRP.parent_objid = PBR.objid AND SBRP.part_objid = IM.part_objid) AS "PRE_BOOKING_QTY", + IMI.out_qty AS "OUT_QTY" + FROM inventory_mgmt IM + LEFT OUTER JOIN inventory_mgmt_in IMI ON IMI.parent_objid = IM.objid + LEFT OUTER JOIN part_bom_report PBR + ON PBR.contract_objid = IM.contract_objid AND PBR.unit_code = IM.unit + WHERE IM.objid IN (${placeholders}) + ) TBL + WHERE COALESCE("USE_CNT", 0) != 0 + ORDER BY "PART_NO" +`, + arr, + ); + + return NextResponse.json({ RESULTLIST: rows }); +} diff --git a/src/app/api/inventory/request/receipt/route.ts b/src/app/api/inventory/request/receipt/route.ts new file mode 100644 index 0000000..a7fd8f9 --- /dev/null +++ b/src/app/api/inventory/request/receipt/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { getDate } from "@/lib/utils"; + +// 불출의뢰서 접수 (receiptInventoryRequestInfo / mergeReceiptInventoryRequestInfo 대응) +// 원본: InventoryMngController.receiptInventoryRequestInfo, inventoryMng.xml line 2073 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const checkArr: string[] = Array.isArray(body.checkArr) + ? body.checkArr.map(String) + : String(body.checkArr || "") + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean); + + if (checkArr.length === 0) { + return NextResponse.json({ success: false, message: "선택된 데이터가 없습니다." }); + } + + try { + for (const objId of checkArr) { + await execute( + `UPDATE inventory_mgmt_out_master + SET reception_status = $1, + reception_date = $2, + reception_id = $3 + WHERE objid = $4`, + ["reception", getDate(), user.userId, objId], + ); + } + return NextResponse.json({ success: true, message: "접수되었습니다." }); + } catch (e) { + console.error("inventory request receipt:", e); + return NextResponse.json( + { success: false, message: "오류가 발생하였습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/inventory/request/route.ts b/src/app/api/inventory/request/route.ts new file mode 100644 index 0000000..a7af3a4 --- /dev/null +++ b/src/app/api/inventory/request/route.ts @@ -0,0 +1,140 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 자재관리 > 불출의뢰서 (inventoryMng.materialRequesrGridtList 대응) +// 원본: /Users/jhj/FITO/src/com/pms/mapper/inventoryMng.xml (line 1957) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + + // 내부 서브쿼리 조건 (INVENTORY_MGMT_OUT_MASTER 기준) + const innerConds: string[] = ["1=1"]; + const innerParams: unknown[] = []; + let idx = 1; + + if (body.Year) { + innerConds.push(`TO_CHAR(IM.regdate, 'YYYY') = $${idx++}`); + innerParams.push(body.Year); + } + if (body.project_no) { + innerConds.push(`IM.contract_mgmt_objid = $${idx++}`); + innerParams.push(body.project_no); + } + if (body.request_user) { + innerConds.push(`IM.writer = $${idx++}`); + innerParams.push(body.request_user); + } + if (body.reception_user) { + innerConds.push(`IM.reception_id = $${idx++}`); + innerParams.push(body.reception_user); + } + if (body.reception_status) { + innerConds.push(`IM.reception_status = $${idx++}`); + innerParams.push(body.reception_status); + } + if (body.out_status) { + innerConds.push( + `(CASE WHEN IM.outstatus = 'complete' THEN 'complete' ELSE 'NG' END) = $${idx++}`, + ); + innerParams.push(body.out_status); + } + if (body.request_start_date) { + innerConds.push(`TO_DATE(IM.request_date, 'YYYY-MM-DD') >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + innerParams.push(body.request_start_date); + } + if (body.request_end_date) { + innerConds.push(`TO_DATE(IM.request_date, 'YYYY-MM-DD') <= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + innerParams.push(body.request_end_date); + } + if (body.reception_start_date) { + innerConds.push( + `TO_DATE(IM.reception_date, 'YYYY-MM-DD') >= TO_DATE($${idx++}, 'YYYY-MM-DD')`, + ); + innerParams.push(body.reception_start_date); + } + if (body.reception_end_date) { + innerConds.push( + `TO_DATE(IM.reception_date, 'YYYY-MM-DD') <= TO_DATE($${idx++}, 'YYYY-MM-DD')`, + ); + innerParams.push(body.reception_end_date); + } + + // 외부 조건 (PART_NO_ARR/PART_NAME_ARR — 서브쿼리 결과에 적용) + const outerConds: string[] = ["1=1"]; + const outerParams: unknown[] = []; + + if (body.part_no) { + outerConds.push(`UPPER(PART_NO_ARR) LIKE '%' || TRIM(UPPER($${idx++})) || '%'`); + outerParams.push(body.part_no); + } + if (body.part_name) { + outerConds.push(`UPPER(PART_NAME_ARR) LIKE '%' || TRIM(UPPER($${idx++})) || '%'`); + outerParams.push(body.part_name); + } + + const params = [...innerParams, ...outerParams]; + + const rows = await queryRows( + `SELECT IM.* + FROM ( + SELECT + IM.objid::text AS "OBJID", + (SELECT STRING_AGG(part_no, ',') FROM part_mng O + WHERE O.objid IN ( + SELECT part_objid FROM inventory_mgmt O1 + WHERE O1.objid IN ( + SELECT parent_objid FROM inventory_mgmt_out O2 + WHERE O2.inventory_request_master_objid = IM.objid + ) + )) AS "PART_NO_ARR", + (SELECT STRING_AGG(part_name, ',') FROM part_mng O + WHERE O.objid IN ( + SELECT part_objid FROM inventory_mgmt O1 + WHERE O1.objid IN ( + SELECT parent_objid FROM inventory_mgmt_out O2 + WHERE O2.inventory_request_master_objid = IM.objid + ) + )) AS "PART_NAME_ARR", + IM.parent_objid AS "PARENT_OBJID", + IM.inventory_out_no AS "INVENTORY_OUT_NO", + IM.request_date AS "REQUEST_DATE", + IM.request_id AS "REQUEST_ID", + IM.reception_status AS "RECEPTION_STATUS", + CASE WHEN IM.reception_status = 'reception' THEN '접수' ELSE '미접수' END + AS "RECEPTION_STATUS_TITLE", + IM.reception_id AS "RECEPTION_ID", + (SELECT user_name FROM user_info O WHERE O.user_id = IM.reception_id) + AS "RECEPTION_USER_NAME", + IM.reception_date AS "RECEPTION_DATE", + IM.outstatus AS "OUTSTATUS", + CASE WHEN IM.outstatus = 'complete' THEN '완료' ELSE '미완료' END + AS "OUTSTATUS_TITLE", + IM.writer AS "WRITER", + (SELECT user_name FROM user_info O WHERE O.user_id = IM.writer) AS "REQUEST_USER_NAME", + IM.regdate AS "REGDATE", + TO_CHAR(IM.regdate, 'YYYY-MM-DD') AS "REG_DATE", + IMO.request_qty AS "REQUEST_QTY", + IMO.out_qty AS "OUT_QTY", + IMO.inventory_request_master_objid AS "INVENTORY_REQUEST_MASTER_OBJID" + FROM inventory_mgmt_out_master IM + LEFT OUTER JOIN ( + SELECT + inventory_request_master_objid, + SUM(COALESCE(NULLIF(request_qty, '')::numeric, 0)) AS request_qty, + SUM(COALESCE(NULLIF(out_qty, '')::numeric, 0)) AS out_qty + FROM inventory_mgmt_out + GROUP BY inventory_request_master_objid + ) IMO ON IMO.inventory_request_master_objid = IM.objid + WHERE ${innerConds.join(" AND ")} + ) IM + WHERE ${outerConds.join(" AND ")} + ORDER BY "REGDATE" DESC +`, + params, + ); + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/inventory/request/save/route.ts b/src/app/api/inventory/request/save/route.ts new file mode 100644 index 0000000..c8298e9 --- /dev/null +++ b/src/app/api/inventory/request/save/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { getDate } from "@/lib/utils"; + +// 불출의뢰서 저장 (inventoryMng.saveInventoryRequest + mergeInventoryRequestMasterInfo 대응) +// 원본: inventoryMng.xml line 1781, 1902 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const masterObjId = String(body.INVENTORY_REQUEST_MASTER_OBJID || ""); + if (!masterObjId) { + return NextResponse.json({ success: false, message: "마스터 OBJID 누락" }, { status: 400 }); + } + const remark = String(body.REMARK || ""); + const requestDate = String(body.REQUEST_DATE || getDate()); + const lines: Array> = Array.isArray(body.lines) ? body.lines : []; + + // 첫 라인의 contract_mgmt_objid를 마스터 contract_mgmt_objid로 사용 + const firstContractObjId = String(lines[0]?.CONTRACT_MGMT_OBJID || ""); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1. inventory_mgmt_out_master UPSERT + // INVENTORY_OUT_NO 자동 생성: Rfw-YYYY-N (연도별 증가, 패딩 없음 — 원본 FITO 포맷) + // 원본: request_id와 writer 모두 current user id. $5를 별도 파라미터로 전달해 타입 추론 충돌 회피 + const year = new Date().getFullYear(); + await client.query( + `INSERT INTO inventory_mgmt_out_master + (objid, parent_objid, inventory_out_no, request_date, request_id, + writer, regdate, remark, contract_mgmt_objid) + VALUES ( + $1, $2, + (SELECT 'Rfw-' || $3 || '-' || + (COALESCE(MAX(CASE WHEN SPLIT_PART(inventory_out_no, '-', 3) = '' + OR inventory_out_no IS NULL + THEN 0 + ELSE SPLIT_PART(inventory_out_no, '-', 3)::numeric + END), 0)::integer + 1)::text + FROM inventory_mgmt_out_master), + $4, $5, $6, now(), $7, $8 + ) + ON CONFLICT (objid) DO UPDATE SET + remark = EXCLUDED.remark`, + [ + masterObjId, + String(body.PARENT_OBJID || ""), + String(year), + requestDate, + user.userId, + user.userId, + remark, + firstContractObjId, + ], + ); + + // 2. 기존 파트 삭제 (initInventoryRequestPart) + await client.query( + `DELETE FROM inventory_mgmt_out WHERE inventory_request_master_objid = $1`, + [masterObjId], + ); + + // 3. 라인 INSERT + for (const line of lines) { + await client.query( + `INSERT INTO inventory_mgmt_out + (objid, parent_objid, request_qty, writer, regdate, + inventory_request_master_objid, contract_mgmt_objid, unit) + VALUES ($1, $2, $3, $4, now(), $5, $6, $7)`, + [ + String(line.OBJID || ""), + String(line.PARENT_OBJID || ""), + String(line.REQUEST_QTY || "0"), + user.userId, + masterObjId, + String(line.CONTRACT_MGMT_OBJID || ""), + String(line.UNIT || ""), + ], + ); + } + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("inventory request save:", e); + return NextResponse.json( + { success: false, message: "오류가 발생하였습니다." }, + { status: 500 }, + ); + } finally { + client.release(); + } +} diff --git a/src/app/api/inventory/request/sign/route.ts b/src/app/api/inventory/request/sign/route.ts new file mode 100644 index 0000000..6256dc6 --- /dev/null +++ b/src/app/api/inventory/request/sign/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 사인 조회 (materialRequestDetailPopUpsign) / 저장 (savesignInventoryTransfer) / 삭제 (materialRequestDetailPopUpsigndelete) +// 원본: inventoryMng.xml line 2082, 1883, 2093 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const action = String(body.action || "list"); + + if (action === "list") { + // OBJID들의 사인 목록 + const checkArr: string[] = Array.isArray(body.checkArr) + ? body.checkArr.map(String) + : String(body.checkArr || "") + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean); + if (checkArr.length === 0) return NextResponse.json([]); + const ph = checkArr.map((_, i) => `$${i + 1}`).join(","); + const rows = await queryRows( + `SELECT objid AS "OBJID", acq_user AS "ACQ_USER", sign AS "SIGN" + FROM inventory_mgmt_out + WHERE objid IN (${ph})`, + checkArr, + ); + return NextResponse.json(rows); + } + + if (action === "save") { + // 단일 또는 다중 OBJID에 사인 저장 + const lines: Array<{ OBJID: string; ACQ_USER: string; SIGN: string }> = Array.isArray(body.lines) + ? body.lines + : []; + try { + for (const line of lines) { + await execute( + `INSERT INTO inventory_mgmt_out (objid, acq_user, sign) + VALUES ($1, $2, $3) + ON CONFLICT (objid) DO UPDATE SET + acq_user = EXCLUDED.acq_user, + sign = EXCLUDED.sign`, + [line.OBJID, line.ACQ_USER || "", line.SIGN || ""], + ); + } + return NextResponse.json({ success: true, message: "사인이 등록 되었습니다." }); + } catch (e) { + console.error("sign save:", e); + return NextResponse.json( + { success: false, message: "오류가 발생하였습니다." }, + { status: 500 }, + ); + } + } + + if (action === "delete") { + const objIds: string[] = Array.isArray(body.objIds) ? body.objIds.map(String) : []; + try { + for (const objId of objIds) { + await execute( + `INSERT INTO inventory_mgmt_out (objid, sign) + VALUES ($1, '') + ON CONFLICT (objid) DO UPDATE SET sign = ''`, + [objId], + ); + } + return NextResponse.json({ success: true, message: "삭제되었습니다.", result: "true" }); + } catch (e) { + console.error("sign delete:", e); + return NextResponse.json( + { success: false, message: "오류가 발생하였습니다." }, + { status: 500 }, + ); + } + } + + return NextResponse.json({ success: false, message: "지원하지 않는 action" }, { status: 400 }); +} diff --git a/src/app/api/inventory/request/transfer/route.ts b/src/app/api/inventory/request/transfer/route.ts new file mode 100644 index 0000000..6daa9c0 --- /dev/null +++ b/src/app/api/inventory/request/transfer/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 불출의뢰서 인계 저장 (saveInventoryTransfer 대응) +// 원본: InventoryMngController.saveInventoryTransfer, inventoryMng.xml line 1848 +// 각 라인의 OUT_QTY/OUT_DATE/ACQ_USER/WRITER 업데이트 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const lines: Array> = Array.isArray(body.lines) ? body.lines : []; + if (lines.length === 0) { + return NextResponse.json({ success: false, message: "저장할 라인이 없습니다." }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + for (const line of lines) { + await client.query( + `INSERT INTO inventory_mgmt_out + (objid, parent_objid, request_qty, writer, regdate, + inventory_request_master_objid, sign) + VALUES ($1, $2, $3, $4, NOW(), $5, $6) + ON CONFLICT (objid) DO UPDATE SET + out_qty = $7, + out_date = $8, + acq_user = $9, + writer = $4`, + [ + String(line.OBJID || ""), + String(line.PARENT_OBJID || ""), + String(line.REQUEST_QTY || "0"), + user.userId, + String(line.INVENTORY_REQUEST_MASTER_OBJID || ""), + String(line.SIGN || ""), + String(line.OUT_QTY || "0"), + String(line.OUT_DATE || ""), + String(line.ACQ_USER || ""), + ], + ); + } + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("inventory request transfer:", e); + return NextResponse.json( + { success: false, message: "오류가 발생하였습니다." }, + { status: 500 }, + ); + } finally { + client.release(); + } +} diff --git a/src/app/api/inventory/route.ts b/src/app/api/inventory/route.ts new file mode 100644 index 0000000..4bed05e --- /dev/null +++ b/src/app/api/inventory/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// inventoryMng/inventoryMngInputGridList.do 대응 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`LEFT(IM.reg_date, 4) = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`CM.contract_no = $${idx++}`); + params.push(body.project_no); + } + if (body.part_no) { + conditions.push(`P.part_no LIKE '%' || $${idx++} || '%'`); + params.push(body.part_no); + } + if (body.part_name) { + conditions.push(`P.part_name LIKE '%' || $${idx++} || '%'`); + params.push(body.part_name); + } + if (body.spec) { + conditions.push(`P.spec LIKE '%' || $${idx++} || '%'`); + params.push(body.spec); + } + if (body.cls_cd) { + conditions.push(`IM.cls_cd = $${idx++}`); + params.push(body.cls_cd); + } + if (body.cau_cd) { + conditions.push(`IM.cau_cd = $${idx++}`); + params.push(body.cau_cd); + } + if (body.location) { + conditions.push(`IM.location = $${idx++}`); + params.push(body.location); + } + if (body.writer) { + conditions.push(`IM.writer = $${idx++}`); + params.push(body.writer); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT IM.objid::text AS "OBJID", + CM.contract_no AS "PROJECT_NO", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = POM.unit_code LIMIT 1), IM.unit) AS "UNIT_NAME", + P.part_no AS "PART_NO", P.part_name AS "PART_NAME", P.spec AS "SPEC", P.maker AS "MAKER", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = IM.cls_cd LIMIT 1), '') AS "CLS_CD_NAME", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = IM.cau_cd LIMIT 1), '') AS "CAU_CD_NAME", + COALESCE(IM.qty::numeric, 0) AS "QTY", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = IM.location LIMIT 1), '') AS "LOCATION_NAME", + COALESCE(IM.price::numeric, 0) AS "PRICE", + IM.reg_date AS "REG_DATE", + COALESCE((SELECT user_name FROM user_info WHERE user_id = IM.writer LIMIT 1), '') AS "WRITER_NAME", + -- 총입고수량 = SUM(inventory_mgmt_in.receipt_qty) + COALESCE((SELECT SUM(COALESCE(NULLIF(IMI.receipt_qty,'')::numeric, 0)) + FROM inventory_mgmt_in IMI WHERE IMI.parent_objid = IM.objid), 0) AS "INPUT_QTY", + -- 최종입고일 + COALESCE((SELECT MAX(TO_CHAR(IMI2.regdate, 'YYYY-MM-DD')) + FROM inventory_mgmt_in IMI2 WHERE IMI2.parent_objid = IM.objid), '') AS "INPUT_DATE", + -- 잔여수량 = qty - SUM(투입이력 input_qty) + COALESCE(NULLIF(IM.qty,'')::numeric, 0) + - COALESCE((SELECT SUM(COALESCE(IMH.input_qty::numeric, 0)) + FROM inventory_mgmt_history IMH WHERE IMH.parent_objid = IM.objid), 0) AS "REMAIN_QTY" + FROM inventory_mgmt IM + LEFT JOIN part_mng P ON P.objid = IM.part_objid + LEFT JOIN contract_mgmt CM ON CM.objid::text = IM.contract_objid + WHERE ${where} + ORDER BY IM.reg_date DESC NULLS LAST + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/inventory/save/route.ts b/src/app/api/inventory/save/route.ts new file mode 100644 index 0000000..61705d4 --- /dev/null +++ b/src/app/api/inventory/save/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 재고 입고 등록 (saveinventoryForm / insertInventoryIn 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const body = await request.json(); + const objId = body.objId || createObjectId(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1. inventory_mgmt 마스터 UPSERT + await client.query( + `INSERT INTO inventory_mgmt (objid, contract_objid, unit, part_objid, + cls_cd, cau_cd, qty, location, sub_location, price, writer, reg_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now()) + ON CONFLICT (contract_objid, unit, part_objid) DO UPDATE SET + cls_cd=EXCLUDED.cls_cd, cau_cd=EXCLUDED.cau_cd, qty=EXCLUDED.qty, + location=EXCLUDED.location, sub_location=EXCLUDED.sub_location, + price=EXCLUDED.price`, + [objId, body.contract_objid||"", body.unit||"", body.part_objid||"", + body.cls_cd||"", body.cau_cd||"", body.qty||"0", + body.location||"", body.sub_location||"", body.price||"0", user.userId] + ); + + // 2. inventory_mgmt_in 트랜잭션 생성 (입고수량 있을 때) + if (body.receipt_qty && Number(body.receipt_qty) > 0) { + await client.query( + `INSERT INTO inventory_mgmt_in ( + objid, parent_objid, receipt_qty, location, sub_location, + writer, regdate, contract_mgmt_objid + ) VALUES ($1, $2, $3, $4, $5, $6, now(), $7)`, + [createObjectId(), objId, String(body.receipt_qty), + body.location||"", body.sub_location||"", user.userId, + body.contract_mgmt_objid || body.contract_objid || ""] + ); + } + + await client.query("COMMIT"); + return NextResponse.json({ success: true, objId, message: "등록되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("Inventory save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { client.release(); } +} diff --git a/src/app/api/inventory/status/route.ts b/src/app/api/inventory/status/route.ts new file mode 100644 index 0000000..da8941f --- /dev/null +++ b/src/app/api/inventory/status/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 자재관리 > 현황 (inventoryMngDashList 대응) +// 프로젝트별 입고/불출/잔여/이동 수량 집계 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`LEFT(IM.reg_date, 4) = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`CM.contract_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.location_cd) { + conditions.push(`EXISTS (SELECT 1 FROM inventory_mgmt_in I_F WHERE I_F.parent_objid = IM.objid AND I_F.location = $${idx++})`); + params.push(body.location_cd); + } + if (body.part_type_cd) { + conditions.push(`P.part_type = $${idx++}`); + params.push(body.part_type_cd); + } + + const where = conditions.join(" AND "); + + const rows = await queryRows( + `SELECT CM.contract_no AS "PROJECT_NO", + IM.unit AS "UNIT_NAME", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = P.part_type LIMIT 1), '') AS "PART_TYPE_NAME", + COALESCE((SELECT ARRAY_TO_STRING(ARRAY_AGG(DISTINCT (SELECT code_name FROM comm_code WHERE code_id = I_L.location LIMIT 1)), ', ') + FROM inventory_mgmt_in I_L WHERE I_L.parent_objid = IM.objid), '') AS "LOCATION_NAME", + -- 총보유(입고) = SUM(receipt_qty - move_qty) + COALESCE((SELECT SUM(CASE WHEN IMI.receipt_qty IS NULL OR IMI.receipt_qty = '' THEN 0 + ELSE IMI.receipt_qty::numeric - COALESCE(NULLIF(IMI.move_qty,'')::numeric, 0) END) + FROM inventory_mgmt_in IMI WHERE IMI.parent_objid = IM.objid), 0) AS "TOTAL_QTY", + -- 불출수량 + COALESCE((SELECT SUM(COALESCE(NULLIF(IMO.request_qty,'')::numeric, 0)) + FROM inventory_mgmt_out IMO WHERE IMO.parent_objid = IM.objid), 0) AS "REQUEST_QTY", + -- 잔여 = 입고 - 불출 + (COALESCE((SELECT SUM(CASE WHEN IMI2.receipt_qty IS NULL OR IMI2.receipt_qty = '' THEN 0 + ELSE IMI2.receipt_qty::numeric - COALESCE(NULLIF(IMI2.move_qty,'')::numeric, 0) END) + FROM inventory_mgmt_in IMI2 WHERE IMI2.parent_objid = IM.objid), 0) + - COALESCE((SELECT SUM(COALESCE(NULLIF(IMO2.request_qty,'')::numeric, 0)) + FROM inventory_mgmt_out IMO2 WHERE IMO2.parent_objid = IM.objid), 0) + ) AS "REMAIN_QTY", + -- 입고수량 (receipt_qty 합계) + COALESCE((SELECT SUM(COALESCE(NULLIF(IMI3.receipt_qty,'')::numeric, 0)) + FROM inventory_mgmt_in IMI3 WHERE IMI3.parent_objid = IM.objid), 0) AS "DELIVERY_QTY", + -- 이동수량 + COALESCE((SELECT SUM(COALESCE(NULLIF(IMI4.move_qty,'')::numeric, 0)) + FROM inventory_mgmt_in IMI4 WHERE IMI4.parent_objid = IM.objid), 0) AS "MOVE_QTY" + FROM inventory_mgmt IM + LEFT JOIN part_mng P ON P.objid = IM.part_objid + LEFT JOIN contract_mgmt CM ON CM.objid::text = IM.contract_objid + WHERE ${where} + ORDER BY CM.contract_no, IM.unit +`, + params + ); + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/m/users/list/route.ts b/src/app/api/m/users/list/route.ts new file mode 100644 index 0000000..a3740d9 --- /dev/null +++ b/src/app/api/m/users/list/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { requireMomoAdmin } from "@/lib/momo-guard"; + +export async function POST() { + const g = await requireMomoAdmin(); + if (g instanceof NextResponse) return g; + + const rows = await queryRows( + `SELECT objid AS "OBJID", email AS "EMAIL", company_name AS "COMPANY_NAME", + ceo_name AS "CEO_NAME", phone AS "PHONE", biz_no AS "BIZ_NO", + role AS "ROLE", status AS "STATUS", + TO_CHAR(regdate, 'YYYY-MM-DD') AS "REGDATE" + FROM momo_users WHERE COALESCE(is_del,'N') != 'Y' + ORDER BY regdate DESC` + ); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/m/users/save/route.ts b/src/app/api/m/users/save/route.ts new file mode 100644 index 0000000..652c0bf --- /dev/null +++ b/src/app/api/m/users/save/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { requireMomoAdmin } from "@/lib/momo-guard"; + +export async function POST(req: NextRequest) { + const g = await requireMomoAdmin(); + if (g instanceof NextResponse) return g; + + const { objid, role, status } = await req.json(); + if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 }); + + const sets: string[] = []; + const params: unknown[] = [objid]; + let i = 2; + if (role) { sets.push(`role = $${i++}`); params.push(role); } + if (status) { sets.push(`status = $${i++}`); params.push(status); } + if (sets.length === 0) return NextResponse.json({ success: false, message: "변경 사항 없음" }); + + sets.push(`update_date = NOW()`); + await execute(`UPDATE momo_users SET ${sets.join(", ")} WHERE objid = $1`, params); + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/order/amount-status/route.ts b/src/app/api/order/amount-status/route.ts new file mode 100644 index 0000000..9b1caea --- /dev/null +++ b/src/app/api/order/amount-status/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: purchaseOrder.xml#purchaseOrderStatusAmountBySupply +// 발주관리 > 업체별_입고요청월 금액현황 +// 입고요청일 기준 월별(M01~M12) 공급단가 합계를 업체(admin_supply_mng)별로 집계. +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const year = body.year ? String(body.year) : ""; + const projectNo = body.project_no ? String(body.project_no) : ""; + const salesMngUserId = body.sales_mng_user_id ? String(body.sales_mng_user_id) : ""; + const partnerObjId = body.partner_objid ? String(body.partner_objid) : ""; + + // $1=year, $2=project_no(LIKE), $3=sales_mng_user_id, $4=partner_objid + const params: unknown[] = [ + year || null, + projectNo || null, + salesMngUserId || null, + partnerObjId || null, + ]; + + const coreSql = ` + SELECT POM.partner_objid, + SUM(COALESCE(NULLIF(POP.supply_unit_price,''),'0')::numeric) AS total_supply_unit_price, + ${Array.from({ length: 12 }, (_, i) => { + const mm = String(i + 1).padStart(2, "0"); + return `SUM(CASE WHEN TO_CHAR(TO_DATE(POM.delivery_date,'YYYY-MM-DD'),'MM') = '${mm}' + THEN COALESCE(NULLIF(POP.supply_unit_price,''),'0')::numeric END) AS m${mm}`; + }).join(",\n ")} + FROM purchase_order_master POM + JOIN purchase_order_part POP ON POM.objid = POP.purchase_order_master_objid + JOIN project_mgmt CM ON POM.contract_mgmt_objid = CM.objid + WHERE POM.status = 'approvalComplete' + AND COALESCE(POM.delivery_date,'') <> '' + AND ($1::text IS NULL OR TO_CHAR(TO_DATE(POM.delivery_date,'YYYY-MM-DD'),'YYYY') = $1) + AND ($2::text IS NULL OR COALESCE(CM.project_no,'') ILIKE '%' || $2 || '%' + OR COALESCE(CM.contract_no,'') ILIKE '%' || $2 || '%') + AND ($3::text IS NULL OR POM.sales_mng_user_id = $3) + GROUP BY POM.partner_objid + `; + + const listSql = ` + SELECT ASM.objid::text AS "OBJID", + ASM.supply_name AS "SUPPLY_NAME", + COALESCE(S1.total_supply_unit_price, 0) AS "TOTAL_SUPPLY_UNIT_PRICE", + ${Array.from({ length: 12 }, (_, i) => { + const mm = String(i + 1).padStart(2, "0"); + return `COALESCE(S1.m${mm}, 0) AS "M${mm}"`; + }).join(",\n ")} + FROM admin_supply_mng ASM + JOIN (${coreSql}) S1 ON ASM.objid::varchar = S1.partner_objid + WHERE ($4::text IS NULL OR ASM.objid::text = $4) + ORDER BY ASM.supply_name + `; + + const sumSql = ` + SELECT COALESCE(SUM(total_supply_unit_price), 0) AS "TOTAL_SUPPLY_UNIT_PRICE", + ${Array.from({ length: 12 }, (_, i) => { + const mm = String(i + 1).padStart(2, "0"); + return `COALESCE(SUM(m${mm}), 0) AS "M${mm}"`; + }).join(",\n ")} + FROM ( + ${coreSql} + ) S1 + JOIN admin_supply_mng ASM ON ASM.objid::varchar = S1.partner_objid + WHERE ($4::text IS NULL OR ASM.objid::text = $4) + `; + + try { + const rows = await queryRows(listSql, params); + const sums = await queryOne(sumSql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length, SUMS: sums }); + } catch (e) { + console.error("order/amount-status:", e); + return NextResponse.json( + { success: false, message: "조회 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/order/bom-parts-by-partner/route.ts b/src/app/api/order/bom-parts-by-partner/route.ts new file mode 100644 index 0000000..facef30 --- /dev/null +++ b/src/app/api/order/bom-parts-by-partner/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: purchaseOrder.purchaseOrderSalesBomPartListByPartnerContract +// 프로젝트 × 유닛 × 공급업체 기반으로 구매BOM(sales_bom_report_part)에서 +// 해당 공급업체가 등록된 부품을 발주서 작성 시 자동 투입용으로 조회. +// 선택된 파트너가 supply_objid/1/2/3/4 중 어느 슬롯에 있든 그 슬롯의 단가만 +// 해당 컬럼(PARTNER_PRICE or PRICE1..4)에 복사, 나머지는 '0'. +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const contractObjId = String(body.contract_mgmt_objid || body.CONTRACT_MGMT_OBJID || ""); + const unitCode = String(body.unit_code || body.UNIT_CODE || ""); + const partnerObjId = String(body.partner_objid || body.PARTNER_OBJID || ""); + if (!contractObjId || !partnerObjId) { + return NextResponse.json({ RESULTLIST: [] }); + } + + const sql = ` + WITH RECURSIVE VIEW_BOM AS ( + SELECT A.bom_report_objid, A.objid, A.parent_objid, A.child_objid, + A.parent_part_no, A.part_no, A.qty, A.seq, + 1 AS lev, + ARRAY[A.child_objid::text] AS path, + COALESCE(NULLIF(A.qty,''),'0')::numeric AS aggregate_qty + FROM part_bom_report PBR + JOIN bom_part_qty A ON PBR.objid = A.bom_report_objid + WHERE PBR.contract_objid = $1 + AND ($2::text = '' OR PBR.unit_code = $2) + AND (A.parent_objid IS NULL OR A.parent_objid = '') + AND A.status = 'deploy' + UNION ALL + SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, + B.parent_part_no, B.part_no, B.qty, B.seq, + V.lev + 1, + V.path || B.child_objid::text, + V.aggregate_qty * COALESCE(NULLIF(B.qty,''),'0')::numeric + FROM bom_part_qty B + JOIN VIEW_BOM V ON B.parent_objid = V.child_objid + AND V.bom_report_objid = B.bom_report_objid + WHERE B.status = 'deploy' + AND NOT (B.child_objid = ANY(V.path)) + ) + SELECT base.*, + -- 재고수량 = 입고수량 합 - 출고요청수량 합 + (COALESCE((SELECT SUM(CASE WHEN COALESCE(NULLIF(receipt_qty,''),'') = '' THEN 0 + ELSE receipt_qty::numeric END) + FROM inventory_mgmt IM + INNER JOIN inventory_mgmt_in O ON O.parent_objid = IM.objid + WHERE IM.part_objid = base."PART_OBJID"), 0) + - COALESCE((SELECT SUM(CASE WHEN COALESCE(NULLIF(request_qty,''),'') = '' THEN 0 + ELSE request_qty::numeric END) + FROM inventory_mgmt IM + INNER JOIN inventory_mgmt_out O ON O.parent_objid = IM.objid + WHERE IM.part_objid = base."PART_OBJID"), 0) + ) AS "STOCK_QTY", + -- 총발주수량 = 이 품번으로 approvalComplete 된 이전 발주 누적 + COALESCE((SELECT SUM(COALESCE(NULLIF(POP.order_qty,''),'0')::numeric) + FROM purchase_order_part POP + JOIN purchase_order_master POM + ON POM.objid = POP.purchase_order_master_objid + WHERE POP.part_objid = base."PART_OBJID" + AND POM.status = 'approvalComplete'), 0) AS "TOTAL_ORDER_QTY" + FROM ( + SELECT T1.parent_objid AS "BOM_REPORT_OBJID", + PM.objid::text AS "PART_OBJID", + T1.objid::text AS "SALES_BOM_REPORT_PART_OBJID", + PM.part_name AS "PART_NAME", + PM.part_no AS "PART_NO", + PM.spec AS "SPEC", + PM.maker AS "MAKER", + PM.remark AS "REMARK", + PM.unit AS "UNIT", + (SELECT MAX(aggregate_qty) FROM VIEW_BOM V + WHERE V.child_objid = BPQ.child_objid + AND V.bom_report_objid = PBR.objid) AS "ORDER_QTY", + BPQ.seq AS "SEQ_SORT", + CASE WHEN T1.supply_objid = $3 THEN T1.price ELSE '0' END AS "PARTNER_PRICE", + CASE WHEN T1.supply_objid1 = $3 THEN T1.price1 ELSE '0' END AS "PRICE1", + CASE WHEN T1.supply_objid2 = $3 THEN T1.price2 ELSE '0' END AS "PRICE2", + CASE WHEN T1.supply_objid3 = $3 THEN T1.price3 ELSE '0' END AS "PRICE3", + CASE WHEN T1.supply_objid4 = $3 THEN T1.price4 ELSE '0' END AS "PRICE4" + FROM sales_bom_report_part T1 + JOIN part_bom_report PBR ON T1.parent_objid = PBR.objid::text + JOIN bom_part_qty BPQ ON PBR.objid = BPQ.bom_report_objid + AND T1.bom_part_qty_objid = BPQ.child_objid + JOIN part_mng PM ON BPQ.last_part_objid = PM.objid::varchar + WHERE PBR.contract_objid = $1 + AND ($2::text = '' OR PBR.unit_code = $2) + AND BPQ.status = 'deploy' + AND COALESCE(PM.part_type,'') <> '0001788' + AND (T1.supply_objid = $3 + OR T1.supply_objid1 = $3 + OR T1.supply_objid2 = $3 + OR T1.supply_objid3 = $3 + OR T1.supply_objid4 = $3) + ) base + ORDER BY base."SEQ_SORT" NULLS LAST + `; + + try { + const rows = await queryRows(sql, [contractObjId, unitCode, partnerObjId]); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); + } catch (e) { + console.error("order/bom-parts-by-partner:", e); + return NextResponse.json( + { success: false, message: "BOM 부품 조회 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/order/detail/route.ts b/src/app/api/order/detail/route.ts new file mode 100644 index 0000000..8741009 --- /dev/null +++ b/src/app/api/order/detail/route.ts @@ -0,0 +1,171 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne, queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: purchaseOrder.xml#getPurchaseOrderMasterInfo + getPurchaseOrderTargetPartList +// 발주서 상세 — master 헤더 + parts 목록 (읽기전용 팝업용) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + const info = await queryOne( + `SELECT POM.objid::text AS "OBJID", + POM.purchase_order_no AS "PURCHASE_ORDER_NO", + POM.title AS "TITLE", + POM.contract_mgmt_objid::text AS "CONTRACT_MGMT_OBJID", + (SELECT project_no FROM project_mgmt CM WHERE CM.objid = POM.contract_mgmt_objid) AS "PROJECT_NO", + (SELECT customer_project_name FROM project_mgmt CM WHERE CM.objid = POM.contract_mgmt_objid) AS "CUSTOMER_PROJECT_NAME", + (SELECT contract_no FROM project_mgmt CM WHERE CM.objid = POM.contract_mgmt_objid) AS "CONTRACT_NO", + (SELECT supply_name FROM supply_mng SM WHERE SM.objid::text = + (SELECT customer_objid FROM project_mgmt CM WHERE CM.objid = POM.contract_mgmt_objid)::text LIMIT 1) AS "CUSTOMER_NAME", + POM.type AS "TYPE", + (SELECT code_name FROM comm_code WHERE code_id = POM.type LIMIT 1) AS "TYPE_NAME", + POM.order_type_cd AS "ORDER_TYPE_CD", + (SELECT code_name FROM comm_code WHERE code_id = POM.order_type_cd LIMIT 1) AS "ORDER_TYPE_CD_NAME", + POM.unit_code AS "UNIT_CODE", + (SELECT COALESCE(O.unit_no,'') || '-' || COALESCE(O.task_name,'') FROM pms_wbs_task O WHERE O.objid = POM.unit_code LIMIT 1) AS "UNIT_NAME", + POM.delivery_date AS "DELIVERY_DATE", + POM.delivery_place AS "DELIVERY_PLACE", + (SELECT code_name FROM comm_code WHERE code_id = POM.delivery_place LIMIT 1) AS "DELIVERY_PLACE_NAME", + POM.effective_date AS "EFFECTIVE_DATE", + POM.payment_terms AS "PAYMENT_TERMS", + (SELECT code_name FROM comm_code WHERE code_id = POM.payment_terms LIMIT 1) AS "PAYMENT_TERMS_NAME", + POM.inspect_method AS "INSPECT_METHOD", + (SELECT code_name FROM comm_code WHERE code_id = POM.inspect_method LIMIT 1) AS "INSPECT_METHOD_NAME", + POM.vat_method AS "VAT_METHOD", + (SELECT code_name FROM comm_code WHERE code_id = POM.vat_method LIMIT 1) AS "VAT_METHOD_NAME", + POM.partner_objid AS "PARTNER_OBJID", + (SELECT supply_name FROM admin_supply_mng WHERE objid::text = POM.partner_objid LIMIT 1) AS "PARTNER_NAME", + POM.po_client_id AS "PO_CLIENT_ID", + SM_PC.supply_name AS "PO_CLIENT_NAME", + SM_PC.bus_reg_no AS "PO_CLIENT_BUS_NO", + SM_PC.charge_user_name AS "PO_CLIENT_CHARGER", + SM_PC.supply_address AS "PO_CLIENT_ADDR", + SM_PC.supply_tel_no AS "PO_CLIENT_TEL", + SM_PC.supply_fax_no AS "PO_CLIENT_FAX", + SM_PC.office_no AS "PO_CLIENT_HP", + SM_PC.email AS "PO_CLIENT_EMAIL", + POM.my_company_objid AS "MY_COMPANY_OBJID", + POM.supply_bus_no AS "SUPPLY_BUS_NO", + POM.supply_user_name AS "SUPPLY_USER_NAME", + POM.supply_user_hp AS "SUPPLY_USER_HP", + POM.supply_user_tel AS "SUPPLY_USER_TEL", + POM.supply_user_fax AS "SUPPLY_USER_FAX", + POM.supply_user_email AS "SUPPLY_USER_EMAIL", + POM.supply_addr AS "SUPPLY_ADDR", + SM_P.bus_reg_no AS "PARTNER_BUS_NO", + SM_P.charge_user_name AS "PARTNER_CHARGER", + SM_P.supply_address AS "PARTNER_ADDR", + SM_P.supply_tel_no AS "PARTNER_TEL", + SM_P.supply_fax_no AS "PARTNER_FAX", + SM_P.office_no AS "PARTNER_HP", + SM_P.email AS "PARTNER_EMAIL", + COALESCE(POM.total_price,'0') AS "TOTAL_PRICE", + COALESCE(POM.total_price_all,'0') AS "TOTAL_PRICE_ALL", + COALESCE(POM.discount_price,'0') AS "DISCOUNT_PRICE", + COALESCE(POM.discount_price_all,'0') AS "DISCOUNT_PRICE_ALL", + COALESCE(POM.total_supply_price,'0') AS "TOTAL_SUPPLY_PRICE", + COALESCE(POM.total_supply_unit_price,'0') AS "TOTAL_SUPPLY_UNIT_PRICE", + COALESCE(POM.total_real_supply_price,'0') AS "TOTAL_REAL_SUPPLY_PRICE", + POM.nego_rate AS "NEGO_RATE", + POM.total_price_txt AS "TOTAL_PRICE_TXT", + POM.total_price_txt_all AS "TOTAL_PRICE_TXT_ALL", + POM.remark AS "REMARK", + POM.writer AS "WRITER", + (SELECT user_name FROM user_info WHERE user_id = POM.writer LIMIT 1) AS "WRITER_NAME", + POM.sales_mng_user_id AS "SALES_MNG_USER_ID", + (SELECT user_name FROM user_info WHERE user_id = POM.sales_mng_user_id LIMIT 1) AS "SALES_MNG_USER_NAME", + POM.regdate AS "REGDATE", + TO_CHAR(POM.regdate, 'YYYY-MM-DD') AS "REGDATE_TITLE", + POM.status AS "STATUS", + CASE POM.status + WHEN 'create' THEN '등록' + WHEN 'approvalRequest' THEN '결재중' + WHEN 'approvalComplete' THEN '결재완료' + WHEN 'reject' THEN '반려' + WHEN 'cancel' THEN '취소' + ELSE '' + END AS "STATUS_TITLE", + COALESCE(POM.multi_yn,'') AS "MULTI_YN", + COALESCE(POM.multi_master_yn,'') AS "MULTI_MASTER_YN", + POM.multi_master_objid AS "MULTI_MASTER_OBJID", + (SELECT ARRAY_TO_STRING(ARRAY_AGG(S.objid), ',') FROM purchase_order_master S + WHERE POM.objid = S.multi_master_objid) AS "MULTI_OBJIDS", + POM.sales_request_objid AS "SALES_REQUEST_OBJID", + POM.bom_report_objid AS "BOM_REPORT_OBJID", + POM.purchase_order_no_org AS "PURCHASE_ORDER_NO_ORG", + POM.sales_status AS "SALES_STATUS", + POM.reception_status AS "RECEPTION_STATUS" + FROM purchase_order_master POM + LEFT OUTER JOIN admin_supply_mng SM_PC ON POM.po_client_id::text = SM_PC.objid::text + LEFT OUTER JOIN admin_supply_mng SM_P ON POM.partner_objid::text = SM_P.objid::text + WHERE POM.objid::text = $1`, + [String(objId)], + ); + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + + const parts = await queryRows( + `SELECT POP.objid::text AS "OBJID", + POP.purchase_order_master_objid::text AS "PURCHASE_ORDER_MASTER_OBJID", + POP.part_objid AS "PART_OBJID", + CASE WHEN POM.type IN ('0001070','0001069') THEN PM.part_no ELSE POP.part_no END AS "PART_NO", + COALESCE(POP.part_name, PM.part_name) AS "PART_NAME", + COALESCE(POP.spec, PM.spec) AS "SPEC", + COALESCE(POP.maker, PM.maker) AS "MAKER", + PM.material AS "MATERIAL", + PM.revision AS "REVISION", + POP.unit AS "UNIT", + (SELECT code_name FROM comm_code WHERE code_id = POP.unit LIMIT 1) AS "UNIT_NAME", + COALESCE(POP.order_qty,'0') AS "ORDER_QTY", + COALESCE(POP.partner_price,'0') AS "PARTNER_PRICE", + COALESCE(POP.price1,'0') AS "PRICE1", + COALESCE(POP.price2,'0') AS "PRICE2", + COALESCE(POP.price3,'0') AS "PRICE3", + COALESCE(POP.price4,'0') AS "PRICE4", + COALESCE(POP.supply_unit_price,'0') AS "SUPPLY_UNIT_PRICE", + COALESCE(POP.supply_unit_vat_price,'0') AS "SUPPLY_UNIT_VAT_PRICE", + COALESCE(POP.supply_unit_vat_sum_price,'0') AS "SUPPLY_UNIT_VAT_SUM_PRICE", + COALESCE(POP.real_supply_price,'0') AS "REAL_SUPPLY_PRICE", + POP.remark AS "REMARK", + POP.ld_part_objid AS "LD_PART_OBJID", + COALESCE(POP.total_order_qty,'0') AS "TOTAL_ORDER_QTY", + COALESCE(POP.stock_qty,'0') AS "STOCK_QTY", + COALESCE(POP.real_order_qty,'0') AS "REAL_ORDER_QTY", + (SELECT SUM(COALESCE(DH.delivery_qty,'0')::numeric) FROM delivery_history DH WHERE DH.purchase_order_part_objid = POP.objid) AS "TOTAL_DELIVERY_QTY", + (SELECT SUM(COALESCE(DH.defect_qty,'0')::numeric) FROM delivery_history DH WHERE DH.purchase_order_part_objid = POP.objid) AS "TOTAL_DEFECT_QTY" + FROM purchase_order_part POP + JOIN purchase_order_master POM ON POM.objid = POP.purchase_order_master_objid + LEFT OUTER JOIN part_mng PM ON POP.part_objid = PM.objid::text + WHERE POP.purchase_order_master_objid::text = $1 + ORDER BY PM.part_no NULLS LAST, POP.regdate DESC`, + [String(objId)], + ); + + // 동시발주 슬레이브 건들 (마스터의 경우만) — 같은 multi_master_objid를 가진 형제들 + const slaves = + info.MULTI_MASTER_YN === "Y" + ? await queryRows( + `SELECT S.objid::text AS "OBJID", + S.purchase_order_no AS "PURCHASE_ORDER_NO", + S.partner_objid AS "PARTNER_OBJID", + (SELECT supply_name FROM admin_supply_mng WHERE objid::text = S.partner_objid LIMIT 1) AS "PARTNER_NAME", + S.contract_mgmt_objid::text AS "CONTRACT_MGMT_OBJID", + (SELECT COALESCE(project_no, contract_no) FROM project_mgmt WHERE objid = S.contract_mgmt_objid LIMIT 1) AS "PROJECT_NO", + S.unit_code AS "UNIT_CODE", + S.delivery_plan_date AS "DELIVERY_PLAN_DATE", + S.delivery_plan_qty AS "DELIVERY_PLAN_QTY", + COALESCE(S.total_price,'0') AS "TOTAL_PRICE", + S.status AS "STATUS" + FROM purchase_order_master S + WHERE S.multi_master_objid::text = $1 + AND S.objid::text != $1 + ORDER BY S.regdate`, + [String(objId)], + ) + : []; + + return NextResponse.json({ success: true, data: info, PARTS: parts, SLAVES: slaves }); +} diff --git a/src/app/api/order/list/route.ts b/src/app/api/order/list/route.ts new file mode 100644 index 0000000..3a62d43 --- /dev/null +++ b/src/app/api/order/list/route.ts @@ -0,0 +1,223 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: purchaseOrder.xml#purchaseOrderMasterList_new +// 발주관리 > 발주서관리 목록. 동시발주는 마스터 행만 노출(MULTI_MASTER_YN='Y' 또는 동시발주 아님). +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + + const conditions: string[] = [ + "1=1", + "(COALESCE(POM.multi_master_yn,'') = 'Y' OR (COALESCE(POM.multi_master_yn,'') != 'Y' AND COALESCE(POM.multi_yn,'') != 'Y'))", + ]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(POM.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.customer_cd) { + conditions.push( + `EXISTS (SELECT 1 FROM project_mgmt SP WHERE POM.contract_mgmt_objid = SP.objid AND SP.customer_objid::text = $${idx++})`, + ); + params.push(body.customer_cd); + } + if (body.project_no) { + // project_no(LIKE) — project_mgmt.project_no 또는 contract_no 부분 일치 + conditions.push( + `EXISTS (SELECT 1 FROM project_mgmt SP WHERE POM.contract_mgmt_objid = SP.objid + AND (COALESCE(SP.project_no,'') LIKE '%' || $${idx} || '%' + OR COALESCE(SP.contract_no,'') LIKE '%' || $${idx} || '%'))`, + ); + params.push(body.project_no); + idx++; + } + if (body.customer_project_name) { + conditions.push(`CM.customer_project_name LIKE '%' || $${idx++} || '%'`); + params.push(body.customer_project_name); + } + if (body.unit_code) { + conditions.push(`COALESCE(POM.unit_code,'') LIKE '%' || $${idx++} || '%'`); + params.push(body.unit_code); + } + if (body.purchase_order_no) { + conditions.push(`POM.purchase_order_no LIKE '%' || $${idx++} || '%'`); + params.push(body.purchase_order_no); + } + if (body.type) { + conditions.push(`POM.type = $${idx++}`); + params.push(body.type); + } + if (body.order_type_cd) { + conditions.push(`POM.order_type_cd = $${idx++}`); + params.push(body.order_type_cd); + } + if (body.delivery_start_date) { + conditions.push( + `COALESCE(POM.delivery_date,'') <> '' AND TO_DATE(POM.delivery_date, 'YYYY-MM-DD') >= TO_DATE($${idx++}, 'YYYY-MM-DD')`, + ); + params.push(body.delivery_start_date); + } + if (body.delivery_end_date) { + conditions.push( + `COALESCE(POM.delivery_date,'') <> '' AND TO_DATE(POM.delivery_date, 'YYYY-MM-DD') <= TO_DATE($${idx++}, 'YYYY-MM-DD')`, + ); + params.push(body.delivery_end_date); + } + if (body.partner_objid) { + conditions.push(`POM.partner_objid = $${idx++}`); + params.push(body.partner_objid); + } + if (body.sales_mng_user_id) { + conditions.push(`POM.sales_mng_user_id = $${idx++}`); + params.push(body.sales_mng_user_id); + } + if (body.reg_start_date) { + conditions.push(`TO_CHAR(POM.regdate, 'YYYY-MM-DD') >= $${idx++}`); + params.push(body.reg_start_date); + } + if (body.reg_end_date) { + conditions.push(`TO_CHAR(POM.regdate, 'YYYY-MM-DD') <= $${idx++}`); + params.push(body.reg_end_date); + } + if (body.po_client_id) { + conditions.push(`POM.po_client_id = $${idx++}`); + params.push(body.po_client_id); + } + if (body.part_no) { + conditions.push( + `EXISTS (SELECT 1 FROM purchase_order_part POP WHERE POP.purchase_order_master_objid = POM.objid + AND TRIM(UPPER(COALESCE(POP.part_no,''))) LIKE '%' || TRIM(UPPER($${idx++})) || '%')`, + ); + params.push(body.part_no); + } + if (body.part_name) { + conditions.push( + `EXISTS (SELECT 1 FROM purchase_order_part POP WHERE POP.purchase_order_master_objid = POM.objid + AND TRIM(UPPER(COALESCE(POP.part_name,''))) LIKE '%' || TRIM(UPPER($${idx++})) || '%')`, + ); + params.push(body.part_name); + } + if (body.part_spec) { + conditions.push( + `EXISTS (SELECT 1 FROM purchase_order_part POP WHERE POP.purchase_order_master_objid = POM.objid + AND TRIM(UPPER(COALESCE(POP.spec,''))) LIKE '%' || TRIM(UPPER($${idx++})) || '%')`, + ); + params.push(body.part_spec); + } + if (body.appr_status) { + const s = String(body.appr_status); + if (s === "cancel") { + conditions.push(`POM.status = 'cancel'`); + } else if (s === "complete") { + conditions.push(`POM.status = 'approvalComplete'`); + } else if (s === "create") { + conditions.push( + `POM.status = 'create' + AND NOT EXISTS (SELECT 1 FROM approval AT2 WHERE AT2.target_objid::text = POM.objid::text) + AND NOT EXISTS (SELECT 1 FROM approval_target AT3 WHERE AT3.target_objid::text = POM.objid::text OR AT3.master_target_objid::text = POM.objid::text)`, + ); + } else { + // inProcess / reject 등은 approval.status 기준 + conditions.push(`A.appr_status = $${idx++}`); + params.push(s); + } + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT POM.objid::text AS "OBJID", + (SELECT ARRAY_TO_STRING(ARRAY_AGG(S.objid), ',') FROM purchase_order_master S + WHERE POM.objid = S.multi_master_objid) AS "MULTI_OBJIDS", + TO_CHAR(CM.regdate, 'YYYY') AS "CM_YEAR", + TO_CHAR(POM.regdate, 'YYYY') AS "PO_YEAR", + (SELECT supply_name FROM supply_mng SM WHERE SM.objid::text = CM.customer_objid LIMIT 1) AS "CUSTOMER_NAME", + CM.customer_project_name AS "CUSTOMER_PROJECT_NAME", + COALESCE(CM.project_no, CM.contract_no) AS "PROJECT_NO", + POM.purchase_order_no AS "PURCHASE_ORDER_NO", + (CASE WHEN COALESCE(POM.multi_yn,'') = 'Y' AND COALESCE(POM.multi_master_yn,'') != 'Y' THEN 'ㅡ' ELSE '' END) + || COALESCE(POM.title,'') AS "TITLE", + POM.delivery_place AS "DELIVERY_PLACE", + (SELECT code_name FROM comm_code WHERE code_id = POM.delivery_place LIMIT 1) AS "DELIVERY_PLACE_NAME", + POM.inspect_method AS "INSPECT_METHOD", + (SELECT code_name FROM comm_code WHERE code_id = POM.inspect_method LIMIT 1) AS "INSPECT_METHOD_NAME", + POM.payment_terms AS "PAYMENT_TERMS", + (SELECT code_name FROM comm_code WHERE code_id = POM.payment_terms LIMIT 1) AS "PAYMENT_TERMS_NAME", + POM.delivery_date AS "DELIVERY_DATE", + POM.type AS "TYPE", + (SELECT code_name FROM comm_code WHERE code_id = POM.type LIMIT 1) AS "TYPE_NAME", + POM.partner_objid AS "PARTNER_OBJID", + (SELECT supply_name FROM admin_supply_mng WHERE objid::text = POM.partner_objid LIMIT 1) AS "PARTNER_NAME", + POM.sales_mng_user_id AS "SALES_MNG_USER_ID", + (SELECT user_name FROM user_info WHERE user_id = POM.sales_mng_user_id LIMIT 1) AS "SALES_MNG_USER_NAME", + TO_CHAR(POM.regdate, 'YYYY-MM-DD') AS "REGDATE", + COALESCE(POM.total_price,'0') AS "TOTAL_PRICE", + COALESCE(POM.total_price_all,'0') AS "TOTAL_PRICE_ALL", + COALESCE(POM.discount_price,'0') AS "DISCOUNT_PRICE", + COALESCE(POM.discount_price_all,'0') AS "DISCOUNT_PRICE_ALL", + COALESCE(POM.total_supply_price,'0') AS "TOTAL_SUPPLY_PRICE", + COALESCE(POM.total_supply_unit_price,'0') AS "TOTAL_SUPPLY_UNIT_PRICE", + COALESCE(POM.total_real_supply_price,'0') AS "TOTAL_REAL_SUPPLY_PRICE", + POM.nego_rate AS "NEGO_RATE", + POM.order_type_cd AS "ORDER_TYPE_CD", + (SELECT code_name FROM comm_code WHERE code_id = POM.order_type_cd LIMIT 1) AS "ORDER_TYPE_CD_NAME", + POM.unit_code AS "UNIT_CODE", + (SELECT COALESCE(O.unit_no,'') || '-' || COALESCE(O.task_name,'') FROM pms_wbs_task O WHERE O.objid = POM.unit_code LIMIT 1) AS "UNIT_NAME", + COALESCE(POM.multi_yn,'') AS "MULTI_YN", + COALESCE(POM.multi_master_yn,'') AS "MULTI_MASTER_YN", + POM.multi_master_objid AS "MULTI_MASTER_OBJID", + CASE WHEN COALESCE(POM.multi_master_yn,'') = 'Y' THEN '' ELSE COALESCE(POM.multi_yn,'') END AS "MULTI_YN_MAKED", + POM.status AS "STATUS", + A.appr_status AS "APPR_STATUS", + CASE WHEN POM.status = 'cancel' THEN '취소' + ELSE COALESCE(A.appr_status_name, '작성중') + END AS "APPR_STATUS_NAME", + A.route_objid AS "ROUTE_OBJID", + A.approval_objid AS "APPROVAL_OBJID", + A.appr_date AS "APPR_DATE" + FROM purchase_order_master POM + LEFT OUTER JOIN ( + SELECT B.objid AS route_objid, + B.status AS appr_status, + CASE B.status + WHEN 'inProcess' THEN '결재중' + WHEN 'complete' THEN '결재완료' + WHEN 'reject' THEN '반려' + WHEN 'cancel' THEN '취소' + ELSE '' + END AS appr_status_name, + AP.objid AS approval_objid, + AP.target_objid, + B.route_seq, + TO_CHAR(B.regdate, 'YYYY-MM-DD') AS appr_date + FROM approval AP + JOIN ( + SELECT T1.* + FROM (SELECT target_objid, MAX(route_seq) AS route_seq FROM route GROUP BY target_objid) T + JOIN route T1 ON T.target_objid = T1.target_objid AND T.route_seq = T1.route_seq + ) B ON AP.objid = B.approval_objid + WHERE AP.target_type IN ('PURCHASE_ORDER') + ) A ON POM.objid::text = A.target_objid::text + LEFT OUTER JOIN project_mgmt CM ON POM.contract_mgmt_objid = CM.objid + WHERE ${where} + ORDER BY + CASE WHEN POM.purchase_order_no ~ '^[^-]+-[^-]+-[0-9]+$' + THEN SPLIT_PART(POM.purchase_order_no, '-', 3)::numeric + ELSE 0 END DESC, + POM.regdate DESC + `; + + try { + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); + } catch (e) { + console.error("order/list:", e); + return NextResponse.json({ success: false, message: "조회 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/order/multi-projects/route.ts b/src/app/api/order/multi-projects/route.ts new file mode 100644 index 0000000..73814a5 --- /dev/null +++ b/src/app/api/order/multi-projects/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: common.getProjectNameList (contract_objid 동일) + salesMng.salesBomReportInfo2(구매BOM 존재 여부) +// 동시발주 추가 프로젝트 후보 조회: +// 1) 마스터 프로젝트(contract_mgmt_objid)와 동일한 CONTRACT_OBJID(상위 계약) 소속 +// 2) 마스터 자신은 제외 +// 3) 해당 프로젝트에 구매BOM(sales_bom_report_part)이 등록되어 있어야 함 +// 4) (옵션) 선택된 공급처가 구매BOM의 supply_objid/1/2/3/4 중 하나로 등록된 프로젝트만 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const masterProjectObjId = String(body.contract_mgmt_objid || ""); + const partnerObjId = String(body.partner_objid || ""); + if (!masterProjectObjId) return NextResponse.json({ RESULTLIST: [] }); + + // partner_objid가 있으면 해당 공급처가 등록된 BOM만, 없으면 BOM 존재 여부만 필터 + const partnerFilter = partnerObjId + ? `AND EXISTS ( + SELECT 1 + FROM part_bom_report PBR + JOIN sales_bom_report_part SBP ON SBP.parent_objid::text = PBR.objid::text + WHERE PBR.contract_objid = P.objid + AND (SBP.supply_objid = $2 OR SBP.supply_objid1 = $2 + OR SBP.supply_objid2 = $2 OR SBP.supply_objid3 = $2 OR SBP.supply_objid4 = $2) + )` + : `AND EXISTS ( + SELECT 1 + FROM part_bom_report PBR + JOIN sales_bom_report_part SBP ON SBP.parent_objid::text = PBR.objid::text + WHERE PBR.contract_objid = P.objid + )`; + + const sql = ` + SELECT P.objid::text AS "OBJID", + P.project_no AS "PROJECT_NO", + P.customer_project_name AS "CUSTOMER_PROJECT_NAME", + COALESCE(P.project_no, '') || ' - ' || COALESCE(P.customer_project_name, '') AS "LABEL" + FROM project_mgmt P + WHERE P.contract_objid = ( + SELECT contract_objid FROM project_mgmt WHERE objid::text = $1 LIMIT 1 + ) + AND P.objid::text <> $1 + ${partnerFilter} + ORDER BY P.project_no + `; + + const params = partnerObjId ? [masterProjectObjId, partnerObjId] : [masterProjectObjId]; + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows }); +} diff --git a/src/app/api/order/prev-orders/route.ts b/src/app/api/order/prev-orders/route.ts new file mode 100644 index 0000000..1aaa2dc --- /dev/null +++ b/src/app/api/order/prev-orders/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 재발주 시 원본 발주서 선택용 — 같은 프로젝트의 approvalComplete 된 발주서 목록 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const contractObjId = String(body.contract_mgmt_objid || ""); + if (!contractObjId) return NextResponse.json({ RESULTLIST: [] }); + + // 재발주 원본 후보: 같은 프로젝트의 결재완료 건. 동시발주 슬레이브만 제외 (마스터/단일은 선택 가능). + const rows = await queryRows( + `SELECT POM.objid::text AS "OBJID", + POM.purchase_order_no AS "PURCHASE_ORDER_NO", + POM.title AS "TITLE", + TO_CHAR(POM.regdate, 'YYYY-MM-DD') AS "REGDATE", + (SELECT supply_name FROM admin_supply_mng WHERE objid::text = POM.partner_objid LIMIT 1) AS "PARTNER_NAME" + FROM purchase_order_master POM + WHERE POM.contract_mgmt_objid::text = $1 + AND POM.status = 'approvalComplete' + AND NOT ( + COALESCE(POM.multi_yn,'') = 'Y' + AND COALESCE(POM.multi_master_yn,'') != 'Y' + ) + ORDER BY POM.regdate DESC +`, + [contractObjId], + ); + return NextResponse.json({ RESULTLIST: rows }); +} diff --git a/src/app/api/order/price-save/route.ts b/src/app/api/order/price-save/route.ts new file mode 100644 index 0000000..a61544b --- /dev/null +++ b/src/app/api/order/price-save/route.ts @@ -0,0 +1,233 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 원본: /purchaseOrder/purchaseOrderFormPopup_PriceSave.do — 단가만 저장 +// 품목의 PARTNER_PRICE, PRICE1~4, SUPPLY_UNIT_PRICE, VAT 관련 값만 업데이트. +// 마스터의 수량/거래처/납품일 등은 건드리지 않음. +// 동시발주 마스터인 경우 슬레이브 품목/합계도 동기화 (마스터 품목을 DELETE+INSERT로 복제). +interface PriceRow { + OBJID?: string; + PARTNER_PRICE?: string | number; + PRICE1?: string | number; + PRICE2?: string | number; + PRICE3?: string | number; + PRICE4?: string | number; + ORDER_QTY?: string | number; +} + +const toNum = (v: unknown) => { + const s = String(v ?? "").replace(/,/g, ""); + const n = Number(s); + return Number.isFinite(n) ? n : 0; +}; +const toStr = (v: unknown) => (v === undefined || v === null ? "" : String(v)); + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const masterObjId = toStr(body.objId || body.MASTER_OBJID); + const vatMethod = toStr(body.vat_method); + const rows: PriceRow[] = Array.isArray(body.parts) ? body.parts : []; + if (!masterObjId) { + return NextResponse.json({ success: false, message: "objId가 필요합니다." }); + } + const vatRate = vatMethod === "0000270" ? 0 : 0.1; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 결재중/결재완료/취소 상태는 단가저장 불가. 슬레이브도 차단 (마스터에서만). + const check = await client.query( + `SELECT COALESCE(status,'') AS status, + COALESCE(multi_yn,'') AS multi_yn, + COALESCE(multi_master_yn,'') AS multi_master_yn + FROM purchase_order_master WHERE objid::text = $1`, + [masterObjId], + ); + const cur = check.rows[0]; + if (cur?.multi_yn === "Y" && cur?.multi_master_yn !== "Y") { + await client.query("ROLLBACK"); + return NextResponse.json({ + success: false, + message: "동시발주 슬레이브건은 마스터 발주서에서 수정해주세요.", + }); + } + if ( + cur?.status === "approvalRequest" || + cur?.status === "approvalComplete" || + cur?.status === "cancel" + ) { + await client.query("ROLLBACK"); + const label = + cur.status === "approvalRequest" + ? "결재중" + : cur.status === "approvalComplete" + ? "결재완료" + : "취소"; + return NextResponse.json({ + success: false, + message: `${label} 상태의 발주서는 단가저장할 수 없습니다.`, + }); + } + + let totalSupplyUnitPrice = 0; + let totalSupplyPrice = 0; + for (const r of rows) { + if (!toStr(r.OBJID)) continue; + const qty = toNum(r.ORDER_QTY); + const unit = toNum(r.PARTNER_PRICE); + const sum = qty * (unit + toNum(r.PRICE1) + toNum(r.PRICE2) + toNum(r.PRICE3) + toNum(r.PRICE4)); + totalSupplyUnitPrice += sum; + totalSupplyPrice += qty * unit; + await client.query( + `UPDATE purchase_order_part SET + partner_price = $2, price1 = $3, price2 = $4, price3 = $5, price4 = $6, + supply_unit_price = $7, + supply_unit_vat_price = $8, + supply_unit_vat_sum_price = $9, + real_supply_price = $9, + update_date = now(), modifier = $10 + WHERE objid = $1`, + [ + toStr(r.OBJID), + String(unit), + String(toNum(r.PRICE1)), + String(toNum(r.PRICE2)), + String(toNum(r.PRICE3)), + String(toNum(r.PRICE4)), + String(sum), + String(Math.round(sum * vatRate)), + String(Math.round(sum * (1 + vatRate))), + user.userId, + ], + ); + } + // 동시발주 슬레이브 품목 동기화: 마스터 현재 품목을 슬레이브로 DELETE+INSERT 복제. + // save/route.ts의 슬레이브 생성 로직과 동일 패턴 — 단가가 바뀌면 슬레이브 품목도 같이. + const slavesRes = await client.query( + `SELECT objid::text AS objid FROM purchase_order_master + WHERE multi_master_objid::text = $1 AND objid::text <> $1`, + [masterObjId], + ); + if (slavesRes.rows.length > 0) { + const masterPartsRes = await client.query( + `SELECT part_objid, part_no, part_name, spec, maker, unit, + order_qty, partner_price, price1, price2, price3, price4, + supply_unit_price, supply_unit_vat_price, supply_unit_vat_sum_price, + real_supply_price, real_order_qty, total_order_qty, stock_qty, + remark, ld_part_objid + FROM purchase_order_part + WHERE purchase_order_master_objid::text = $1 + ORDER BY regdate ASC, objid ASC`, + [masterObjId], + ); + for (const slave of slavesRes.rows) { + await client.query( + `DELETE FROM purchase_order_part WHERE purchase_order_master_objid::text = $1`, + [slave.objid], + ); + for (const mp of masterPartsRes.rows) { + await client.query( + `INSERT INTO purchase_order_part ( + objid, purchase_order_master_objid, part_objid, part_no, part_name, + spec, maker, unit, order_qty, partner_price, + price1, price2, price3, price4, + supply_unit_price, supply_unit_vat_price, supply_unit_vat_sum_price, + real_supply_price, real_order_qty, total_order_qty, stock_qty, + remark, writer, regdate, status, ld_part_objid + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, + $22, $23, now(), 'create', $24 + )`, + [ + createObjectId(), + slave.objid, + toStr(mp.part_objid), + toStr(mp.part_no), + toStr(mp.part_name), + toStr(mp.spec), + toStr(mp.maker), + toStr(mp.unit), + toStr(mp.order_qty), + toStr(mp.partner_price), + toStr(mp.price1), + toStr(mp.price2), + toStr(mp.price3), + toStr(mp.price4), + toStr(mp.supply_unit_price), + toStr(mp.supply_unit_vat_price), + toStr(mp.supply_unit_vat_sum_price), + toStr(mp.real_supply_price), + toStr(mp.real_order_qty), + toStr(mp.total_order_qty), + toStr(mp.stock_qty), + toStr(mp.remark), + user.userId, + toStr(mp.ld_part_objid), + ], + ); + } + } + } + + // 마스터 + 슬레이브의 단건 합계 동기화 (discount_price_all은 건드리지 않음 — 원본 동작) + const discount = toNum(body.discount_price); + const totalPrice = totalSupplyUnitPrice - discount; + const realSupply = Math.round(totalPrice * (1 + vatRate)); + const negoRate = + totalSupplyPrice > 0 ? Math.round((discount / totalSupplyPrice) * 10000) / 100 : 0; + await client.query( + `UPDATE purchase_order_master SET + total_supply_unit_price = $2, total_supply_price = $3, + total_real_supply_price = $4, total_price = $5, + discount_price = $6, nego_rate = $7 + WHERE objid::text = $1 OR multi_master_objid::text = $1`, + [ + masterObjId, + String(totalSupplyUnitPrice), + String(totalSupplyPrice), + String(realSupply), + String(totalPrice), + String(discount), + String(negoRate), + ], + ); + + // 원본 updatePurchaseOrderMasterPriceAll + updatePurchaseOrderPriceTxt + // 마스터+슬레이브에 total_supply_unit_price_all, total_price_all, 한글 금액 세팅 + // (save/route.ts와 동일 패턴) + const supplyUnitAll = totalSupplyUnitPrice * (1 + slavesRes.rows.length); + await client.query( + `UPDATE purchase_order_master SET + total_supply_unit_price_all = $2, + total_price_all = CASE + WHEN COALESCE(discount_price, '0')::numeric != 0 + THEN (COALESCE(total_real_supply_price, '0')::numeric - COALESCE(discount_price, '0')::numeric)::text + ELSE NULL END, + total_price_txt = NUM_TO_KOR(COALESCE(total_supply_unit_price, '0'), '일금 ', ' 원정 (₩ ') + || TO_CHAR(COALESCE(total_supply_unit_price, '0')::numeric, '999,999,999,999') || ')', + total_price_txt_all = NUM_TO_KOR(COALESCE(total_supply_unit_price_all, '0'), '일금 ', ' 원정 (₩ ') + || TO_CHAR(COALESCE(total_supply_unit_price_all, '0')::numeric, '999,999,999,999') || ')' + WHERE objid::text = $1 OR multi_master_objid::text = $1`, + [masterObjId, String(supplyUnitAll)], + ); + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "단가가 저장되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("order/price-save:", e); + return NextResponse.json( + { success: false, message: "단가 저장 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } finally { + client.release(); + } +} diff --git a/src/app/api/order/save/route.ts b/src/app/api/order/save/route.ts new file mode 100644 index 0000000..e9b3b3a --- /dev/null +++ b/src/app/api/order/save/route.ts @@ -0,0 +1,581 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; +import { COMPANY_INFO } from "@/lib/constants"; + +// 원본: purchaseOrder.mergePurchaseOrderMaster + purchaseOrder.initPurchaseOrderPart + mergePurchaseOrderPart +// 발주서 UPSERT (단일발주 + 동시발주) +// 동시발주: 하나의 공급처에 대해 여러 프로젝트를 묶어서 발주. 마스터 1건 + +// 추가 프로젝트 수만큼 슬레이브 PO 생성. 슬레이브는 multi_yn='Y', multi_master_yn='N', +// multi_master_objid=master, 품목은 마스터 것을 복사 (각 프로젝트별 contract_mgmt_objid만 다름). +interface MultiProjectEntry { + contract_mgmt_objid?: string; + unit_code?: string; + delivery_plan_date?: string; + delivery_plan_qty?: string | number; + objid?: string; // 기존 슬레이브 OBJID (편집 시) +} + +interface PartRow { + OBJID?: string; + PART_OBJID?: string; + PART_NO?: string; + PART_NAME?: string; + SPEC?: string; + MAKER?: string; + UNIT?: string; + ORDER_QTY?: string | number; + PARTNER_PRICE?: string | number; + PRICE1?: string | number; + PRICE2?: string | number; + PRICE3?: string | number; + PRICE4?: string | number; + SUPPLY_UNIT_PRICE?: string | number; + SUPPLY_UNIT_VAT_PRICE?: string | number; + SUPPLY_UNIT_VAT_SUM_PRICE?: string | number; + REMARK?: string; + LD_PART_OBJID?: string; + TOTAL_ORDER_QTY?: string | number; + STOCK_QTY?: string | number; + REAL_ORDER_QTY?: string | number; +} + +const toStr = (v: unknown) => (v === undefined || v === null ? "" : String(v)); +const toNum = (v: unknown) => { + const s = toStr(v).replace(/,/g, ""); + const n = Number(s); + return Number.isFinite(n) ? n : 0; +}; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const inputObjId = toStr(body.objId || body.MASTER_OBJID); + const isNew = !inputObjId; + const masterObjId = isNew ? createObjectId() : inputObjId; + const parts: PartRow[] = Array.isArray(body.parts) ? body.parts : []; + const rawMultiProjects: MultiProjectEntry[] = Array.isArray(body.multi_projects) + ? body.multi_projects.filter((m: MultiProjectEntry) => toStr(m?.contract_mgmt_objid)) + : []; + // 동시적용 중복 방어: + // 1) 마스터와 같은 프로젝트는 거부 (프론트 우회 방어) + // 2) 슬레이브끼리 같은 프로젝트 중복은 첫 건만 남기고 제거 + const masterProject = toStr(body.contract_mgmt_objid); + if (rawMultiProjects.some((m) => toStr(m.contract_mgmt_objid) === masterProject)) { + return NextResponse.json({ + success: false, + message: "동시적용 프로젝트에 마스터 프로젝트를 포함할 수 없습니다.", + }); + } + const seenProjects = new Set(); + const multiProjects: MultiProjectEntry[] = []; + for (const m of rawMultiProjects) { + const key = toStr(m.contract_mgmt_objid); + if (seenProjects.has(key)) { + return NextResponse.json({ + success: false, + message: "동시적용 프로젝트가 중복되었습니다. 같은 프로젝트는 1회만 선택할 수 있습니다.", + }); + } + seenProjects.add(key); + multiProjects.push(m); + } + const isMulti = multiProjects.length > 0; + // 발주처는 FITO 고정 — 클라이언트 입력 무시 + const poClientId = COMPANY_INFO.PO_CLIENT_ID; + + // 필수 검증 + if (!toStr(body.partner_objid)) { + return NextResponse.json({ success: false, message: "공급처(PARTNER)를 선택하세요." }); + } + if (!toStr(body.contract_mgmt_objid)) { + return NextResponse.json({ success: false, message: "프로젝트를 선택하세요." }); + } + if (!toStr(body.type)) { + return NextResponse.json({ success: false, message: "발주부품을 선택하세요." }); + } + + // 합계 계산 (서버사이드에서 재계산: 품목 공급가 = qty * (partner_price + price1..4)) + let totalSupplyUnitPrice = 0; // 공급단가 합계 (POP.supply_unit_price 합) + let totalSupplyPrice = 0; // 공급가 합계 (price1..4 포함하지 않은 단가*수량 합) + for (const p of parts) { + const qty = toNum(p.ORDER_QTY); + const unit = toNum(p.PARTNER_PRICE); + const sum = qty * (unit + toNum(p.PRICE1) + toNum(p.PRICE2) + toNum(p.PRICE3) + toNum(p.PRICE4)); + totalSupplyUnitPrice += sum; + totalSupplyPrice += qty * unit; + } + const discountPrice = toNum(body.discount_price); + const totalPrice = totalSupplyUnitPrice - discountPrice; + // VAT 10% 기본 + const vatRate = toStr(body.vat_method) === "0000270" ? 0 : 0.1; // 0000270 = 부가세 없음 (표준코드 추정) + const totalRealSupplyPrice = Math.round(totalPrice * (1 + vatRate)); + const negoRate = + totalSupplyPrice > 0 ? Math.round((discountPrice / totalSupplyPrice) * 10000) / 100 : 0; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // PO 번호 자동생성 (신규 때만). 형식: PO-YYMM-SEQ + let poNo = toStr(body.purchase_order_no); + if (isNew || !poNo) { + const now = new Date(); + const yymm = + String(now.getFullYear()).slice(2) + String(now.getMonth() + 1).padStart(2, "0"); + const r = await client.query( + `SELECT COALESCE(MAX( + CASE WHEN SPLIT_PART(purchase_order_no, '-', 3) ~ '^[0-9]+$' + THEN SPLIT_PART(purchase_order_no, '-', 3)::int ELSE 0 END), 0) + 1 AS next_seq + FROM purchase_order_master`, + ); + const nextSeq = Number(r.rows[0]?.next_seq ?? 1); + poNo = `PO-${yymm}-${nextSeq}`; + } + + // 공급처 정보 자동 채우기 (admin_supply_mng 조회) + let supplyInfo: Record = {}; + if (toStr(body.partner_objid)) { + const r = await client.query( + `SELECT bus_reg_no, supply_address, supply_tel_no, supply_fax_no, office_no, email, charge_user_name + FROM admin_supply_mng WHERE objid::text = $1`, + [toStr(body.partner_objid)], + ); + if (r.rows[0]) { + supplyInfo = { + SUPPLY_BUS_NO: toStr(r.rows[0].bus_reg_no), + SUPPLY_USER_NAME: toStr(r.rows[0].charge_user_name), + SUPPLY_USER_HP: toStr(r.rows[0].office_no), + SUPPLY_USER_TEL: toStr(r.rows[0].supply_tel_no), + SUPPLY_USER_FAX: toStr(r.rows[0].supply_fax_no), + SUPPLY_USER_EMAIL: toStr(r.rows[0].email), + SUPPLY_ADDR: toStr(r.rows[0].supply_address), + }; + } + } + + if (isNew) { + await client.query( + `INSERT INTO purchase_order_master ( + objid, purchase_order_no, po_client_id, partner_objid, my_company_objid, + contract_mgmt_objid, unit_code, bom_report_objid, sales_request_objid, + type, order_type_cd, delivery_date, delivery_place, effective_date, + payment_terms, inspect_method, vat_method, sales_mng_user_id, + title, remark, status, writer, regdate, purchase_date, + supply_bus_no, supply_user_name, supply_user_hp, supply_user_tel, + supply_user_fax, supply_user_email, supply_addr, + total_supply_unit_price, total_supply_price, total_real_supply_price, + total_price, total_price_all, discount_price, discount_price_all, + nego_rate, multi_yn, multi_master_yn, multi_master_objid, purchase_order_no_org + ) VALUES ( + $1, $2, $3, $4, $5, $6::numeric, $7, $8, $9, + $10, $11, $12, $13, $14, $15, $16, $17, $18, + $19, $20, 'create', $21, now(), TO_CHAR(NOW(), 'YYYY-MM-DD'), + $22, $23, $24, $25, $26, $27, $28, + $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40 + )`, + [ + masterObjId, + poNo, + poClientId, + toStr(body.partner_objid), + toStr(body.my_company_objid) || poClientId, + toStr(body.contract_mgmt_objid), + toStr(body.unit_code), + toStr(body.bom_report_objid), + toStr(body.sales_request_objid), + toStr(body.type), + toStr(body.order_type_cd), + toStr(body.delivery_date), + toStr(body.delivery_place), + toStr(body.effective_date), + toStr(body.payment_terms), + toStr(body.inspect_method), + toStr(body.vat_method), + toStr(body.sales_mng_user_id) || user.userId, + toStr(body.title), + toStr(body.remark), + user.userId, + supplyInfo.SUPPLY_BUS_NO || "", + supplyInfo.SUPPLY_USER_NAME || "", + supplyInfo.SUPPLY_USER_HP || "", + supplyInfo.SUPPLY_USER_TEL || "", + supplyInfo.SUPPLY_USER_FAX || "", + supplyInfo.SUPPLY_USER_EMAIL || "", + supplyInfo.SUPPLY_ADDR || "", + String(totalSupplyUnitPrice), + String(totalSupplyPrice), + String(totalRealSupplyPrice), + String(totalPrice), + String(totalRealSupplyPrice), // 단일발주는 전체=자신 + String(discountPrice), + String(discountPrice), + String(negoRate), + isMulti ? "Y" : "", + isMulti ? "Y" : "", + "", // multi_master_objid (master 자기 자신이므로 비움) + toStr(body.purchase_order_no_org), + ], + ); + } else { + // 결재중/결재완료/취소 상태는 수정 불가. 슬레이브도 차단 (마스터를 통해서만). + const check = await client.query( + `SELECT COALESCE(multi_yn,'') AS multi_yn, + COALESCE(multi_master_yn,'') AS multi_master_yn, + COALESCE(status,'') AS status + FROM purchase_order_master WHERE objid::text = $1`, + [masterObjId], + ); + const cur = check.rows[0]; + if (cur?.multi_yn === "Y" && cur?.multi_master_yn !== "Y") { + await client.query("ROLLBACK"); + return NextResponse.json({ + success: false, + message: "동시발주 슬레이브건은 마스터 발주서에서 수정해주세요.", + }); + } + if ( + cur?.status === "approvalRequest" || + cur?.status === "approvalComplete" || + cur?.status === "cancel" + ) { + await client.query("ROLLBACK"); + const label = + cur.status === "approvalRequest" + ? "결재중" + : cur.status === "approvalComplete" + ? "결재완료" + : "취소"; + return NextResponse.json({ + success: false, + message: `${label} 상태의 발주서는 수정할 수 없습니다.`, + }); + } + await client.query( + `UPDATE purchase_order_master SET + po_client_id = $2, partner_objid = $3, + contract_mgmt_objid = $4::numeric, unit_code = $5, + type = $6, order_type_cd = $7, delivery_date = $8, delivery_place = $9, + effective_date = $10, payment_terms = $11, inspect_method = $12, + vat_method = $13, sales_mng_user_id = $14, title = $15, remark = $16, + supply_bus_no = $17, supply_user_name = $18, supply_user_hp = $19, + supply_user_tel = $20, supply_user_fax = $21, supply_user_email = $22, + supply_addr = $23, + total_supply_unit_price = $24, total_supply_price = $25, + total_real_supply_price = $26, total_price = $27, total_price_all = $28, + discount_price = $29, discount_price_all = $30, nego_rate = $31, + multi_yn = $32, multi_master_yn = $33 + WHERE objid::text = $1`, + [ + masterObjId, + poClientId, + toStr(body.partner_objid), + toStr(body.contract_mgmt_objid), + toStr(body.unit_code), + toStr(body.type), + toStr(body.order_type_cd), + toStr(body.delivery_date), + toStr(body.delivery_place), + toStr(body.effective_date), + toStr(body.payment_terms), + toStr(body.inspect_method), + toStr(body.vat_method), + toStr(body.sales_mng_user_id) || user.userId, + toStr(body.title), + toStr(body.remark), + supplyInfo.SUPPLY_BUS_NO || "", + supplyInfo.SUPPLY_USER_NAME || "", + supplyInfo.SUPPLY_USER_HP || "", + supplyInfo.SUPPLY_USER_TEL || "", + supplyInfo.SUPPLY_USER_FAX || "", + supplyInfo.SUPPLY_USER_EMAIL || "", + supplyInfo.SUPPLY_ADDR || "", + String(totalSupplyUnitPrice), + String(totalSupplyPrice), + String(totalRealSupplyPrice), + String(totalPrice), + String(totalRealSupplyPrice), + String(discountPrice), + String(discountPrice), + String(negoRate), + isMulti ? "Y" : "", + isMulti ? "Y" : "", + ], + ); + } + + // 품목 재등록: 기존 delete 후 재삽입 + await client.query( + `DELETE FROM purchase_order_part WHERE purchase_order_master_objid = $1`, + [masterObjId], + ); + for (const p of parts) { + if (!toStr(p.PART_OBJID) && !toStr(p.PART_NO)) continue; + const qty = toNum(p.ORDER_QTY); + const unit = toNum(p.PARTNER_PRICE); + const sum = qty * (unit + toNum(p.PRICE1) + toNum(p.PRICE2) + toNum(p.PRICE3) + toNum(p.PRICE4)); + await client.query( + `INSERT INTO purchase_order_part ( + objid, purchase_order_master_objid, part_objid, part_no, part_name, + spec, maker, unit, order_qty, partner_price, + price1, price2, price3, price4, + supply_unit_price, supply_unit_vat_price, supply_unit_vat_sum_price, + real_supply_price, real_order_qty, total_order_qty, stock_qty, + remark, writer, regdate, status, ld_part_objid + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, + $22, $23, now(), 'create', $24 + )`, + [ + createObjectId(), + masterObjId, + toStr(p.PART_OBJID), + toStr(p.PART_NO), + toStr(p.PART_NAME), + toStr(p.SPEC), + toStr(p.MAKER), + toStr(p.UNIT), + String(qty), + String(unit), + String(toNum(p.PRICE1)), + String(toNum(p.PRICE2)), + String(toNum(p.PRICE3)), + String(toNum(p.PRICE4)), + String(sum), + String(Math.round(sum * vatRate)), + String(Math.round(sum * (1 + vatRate))), + String(Math.round(sum * (1 + vatRate))), + String(toNum(p.REAL_ORDER_QTY) || qty), + String(toNum(p.TOTAL_ORDER_QTY)), + String(toNum(p.STOCK_QTY)), + toStr(p.REMARK), + user.userId, + toStr(p.LD_PART_OBJID), + ], + ); + } + + // === 동시발주 슬레이브 처리 === + // 기존 슬레이브 전부 삭제 후 새로 생성 (parts도 동일하게) + await client.query( + `DELETE FROM purchase_order_part + WHERE purchase_order_master_objid IN ( + SELECT objid FROM purchase_order_master + WHERE multi_master_objid::text = $1 AND objid::text <> $1 + )`, + [masterObjId], + ); + await client.query( + `DELETE FROM purchase_order_master + WHERE multi_master_objid::text = $1 AND objid::text <> $1`, + [masterObjId], + ); + + // 슬레이브 unit_code 재조회용: 마스터 유닛의 task_name 한 번만 조회 + // 원본 common.getUnitCodeList — 동시 BOM으로 올라온 프로젝트는 같은 task_name의 유닛을 공유. + let masterTaskName = ""; + if (isMulti && toStr(body.unit_code)) { + const r = await client.query( + `SELECT task_name FROM pms_wbs_task WHERE objid::text = $1`, + [toStr(body.unit_code)], + ); + masterTaskName = toStr(r.rows[0]?.task_name); + } + + for (const m of multiProjects) { + const slaveObjId = createObjectId(); + const slaveContract = toStr(m.contract_mgmt_objid); + // 슬레이브 프로젝트에서 같은 task_name의 유닛 찾아 unit_code 재조회 (원본 getUnitCodeList) + const unitRes = await client.query( + `SELECT objid::text AS code FROM pms_wbs_task + WHERE contract_objid::text = $1 + AND (TRIM(UPPER(COALESCE(task_name, ''))) = TRIM(UPPER($2)) + OR TRIM(UPPER(COALESCE(unit_no, '') || '-' || COALESCE(task_name, ''))) = TRIM(UPPER($2))) + ORDER BY unit_no ASC LIMIT 1`, + [slaveContract, masterTaskName], + ); + const slaveUnit = toStr(unitRes.rows[0]?.code); + if (!slaveUnit) { + const pr = await client.query( + `SELECT project_no FROM project_mgmt WHERE objid::text = $1`, + [slaveContract], + ); + await client.query("ROLLBACK"); + return NextResponse.json({ + success: false, + message: `${toStr(pr.rows[0]?.project_no) || slaveContract} 프로젝트에 '${masterTaskName}' 유닛이 없습니다. 동시 BOM 설정을 확인하세요.`, + }); + } + const slaveDelivDate = toStr(m.delivery_plan_date) || toStr(body.delivery_date); + const slaveDelivQty = toStr(m.delivery_plan_qty); + // 슬레이브 PO 번호 자동생성 + const seqRes = await client.query( + `SELECT COALESCE(MAX( + CASE WHEN SPLIT_PART(purchase_order_no, '-', 3) ~ '^[0-9]+$' + THEN SPLIT_PART(purchase_order_no, '-', 3)::int ELSE 0 END), 0) + 1 AS next_seq + FROM purchase_order_master`, + ); + const slaveSeq = Number(seqRes.rows[0]?.next_seq ?? 1); + const now = new Date(); + const yymm = + String(now.getFullYear()).slice(2) + String(now.getMonth() + 1).padStart(2, "0"); + const slavePoNo = `PO-${yymm}-${slaveSeq}`; + + await client.query( + `INSERT INTO purchase_order_master ( + objid, purchase_order_no, po_client_id, partner_objid, my_company_objid, + contract_mgmt_objid, unit_code, bom_report_objid, sales_request_objid, + type, order_type_cd, delivery_date, delivery_place, effective_date, + payment_terms, inspect_method, vat_method, sales_mng_user_id, + title, remark, status, writer, regdate, purchase_date, + supply_bus_no, supply_user_name, supply_user_hp, supply_user_tel, + supply_user_fax, supply_user_email, supply_addr, + total_supply_unit_price, total_supply_price, total_real_supply_price, + total_price, total_price_all, discount_price, discount_price_all, + nego_rate, multi_yn, multi_master_yn, multi_master_objid, + delivery_plan_date, delivery_plan_qty, purchase_order_no_org + ) VALUES ( + $1, $2, $3, $4, $5, $6::numeric, $7, $8, $9, + $10, $11, $12, $13, $14, $15, $16, $17, $18, + $19, $20, 'create', $21, now(), TO_CHAR(NOW(), 'YYYY-MM-DD'), + $22, $23, $24, $25, $26, $27, $28, + $29, $30, $31, $32, $33, $34, $35, $36, 'Y', 'N', $37, + $38, $39, '' + )`, + [ + slaveObjId, + slavePoNo, + poClientId, + toStr(body.partner_objid), + toStr(body.my_company_objid) || poClientId, + slaveContract, + slaveUnit, + "", + "", + toStr(body.type), + toStr(body.order_type_cd), + slaveDelivDate, + toStr(body.delivery_place), + toStr(body.effective_date), + toStr(body.payment_terms), + toStr(body.inspect_method), + toStr(body.vat_method), + toStr(body.sales_mng_user_id) || user.userId, + toStr(body.title), + toStr(body.remark), + user.userId, + supplyInfo.SUPPLY_BUS_NO || "", + supplyInfo.SUPPLY_USER_NAME || "", + supplyInfo.SUPPLY_USER_HP || "", + supplyInfo.SUPPLY_USER_TEL || "", + supplyInfo.SUPPLY_USER_FAX || "", + supplyInfo.SUPPLY_USER_EMAIL || "", + supplyInfo.SUPPLY_ADDR || "", + String(totalSupplyUnitPrice), + String(totalSupplyPrice), + String(totalRealSupplyPrice), + String(totalPrice), + String(totalRealSupplyPrice), + String(discountPrice), + String(discountPrice), + String(negoRate), + masterObjId, + slaveDelivDate, + slaveDelivQty, + ], + ); + // 슬레이브 품목도 마스터 것을 그대로 복사 + for (const p of parts) { + if (!toStr(p.PART_OBJID) && !toStr(p.PART_NO)) continue; + const qty = toNum(p.ORDER_QTY); + const unit = toNum(p.PARTNER_PRICE); + const sum = + qty * + (unit + toNum(p.PRICE1) + toNum(p.PRICE2) + toNum(p.PRICE3) + toNum(p.PRICE4)); + await client.query( + `INSERT INTO purchase_order_part ( + objid, purchase_order_master_objid, part_objid, part_no, part_name, + spec, maker, unit, order_qty, partner_price, + price1, price2, price3, price4, + supply_unit_price, supply_unit_vat_price, supply_unit_vat_sum_price, + real_supply_price, real_order_qty, total_order_qty, stock_qty, + remark, writer, regdate, status, ld_part_objid + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, + $22, $23, now(), 'create', $24 + )`, + [ + createObjectId(), + slaveObjId, + toStr(p.PART_OBJID), + toStr(p.PART_NO), + toStr(p.PART_NAME), + toStr(p.SPEC), + toStr(p.MAKER), + toStr(p.UNIT), + String(qty), + String(unit), + String(toNum(p.PRICE1)), + String(toNum(p.PRICE2)), + String(toNum(p.PRICE3)), + String(toNum(p.PRICE4)), + String(sum), + String(Math.round(sum * vatRate)), + String(Math.round(sum * (1 + vatRate))), + String(Math.round(sum * (1 + vatRate))), + String(toNum(p.REAL_ORDER_QTY) || qty), + String(toNum(p.TOTAL_ORDER_QTY)), + String(toNum(p.STOCK_QTY)), + toStr(p.REMARK), + user.userId, + toStr(p.LD_PART_OBJID), + ], + ); + } + } + + // 원본 updatePurchaseOrderMasterPriceAll + updatePurchaseOrderPriceTxt 를 1건으로 병합. + // 마스터와 모든 슬레이브에 동일한 집계값을 세팅: + // - total_supply_unit_price_all: 마스터 공급단가 × (1 + 슬레이브수) + // - total_price_all: real - discount (할인 없으면 NULL) + // - total_price_txt / _all: NUM_TO_KOR 한글 금액 (PG 함수) + // discount_price_all은 저장 시점에 건드리지 않음 — 별도 네고 수정 화면에서 세팅하는 게 원본 동작. + const supplyUnitAll = totalSupplyUnitPrice * (1 + multiProjects.length); + await client.query( + `UPDATE purchase_order_master SET + total_supply_unit_price_all = $2, + total_price_all = CASE + WHEN COALESCE(discount_price, '0')::numeric != 0 + THEN (COALESCE(total_real_supply_price, '0')::numeric - COALESCE(discount_price, '0')::numeric)::text + ELSE NULL END, + total_price_txt = NUM_TO_KOR(COALESCE(total_supply_unit_price, '0'), '일금 ', ' 원정 (₩ ') + || TO_CHAR(COALESCE(total_supply_unit_price, '0')::numeric, '999,999,999,999') || ')', + total_price_txt_all = NUM_TO_KOR(COALESCE(total_supply_unit_price_all, '0'), '일금 ', ' 원정 (₩ ') + || TO_CHAR(COALESCE(total_supply_unit_price_all, '0')::numeric, '999,999,999,999') || ')' + WHERE objid::text = $1 OR multi_master_objid::text = $1`, + [masterObjId, String(supplyUnitAll)], + ); + + await client.query("COMMIT"); + return NextResponse.json({ + success: true, + objId: masterObjId, + purchaseOrderNo: poNo, + message: isNew ? "등록되었습니다." : "수정되었습니다.", + }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("order/save:", e); + return NextResponse.json( + { success: false, message: "저장 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } finally { + client.release(); + } +} diff --git a/src/app/api/order/status/route.ts b/src/app/api/order/status/route.ts new file mode 100644 index 0000000..f8329f9 --- /dev/null +++ b/src/app/api/order/status/route.ts @@ -0,0 +1,170 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: purchaseOrder.xml#purchaseOrderStatusByProject +// 발주관리 > 현황 — (프로젝트 × 유닛) 단위 발주율 / 재발주 / 턴키 / 총발주금액 집계 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + + // 필터 조건 (모든 서브쿼리에 공통 적용) + const year = body.year ? String(body.year) : ""; + const projectNo = body.project_no ? String(body.project_no) : ""; + const unitCode = body.unit_code ? String(body.unit_code) : ""; + + // 공통 WHERE를 CTE별로 동일하게 쓰기 위해 파라미터 플레이스홀더를 정렬 + // $1=year(POM.regdate YYYY), $2=project_no(프로젝트 LIKE), $3=unit_code + const params: unknown[] = [year || null, projectNo || null, unitCode || null]; + + const sql = ` + WITH base AS ( + -- 프로젝트 × 유닛(WBS) × (있다면)구매BOM 카르테시안 + SELECT CM.objid::text AS project_objid, + COALESCE(CM.project_no, CM.contract_no) AS project_no, + CM.customer_project_name, + CM.project_name, + TO_CHAR(CM.regdate, 'YYYY') AS cm_year, + (SELECT supply_name FROM supply_mng O WHERE O.objid::text = CM.customer_objid LIMIT 1) AS customer_name, + PWT.objid::text AS unit_code, + COALESCE(PWT.unit_no,'') || '-' || COALESCE(PWT.task_name,'') AS unit_part_name, + PBR.objid::text AS bom_report_objid, + (SELECT COUNT(*) FROM bom_part_qty A WHERE A.bom_report_objid = PBR.objid) AS bom_cnt + FROM project_mgmt CM + LEFT JOIN pms_wbs_task PWT ON PWT.contract_objid = CM.objid + LEFT JOIN part_bom_report PBR ON PBR.contract_objid = CM.objid AND PBR.unit_code = PWT.objid::varchar + WHERE ($2::text IS NULL OR COALESCE(CM.project_no,'') ILIKE '%' || $2 || '%' OR COALESCE(CM.contract_no,'') ILIKE '%' || $2 || '%') + AND ($3::text IS NULL OR PWT.objid::text = $3) + ), + -- 구매BOM 부품 개수 (distinct part_no) — 발주율 분모 + bom_rollup AS ( + SELECT PBR.objid::text AS bom_report_objid, + COUNT(DISTINCT BPQ.part_no) FILTER ( + WHERE COALESCE(PM.part_type,'') != '' + AND BPQ.status IN ('beforeEdit','editing','deleting','deploy') + ) AS total_bom_part_cnt + FROM part_bom_report PBR + LEFT JOIN bom_part_qty BPQ ON BPQ.bom_report_objid = PBR.objid + LEFT JOIN part_mng PM ON COALESCE(NULLIF(BPQ.last_part_objid,''), BPQ.part_no) = PM.objid::text + GROUP BY PBR.objid + ), + -- 발주 집계 (approvalComplete만) — 프로젝트 × 유닛 단위 + po_rollup AS ( + SELECT POM.contract_mgmt_objid::text AS project_objid, + POM.unit_code, + SUM(COALESCE(NULLIF(POP.supply_unit_price,''),'0')::numeric) AS total_supply_unit_price, + SUM(CASE WHEN PM.part_type IN ('0000063') + THEN COALESCE(NULLIF(POP.supply_unit_price,''),'0')::numeric END) AS price_pt_1, + SUM(CASE WHEN PM.part_type IN ('0000064','0001540','0001398','0001397','0001396') + THEN COALESCE(NULLIF(POP.supply_unit_price,''),'0')::numeric END) AS price_pt_2, + SUM(CASE WHEN PM.part_type IN ('0000065') + THEN COALESCE(NULLIF(POP.supply_unit_price,''),'0')::numeric END) AS price_pt_3, + SUM(CASE WHEN PM.part_type NOT IN ('0000063','0000064','0001540','0001398','0001397','0001396','0000065') OR PM.part_type IS NULL + THEN COALESCE(NULLIF(POP.supply_unit_price,''),'0')::numeric END) AS price_pt_etc, + COUNT(DISTINCT POP.part_objid) AS total_po_part_cnt + FROM purchase_order_master POM + JOIN purchase_order_part POP ON POP.purchase_order_master_objid = POM.objid + LEFT JOIN part_mng PM ON POP.part_objid = PM.objid::text + WHERE POM.status = 'approvalComplete' + AND COALESCE(POM.type,'') != '0001785' + AND ($1::text IS NULL OR TO_CHAR(POM.regdate, 'YYYY') = $1) + GROUP BY POM.contract_mgmt_objid, POM.unit_code + ), + -- 재발주 집계 + re_po_rollup AS ( + SELECT POM.contract_mgmt_objid::text AS project_objid, + POM.unit_code, + COUNT(DISTINCT POM.objid) AS re_count, + SUM(COALESCE(NULLIF(POP.supply_unit_price,''),'0')::numeric) AS re_price + FROM purchase_order_master POM + JOIN purchase_order_part POP ON POP.purchase_order_master_objid = POM.objid + WHERE POM.status = 'approvalComplete' + AND POM.order_type_cd = '0001408' + AND COALESCE(POM.type,'') != '0001785' + AND ($1::text IS NULL OR TO_CHAR(POM.regdate, 'YYYY') = $1) + GROUP BY POM.contract_mgmt_objid, POM.unit_code + ), + -- 턴키 집계 + turnkey_rollup AS ( + SELECT POM.contract_mgmt_objid::text AS project_objid, + POM.unit_code, + COUNT(DISTINCT POM.objid) AS turnkey_count, + SUM(COALESCE(NULLIF(POP.supply_unit_price,''),'0')::numeric) AS turnkey_price + FROM purchase_order_master POM + JOIN purchase_order_part POP ON POP.purchase_order_master_objid = POM.objid + WHERE POM.status = 'approvalComplete' + AND POM.type = '0001785' + AND ($1::text IS NULL OR TO_CHAR(POM.regdate, 'YYYY') = $1) + GROUP BY POM.contract_mgmt_objid, POM.unit_code + ), + -- 총 발주금액 (프로젝트 × 유닛) — total_price_all이 비어있으면 total_price로 폴백 + total_rollup AS ( + SELECT POM.contract_mgmt_objid::text AS project_objid, + POM.unit_code, + SUM(CASE WHEN COALESCE(NULLIF(POM.total_price_all,''),'') <> '' + THEN POM.total_price_all::numeric + ELSE COALESCE(NULLIF(POM.total_price,''),'0')::numeric END) AS total_price_all, + SUM(COALESCE(NULLIF(POM.total_supply_price,''),'0')::numeric) AS total_supply_price, + SUM(COALESCE(NULLIF(POM.discount_price,''),'0')::numeric) AS discount_price, + SUM(COALESCE(NULLIF(POM.total_price,''),'0')::numeric) AS total_price + FROM purchase_order_master POM + WHERE POM.status = 'approvalComplete' + AND (COALESCE(POM.multi_master_yn,'') = 'Y' OR (COALESCE(POM.multi_master_yn,'') != 'Y' AND COALESCE(POM.multi_yn,'') != 'Y')) + AND ($1::text IS NULL OR TO_CHAR(POM.regdate, 'YYYY') = $1) + GROUP BY POM.contract_mgmt_objid, POM.unit_code + ) + SELECT B.project_objid AS "PROJECT_OBJID", + B.cm_year AS "CM_YEAR", + B.customer_name AS "CUSTOMER_NAME", + COALESCE(B.project_name, B.customer_project_name) AS "PROJECT_NAME", + B.customer_project_name AS "CUSTOMER_PROJECT_NAME", + B.unit_part_name AS "UNIT_PART_NAME", + B.project_no AS "PROJECT_NO", + B.bom_report_objid AS "BOM_REPORT_OBJID", + COALESCE(B.bom_cnt, 0) AS "BOM_CNT", + COALESCE(BR.total_bom_part_cnt, 0) AS "TOTAL_BOM_PART_CNT", + COALESCE(P.total_po_part_cnt, 0) AS "TOTAL_PO_PART_CNT", + GREATEST(COALESCE(BR.total_bom_part_cnt, 0) - COALESCE(P.total_po_part_cnt, 0), 0) AS "NON_PO_PART_CNT", + CASE WHEN COALESCE(BR.total_bom_part_cnt, 0) = 0 THEN 0 + ELSE ROUND(COALESCE(P.total_po_part_cnt, 0)::numeric / BR.total_bom_part_cnt * 100, 1) + END AS "RATE_PO", + COALESCE(P.price_pt_1, 0) AS "PRICE_PT_1", + COALESCE(P.price_pt_2, 0) AS "PRICE_PT_2", + COALESCE(P.price_pt_3, 0) AS "PRICE_PT_3", + COALESCE(P.price_pt_etc, 0) AS "PRICE_PT_ETC", + COALESCE(R.re_count, 0) AS "RE_COUNT", + COALESCE(R.re_price, 0) AS "RE_PRICE", + COALESCE(TK.turnkey_count, 0) AS "TURNKEY_COUNT", + COALESCE(TK.turnkey_price, 0) AS "TURNKEY_PRICE", + COALESCE(TR.total_price_all, 0) AS "TOTAL_PRICE_ALL", + COALESCE(TR.total_price, 0) AS "TOTAL_PRICE" + FROM base B + LEFT JOIN bom_rollup BR ON BR.bom_report_objid = B.bom_report_objid + LEFT JOIN po_rollup P ON P.project_objid = B.project_objid AND P.unit_code = B.unit_code + LEFT JOIN re_po_rollup R ON R.project_objid = B.project_objid AND R.unit_code = B.unit_code + LEFT JOIN turnkey_rollup TK ON TK.project_objid = B.project_objid AND TK.unit_code = B.unit_code + LEFT JOIN total_rollup TR ON TR.project_objid = B.project_objid AND TR.unit_code = B.unit_code + ORDER BY B.project_no DESC NULLS LAST, B.unit_part_name + `; + + try { + const rows = await queryRows(sql, params); + // 그랜드 토탈 — FITO 원본 purchaseOrderStatusByProjectSum 매퍼와 일치: + // 총발주금액 = SUM(total_real_supply_price) (VAT 포함 실공급가 합계) + // 단일발주금액 = SUM(total_supply_price) (공급가 합계) + const totals = await queryOne( + `SELECT SUM(COALESCE(NULLIF(POM.total_real_supply_price,''),'0')::numeric) AS "TOTAL_PRICE_ALL_SUM", + SUM(COALESCE(NULLIF(POM.total_supply_price,''),'0')::numeric) AS "SINGLE_PRICE_SUM" + FROM purchase_order_master POM + WHERE POM.status = 'approvalComplete' + AND ($1::text IS NULL OR TO_CHAR(POM.regdate, 'YYYY') = $1)`, + [year || null], + ); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length, SUMS: totals }); + } catch (e) { + console.error("order/status:", e); + return NextResponse.json({ success: false, message: "조회 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/part-mgmt/route.ts b/src/app/api/part-mgmt/route.ts new file mode 100644 index 0000000..5ecfe7c --- /dev/null +++ b/src/app/api/part-mgmt/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// PartMngController / partmgmt.getPartMgntList 대응 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const { part_no, part_name, part_type, page = 1 } = body; + const countPerPage = 20; + + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (part_no) { conditions.push(`P.PART_NO LIKE '%' || $${idx++} || '%'`); params.push(part_no); } + if (part_name) { conditions.push(`P.PART_NAME LIKE '%' || $${idx++} || '%'`); params.push(part_name); } + if (part_type) { conditions.push(`P.PART_TYPE = $${idx++}`); params.push(part_type); } + + const where = conditions.join(" AND "); + const offset = (Number(page) - 1) * countPerPage; + + const list = await queryRows( + `SELECT P.OBJID, P.PART_NO, P.PART_NAME, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE PARENT_CODE_ID = '0000062' AND CODE_ID = P.PART_TYPE LIMIT 1), '') AS PART_TYPE_NAME, + P.SPEC, P.MATERIAL, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE PARENT_CODE_ID = '0005126' AND CODE_ID = P.MATERIAL LIMIT 1), '') AS MATERIAL_NAME, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE PARENT_CODE_ID = '0000059' AND CODE_ID = P.UNIT LIMIT 1), '') AS UNIT_NAME, + P.WEIGHT, P.MAKER, + (SELECT USER_NAME FROM USER_INFO WHERE USER_ID = P.WRITER LIMIT 1) AS WRITER_NAME, + TO_CHAR(P.REGDATE::date, 'YYYY-MM-DD') AS REGDATE, + COALESCE((SELECT COUNT(*) FROM ATTACH_FILE_INFO WHERE TARGET_OBJID = P.OBJID AND DOC_TYPE = 'PART_2D'), 0) AS DRAWING_2D_CNT, + COALESCE((SELECT COUNT(*) FROM ATTACH_FILE_INFO WHERE TARGET_OBJID = P.OBJID AND DOC_TYPE = 'PART_3D'), 0) AS DRAWING_3D_CNT + FROM PART_MGMT P + WHERE ${where} + ORDER BY P.REGDATE DESC + LIMIT $${idx++} OFFSET $${idx++}`, + [...params, countPerPage, offset] + ); + + return NextResponse.json({ RESULTLIST: list, TOTAL_CNT: list.length }); +} diff --git a/src/app/api/part/register/route.ts b/src/app/api/part/register/route.ts new file mode 100644 index 0000000..dff8312 --- /dev/null +++ b/src/app/api/part/register/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// PART 등록 페이지 전용 조회 (part-register에서 호출) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const body = await request.json(); + + const conditions: string[] = ["P.status = 'release'"]; + const params: unknown[] = []; + let idx = 1; + if (body.SEARCH_PART_NO) { conditions.push(`P.part_no LIKE '%' || $${idx++} || '%'`); params.push(body.SEARCH_PART_NO); } + if (body.SEARCH_PART_NAME) { conditions.push(`P.part_name LIKE '%' || $${idx++} || '%'`); params.push(body.SEARCH_PART_NAME); } + + const rows = await queryRows( + `SELECT P.objid::text AS "OBJID", P.part_no AS "PART_NO", P.part_name AS "PART_NAME", + P.qty AS "BOM_QTY", P.material AS "MATERIAL", P.spec AS "SPEC", + P.post_processing AS "POST_PROCESSING", P.maker AS "MAKER", P.revision AS "REVISION", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = P.part_type LIMIT 1), '') AS "PART_TYPE_TITLE", + P.remark AS "REMARK", + COALESCE((SELECT COUNT(*) FROM attach_file_info WHERE target_objid = P.objid::text AND doc_type = '3D_CAD'), 0) AS "CU01_CNT", + COALESCE((SELECT COUNT(*) FROM attach_file_info WHERE target_objid = P.objid::text AND doc_type = '2D_DRAWING_CAD'), 0) AS "CU02_CNT", + COALESCE((SELECT COUNT(*) FROM attach_file_info WHERE target_objid = P.objid::text AND doc_type = '2D_PDF_CAD'), 0) AS "CU03_CNT" + FROM part_mng P + WHERE ${conditions.join(" AND ")} + ORDER BY P.part_no, P.revision DESC +`, + params + ); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/procurement-std/detail/route.ts b/src/app/api/procurement-std/detail/route.ts new file mode 100644 index 0000000..47ebafe --- /dev/null +++ b/src/app/api/procurement-std/detail/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne, queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { STD_CATEGORIES } from "@/lib/procurement-std"; + +// 팝업 로드: 코드 단건 + 자재코드 단건 + 자재 팝업용 드롭다운 옵션 5종 +// body: { mode: "code"|"material"|"options", objId? } +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const mode = String(body.mode || "code"); + const objId = body.objId ? String(body.objId) : ""; + + if (mode === "options") { + // 자재코드 팝업에서 쓰는 5개 카테고리 옵션 목록 + const result: Record = {}; + for (const def of Object.values(STD_CATEGORIES)) { + const rows = await queryRows( + `SELECT objid::text AS "OBJID", code_id AS "CODE_ID", code_name AS "CODE_NAME" + FROM procurement_standard + WHERE category = $1 + AND COALESCE(status, 'active') = 'active' + ORDER BY code_id`, + [def.category] + ); + result[def.alias] = rows.map((r) => ({ + value: String(r.OBJID), + label: `${r.CODE_ID} - ${r.CODE_NAME}`, + code_id: String(r.CODE_ID), + code_name: String(r.CODE_NAME), + })); + } + return NextResponse.json({ success: true, data: result }); + } + + if (mode === "material") { + if (!objId) return NextResponse.json({ success: true, data: null }); + const row = await queryOne( + `SELECT T.objid::text AS "OBJID", + T.part_no AS "PART_NO", + T.major_category AS "MAJOR_CATEGORY", + T.sub_category AS "SUB_CATEGORY", + T.maker AS "MAKER", + T.part_name AS "PART_NAME", + T.spec AS "SPEC", + T.code1 AS "CODE1", + T.code2 AS "CODE2", + T.code3 AS "CODE3", + T.code4 AS "CODE4", + T.code5 AS "CODE5", + (SELECT objid::text FROM procurement_standard WHERE code_id = T.code1 AND category = '0001668' LIMIT 1) AS "CODE_OBJID1", + (SELECT objid::text FROM procurement_standard WHERE code_id = T.code2 AND category = '0001669' LIMIT 1) AS "CODE_OBJID2", + (SELECT objid::text FROM procurement_standard WHERE code_id = T.code3 AND category = '0001670' LIMIT 1) AS "CODE_OBJID3", + (SELECT objid::text FROM procurement_standard WHERE code_id = T.code4 AND category = '0001671' LIMIT 1) AS "CODE_OBJID4", + (SELECT objid::text FROM procurement_standard WHERE code_id = T.code5 AND category = '0001672' LIMIT 1) AS "CODE_OBJID5", + T.status AS "STATUS" + FROM part_mng T + WHERE T.objid::text = $1`, + [objId] + ); + return NextResponse.json({ success: true, data: row || null }); + } + + // code mode + if (!objId) return NextResponse.json({ success: true, data: null }); + const row = await queryOne( + `SELECT objid::text AS "OBJID", + code_id AS "CODE_ID", + code_name AS "CODE_NAME", + detail AS "DETAIL", + category AS "CATEGORY", + COALESCE(status, 'active') AS "STATUS" + FROM procurement_standard + WHERE objid::text = $1`, + [objId] + ); + return NextResponse.json({ success: true, data: row || null }); +} diff --git a/src/app/api/procurement-std/material-save/route.ts b/src/app/api/procurement-std/material-save/route.ts new file mode 100644 index 0000000..93f19ad --- /dev/null +++ b/src/app/api/procurement-std/material-save/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 자재코드 저장 (procurStandMgmt.mergeMaterialCode 대응) +// body: { actionType, objId?, code_objid1..5 } — procurement_standard.objid 5개 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const isNew = !body.objId || body.actionType === "regist"; + const objId = isNew ? createObjectId() : String(body.objId); + + const codeObjIds: string[] = [ + String(body.code_objid1 || ""), + String(body.code_objid2 || ""), + String(body.code_objid3 || ""), + String(body.code_objid4 || ""), + String(body.code_objid5 || ""), + ]; + + if (codeObjIds.some((v) => !v)) { + return NextResponse.json({ success: false, message: "대분류/중분류/Maker/품명/규격을 모두 선택하세요." }, { status: 400 }); + } + + try { + // 5개 카테고리 각각의 code_id, code_name 조회 + const categories = ["0001668", "0001669", "0001670", "0001671", "0001672"]; + const codes: { code_id: string; code_name: string }[] = []; + for (let i = 0; i < 5; i++) { + const row = await queryOne( + `SELECT code_id AS "CODE_ID", code_name AS "CODE_NAME" + FROM procurement_standard + WHERE objid::text = $1 AND category = $2`, + [codeObjIds[i], categories[i]] + ); + if (!row) { + return NextResponse.json({ success: false, message: `${["대분류","중분류","Maker","품명","규격"][i]} 선택값이 올바르지 않습니다.` }, { status: 400 }); + } + codes.push({ code_id: String(row.CODE_ID), code_name: String(row.CODE_NAME) }); + } + const materialCode = codes.map((c) => c.code_id).join(""); + + // 중복 체크 + const dup = await queryOne( + `SELECT objid::text AS "OBJID" FROM part_mng + WHERE objid::text != $1 + AND REPLACE(TRIM(UPPER(part_no)), ' ', '') = REPLACE(TRIM(UPPER($2)), ' ', '')`, + [objId, materialCode] + ); + if (dup) { + return NextResponse.json({ success: false, message: "중복되는 자재코드가 존재합니다." }, { status: 400 }); + } + + // EO_NO 생성 (원본: 'EOB' + yy + '-' + 4자리 순번, 신규만) + let eoNo: string | null = null; + if (isNew) { + const row = await queryOne<{ EO_NO: string }>( + `SELECT 'EOB' || TO_CHAR(NOW(), 'YY') || '-' || + LPAD( + (SELECT COALESCE(MAX(SUBSTR(eo_no, 7, 8)::INTEGER) + 1, 1)::TEXT + FROM part_mng + WHERE eo_no LIKE 'EOB%'), 4, '0' + ) AS "EO_NO"`, + [] + ); + eoNo = row ? String(row.EO_NO) : null; + } + + if (isNew) { + await execute( + `INSERT INTO part_mng ( + objid, part_no, part_name, spec, major_category, sub_category, maker, + code1, code2, code3, code4, code5, + part_type, revision, status, reg_date, writer, is_last, is_new, is_longd, eo_no, design_date + ) VALUES ( + $1::numeric, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, + '0001788', 'RE', 'release', NOW(), $13, '1', '1', '1', $14, TO_CHAR(NOW(), 'YYYY-MM-DD') + )`, + [ + objId, materialCode, codes[3].code_name, codes[4].code_name, + codes[0].code_name, codes[1].code_name, codes[2].code_name, + codes[0].code_id, codes[1].code_id, codes[2].code_id, codes[3].code_id, codes[4].code_id, + user.userId, eoNo, + ] + ); + } else { + await execute( + `UPDATE part_mng SET + part_no = $2, + part_name = $3, + spec = $4, + major_category = $5, + sub_category = $6, + maker = $7, + code1 = $8, code2 = $9, code3 = $10, code4 = $11, code5 = $12, + edit_date = NOW(), + writer = $13 + WHERE objid::text = $1`, + [ + objId, materialCode, codes[3].code_name, codes[4].code_name, + codes[0].code_name, codes[1].code_name, codes[2].code_name, + codes[0].code_id, codes[1].code_id, codes[2].code_id, codes[3].code_id, codes[4].code_id, + user.userId, + ] + ); + } + + return NextResponse.json({ success: true, objId, materialCode, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (error) { + console.error("Material code save:", error); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/procurement-std/overlap/route.ts b/src/app/api/procurement-std/overlap/route.ts new file mode 100644 index 0000000..4cdb691 --- /dev/null +++ b/src/app/api/procurement-std/overlap/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { getStdCategoryByAlias } from "@/lib/procurement-std"; + +// 구매품표준 중복체크 (procurStandMgmt.overlapStandardMng / overlapMaterialCode 대응) +// body: { mode: "code"|"material", objId, code_group?, code_id?, code_name?, material_code? } +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const mode = String(body.mode || "code"); + const objId = String(body.objId || "0"); + + if (mode === "material") { + const materialCode = String(body.material_code || "").trim().toUpperCase(); + if (!materialCode) return NextResponse.json({ duplicate: false }); + const rows = await queryRows( + `SELECT objid::text AS "OBJID" + FROM part_mng + WHERE objid::text != $1 + AND REPLACE(TRIM(UPPER(part_no)), ' ', '') = REPLACE(TRIM(UPPER($2)), ' ', '')`, + [objId, materialCode] + ); + return NextResponse.json({ duplicate: rows.length > 0 }); + } + + // code mode + const alias = String(body.code_group || ""); + const def = getStdCategoryByAlias(alias); + if (!def) return NextResponse.json({ duplicate: false }); + + const codeId = String(body.code_id || "").trim().toUpperCase(); + const codeName = String(body.code_name || "").trim(); + const rows = await queryRows( + `SELECT objid::text AS "OBJID" + FROM procurement_standard + WHERE objid::text != $1 + AND category = $2 + AND ( + REPLACE(TRIM(UPPER(code_name)), ' ', '') = REPLACE(TRIM(UPPER($3)), ' ', '') + OR REPLACE(TRIM(UPPER(code_id)), ' ', '') = REPLACE(TRIM(UPPER($4)), ' ', '') + )`, + [objId, def.category, codeName, codeId] + ); + return NextResponse.json({ duplicate: rows.length > 0 }); +} diff --git a/src/app/api/procurement-std/route.ts b/src/app/api/procurement-std/route.ts new file mode 100644 index 0000000..9747570 --- /dev/null +++ b/src/app/api/procurement-std/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { getStdCategoryByAlias } from "@/lib/procurement-std"; + +// 구매품표준관리 목록 조회 (procurStandMgmt.mainCategoryGridList / materialCodeGridList 대응) +// code_group = "CODE1"~"CODE5" → procurement_standard (카테고리별) +// code_group = "MATERIAL_CODE" → part_mng +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const codeGroup = String(body.code_group || ""); + + // === 자재코드 모드 === + if (codeGroup === "MATERIAL_CODE") { + const conditions: string[] = ["T.is_new = '1'"]; + const params: unknown[] = []; + let idx = 1; + + if (body.part_no) { + conditions.push(`UPPER(T.part_no) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.part_no); + } + if (body.large_cd) { + conditions.push(`UPPER(T.major_category) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.large_cd); + } + if (body.middle_cd) { + conditions.push(`UPPER(T.sub_category) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.middle_cd); + } + if (body.maker) { + conditions.push(`UPPER(T.maker) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.maker); + } + if (body.part_name) { + conditions.push(`UPPER(T.part_name) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.part_name); + } + if (body.spec) { + conditions.push(`UPPER(T.spec) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.spec); + } + + const rows = await queryRows( + `SELECT T.objid::text AS "OBJID", + T.part_no AS "PART_NO", + T.major_category AS "MAJOR_CATEGORY", + T.sub_category AS "SUB_CATEGORY", + T.maker AS "MAKER", + T.part_name AS "PART_NAME", + T.spec AS "SPEC", + T.code1 AS "CODE1", + T.code2 AS "CODE2", + T.code3 AS "CODE3", + T.code4 AS "CODE4", + T.code5 AS "CODE5", + T.revision AS "REVISION", + T.eo_no AS "EO_NO", + TO_CHAR(T.eo_date::date, 'YYYY-MM-DD') AS "EO_DATE" + FROM part_mng T + WHERE ${conditions.join(" AND ")} + ORDER BY T.reg_date DESC +`, + params + ); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); + } + + // === 코드1~5 카테고리 모드 === + const def = getStdCategoryByAlias(codeGroup); + const conditions: string[] = []; + const params: unknown[] = []; + let idx = 1; + + if (def) { + conditions.push(`PS.category = $${idx++}`); + params.push(def.category); + } + if (body.code_name) { + conditions.push(`PS.code_name LIKE '%' || $${idx++} || '%'`); + params.push(body.code_name); + } + if (body.code_id) { + conditions.push(`PS.code_id LIKE '%' || $${idx++} || '%'`); + params.push(body.code_id); + } + + const where = conditions.length > 0 ? conditions.join(" AND ") : "1=1"; + + const rows = await queryRows( + `SELECT PS.objid::text AS "OBJID", + PS.code_id AS "CODE_ID", + PS.code_name AS "CODE_NAME", + PS.detail AS "DETAIL", + PS.category AS "CATEGORY", + COALESCE(PS.status, 'active') AS "STATUS", + COALESCE((SELECT user_name FROM user_info WHERE user_id = PS.writer LIMIT 1), PS.writer) AS "WRITER_NAME", + TO_CHAR(PS.regdate, 'YYYY-MM-DD') AS "REG_DATE" + FROM procurement_standard PS + WHERE ${where} + ORDER BY PS.code_id`, + params + ); + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} + +// 구매품표준관리 삭제 +export async function DELETE(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const { objIds, mode } = body; + + if (!objIds || !Array.isArray(objIds) || objIds.length === 0) { + return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }); + } + + try { + const placeholders = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + if (mode === "material") { + await execute(`DELETE FROM part_mng WHERE objid IN (${placeholders})`, objIds); + } else { + await execute(`DELETE FROM procurement_standard WHERE objid IN (${placeholders})`, objIds); + } + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (error) { + console.error("Procurement std delete:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/procurement-std/save/route.ts b/src/app/api/procurement-std/save/route.ts new file mode 100644 index 0000000..a6d144d --- /dev/null +++ b/src/app/api/procurement-std/save/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; +import { getStdCategoryByAlias, validateStdCodeId } from "@/lib/procurement-std"; + +// 구매품표준코드 저장 (procurStandMgmt.saveCODE1Info 대응) +// body: { actionType, objId?, code_group(alias), code_id, code_name, detail?, status? } +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const alias = String(body.code_group || ""); + const def = getStdCategoryByAlias(alias); + if (!def) { + return NextResponse.json({ success: false, message: "카테고리가 올바르지 않습니다." }, { status: 400 }); + } + + const codeId = String(body.code_id || "").toUpperCase(); + const codeName = String(body.code_name || "").trim(); + + if (!codeName) { + return NextResponse.json({ success: false, message: "구분을 입력하세요." }, { status: 400 }); + } + const v = validateStdCodeId(alias, codeId); + if (!v.ok) { + return NextResponse.json({ success: false, message: v.message }, { status: 400 }); + } + + const isNew = !body.objId || body.actionType === "regist"; + const objId = isNew ? createObjectId() : String(body.objId); + const detail = def.showDetail ? (body.detail || "") : ""; + const status = body.status || "active"; + + try { + await execute( + `INSERT INTO procurement_standard (objid, code_name, code_id, detail, category, regdate, writer, status) + VALUES ($1, $2, $3, $4, $5, NOW(), $6, $7) + ON CONFLICT (objid) DO UPDATE SET + code_name = EXCLUDED.code_name, + code_id = EXCLUDED.code_id, + detail = EXCLUDED.detail, + category = EXCLUDED.category, + status = EXCLUDED.status, + editdate = NOW(), + edit_user = EXCLUDED.writer`, + [objId, codeName, codeId, detail, def.category, user.userId, status] + ); + // 코드명 변경 시 part_mng 쪽 컬럼 동기화 (원본 updateCodeName 대응) + // 해당 category에 소속된 code_id일 때만, part_mng.MAJOR_CATEGORY 등 컬럼 업데이트 + const colMap: Record = { + CODE1: "major_category", + CODE2: "sub_category", + CODE3: "maker", + CODE4: "part_name", + CODE5: "spec", + }; + const partMngCol = colMap[alias]; + if (partMngCol) { + await execute( + `UPDATE part_mng SET ${partMngCol} = $1 WHERE ${alias.toLowerCase()} = $2`, + [codeName, codeId] + ); + } + + return NextResponse.json({ success: true, objId, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (error) { + console.error("Procurement std save:", error); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/product-mgmt/detail/route.ts b/src/app/api/product-mgmt/detail/route.ts new file mode 100644 index 0000000..d0bb778 --- /dev/null +++ b/src/app/api/product-mgmt/detail/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + const info = await queryOne( + `SELECT P.objid::text AS "OBJID", P.product_category AS "PRODUCT_CATEGORY", + P.product_type AS "PRODUCT_TYPE", P.product_name AS "PRODUCT_NAME", + P.product_code AS "PRODUCT_CODE", P.product_name_code AS "PRODUCT_NAME_CODE", + P.product_grade AS "PRODUCT_GRADE", P.product_ton AS "PRODUCT_TON", + P.product_boom AS "PRODUCT_BOOM", P.product_vehicle AS "PRODUCT_VEHICLE", + P.production_flag AS "PRODUCTION_FLAG", P.note AS "NOTE", + TO_CHAR(P.regdate, 'YYYY-MM-DD') AS "REGDATE" + FROM product_mgmt P WHERE P.objid = $1`, [objId] + ); + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return NextResponse.json({ success: true, data: info }); +} diff --git a/src/app/api/product-mgmt/route.ts b/src/app/api/product-mgmt/route.ts new file mode 100644 index 0000000..28f7035 --- /dev/null +++ b/src/app/api/product-mgmt/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, queryOne, execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// ProductMgmtController.productmgmtList 대응 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const { year, product_category, page = 1, countPerPage = 20 } = body; + + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let paramIdx = 1; + + if (year) { + conditions.push(`EXTRACT(YEAR FROM P.REGDATE) = $${paramIdx++}`); + params.push(year); + } + if (product_category) { + conditions.push(`P.PRODUCT_CATEGORY = $${paramIdx++}`); + params.push(product_category); + } + + const where = conditions.join(" AND "); + + const countResult = await queryOne<{ CNT: string }>( + `SELECT COUNT(*) AS CNT FROM PRODUCT_MGMT P WHERE ${where}`, + params + ); + const totalCount = Number(countResult?.CNT || 0); + const maxPage = Math.max(1, Math.ceil(totalCount / countPerPage)); + const offset = (Number(page) - 1) * countPerPage; + + const list = await queryRows( + `SELECT P.OBJID::text AS "OBJID", P.PRODUCT_CATEGORY, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE PARENT_CODE_ID = '0000917' AND CODE_ID = P.PRODUCT_CATEGORY LIMIT 1), '') AS "PRODUCT_CATEGORY_NAME", + P.PRODUCT_TYPE, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE PARENT_CODE_ID = '0005116' AND CODE_ID = P.PRODUCT_TYPE LIMIT 1), '') AS "PRODUCT_TYPE_NAME", + P.PRODUCT_NAME AS "PRODUCT_NAME", + P.PRODUCT_CODE AS "PRODUCT_CODE", + COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = P.WRITER LIMIT 1), P.WRITER) AS "WRITER_NAME", + TO_CHAR(P.REGDATE, 'YYYY-MM-DD') AS "REGDATE", + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE PARENT_CODE_ID = '0000039' AND CODE_ID = P.PRODUCTION_FLAG LIMIT 1), '') AS "PRODUCTION_FLAG_NAME", + COALESCE((SELECT COUNT(*) FROM ATTACH_FILE_INFO WHERE TARGET_OBJID = P.OBJID::text), 0) AS "FILE_CNT" + FROM PRODUCT_MGMT P + WHERE ${where} + ORDER BY P.REGDATE DESC + LIMIT $${paramIdx++} OFFSET $${paramIdx++}`, + [...params, countPerPage, offset] + ); + + return NextResponse.json({ + RESULTLIST: list, + TOTAL_CNT: totalCount, + MAX_PAGE_SIZE: maxPage, + COUNT_PER_PAGE: countPerPage, + }); +} + +export async function DELETE(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const { objIds } = body; + + if (!objIds || !Array.isArray(objIds) || objIds.length === 0) { + return NextResponse.json({ success: false, message: "선택한 항목이 없습니다." }); + } + + try { + const placeholders = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + await execute(`DELETE FROM PRODUCT_MGMT WHERE OBJID IN (${placeholders})`, objIds); + return NextResponse.json({ success: true, message: "삭제되었습니다." }); + } catch (error) { + console.error("Product delete error:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }); + } +} diff --git a/src/app/api/product-mgmt/save/route.ts b/src/app/api/product-mgmt/save/route.ts new file mode 100644 index 0000000..017fe97 --- /dev/null +++ b/src/app/api/product-mgmt/save/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const isNew = body.actionType === "regist" || !body.objId; + + try { + if (isNew) { + if (body.product_code) { + const dup = await queryOne(`SELECT COUNT(*) AS cnt FROM product_mgmt WHERE product_code = $1`, [body.product_code]); + if (Number(dup?.cnt || 0) > 0) { + return NextResponse.json({ success: false, message: "이미 등록된 제품코드입니다." }); + } + } + + const objId = createObjectId(); + await execute( + `INSERT INTO product_mgmt (objid, product_category, product_type, product_name, + product_code, product_name_code, product_grade, product_ton, product_boom, + product_vehicle, production_flag, note, writer, regdate) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, now())`, + [objId, body.product_category||"", body.product_type||"", + body.product_name||"", body.product_code||"", body.product_name_code||"", + body.product_grade||"", body.product_ton||"", body.product_boom||"", + body.product_vehicle||"", body.production_flag||"", body.note||"", + user.userId] + ); + return NextResponse.json({ success: true, objId, message: "등록되었습니다." }); + } else { + await execute( + `UPDATE product_mgmt SET + product_category=$1, product_type=$2, product_name=$3, product_code=$4, + product_name_code=$5, product_grade=$6, product_ton=$7, product_boom=$8, + product_vehicle=$9, production_flag=$10, note=$11 + WHERE objid=$12`, + [body.product_category||"", body.product_type||"", + body.product_name||"", body.product_code||"", body.product_name_code||"", + body.product_grade||"", body.product_ton||"", body.product_boom||"", + body.product_vehicle||"", body.production_flag||"", body.note||"", + body.objId] + ); + return NextResponse.json({ success: true, message: "수정되었습니다." }); + } + } catch (error) { + console.error("Product save error:", error); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }); + } +} diff --git a/src/app/api/product/bom-upg-matrix/route.ts b/src/app/api/product/bom-upg-matrix/route.ts new file mode 100644 index 0000000..b815fd0 --- /dev/null +++ b/src/app/api/product/bom-upg-matrix/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// partBomList.jsp UPG 매트릭스 데이터 (원본 getPartBomList.do 대응) +// product_code 선택 시: productList(SPEC) + LIST(UPG별 VC 매트릭스) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const productCode = String(body.product_code || ""); + + // 상단 SPEC 헤더 리스트 (PRODUCT_MGMT_UPG_MASTER) + let productList: Array> = []; + if (productCode) { + productList = await queryRows( + `SELECT PMUM.objid::text AS "MASTER_OBJID", + PMUM.target_objid::text AS "PRODUCT_MGMT_OBJID", + PMUM.spec_name AS "SPEC_NAME" + FROM product_mgmt_upg_master PMUM + WHERE PMUM.target_objid = $1::integer + ORDER BY PMUM.objid`, + [productCode], + ); + } + + // UPG 코드 리스트 (unique UPG_CODE + UPG_NAME) — 각 코드에 대해 SPEC별 VC 값 매트릭스 + let upgList: Array> = []; + if (productCode) { + upgList = await queryRows( + `SELECT DISTINCT + PMUD.upg_code AS "UPG_CODE", + PMUD.upg_name AS "UPG_NAME", + PMUD.objid::text AS "CODE_ID" + FROM product_mgmt_upg_detail PMUD + WHERE PMUD.product_objid = $1::integer + ORDER BY PMUD.upg_code`, + [productCode], + ); + + // 각 UPG_CODE에 대해 SPEC별 VC 값 채우기 + for (const u of upgList) { + const upgCode = String(u.UPG_CODE); + const matrix = await queryRows( + `SELECT PMUD.target_objid::text AS "MASTER_OBJID", + PMUD.vc AS "VC", + PMUD.upg_code || '-' || + (SELECT product_code FROM product_mgmt PM WHERE PM.objid = PMUD.product_objid) || + '-' || PMUD.vc AS "UPG_NO" + FROM product_mgmt_upg_detail PMUD + WHERE PMUD.product_objid = $1::integer AND PMUD.upg_code = $2 + ORDER BY PMUD.target_objid`, + [productCode, upgCode], + ); + productList.forEach((p, i) => { + const hit = matrix.find((m) => String(m.MASTER_OBJID) === String(p.MASTER_OBJID)); + u[`UPG_NO${i}`] = hit ? hit.UPG_NO : ""; + }); + } + } + + return NextResponse.json({ + productList, + LIST: upgList, + productListCnt: productList.length, + }); +} diff --git a/src/app/api/product/bom/ascending/route.ts b/src/app/api/product/bom/ascending/route.ts new file mode 100644 index 0000000..bb9925a --- /dev/null +++ b/src/app/api/product/bom/ascending/route.ts @@ -0,0 +1,188 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 제품관리_BOM 조회 (정전개) - 원본: partMng.structureAscendingList / getStructureAscendingList.do +// PRODUCT_MGMT_SPEC (MASTER_OBJID) 또는 UPG_NO 기반으로 PART_BOM_REPORT 매칭, +// BOM_PART_QTY 재귀 CTE로 트리 구조 조회 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const productCode = String(body.product_code || ""); // product_mgmt.objid 또는 PMUM.objid + const productMgmtSpec = String(body.product_mgmt_spec || ""); // spec_name 문자열 + const upgNo = String(body.upg_no || ""); + const customerCd = String(body.customer_cd || ""); + const projectName = String(body.project_name || ""); + const unitCode = String(body.unit_code || ""); + const searchPartNo = String(body.search_partNo || ""); + const searchPartName = String(body.search_partName || ""); + const searchLevel = String(body.search_level || ""); + + // 매칭되는 bom_report_objid 리스트 확보 + const reportConds: string[] = ["1=1"]; + const reportParams: unknown[] = []; + let pi = 1; + if (productCode) { + reportConds.push(`PBM.product_mgmt_objid::varchar = $${pi++}::varchar`); + reportParams.push(productCode); + } + if (productMgmtSpec) { + reportConds.push(`PBM.spec_name::varchar = $${pi++}::varchar`); + reportParams.push(productMgmtSpec); + } + if (upgNo) { + reportConds.push(`PBM.product_mgmt_upg = $${pi++}`); + reportParams.push(upgNo); + } + if (customerCd) { + reportConds.push(`PBM.customer_objid = $${pi++}`); + reportParams.push(customerCd); + } + if (projectName) { + reportConds.push(`PBM.contract_objid = $${pi++}`); + reportParams.push(projectName); + } + if (unitCode) { + reportConds.push(`PBM.unit_code = $${pi++}`); + reportParams.push(unitCode); + } + // 품번만으로 조회 시 상위 200 report 내에 해당 품번이 없으면 빈 결과가 나오므로, + // 품번 정확 일치 노드가 존재하는 bom_report 로 사전 필터링 (anchor 와 동일 기준) + if (searchPartNo) { + reportConds.push(`PBM.objid IN ( + SELECT DISTINCT BPQ.bom_report_objid + FROM bom_part_qty BPQ + JOIN part_mng PT ON PT.objid::text = BPQ.part_no + WHERE UPPER(TRIM(PT.part_no)) = UPPER(TRIM($${pi++})) + )`); + reportParams.push(searchPartNo); + } + if (searchPartName) { + reportConds.push(`PBM.objid IN ( + SELECT DISTINCT BPQ.bom_report_objid + FROM bom_part_qty BPQ + JOIN part_mng PT ON PT.objid::text = BPQ.part_no + WHERE UPPER(PT.part_name) LIKE UPPER('%' || $${pi++} || '%') + )`); + reportParams.push(searchPartName); + } + + const reports = await queryRows<{ OBJID: string }>( + `SELECT objid::text AS "OBJID" FROM part_bom_report PBM WHERE ${reportConds.join(" AND ")}`, + reportParams, + ); + if (reports.length === 0) { + return NextResponse.json({ RESULTLIST: [], TOTAL_CNT: 0, MAX_LEVEL: 0 }); + } + const reportIds = reports.map((r) => r.OBJID); + + // 재귀 CTE로 BOM 트리 조회 + const placeholders = reportIds.map((_, i) => `$${i + 1}`).join(","); + const idx = reportIds.length; + + // 품번이 입력되면 재귀 anchor 를 "품번 정확 일치 노드"로 바꿔 그 subtree 만 전개. + // (품번 포함 매칭이 아니라 정확 일치 기준. 대소문자/공백 무시) + // 품명은 subtree 내 추가 필터로만 사용 (기존대로 LIKE). + const extraParams: unknown[] = []; + let ei = idx + 1; + let anchorWhere: string; + if (searchPartNo) { + anchorWhere = `A.bom_report_objid IN (${placeholders}) + AND EXISTS ( + SELECT 1 FROM part_mng PT + WHERE PT.objid::text = A.part_no + AND UPPER(TRIM(PT.part_no)) = UPPER(TRIM($${ei++})) + )`; + extraParams.push(searchPartNo); + } else { + anchorWhere = `(A.parent_objid IS NULL OR A.parent_objid = '') + AND A.bom_report_objid IN (${placeholders})`; + } + + const extraConds: string[] = []; + if (searchPartName) { + extraConds.push(`UPPER(P.part_name) LIKE UPPER('%' || $${ei++} || '%')`); + extraParams.push(searchPartName); + } + const extraWhere = extraConds.length > 0 ? " AND " + extraConds.join(" AND ") : ""; + + const sql = ` + WITH RECURSIVE VIEW_BOM AS ( + SELECT + A.bom_report_objid, A.objid, A.parent_objid, A.child_objid, + A.parent_part_no, A.part_no, A.qty, A.regdate, A.seq, + 1::int AS lev, + ARRAY[A.child_objid::text] AS path, + FALSE AS cycle, + A.child_objid AS root_objid, + A.child_objid AS sub_root_objid + FROM bom_part_qty A + WHERE ${anchorWhere} + UNION ALL + SELECT + B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, + B.parent_part_no, B.part_no, B.qty, B.regdate, B.seq, + V.lev + 1, + V.path || B.child_objid::text, + B.parent_objid = ANY(V.path), + V.root_objid, + CASE WHEN V.lev = 1 THEN B.child_objid ELSE V.sub_root_objid END + FROM bom_part_qty B + JOIN VIEW_BOM V + ON B.parent_objid = V.child_objid + AND V.bom_report_objid = B.bom_report_objid + ), + MAX_LEV AS ( + SELECT MAX(lev) AS max_level FROM VIEW_BOM + ) + SELECT + V.bom_report_objid AS "BOM_REPORT_OBJID", + V.objid AS "OBJID", + V.parent_objid AS "PARENT_OBJID", + V.child_objid AS "CHILD_OBJID", + V.root_objid AS "ROOT_OBJID", + V.sub_root_objid AS "SUB_ROOT_OBJID", + V.part_no AS "PART_NO_RAW", + V.qty AS "QTY", + V.lev AS "LEV", + V.lev AS "LEVEL", + (SELECT max_level FROM MAX_LEV) AS "MAX_LEVEL", + (CASE WHEN EXISTS (SELECT 1 FROM bom_part_qty C WHERE C.parent_objid = V.child_objid + AND C.bom_report_objid = V.bom_report_objid) THEN 0 ELSE 1 END) AS "LEAF", + P.objid::text AS "PART_OBJID", + P.part_no AS "PART_NO", + P.part_name AS "PART_NAME", + P.revision AS "REVISION", + P.material AS "MATERIAL", + P.spec AS "SPEC", + P.post_processing AS "POST_PROCESSING", + P.maker AS "MAKER", + P.eo_no AS "EO_NO", + P.eo_date AS "EO_DATE", + P.remark AS "REMARK", + P.part_type AS "PART_TYPE", + (SELECT code_name FROM comm_code WHERE code_id = P.part_type) AS "PART_TYPE_TITLE", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid = P.objid::text AND doc_type = '3D_CAD') AS "FILE_3D_CNT", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid = P.objid::text AND doc_type = '2D_DRAWING_CAD') AS "FILE_2D_CNT", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid = P.objid::text AND doc_type = '2D_PDF_CAD') AS "FILE_PDF_CNT" + FROM VIEW_BOM V + LEFT JOIN part_mng P ON P.objid::text = V.part_no + WHERE 1=1 ${extraWhere} + ORDER BY V.bom_report_objid, V.root_objid, V.path, V.regdate + `; + + const rows = await queryRows(sql, [...reportIds, ...extraParams]); + let maxLevel = rows.length > 0 ? Number(rows[0].MAX_LEVEL || 0) : 0; + + // search_level 필터: 해당 레벨 이하만 남김 + let filtered = rows; + if (searchLevel) { + const n = Number(searchLevel); + filtered = rows.filter((r) => Number(r.LEV || 0) <= n); + if (filtered.length > 0) maxLevel = Math.max(...filtered.map((r) => Number(r.LEV || 0))); + } + + return NextResponse.json({ RESULTLIST: filtered, TOTAL_CNT: filtered.length, MAX_LEVEL: maxLevel }); +} diff --git a/src/app/api/product/bom/check-top-duplicate/route.ts b/src/app/api/product/bom/check-top-duplicate/route.ts new file mode 100644 index 0000000..2c95085 --- /dev/null +++ b/src/app/api/product/bom/check-top-duplicate/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 1레벨 같은 Part No 중복 체크 (원본: partMng/checkSameTopPartNo.do) +// 1레벨 (parent_objid IS NULL) 에 이미 등록된 part_mng.objid 가 rightCheckedArr 에 있으면 true +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ result: false }); + + const body = await request.json(); + const bomReportObjId = String(body.OBJID || body.BOM_REPORT_OBJID || ""); + const rightCheckedArr: string[] = Array.isArray(body.rightCheckedArr) + ? body.rightCheckedArr.map((s: unknown) => String(s)) + : []; + + if (!bomReportObjId || rightCheckedArr.length === 0) { + return NextResponse.json({ result: false }); + } + + // 1레벨 기존 part_no (part_mng.objid) 목록 + const existing = await queryRows<{ part_no: string }>( + `SELECT DISTINCT Q.part_no AS part_no + FROM bom_part_qty Q + WHERE Q.bom_report_objid = $1 + AND (Q.parent_objid IS NULL OR Q.parent_objid = '')`, + [bomReportObjId], + ); + const existingSet = new Set(existing.map((r) => String(r.part_no))); + + // 기존 1레벨에 rightChecked 중 하나라도 있으면 중복 + // 단순히 part_no (objid) 매칭이 아니라, 실제 동일 PART_NO(문자열) 매칭도 고려해야 함 + const directHit = rightCheckedArr.some((id) => existingSet.has(id)); + if (directHit) return NextResponse.json({ result: true }); + + // objid가 달라도 part_no(문자열)가 같으면 중복으로 판정 + if (existing.length > 0 && rightCheckedArr.length > 0) { + const rows = await queryRows<{ cnt: string }>( + `SELECT COUNT(*)::text AS cnt FROM part_mng P1 + WHERE P1.objid::text = ANY($1::text[]) + AND EXISTS ( + SELECT 1 FROM part_mng P2 + WHERE P2.objid::text = ANY($2::text[]) + AND P2.part_no = P1.part_no + )`, + [rightCheckedArr, existing.map((e) => String(e.part_no))], + ); + if (Number(rows[0]?.cnt || 0) > 0) return NextResponse.json({ result: true }); + } + + return NextResponse.json({ result: false }); +} diff --git a/src/app/api/product/bom/delete/route.ts b/src/app/api/product/bom/delete/route.ts new file mode 100644 index 0000000..ad3e7a4 --- /dev/null +++ b/src/app/api/product/bom/delete/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// BOM 삭제 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const { objIds } = body; + + if (!objIds || !Array.isArray(objIds) || objIds.length === 0) { + return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }); + } + + try { + const placeholders = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + await execute(`DELETE FROM bom_part_qty WHERE objid IN (${placeholders})`, objIds); + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (error) { + console.error("BOM delete error:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/product/bom/deploy/route.ts b/src/app/api/product/bom/deploy/route.ts new file mode 100644 index 0000000..f2a7026 --- /dev/null +++ b/src/app/api/product/bom/deploy/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// BOM 배포 - 배포사유 입력 후 (changeDesignNotePopUp.jsp → saveChangeDesignInfo + deployStructure) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const note = String(body.NOTE || ""); + const objIdsRaw = String(body.OBJID || ""); + const objIds = objIdsRaw.split(",").map((s) => s.trim()).filter(Boolean); + if (objIds.length === 0) { + return NextResponse.json({ success: false, message: "배포할 항목이 없습니다." }); + } + + try { + const placeholders = objIds.map((_, i) => `$${i + 2}`).join(","); + // saveChangeDesignInfo: NOTE 업데이트 + await execute( + `UPDATE part_bom_report SET note = $1 WHERE objid IN (${placeholders})`, + [note, ...objIds], + ); + // deployStructure: status = deploy, deploy_date = now + await execute( + `UPDATE part_bom_report SET status = 'deploy', deploy_date = TO_CHAR(NOW(), 'YYYY-MM-DD') + WHERE objid IN (${placeholders.replace(/\$(\d+)/g, (_, n) => `$${Number(n) - 1}`)})`, + objIds, + ); + return NextResponse.json({ success: true, message: "배포되었습니다." }); + } catch (error) { + console.error("bom deploy:", error); + return NextResponse.json({ success: false, message: "배포 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/product/bom/excel-save/route.ts b/src/app/api/product/bom/excel-save/route.ts new file mode 100644 index 0000000..d64acef --- /dev/null +++ b/src/app/api/product/bom/excel-save/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// BOM Excel 등록 (원본: partMng/partBomApplySave.do → savePartBomMaster) +// 엑셀 파싱된 rows + 기준 정보(customer/project/unit/bom_report)를 받아 +// PART_BOM_REPORT + BOM_PART_QTY + PART_MNG(신규)를 저장한다. +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const rows: Array> = Array.isArray(body.rows) ? body.rows : []; + if (rows.length === 0) { + return NextResponse.json({ success: false, message: "저장할 데이터가 없습니다." }, { status: 400 }); + } + + const customerObjId = String(body.CUSTOMER_OBJID || ""); + const contractObjId = String(body.CONTRACT_OBJID || ""); + const unitCode = String(body.UNIT_CODE || ""); + let bomReportObjId = String(body.BOM_REPORT_OBJID || ""); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1. BOM_REPORT 확보 (없으면 신규 생성) + if (!bomReportObjId) { + bomReportObjId = createObjectId(); + await client.query( + `INSERT INTO part_bom_report ( + objid, customer_objid, contract_objid, unit_code, + status, writer, regdate + ) VALUES ($1, $2, $3, $4, 'create', $5, NOW())`, + [bomReportObjId, customerObjId, contractObjId, unitCode, user.userId], + ); + } + + // 2. PART_NO → part_mng.objid 매핑 확보 (신규면 생성) + const partNoToObjId = new Map(); + const allPartNos = new Set(); + rows.forEach((r) => { + if (r.PART_NO) allPartNos.add(r.PART_NO.trim()); + if (r.PARENT_PART_NO) allPartNos.add(r.PARENT_PART_NO.trim()); + }); + + for (const partNo of allPartNos) { + const existing = await queryOne<{ objid: string }>( + `SELECT objid::text AS objid FROM part_mng + WHERE part_no = $1 AND status = 'release' AND COALESCE(is_last, '1') = '1' + ORDER BY reg_date DESC LIMIT 1`, + [partNo], + ); + if (existing) { + partNoToObjId.set(partNo, existing.objid); + } else { + // 해당 PART_NO의 row를 찾아 PART_MNG 신규 생성 + const src = rows.find((r) => r.PART_NO?.trim() === partNo); + const newObjId = createObjectId(); + await client.query( + `INSERT INTO part_mng ( + objid, part_no, part_name, qty, material, spec, post_processing, maker, + part_type, remark, revision, status, is_last, writer, reg_date + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, '', 'release', '1', $11, NOW() + )`, + [ + newObjId, partNo, + src?.PART_NAME || partNo, + src?.QTY || "1", + src?.MATERIAL || "", + src?.SPEC || "", + src?.POST_PROCESSING || "", + src?.MAKER || "", + src?.PART_TYPE || "", + src?.REMARK || "", + user.userId, + ], + ); + partNoToObjId.set(partNo, newObjId); + } + } + + // 3. 기존 bom_part_qty 삭제 후 재등록 (BOM_REPORT_OBJID 기준) + await client.query(`DELETE FROM bom_part_qty WHERE bom_report_objid = $1`, [bomReportObjId]); + + // 4. 각 row를 bom_part_qty 에 insert + let seq = 1; + for (const r of rows) { + const partNo = (r.PART_NO || "").trim(); + const parentPartNo = (r.PARENT_PART_NO || "").trim(); + if (!partNo) continue; + const childObjId = createObjectId(); + const partObjId = partNoToObjId.get(partNo) || ""; + const parentObjId = parentPartNo ? partNoToObjId.get(parentPartNo) || "" : ""; + + await client.query( + `INSERT INTO bom_part_qty ( + objid, bom_report_objid, parent_objid, child_objid, + parent_part_no, part_no, last_part_objid, + qty, qty_temp, status, seq, regdate + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $8, 'create', $9, NOW() + )`, + [ + childObjId, bomReportObjId, + parentObjId || null, + childObjId, + parentObjId || null, + partObjId, + partObjId, + r.QTY || "1", + seq++, + ], + ); + } + + await client.query("COMMIT"); + return NextResponse.json({ success: true, bomReportObjId, message: "등록되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("bom excel save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/product/bom/route.ts b/src/app/api/product/bom/route.ts new file mode 100644 index 0000000..1f80a1f --- /dev/null +++ b/src/app/api/product/bom/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 제품관리_PART 및 구조등록 (structureList.jsp) 및 단순 BOM 조회 공용 +// - mode: "structure" → PART_BOM_REPORT 구조 리스트 (getBOMStandardStructureGridList 대응) +// - 기본 → bom_part_qty 기반 단순 리스트 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const mode = String(body.mode || "structure"); + + if (mode === "structure" || mode === "register") { + // getBOMStandardStructureGridList 대응 + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.customer_cd) { + conditions.push(`T.customer_objid = $${idx++}`); + params.push(body.customer_cd); + } + if (body.project_name) { + conditions.push(`T.contract_objid = $${idx++}`); + params.push(body.project_name); + } + if (body.unit_code) { + conditions.push(`T.unit_code = $${idx++}`); + params.push(body.unit_code); + } + if (body.SEARCH_UNIT_NAME) { + conditions.push(`EXISTS (SELECT 'E' FROM pms_wbs_task W WHERE W.objid = T.unit_code + AND (W.task_name LIKE UPPER('%'||$${idx}||'%') OR W.unit_no LIKE UPPER('%'||$${idx}||'%')))`); + params.push(body.SEARCH_UNIT_NAME); + idx++; + } + if (body.SEARCH_WRITER) { + conditions.push(`T.writer = $${idx++}`); + params.push(body.SEARCH_WRITER); + } + if (body.SEARCH_DEPLOY_DATE_FROM) { + conditions.push(`T.deploy_date IS NOT NULL AND TO_DATE(T.deploy_date,'YYYY-MM-DD') >= $${idx++}::timestamp`); + params.push(body.SEARCH_DEPLOY_DATE_FROM); + } + if (body.SEARCH_DEPLOY_DATE_TO) { + conditions.push(`T.deploy_date IS NOT NULL AND TO_DATE(T.deploy_date,'YYYY-MM-DD') <= $${idx++}::timestamp`); + params.push(body.SEARCH_DEPLOY_DATE_TO); + } + if (body.status) { + conditions.push(`T.status = $${idx++}`); + params.push(body.status); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT ROW_NUMBER() OVER(ORDER BY T.regdate DESC) AS "NUM", + T.objid::text AS "OBJID", + T.customer_objid AS "CUSTOMER_OBJID", + (SELECT supply_name FROM supply_mng O WHERE O.objid::varchar = T.customer_objid) AS "CUSTOMER_NAME", + T.contract_objid AS "CONTRACT_OBJID", + (SELECT customer_project_name FROM project_mgmt O WHERE O.objid = T.contract_objid) AS "CUSTOMER_PROJECT_NAME", + (SELECT project_no FROM project_mgmt O WHERE O.objid = T.contract_objid) AS "PROJECT_NO", + T.unit_code AS "UNIT_CODE", + (SELECT O.unit_no || '-' || O.task_name FROM pms_wbs_task O WHERE O.objid = T.unit_code) AS "UNIT_NAME", + T.status AS "STATUS", + CASE UPPER(T.status) + WHEN 'CREATE' THEN '등록중' + WHEN 'CHANGEDESIGN' THEN '설계변경미배포' + WHEN 'DEPLOY' THEN '배포완료' + ELSE '' + END AS "STATUS_TITLE", + T.writer AS "WRITER", + (SELECT dept_name FROM user_info WHERE user_id = T.writer) AS "DEPT_NAME", + (SELECT user_name FROM user_info WHERE user_id = T.writer) AS "USER_NAME", + COALESCE((SELECT dept_name FROM user_info WHERE user_id = T.writer), '') || '/' || + COALESCE((SELECT user_name FROM user_info WHERE user_id = T.writer), '') AS "DEPT_USER_NAME", + TO_CHAR(T.regdate, 'YYYY-MM-DD') AS "REG_DATE", + T.deploy_date AS "DEPLOY_DATE", + T.revision AS "REVISION", + (SELECT MAX(PM.eo_no) FROM bom_part_qty BP LEFT JOIN part_mng PM ON BP.part_no = PM.objid::varchar + WHERE BP.bom_report_objid = T.objid) AS "EO_NO", + (SELECT MAX(PM.eo_date) FROM bom_part_qty BP LEFT JOIN part_mng PM ON BP.part_no = PM.objid::varchar + WHERE BP.bom_report_objid = T.objid) AS "EO_DATE", + T.note AS "NOTE", + T.multi_yn AS "MULTI_YN", + T.multi_master_yn AS "MULTI_MASTER_YN", + T.multi_break_yn AS "MULTI_BREAK_YN", + T.multi_master_objid AS "MULTI_MASTER_OBJID", + (SELECT COUNT(*) FROM bom_part_qty A WHERE A.bom_report_objid = T.objid) AS "BOM_CNT" + FROM part_bom_report T + WHERE ${where} + ORDER BY T.regdate DESC + `; + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); + } + + // 단순 BOM 리스트 (기존 방식 유지) + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.part_name) { conditions.push(`P.part_name LIKE '%' || $${idx++} || '%'`); params.push(body.part_name); } + if (body.part_no) { conditions.push(`P.part_no LIKE '%' || $${idx++} || '%'`); params.push(body.part_no); } + if (body.project_no) { conditions.push(`B.project_no LIKE '%' || $${idx++} || '%'`); params.push(body.project_no); } + + const where = conditions.join(" AND "); + const rows = await queryRows( + `SELECT B.objid AS "OBJID", + P.objid::text AS "PART_OBJID", + P.part_no AS "PART_NO", P.part_name AS "PART_NAME", + P.spec AS "SPEC", P.material AS "MATERIAL", P.unit AS "UNIT", + COALESCE(B.qty::text, '') AS "BOM_QTY", + COALESCE(B.bom_level::text, '1') AS "BOM_LEVEL", + P.maker AS "MAKER", P.remark AS "REMARK", P.revision AS "REVISION", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = P.part_type LIMIT 1), '') AS "PART_TYPE_TITLE" + FROM bom_part_qty B + JOIN part_mng P ON P.objid::text = B.part_no + WHERE P.status = 'release' AND ${where} + ORDER BY B.parent_part_no, P.part_no +`, params, + ); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/product/bom/save/route.ts b/src/app/api/product/bom/save/route.ts new file mode 100644 index 0000000..1f684bc --- /dev/null +++ b/src/app/api/product/bom/save/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// BOM 저장 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const body = await request.json(); + const isNew = !body.objId || body.actionType === "regist"; + const objId = isNew ? createObjectId() : body.objId; + + const client = await pool.connect(); + try { + await client.query( + `INSERT INTO bom_part_qty (objid, parent_part_no, part_no, qty, project_no, bom_level, writer, regdate) + VALUES ($1, $2, $3, $4, $5, $6, $7, now()) + ON CONFLICT (objid) DO UPDATE SET + parent_part_no=EXCLUDED.parent_part_no, part_no=EXCLUDED.part_no, + qty=EXCLUDED.qty, project_no=EXCLUDED.project_no, bom_level=EXCLUDED.bom_level`, + [objId, body.parent_part_no || "", body.part_no || "", + body.qty || "1", body.project_no || "", body.bom_level || "1", user.userId] + ); + return NextResponse.json({ success: true, objId, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (e) { + console.error("BOM save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { client.release(); } +} diff --git a/src/app/api/product/bom/structure-change/route.ts b/src/app/api/product/bom/structure-change/route.ts new file mode 100644 index 0000000..bdfba28 --- /dev/null +++ b/src/app/api/product/bom/structure-change/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 구조 연결 변경 (원본: partMng/changeRelatePartInfo.do → partMngService.changeRelatePartInfo) +// 1) UPDATE BOM_PART_QTY SET LAST_PART_OBJID = #{RIGHT_OBJID} WHERE BOM_REPORT_OBJID = ? AND OBJID = ? +// 2) insertPartMngHistory (HIS_STATUS='CHANGE') +// 3) changeStatusBomReport +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const bomReportObjId = String(body.BOM_REPORT_OBJID || body.OBJID || ""); + const bomObjId = String(body.B_OBJID || body.leftPartBomQtyObjId || ""); // bom_part_qty.objid + const rightObjId = String(body.rightObjId || body.rightPartObjId || ""); // 새 part_mng.objid + const leftPartObjid = String(body.leftPartObjid || ""); // 기존 part_mng.objid (PART_MNG의 P) + const leftObjId = String(body.leftObjId || ""); // parent bom_part_qty.child_objid + const leftPartNoQty = String(body.leftPartNoQty || ""); + const leftPartChildObjId = String(body.leftPartChildObjId || ""); + const rightPartNo = String(body.rightPartNo || ""); + const rightPartRev = String(body.rightPartRev || ""); + const changeType = String(body.CHANGE_TYPE || ""); + const changeOption = String(body.CHANGE_OPTION || ""); + + if (!bomReportObjId || !bomObjId || !rightObjId) { + return NextResponse.json({ success: false, message: "필수 파라미터 누락" }, { status: 400 }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1) bom_part_qty.last_part_objid 교체 + await client.query( + `UPDATE bom_part_qty + SET last_part_objid = $1 + WHERE bom_report_objid = $2 AND objid = $3`, + [rightObjId, bomReportObjId, bomObjId], + ); + + // 2) insertPartMngHistory (HIS_STATUS='CHANGE') + const newChildObjId = createObjectId(); // 원본 서비스: 새 CHILD_OBJID로 세팅 + await client.query( + `INSERT INTO part_mng_history ( + objid, product_mgmt_objid, upg_no, part_no, part_name, unit, qty, + spec, material, weight, part_type, remark, es_spec, ms_spec, + change_option, design_apply_point, management_flag, revision, status, + reg_date, edit_date, writer, is_last, eo_no, eo_temp, + excel_upload_seq, sourcing_code, sub_material, parent_part_no, + design_date, eo_date, deploy_date, + thickness, width, height, out_diameter, in_diameter, length, supply_code, + change_type, contract_objid, maker, qty_temp, + bom_report_objid, parent_part_objid, parent_qty_child_objid, + bom_qty_status, his_reg_date, his_writer, his_status, qty_child_objid, + bom_status, bom_deploy_date, chg_part_objid, chg_part_no, chg_part_rev + ) + SELECT + P.objid::numeric, P.product_mgmt_objid, P.upg_no, P.part_no, P.part_name, P.unit, Q.qty, + P.spec, P.material, P.weight, P.part_type, P.remark, P.es_spec, P.ms_spec, + COALESCE(NULLIF($1::varchar, ''), P.change_option), + P.design_apply_point, P.management_flag, P.revision, P.status, + P.reg_date, NOW(), $2::varchar, P.is_last, P.eo_no, P.eo_temp, + P.excel_upload_seq, P.sourcing_code, P.sub_material, + COALESCE(NULLIF($3::varchar, ''), Q.parent_part_no), + P.design_date, P.eo_date, P.deploy_date, + P.thickness, P.width, P.height, P.out_diameter, P.in_diameter, P.length, P.supply_code, + COALESCE(NULLIF($4::varchar, ''), P.change_type), + P.contract_objid, P.maker, Q.qty_temp, + COALESCE(NULLIF($5::varchar, ''), Q.bom_report_objid), + COALESCE(NULLIF($6::varchar, ''), Q.parent_part_no), + COALESCE(NULLIF($7::varchar, ''), Q.parent_objid), + Q.status, NOW(), $2::varchar, 'CHANGE', $8::varchar, + '', NULL, $10::varchar, $11::varchar, $12::varchar + FROM part_mng P + LEFT OUTER JOIN bom_part_qty Q + ON Q.child_objid = $8::varchar + AND P.part_no IN (SELECT PM2.part_no FROM part_mng PM2 WHERE PM2.objid = Q.part_no) + WHERE P.objid = $9::varchar`, + [ + changeOption || "", // $1 CHANGE_OPTION + user.userId, // $2 WRITER + leftPartNoQty || "", // $3 PARENT_PART_NO + changeType || "", // $4 CHANGE_TYPE + bomReportObjId, // $5 BOM_REPORT_OBJID + leftObjId || "", // $6 PARENT_PART_OBJID + leftPartChildObjId || "", // $7 PARENT_QTY_CHILD_OBJID + newChildObjId, // $8 CHILD_OBJID (JOIN + qty_child_objid) + leftPartObjid || rightObjId, // $9 OBJID (원본 서비스: leftPartObjid) + rightObjId, // $10 CHG_PART_OBJID + rightPartNo, // $11 CHG_PART_NO + rightPartRev, // $12 CHG_PART_REV + ], + ); + + // 3) changeStatusBomReport + await client.query( + `UPDATE part_bom_report + SET status = CASE WHEN status != 'create' THEN 'changeDesign' ELSE status END + WHERE objid = $1`, + [bomReportObjId], + ); + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "변경되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("bom change:", e); + return NextResponse.json({ success: false, message: "변경 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/product/bom/structure-connect/route.ts b/src/app/api/product/bom/structure-connect/route.ts new file mode 100644 index 0000000..61aa02e --- /dev/null +++ b/src/app/api/product/bom/structure-connect/route.ts @@ -0,0 +1,126 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 구조 연결 (원본: partMng/relatePartInfo.do → partMngService.relatePartInfo) +// leftObjId (부모 bom_part_qty.child_objid) 아래에 rightCheckedArr(part_mng.objid 배열)를 자식으로 추가. +// 원본은 STATUS='adding' 고정 + seq는 nextval('seq_bom_qty'). +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const bomReportObjId = String(body.BOM_REPORT_OBJID || body.OBJID || ""); + const leftObjId = String(body.leftObjId || ""); // 부모 bom_part_qty.child_objid + const leftPartLastObjId = String(body.partObjId || body.leftPartLastObjId || ""); + const leftPartNoQty = String(body.leftPartNoQty || ""); // 부모 part_no (PARENT_PART_NO) + const leftPartChildObjId = String(body.leftPartChildObjId || ""); // PARENT_QTY_CHILD_OBJID + const rightCheckedArr: string[] = Array.isArray(body.rightCheckedArr) ? body.rightCheckedArr.map((s: unknown) => String(s)) : []; + const changeType = String(body.CHANGE_TYPE || ""); + const changeOption = String(body.CHANGE_OPTION || ""); + + if (!bomReportObjId) { + return NextResponse.json({ success: false, message: "BOM_REPORT_OBJID 누락" }, { status: 400 }); + } + if (rightCheckedArr.length === 0) { + return NextResponse.json({ success: false, message: "추가할 Part가 없습니다." }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + for (const rightPartObjId of rightCheckedArr) { + const newObjId = createObjectId(); + const newChildObjId = createObjectId(); + + await client.query( + `INSERT INTO bom_part_qty ( + objid, bom_report_objid, parent_objid, child_objid, + parent_part_no, part_no, last_part_objid, + qty, qty_temp, status, seq, regdate, writer + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $8, 'adding', + nextval('seq_bom_qty'), NOW(), $9 + )`, + [ + newObjId, bomReportObjId, + leftObjId || null, + newChildObjId, + leftPartLastObjId || null, + rightPartObjId, + rightPartObjId, + "1", + user.userId, + ], + ); + + // insertPartMngHistory (HIS_STATUS='ADD') — 원본 partMng.insertPartMngHistory + await client.query( + `INSERT INTO part_mng_history ( + objid, product_mgmt_objid, upg_no, part_no, part_name, unit, qty, + spec, material, weight, part_type, remark, es_spec, ms_spec, + change_option, design_apply_point, management_flag, revision, status, + reg_date, edit_date, writer, is_last, eo_no, eo_temp, + excel_upload_seq, sourcing_code, sub_material, parent_part_no, + design_date, eo_date, deploy_date, + thickness, width, height, out_diameter, in_diameter, length, supply_code, + change_type, contract_objid, maker, qty_temp, + bom_report_objid, parent_part_objid, parent_qty_child_objid, + bom_qty_status, his_reg_date, his_writer, his_status, qty_child_objid, + bom_status, bom_deploy_date, chg_part_objid, chg_part_no, chg_part_rev + ) + SELECT + P.objid::numeric, P.product_mgmt_objid, P.upg_no, P.part_no, P.part_name, P.unit, Q.qty, + P.spec, P.material, P.weight, P.part_type, P.remark, P.es_spec, P.ms_spec, + COALESCE(NULLIF($1::varchar, ''), P.change_option), + P.design_apply_point, P.management_flag, P.revision, P.status, + P.reg_date, NOW(), $2::varchar, P.is_last, P.eo_no, P.eo_temp, + P.excel_upload_seq, P.sourcing_code, P.sub_material, + COALESCE(NULLIF($3::varchar, ''), Q.parent_part_no), + P.design_date, P.eo_date, P.deploy_date, + P.thickness, P.width, P.height, P.out_diameter, P.in_diameter, P.length, P.supply_code, + COALESCE(NULLIF($4::varchar, ''), P.change_type), + P.contract_objid, P.maker, Q.qty_temp, + COALESCE(NULLIF($5::varchar, ''), Q.bom_report_objid), + COALESCE(NULLIF($6::varchar, ''), Q.parent_part_no), + COALESCE(NULLIF($7::varchar, ''), Q.parent_objid), + Q.status, NOW(), $2::varchar, 'ADD', $8::varchar, + '', NULL, '', '', '' + FROM part_mng P + LEFT OUTER JOIN bom_part_qty Q + ON Q.child_objid = $8::varchar + AND P.part_no IN (SELECT PM2.part_no FROM part_mng PM2 WHERE PM2.objid = Q.part_no) + WHERE P.objid = $9::varchar`, + [ + changeOption || "", // $1 CHANGE_OPTION + user.userId, // $2 WRITER + leftPartNoQty || "", // $3 PARENT_PART_NO (원본 서비스: paramMap.leftPartNoQty) + changeType || "", // $4 CHANGE_TYPE + bomReportObjId, // $5 BOM_REPORT_OBJID + leftObjId || "", // $6 PARENT_PART_OBJID (=leftObjId) + leftPartChildObjId || "", // $7 PARENT_QTY_CHILD_OBJID + newChildObjId, // $8 CHILD_OBJID + rightPartObjId, // $9 OBJID (part_mng.objid) + ], + ); + } + + await client.query( + `UPDATE part_bom_report + SET status = CASE WHEN status != 'create' THEN 'changeDesign' ELSE status END + WHERE objid = $1`, + [bomReportObjId], + ); + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "연결되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("bom connect:", e); + return NextResponse.json({ success: false, message: "연결 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/product/bom/structure-delete/route.ts b/src/app/api/product/bom/structure-delete/route.ts new file mode 100644 index 0000000..eaadb2a --- /dev/null +++ b/src/app/api/product/bom/structure-delete/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// PART_BOM_REPORT 삭제 (structureList.jsp fn_delete → /partMng/deleteStructure.do) +// 배포완료/설계변경미배포 상태는 삭제 불가 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const { objIds } = body; + if (!Array.isArray(objIds) || objIds.length === 0) { + return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }); + } + + try { + const placeholders = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + await execute( + `DELETE FROM bom_part_qty WHERE bom_report_objid IN (${placeholders})`, + objIds, + ); + await execute( + `DELETE FROM part_bom_report WHERE objid IN (${placeholders}) AND status NOT IN ('deploy','changeDesign')`, + objIds, + ); + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (error) { + console.error("structure delete:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/product/bom/structure-disconnect/route.ts b/src/app/api/product/bom/structure-disconnect/route.ts new file mode 100644 index 0000000..405e7bd --- /dev/null +++ b/src/app/api/product/bom/structure-disconnect/route.ts @@ -0,0 +1,142 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 구조 연결 해제 (원본: partMng/deleteStatusPartRelateInfo.do → partMngService.deleteStatusPartRelateInfo) +// 1) insertPartMngHistoryWhenDelPartRelation (HIS_STATUS='DEL') — 재귀 DEL 이력 +// 2) deletePartRelateInfoHis — adding 상태의 하위 이력 제거 +// 3) deleteStatusPartRelateInfo — 재귀 논리 삭제 (status='deleting') +// 4) changeStatusBomReport +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const bomReportObjId = String(body.BOM_REPORT_OBJID || body.OBJID || ""); + const leftObjId = String(body.leftObjId || ""); // bom_part_qty.child_objid + const changeType = String(body.CHANGE_TYPE || ""); + const changeOption = String(body.CHANGE_OPTION || ""); + + if (!bomReportObjId || !leftObjId) { + return NextResponse.json({ success: false, message: "필수 파라미터 누락" }, { status: 400 }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1) DEL 이력 — 재귀 서브트리 전체를 part_mng_history에 기록 + await client.query( + `WITH RECURSIVE VIEW_BOM AS ( + SELECT A.objid, A.child_objid, A.parent_objid, A.bom_report_objid + FROM bom_part_qty A + WHERE A.child_objid = $9::varchar AND A.bom_report_objid = $5::varchar + UNION ALL + SELECT B.objid, B.child_objid, B.parent_objid, B.bom_report_objid + FROM bom_part_qty B + JOIN VIEW_BOM V ON B.parent_objid = V.child_objid + AND V.bom_report_objid = B.bom_report_objid + AND B.bom_report_objid = $5::varchar + AND B.status NOT IN ('deleting', 'deleted') + ) + INSERT INTO part_mng_history ( + objid, product_mgmt_objid, upg_no, part_no, part_name, unit, qty, + spec, material, weight, part_type, remark, es_spec, ms_spec, + change_option, design_apply_point, management_flag, revision, status, + reg_date, edit_date, writer, is_last, eo_no, eo_temp, + excel_upload_seq, sourcing_code, sub_material, parent_part_no, + design_date, eo_date, deploy_date, + thickness, width, height, out_diameter, in_diameter, length, supply_code, + change_type, contract_objid, maker, qty_temp, + bom_report_objid, parent_part_objid, parent_qty_child_objid, + bom_qty_status, his_reg_date, his_writer, his_status, qty_child_objid, + bom_status + ) + SELECT + P.objid::numeric, P.product_mgmt_objid, P.upg_no, P.part_no, P.part_name, P.unit, Q.qty, + P.spec, P.material, P.weight, P.part_type, P.remark, P.es_spec, P.ms_spec, + COALESCE(NULLIF($1::varchar, ''), P.change_option), + P.design_apply_point, P.management_flag, P.revision, P.status, + P.reg_date, NOW(), $2::varchar, P.is_last, P.eo_no, P.eo_temp, + P.excel_upload_seq, P.sourcing_code, P.sub_material, + COALESCE(NULLIF($3::varchar, ''), Q.parent_part_no), + P.design_date, P.eo_date, P.deploy_date, + P.thickness, P.width, P.height, P.out_diameter, P.in_diameter, P.length, P.supply_code, + COALESCE(NULLIF($4::varchar, ''), P.change_type), + P.contract_objid, P.maker, Q.qty_temp, + COALESCE(NULLIF($5::varchar, ''), Q.bom_report_objid), + COALESCE(NULLIF($6::varchar, ''), Q.parent_part_no), + COALESCE(NULLIF($7::varchar, ''), Q.parent_objid), + Q.status, NOW(), $2::varchar, $8::varchar, Q.child_objid, + '' + FROM bom_part_qty Q + INNER JOIN part_mng P ON Q.last_part_objid = P.objid + WHERE Q.objid IN (SELECT objid FROM VIEW_BOM)`, + [ + changeOption || "", // $1 CHANGE_OPTION + user.userId, // $2 WRITER + "", // $3 PARENT_PART_NO (원본 서비스는 "") + changeType || "", // $4 CHANGE_TYPE + bomReportObjId, // $5 BOM_REPORT_OBJID + "", // $6 PARENT_PART_OBJID (원본 서비스는 "") + "", // $7 PARENT_QTY_CHILD_OBJID (원본 서비스는 "") + "DEL", // $8 HIS_STATUS + leftObjId, // $9 leftObjId (재귀 시작점) + ], + ); + + // 2) deletePartRelateInfoHis — 하위 트리 중 'adding' 상태인 행의 이력만 삭제 + await client.query( + `WITH RECURSIVE VIEW_BOM AS ( + SELECT A.child_objid, A.parent_objid, A.bom_report_objid, A.status + FROM bom_part_qty A + WHERE A.child_objid = $2 AND A.bom_report_objid = $1 + UNION ALL + SELECT B.child_objid, B.parent_objid, B.bom_report_objid, B.status + FROM bom_part_qty B + JOIN VIEW_BOM V ON B.parent_objid = V.child_objid + AND V.bom_report_objid = B.bom_report_objid + AND B.bom_report_objid = $1 + ) + DELETE FROM part_mng_history + WHERE qty_child_objid IN ( + SELECT child_objid FROM VIEW_BOM V WHERE V.status = 'adding' + )`, + [bomReportObjId, leftObjId], + ); + + // 3) deleteStatusPartRelateInfo — 재귀 논리 삭제 + await client.query( + `WITH RECURSIVE VIEW_BOM(objid, child_objid) AS ( + SELECT A.objid, A.child_objid FROM bom_part_qty A + WHERE A.child_objid = $2 AND A.bom_report_objid = $1 + UNION ALL + SELECT B.objid, B.child_objid FROM bom_part_qty B + JOIN VIEW_BOM V ON B.parent_objid = V.child_objid + WHERE B.bom_report_objid = $1 + AND B.status NOT IN ('deleting', 'deleted') + ) + UPDATE bom_part_qty + SET edit_date = NOW(), status = 'deleting' + WHERE objid IN (SELECT objid FROM VIEW_BOM)`, + [bomReportObjId, leftObjId], + ); + + // 4) changeStatusBomReport + await client.query( + `UPDATE part_bom_report + SET status = CASE WHEN status != 'create' THEN 'changeDesign' ELSE status END + WHERE objid = $1`, + [bomReportObjId], + ); + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "해제되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("bom disconnect:", e); + return NextResponse.json({ success: false, message: "해제 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/product/bom/structure-qty/route.ts b/src/app/api/product/bom/structure-qty/route.ts new file mode 100644 index 0000000..e220f8b --- /dev/null +++ b/src/app/api/product/bom/structure-qty/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 구조 수량 저장 (원본: partMng/structureQtySave.do) +// child_objid 기준으로 qty_temp 갱신 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const bomReportObjId = String(body.BOM_REPORT_OBJID || ""); + const childObjId = String(body.CHILD_OBJID || ""); + const qty = String(body.QTY_TEMP ?? body.QTY ?? ""); + + if (!childObjId) { + return NextResponse.json({ success: false, message: "CHILD_OBJID 누락" }, { status: 400 }); + } + + try { + await execute( + `UPDATE bom_part_qty SET qty_temp = $1 + WHERE bom_report_objid = $2 AND child_objid = $3`, + [qty, bomReportObjId, childObjId], + ); + return NextResponse.json({ success: true, result: true }); + } catch (e) { + console.error("bom qty save:", e); + return NextResponse.json({ success: false, message: "저장 실패" }, { status: 500 }); + } +} diff --git a/src/app/api/product/bom/structure-seq/route.ts b/src/app/api/product/bom/structure-seq/route.ts new file mode 100644 index 0000000..65d3063 --- /dev/null +++ b/src/app/api/product/bom/structure-seq/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 구조 순번 저장 (원본: partMng/structureSeqSave.do) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const childObjId = String(body.CHILD_OBJID || ""); + const seq = String(body.SEQ ?? ""); + + if (!childObjId) { + return NextResponse.json({ success: false, message: "CHILD_OBJID 누락" }, { status: 400 }); + } + + try { + await execute( + `UPDATE bom_part_qty SET seq = $1::numeric WHERE child_objid = $2`, + [seq, childObjId], + ); + return NextResponse.json({ success: true, result: true }); + } catch (e) { + console.error("bom seq save:", e); + return NextResponse.json({ success: false, message: "저장 실패" }, { status: 500 }); + } +} diff --git a/src/app/api/product/bom/structure-tree/route.ts b/src/app/api/product/bom/structure-tree/route.ts new file mode 100644 index 0000000..628418f --- /dev/null +++ b/src/app/api/product/bom/structure-tree/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 구조등록 팝업 좌측 트리 (원본: partMng/structurePopupLeft.do) +// bomReportObjId 기준 bom_part_qty 재귀 트리 + 파일카운트 + PART_MNG 정보 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const bomReportObjId = String(body.bomReportObjId || body.objId || ""); + if (!bomReportObjId) return NextResponse.json({ RESULTLIST: [], HEADER: {} }); + + const header = await queryOne( + `SELECT T.objid::text AS "OBJID", + T.status AS "STATUS", + T.revision AS "REV", + T.note AS "NOTE", + T.deploy_date AS "DEPLOY_DATE", + (SELECT supply_name FROM supply_mng O WHERE O.objid::varchar = T.customer_objid) AS "CUSTOMER_NAME", + (SELECT project_no FROM project_mgmt O WHERE O.objid = T.contract_objid) AS "PROJECT_NO", + (SELECT customer_project_name FROM project_mgmt O WHERE O.objid = T.contract_objid) AS "CUSTOMER_PROJECT_NAME", + (SELECT O.unit_no || '-' || O.task_name FROM pms_wbs_task O WHERE O.objid = T.unit_code) AS "UNIT_NAME" + FROM part_bom_report T WHERE T.objid = $1`, + [bomReportObjId], + ); + + const rows = await queryRows( + `WITH RECURSIVE VIEW_BOM AS ( + SELECT + A.bom_report_objid, A.objid, A.parent_objid, A.child_objid, + A.parent_part_no, A.part_no, A.last_part_objid, + A.qty, A.qty_temp, A.status, A.regdate, A.seq, + 1::int AS lev, + ARRAY[A.child_objid::text] AS path, + ARRAY[A.part_no::text] AS parent_parts_arr + FROM bom_part_qty A + WHERE (A.parent_objid IS NULL OR A.parent_objid = '') + AND A.bom_report_objid = $1 + AND (A.status NOT IN ('deleting', 'deleted') OR A.status IS NULL) + UNION ALL + SELECT + B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, + B.parent_part_no, B.part_no, B.last_part_objid, + B.qty, B.qty_temp, B.status, B.regdate, B.seq, + V.lev + 1, + V.path || B.child_objid::text, + V.parent_parts_arr || B.part_no::text + FROM bom_part_qty B + JOIN VIEW_BOM V ON B.parent_objid = V.child_objid + AND V.bom_report_objid = B.bom_report_objid + AND (B.status NOT IN ('deleting', 'deleted') OR B.status IS NULL) + ), + MAX_LEV AS (SELECT MAX(lev) AS max_level FROM VIEW_BOM) + SELECT + V.objid::text AS "OBJID", + V.bom_report_objid AS "BOM_REPORT_OBJID", + V.parent_objid AS "PARENT_OBJID", + V.child_objid AS "CHILD_OBJID", + V.parent_part_no AS "PARENT_PART_NO", + V.last_part_objid AS "LAST_PART_OBJID", + V.last_part_objid AS "BOM_LAST_PART_OBJID", + V.qty AS "QTY", + V.qty_temp AS "QTY_TEMP", + V.status AS "STATUS", + V.seq AS "SEQ", + V.lev AS "LEVEL", + (SELECT max_level FROM MAX_LEV) AS "MAX_LEVEL", + (CASE WHEN EXISTS (SELECT 1 FROM bom_part_qty C WHERE C.parent_objid = V.child_objid + AND C.bom_report_objid = V.bom_report_objid) THEN 0 ELSE 1 END) AS "LEAF", + array_to_string(V.parent_parts_arr, ',') AS "PARENT_PARTS", + P.objid::text AS "PART_OBJID", + P.part_no AS "PART_NO", + P.part_name AS "PART_NAME", + P.revision AS "REVISION", + P.material AS "MATERIAL", + P.spec AS "SPEC", + P.maker AS "MAKER", + P.eo_no AS "EO_NO", + P.eo_date AS "EO_DATE", + P.part_type AS "PART_TYPE", + (SELECT code_name FROM comm_code WHERE code_id = P.part_type) AS "PART_TYPE_TITLE", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid = P.objid::text AND doc_type = '3D_CAD') AS "CU01_CNT", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid = P.objid::text AND doc_type = '2D_DRAWING_CAD') AS "CU02_CNT", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid = P.objid::text AND doc_type = '2D_PDF_CAD') AS "CU03_CNT" + FROM VIEW_BOM V + LEFT JOIN part_mng P ON P.objid::text = V.last_part_objid + ORDER BY V.path, V.seq NULLS LAST, V.regdate`, + [bomReportObjId], + ); + + return NextResponse.json({ RESULTLIST: rows, HEADER: header ?? {} }); +} diff --git a/src/app/api/product/bom/tree/route.ts b/src/app/api/product/bom/tree/route.ts new file mode 100644 index 0000000..fe93ef6 --- /dev/null +++ b/src/app/api/product/bom/tree/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// BOM 구조 트리 조회 - bom_report_objid 기준 계층 + PART 정보 +// (setStructurePopupMainFS 및 bom-ascending 용) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const bomReportObjId = String(body.bomReportObjId || body.OBJID || ""); + + let info: Record | null = null; + if (bomReportObjId) { + info = await queryOne( + `SELECT T.objid::text AS "OBJID", + T.status AS "STATUS", + T.revision AS "REVISION", + T.note AS "NOTE", + T.deploy_date AS "DEPLOY_DATE", + (SELECT supply_name FROM supply_mng O WHERE O.objid::varchar = T.customer_objid) AS "CUSTOMER_NAME", + (SELECT project_no FROM project_mgmt O WHERE O.objid = T.contract_objid) AS "PROJECT_NO", + (SELECT customer_project_name FROM project_mgmt O WHERE O.objid = T.contract_objid) AS "CUSTOMER_PROJECT_NAME", + (SELECT O.unit_no || '-' || O.task_name FROM pms_wbs_task O WHERE O.objid = T.unit_code) AS "UNIT_NAME" + FROM part_bom_report T WHERE T.objid = $1`, + [bomReportObjId], + ); + } + + const parts = bomReportObjId + ? await queryRows( + `SELECT Q.objid::text AS "OBJID", + Q.parent_part_no AS "PARENT_PART_NO", + Q.last_part_objid AS "PART_OBJID", + Q.qty AS "QTY", + Q.status AS "Q_STATUS", + P.part_no AS "PART_NO", + P.part_name AS "PART_NAME", + P.material AS "MATERIAL", + P.spec AS "SPEC", + P.maker AS "MAKER", + P.revision AS "REVISION", + P.eo_no AS "EO_NO", + P.eo_date AS "EO_DATE", + P.part_type AS "PART_TYPE", + (SELECT code_name FROM comm_code WHERE code_id = P.part_type) AS "PART_TYPE_TITLE", + P.remark AS "REMARK", + (SELECT part_no || ' ' || part_name FROM part_mng SP WHERE SP.objid = Q.parent_part_no) AS "PARENT_PART_INFO" + FROM bom_part_qty Q + LEFT JOIN part_mng P ON P.objid = Q.last_part_objid + WHERE Q.bom_report_objid = $1 + ORDER BY Q.parent_part_no, P.part_no`, + [bomReportObjId], + ) + : []; + + return NextResponse.json({ RESULTLIST: parts, HEADER: info ?? {} }); +} diff --git a/src/app/api/product/design-change/route.ts b/src/app/api/product/design-change/route.ts new file mode 100644 index 0000000..cdfc2a0 --- /dev/null +++ b/src/app/api/product/design-change/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 제품관리_설계변경 리스트 (원본: partMng.partMngHistList) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = [ + "1=1", + `NOT (PM.his_status = 'DEPLOY' AND PM.change_type IS NULL AND PM.revision = 'RE')`, + "PM.revision IS NOT NULL", + "COALESCE(PM.bom_status, '') = 'deploy'", + ]; + const params: unknown[] = []; + let idx = 1; + + if (body.Year) { + conditions.push(`TO_CHAR(CM.regdate, 'YYYY') = $${idx++}`); + params.push(String(body.Year)); + } + if (body.contract_objid) { + conditions.push(`CM.objid = $${idx++}`); + params.push(body.contract_objid); + } + if (body.unit_code) { + conditions.push(`B.unit_code = $${idx++}`); + params.push(body.unit_code); + } + if (body.part_no) { + conditions.push(`UPPER(PM.part_no) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.part_no); + } + if (body.part_name) { + conditions.push(`UPPER(PM.part_name) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.part_name); + } + if (body.change_option) { + conditions.push(`PM.change_option = $${idx++}`); + params.push(body.change_option); + } + if (body.eo_start_date) { + conditions.push(`TO_DATE(PM.eo_date, 'YYYY-MM-DD') >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(body.eo_start_date); + } + if (body.eo_end_date) { + conditions.push(`TO_DATE(PM.eo_date, 'YYYY-MM-DD') <= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(body.eo_end_date); + } + if (body.change_type) { + conditions.push(`PM.change_type = $${idx++}`); + params.push(body.change_type); + } + if (body.part_type) { + conditions.push(`PM.part_type = $${idx++}`); + params.push(body.part_type); + } + if (body.writer_id) { + conditions.push(`PM.writer = $${idx++}`); + params.push(body.writer_id); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT PM.objid::text AS "OBJID", + PM.eo_no AS "EO_NO", + TO_CHAR(CM.regdate, 'YYYY') AS "YEAR", + COALESCE(CM.customer_project_name, CM2.customer_project_name) AS "PROJECT_NAME", + COALESCE(CM2.project_no, CM.project_no) AS "PROJECT_NO", + (SELECT part_no || ' ' || part_name FROM part_mng SP WHERE SP.objid = PM.parent_part_no) AS "PARENT_PART_INFO", + CASE WHEN PM.change_option = '0001790' THEN PM.part_no || '->' || PM.chg_part_no ELSE PM.part_no END AS "PART_NO", + CASE WHEN PM.change_option = '0001790' + THEN PM.part_name || '->' || (SELECT part_name FROM part_mng P WHERE P.objid = PM.chg_part_objid::varchar) + ELSE PM.part_name END AS "PART_NAME", + PM.bom_qty_status AS "BOM_QTY_STATUS", + CASE WHEN PM.bom_qty_status = 'adding' THEN PM.qty_temp ELSE PM.qty END AS "QTY", + CASE + WHEN PM.bom_qty_status = 'adding' THEN '' + WHEN PM.bom_qty_status = 'beforeEdit' AND PM.qty = PM.qty_temp THEN '' + ELSE PM.qty_temp END AS "QTY_TEMP", + PM.change_type AS "CHANGE_TYPE", + (SELECT code_name FROM comm_code WHERE code_id = PM.change_type) AS "CHANGE_TYPE_NAME", + PM.change_option AS "CHANGE_OPTION", + (SELECT code_name FROM comm_code WHERE code_id = PM.change_option) AS "CHANGE_OPTION_NAME", + CASE WHEN PM.change_option = '0001790' THEN PM.revision || '->' || PM.chg_part_rev ELSE PM.revision END AS "REVISION", + PM.eo_date AS "EO_DATE", + PM.part_type AS "PART_TYPE", + (SELECT code_name FROM comm_code WHERE code_id = PM.part_type) AS "PART_TYPE_NAME", + PM.writer AS "WRITER", + (SELECT user_name FROM user_info WHERE user_id = PM.writer) AS "WRITER_NAME", + WTS.unit_no || '-' || WTS.task_name AS "UNIT_NAME", + TO_CHAR(PM.his_reg_date, 'YYYY-MM-DD') AS "HIS_REG_DATE_TITLE" + FROM part_mng_history PM + LEFT OUTER JOIN project_mgmt CM ON PM.contract_objid = CM.objid + LEFT OUTER JOIN part_bom_report B ON PM.bom_report_objid = B.objid + LEFT OUTER JOIN project_mgmt CM2 ON B.contract_objid = CM2.objid + LEFT OUTER JOIN pms_wbs_task WTS ON B.unit_code = WTS.objid + WHERE ${where} + ORDER BY COALESCE(PM.his_reg_date, PM.reg_date) DESC, PM.part_no + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/product/design-change/save/route.ts b/src/app/api/product/design-change/save/route.ts new file mode 100644 index 0000000..275fe78 --- /dev/null +++ b/src/app/api/product/design-change/save/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 설계변경 저장 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const body = await request.json(); + const isNew = !body.objId || body.actionType === "regist"; + const objId = isNew ? createObjectId() : body.objId; + + const client = await pool.connect(); + try { + let changeNo = body.change_no || ""; + if (isNew && !changeNo) { + const year = new Date().getFullYear().toString().slice(2); + const r = await client.query( + `SELECT COALESCE(MAX(SUBSTR(change_no, 6)::numeric), 0)::int + 1 AS seq + FROM design_change WHERE change_no LIKE $1`, [`DC${year}-%`] + ); + changeNo = `DC${year}-${String(r.rows[0]?.seq || 1).padStart(4, "0")}`; + } + + await client.query( + `INSERT INTO design_change ( + objid, change_no, contract_objid, change_title, change_type, change_reason, + request_date, request_user_id, status, writer, reg_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now()) + ON CONFLICT (objid) DO UPDATE SET + change_title=EXCLUDED.change_title, change_type=EXCLUDED.change_type, + change_reason=EXCLUDED.change_reason, status=EXCLUDED.status`, + [objId, changeNo, body.contract_objid || body.project_no || "", + body.change_title || "", body.change_type || "", + body.change_reason || "", body.request_date || new Date().toISOString().slice(0, 10), + body.request_user_id || user.userId, body.status || "draft", user.userId] + ); + return NextResponse.json({ success: true, objId, changeNo, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (e) { + console.error("Design change save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { client.release(); } +} diff --git a/src/app/api/product/history/detail/route.ts b/src/app/api/product/history/detail/route.ts new file mode 100644 index 0000000..4c96adf --- /dev/null +++ b/src/app/api/product/history/detail/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne, queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 품목 상세 (원본: partMng/partMngHisDetailPopUp.do → partMngService.getPartMngInfo) +// 설계변경 리스트에서 넘긴 OBJID 로 PART_MNG 를 조회 (part_mng_history 아님) +// + 첨부파일(3D_CAD / 2D_DRAWING_CAD / 2D_PDF_CAD) 목록 동봉 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + const info = await queryOne( + `SELECT P.objid::text AS "OBJID", + P.part_no AS "PART_NO", P.part_name AS "PART_NAME", + P.spec AS "SPEC", P.material AS "MATERIAL", P.weight AS "WEIGHT", + P.unit AS "UNIT", P.qty AS "QTY", + P.part_type AS "PART_TYPE", + (SELECT code_name FROM comm_code WHERE code_id = P.part_type) AS "PART_TYPE_TITLE", + P.maker AS "MAKER", P.post_processing AS "POST_PROCESSING", + P.remark AS "REMARK", + P.revision AS "REVISION", P.status AS "STATUS", P.is_last AS "IS_LAST", + P.change_type AS "CHANGE_TYPE", + (SELECT code_name FROM comm_code WHERE code_id = P.change_type) AS "CHANGE_TYPE_NAME", + P.change_option AS "CHANGE_OPTION", + (SELECT code_name FROM comm_code WHERE code_id = P.change_option) AS "CHANGE_OPTION_NAME", + P.thickness AS "THICKNESS", P.width AS "WIDTH", P.height AS "HEIGHT", + P.out_diameter AS "OUT_DIAMETER", P.in_diameter AS "IN_DIAMETER", P.length AS "LENGTH", + P.major_category AS "MAJOR_CATEGORY", P.sub_category AS "SUB_CATEGORY", + P.eo_no AS "EO_NO", P.eo_date AS "EO_DATE", + P.writer AS "WRITER", + (SELECT user_name FROM user_info WHERE user_id = P.writer) AS "WRITER_NAME", + TO_CHAR(P.reg_date, 'YYYY-MM-DD') AS "REG_DATE" + FROM part_mng P WHERE P.objid = $1`, + [objId], + ); + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + + const files = await queryRows( + `SELECT objid::text AS "OBJID", real_file_name AS "REAL_FILE_NAME", + doc_type AS "DOC_TYPE" + FROM attach_file_info + WHERE target_objid = $1 + AND doc_type IN ('3D_CAD', '2D_DRAWING_CAD', '2D_PDF_CAD') + ORDER BY regdate DESC`, + [objId], + ); + + return NextResponse.json({ success: true, data: info, files }); +} diff --git a/src/app/api/product/part-change/route.ts b/src/app/api/product/part-change/route.ts new file mode 100644 index 0000000..1215045 --- /dev/null +++ b/src/app/api/product/part-change/route.ts @@ -0,0 +1,402 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool, queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { getNextRevision } from "@/lib/utils"; + +// 설변대상 PART 조회 (원본: partMng.partMngChangeGridList) +// 저장 원본: PartMngService.savePartMngChangeList → savePartMng(ACTION_TYPE='changeDesign') +// action: list(default) | save +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const action = String(body.action || "list"); + + if (action === "save") { + const rows: Array> = Array.isArray(body.rows) ? body.rows : []; + const client = await pool.connect(); + try { + await client.query("BEGIN"); + for (const r of rows) { + // Q_STATUS='deploy' 건만 저장 (원본 fn_save 체크) + // 주: PART_SEQ 필터 없음. 같은 파트가 여러 BOM에 있을 때(동시프로젝트 등) + // 각 bom_part_qty row(child_objid)가 모두 beforeEdit 으로 전환되어야 함. + if (String(r.Q_STATUS || "") !== "deploy") continue; + + const objid = String(r.OBJID || ""); + if (!objid) continue; + + // REVISION 자동 증가: 'RE' → 'A', 그 외 → getNextRevision + const prevRevision = String(r.REVISION || ""); + const nextRevision = prevRevision === "RE" ? "A" : getNextRevision(prevRevision); + + // partMng.mergePartMngchangeDesign — INSERT ON CONFLICT(OBJID) DO UPDATE + // STATUS='changing', EO_DATE='', EO_NO='' 고정 (원본 line 576-578) + await client.query( + `INSERT INTO part_mng ( + objid, product_mgmt_objid, contract_objid, upg_no, part_no, part_name, + unit, qty, spec, material, weight, part_type, remark, + es_spec, ms_spec, change_type, change_option, + design_apply_point, management_flag, revision, status, + reg_date, writer, is_last, parent_part_no, sub_material, + eo_no, eo_date, design_date, + thickness, width, height, out_diameter, in_diameter, length, + maker, post_processing, major_category, sub_category, + code1, code2, code3, code4, code5, is_new, is_longd + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, $17, + $18, $19, UPPER($20), 'changing', + NOW(), $21, COALESCE($22, '0'), $23, $24, + '', '', $25, + $26, $27, $28, $29, $30, $31, + $32, $33, $34, $35, + $36, $37, $38, $39, $40, $41, $42 + ) + ON CONFLICT (objid) DO UPDATE SET + product_mgmt_objid = EXCLUDED.product_mgmt_objid, + upg_no = EXCLUDED.upg_no, + part_no = EXCLUDED.part_no, + part_name = EXCLUDED.part_name, + unit = EXCLUDED.unit, + qty = EXCLUDED.qty, + spec = EXCLUDED.spec, + material = EXCLUDED.material, + weight = EXCLUDED.weight, + part_type = EXCLUDED.part_type, + remark = EXCLUDED.remark, + es_spec = EXCLUDED.es_spec, + ms_spec = EXCLUDED.ms_spec, + change_type = EXCLUDED.change_type, + change_option = EXCLUDED.change_option, + design_apply_point = EXCLUDED.design_apply_point, + management_flag = EXCLUDED.management_flag, + revision = EXCLUDED.revision, + status = EXCLUDED.status, + edit_date = NOW(), + writer = EXCLUDED.writer, + parent_part_no = EXCLUDED.parent_part_no, + sub_material = EXCLUDED.sub_material, + eo_no = '', + eo_date = '', + design_date = EXCLUDED.design_date, + thickness = EXCLUDED.thickness, + width = EXCLUDED.width, + height = EXCLUDED.height, + out_diameter = EXCLUDED.out_diameter, + in_diameter = EXCLUDED.in_diameter, + length = EXCLUDED.length, + maker = EXCLUDED.maker, + post_processing = EXCLUDED.post_processing, + major_category = EXCLUDED.major_category, + sub_category = EXCLUDED.sub_category, + code1 = EXCLUDED.code1, + code2 = EXCLUDED.code2, + code3 = EXCLUDED.code3, + code4 = EXCLUDED.code4, + code5 = EXCLUDED.code5, + is_new = EXCLUDED.is_new, + is_longd = EXCLUDED.is_longd`, + [ + objid, // $1 objid + String(r.PRODUCT_MGMT_OBJID || ""), // $2 + String(r.CONTRACT_OBJID || ""), // $3 + String(r.UPG_NO || ""), // $4 + String(r.PART_NO || ""), // $5 + String(r.PART_NAME || ""), // $6 + String(r.UNIT || ""), // $7 unit + String(r.QTY || ""), // $8 qty + String(r.SPEC || ""), // $9 + String(r.MATERIAL || ""), // $10 + String(r.WEIGHT || ""), // $11 + String(r.PART_TYPE || ""), // $12 + String(r.REMARK || ""), // $13 + String(r.ES_SPEC || ""), // $14 + String(r.MS_SPEC || ""), // $15 + String(r.CHANGE_TYPE || ""), // $16 + String(r.CHANGE_OPTION || ""), // $17 + String(r.DESIGN_APPLY_POINT || ""), // $18 + String(r.MANAGEMENT_FLAG || ""), // $19 + nextRevision, // $20 revision (UPPER) + user.userId, // $21 writer + r.IS_LAST == null ? null : String(r.IS_LAST), // $22 is_last (COALESCE '0') + String(r.PARENT_PART_NO || ""), // $23 + String(r.SUB_MATERIAL || ""), // $24 + String(r.DESIGN_DATE || ""), // $25 design_date (eo_no/eo_date 빈값 고정) + String(r.THICKNESS || ""), // $26 + String(r.WIDTH || ""), // $27 + String(r.HEIGHT || ""), // $28 + String(r.OUT_DIAMETER || ""), // $29 + String(r.IN_DIAMETER || ""), // $30 + String(r.LENGTH || ""), // $31 + String(r.MAKER || ""), // $32 + String(r.POST_PROCESSING || ""), // $33 + String(r.MAJOR_CATEGORY || ""), // $34 + String(r.SUB_CATEGORY || ""), // $35 + String(r.CODE1 || ""), // $36 + String(r.CODE2 || ""), // $37 + String(r.CODE3 || ""), // $38 + String(r.CODE4 || ""), // $39 + String(r.CODE5 || ""), // $40 + String(r.IS_NEW || ""), // $41 + String(r.IS_LONGD || ""), // $42 + ], + ); + + // partMng.mergePartMngHistory — PART_MNG_HISTORY 스냅샷 UPSERT (원본 582줄 주석 해제) + // status='changing', revision=next, eo_no='' 상태 기준으로 저장 + await client.query( + `INSERT INTO part_mng_history ( + objid, product_mgmt_objid, upg_no, part_no, part_name, unit, qty, + spec, material, weight, part_type, remark, es_spec, ms_spec, + change_type, change_option, design_apply_point, management_flag, + revision, status, reg_date, writer, is_last, + parent_part_no, sub_material, eo_no, eo_date, design_date, + thickness, width, height, out_diameter, in_diameter, length, + contract_objid, maker + ) + SELECT + $1::numeric, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, $13, $14, + $15, $16, $17, $18, + UPPER($19), $20, NOW(), $21, '0', + $22, $23, COALESCE((SELECT eo_no FROM part_mng WHERE objid = $1::varchar), ''), + $24, $25, + $26, $27, $28, $29, $30, $31, + $32, $33 + ON CONFLICT (objid) DO UPDATE SET + product_mgmt_objid = EXCLUDED.product_mgmt_objid, + upg_no = EXCLUDED.upg_no, + part_no = EXCLUDED.part_no, + part_name = EXCLUDED.part_name, + unit = EXCLUDED.unit, + qty = EXCLUDED.qty, + spec = EXCLUDED.spec, + material = EXCLUDED.material, + weight = EXCLUDED.weight, + part_type = EXCLUDED.part_type, + remark = EXCLUDED.remark, + es_spec = EXCLUDED.es_spec, + ms_spec = EXCLUDED.ms_spec, + change_type = EXCLUDED.change_type, + change_option = EXCLUDED.change_option, + design_apply_point = EXCLUDED.design_apply_point, + management_flag = EXCLUDED.management_flag, + revision = EXCLUDED.revision, + status = EXCLUDED.status, + edit_date = NOW(), + writer = EXCLUDED.writer, + parent_part_no = EXCLUDED.parent_part_no, + sub_material = EXCLUDED.sub_material, + eo_date = EXCLUDED.eo_date, + design_date = EXCLUDED.design_date, + thickness = EXCLUDED.thickness, + width = EXCLUDED.width, + height = EXCLUDED.height, + out_diameter = EXCLUDED.out_diameter, + in_diameter = EXCLUDED.in_diameter, + length = EXCLUDED.length, + contract_objid = COALESCE((SELECT contract_objid FROM part_mng WHERE objid = $1::varchar), ''), + maker = EXCLUDED.maker`, + [ + objid, // $1 + String(r.PRODUCT_MGMT_OBJID || ""), // $2 + String(r.UPG_NO || ""), // $3 + String(r.PART_NO || ""), // $4 + String(r.PART_NAME || ""), // $5 + String(r.UNIT || ""), // $6 + String(r.QTY || ""), // $7 + String(r.SPEC || ""), // $8 + String(r.MATERIAL || ""), // $9 + String(r.WEIGHT || ""), // $10 + String(r.PART_TYPE || ""), // $11 + String(r.REMARK || ""), // $12 + String(r.ES_SPEC || ""), // $13 + String(r.MS_SPEC || ""), // $14 + String(r.CHANGE_TYPE || ""), // $15 + String(r.CHANGE_OPTION || ""), // $16 + String(r.DESIGN_APPLY_POINT || ""), // $17 + String(r.MANAGEMENT_FLAG || ""), // $18 + nextRevision, // $19 revision (UPPER) + "changing", // $20 status + user.userId, // $21 writer + String(r.PARENT_PART_NO || ""), // $22 + String(r.SUB_MATERIAL || ""), // $23 + "", // $24 eo_date (리셋) + String(r.DESIGN_DATE || ""), // $25 + String(r.THICKNESS || ""), // $26 + String(r.WIDTH || ""), // $27 + String(r.HEIGHT || ""), // $28 + String(r.OUT_DIAMETER || ""), // $29 + String(r.IN_DIAMETER || ""), // $30 + String(r.LENGTH || ""), // $31 + String(r.CONTRACT_OBJID || ""), // $32 + String(r.MAKER || ""), // $33 + ], + ); + + // partMng.structureQtySave — bom_part_qty UPDATE (원본 line 586-588) + // WHERE child_objid = ? + const childObjId = String(r.CHILD_OBJID || ""); + if (childObjId) { + await client.query( + `UPDATE bom_part_qty + SET status = CASE WHEN COALESCE($1,'') = '' THEN status ELSE $1 END, + qty_temp = CASE WHEN COALESCE($2,'') = '' THEN qty_temp ELSE $2 END + WHERE child_objid = $3`, + ["beforeEdit", String(r.Q_QTY || ""), childObjId], + ); + } + + // changeStatusBomReportByPartNo — 해당 PART를 사용한 BOM 전체를 설계변경 상태로 전환 + // (원본 partMngService.savePartMng 591줄 주석 해제 반영) + const partNo = String(r.PART_NO || ""); + if (partNo) { + await client.query( + `UPDATE part_bom_report + SET status = 'changeDesign', edit_date = NOW(), editer = $1 + WHERE objid IN ( + SELECT Q.bom_report_objid + FROM bom_part_qty Q, part_mng P + WHERE P.part_no = $2 AND P.objid = Q.part_no + ) + AND status != 'changeDesign'`, + [user.userId, partNo], + ); + } + } + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("part-change save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } + } + + // list - partMngChangeGridList 대응 + const conditions: string[] = ["T.status = 'release'"]; + const params: unknown[] = []; + let idx = 1; + + if (body.project_name) { + // 동시 프로젝트 같은 파트 조회 + conditions.push(`EXISTS (SELECT 'E' FROM project_mgmt SP + WHERE SP.objid = B.contract_objid + AND EXISTS (SELECT 'E' FROM contract_mgmt SC + WHERE SC.objid = SP.contract_objid + AND EXISTS (SELECT 'E' FROM project_mgmt SP2 + WHERE SC.objid = SP2.contract_objid + AND SP2.objid = $${idx})))`); + params.push(body.project_name); + idx++; + } + if (body.unit_code) { + conditions.push(`EXISTS (SELECT 'E' FROM pms_wbs_task O + WHERE O.objid = B.unit_code + AND (UPPER(O.task_name) = (SELECT UPPER(task_name) FROM pms_wbs_task ST WHERE ST.objid = $${idx}) + OR UPPER(O.unit_no || '-' || O.task_name) = (SELECT UPPER(task_name) FROM pms_wbs_task ST WHERE ST.objid = $${idx})))`); + params.push(body.unit_code); + idx++; + } + if (body.SEARCH_PART_OBJID) { + conditions.push(`T.objid = $${idx++}`); + params.push(body.SEARCH_PART_OBJID); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT + ROW_NUMBER() OVER(ORDER BY T.part_no, + (CASE WHEN T.revision LIKE 'RE%' THEN 0 ELSE 1 END) DESC, + T.revision DESC) AS "NUM", + DENSE_RANK() OVER(PARTITION BY T.objid ORDER BY Q.seq) AS "PART_SEQ", + T.objid::text AS "OBJID", + T.objid::text AS "OBJID_ORG", + T.part_no AS "PART_NO", + T.part_name AS "PART_NAME", + T.revision AS "REVISION", + T.eo_no AS "EO_NO", + T.eo_date AS "EO_DATE", + T.material AS "MATERIAL", + T.spec AS "SPEC", + T.post_processing AS "POST_PROCESSING", + T.maker AS "MAKER", + T.major_category AS "MAJOR_CATEGORY", + T.sub_category AS "SUB_CATEGORY", + T.remark AS "REMARK", + T.part_type AS "PART_TYPE", + T.change_type AS "CHANGE_TYPE", + T.change_option AS "CHANGE_OPTION", + T.product_mgmt_objid AS "PRODUCT_MGMT_OBJID", + T.upg_no AS "UPG_NO", + T.unit AS "UNIT", + T.qty AS "QTY", + T.weight AS "WEIGHT", + T.es_spec AS "ES_SPEC", + T.ms_spec AS "MS_SPEC", + T.design_apply_point AS "DESIGN_APPLY_POINT", + T.management_flag AS "MANAGEMENT_FLAG", + T.is_last AS "IS_LAST", + T.parent_part_no AS "PARENT_PART_NO", + T.sub_material AS "SUB_MATERIAL", + T.design_date AS "DESIGN_DATE", + T.thickness AS "THICKNESS", + T.width AS "WIDTH", + T.height AS "HEIGHT", + T.out_diameter AS "OUT_DIAMETER", + T.in_diameter AS "IN_DIAMETER", + T.length AS "LENGTH", + T.code1 AS "CODE1", + T.code2 AS "CODE2", + T.code3 AS "CODE3", + T.code4 AS "CODE4", + T.code5 AS "CODE5", + T.is_new AS "IS_NEW", + T.is_longd AS "IS_LONGD", + Q.objid::text AS "OBJID_QTY", + Q.child_objid::text AS "CHILD_OBJID", + Q.status AS "Q_STATUS", + CASE WHEN Q.status = 'deploy' THEN Q.qty + WHEN (Q.qty_temp IS NULL OR Q.qty_temp = '') THEN Q.qty + ELSE Q.qty_temp END AS "Q_QTY", + (SELECT SUM(I.qty::numeric) + FROM inventory_mng I, resource_mng RM + WHERE RM.objid = I.parent_objid + AND I.is_last = 'Y' + AND RM.part_objid = T.objid::varchar + GROUP BY I.parent_objid + LIMIT 1) AS "INVEN_TOTAL_QTY", + (SELECT part_no || ' ' || part_name FROM part_mng SP WHERE SP.objid = Q.parent_part_no) AS "PARENT_PART_INFO", + (SELECT project_no FROM project_mgmt O WHERE O.objid = B.contract_objid) AS "PROJECT_NO", + (SELECT O.unit_no || '-' || O.task_name FROM pms_wbs_task O WHERE O.objid = B.unit_code) AS "UNIT_NAME", + B.contract_objid AS "CONTRACT_OBJID", + B.unit_code AS "UNIT_CODE", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid = T.objid::text AND doc_type = '3D_CAD') AS "CU01_CNT", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid = T.objid::text AND doc_type = '2D_DRAWING_CAD') AS "CU02_CNT", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid = T.objid::text AND doc_type = '2D_PDF_CAD') AS "CU03_CNT" + FROM part_mng T + LEFT OUTER JOIN bom_part_qty Q + ON ( + (Q.last_part_objid = T.objid AND Q.status IN ('deploy','beforeEdit')) + OR + (T.objid = (SELECT PM1.objid FROM part_mng PM1, part_mng PM2 + WHERE PM1.is_last = '1' AND PM2.objid = Q.part_no AND PM1.part_no = PM2.part_no) + AND Q.status IN ('editing')) + ) + AND Q.status IN ('deploy','beforeEdit') + LEFT OUTER JOIN part_bom_report B ON B.objid = Q.bom_report_objid + WHERE ${where} + ORDER BY T.part_no, + (CASE WHEN T.revision LIKE 'RE%' THEN 0 ELSE 1 END) DESC, + T.revision DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/product/part/delete/route.ts b/src/app/api/product/part/delete/route.ts new file mode 100644 index 0000000..9dd89b9 --- /dev/null +++ b/src/app/api/product/part/delete/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// PART 삭제 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const { objIds } = body; + + if (!objIds || !Array.isArray(objIds) || objIds.length === 0) { + return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }); + } + + try { + const placeholders = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + // BOM 참조 먼저 삭제 + await execute(`DELETE FROM bom_part_qty WHERE part_no IN (${placeholders})`, objIds); + // PART 삭제 + await execute(`DELETE FROM part_mng WHERE objid IN (${placeholders})`, objIds); + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (error) { + console.error("Part delete error:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/product/part/deploy/route.ts b/src/app/api/product/part/deploy/route.ts new file mode 100644 index 0000000..823162f --- /dev/null +++ b/src/app/api/product/part/deploy/route.ts @@ -0,0 +1,198 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// PART 확정(배포) — 원본 PartMngService.partMngDeploy +// 1) partMngIsLastInit — 같은 part_no의 모든 행 is_last='0' +// 2) partMngDeploy — 대상 행 is_last='1', status='release', revision RE 기본, EO_NO 자동채번 +// 3) insertPartMngHistory (HIS_STATUS='DEPLOY') — 이력 저장 +// 4) structureQtySave (STATUS='editing') — bom_part_qty 상태 전환 +// 5) changeStatusBomReportByPartObjid — 사용된 BOM 설계변경중 +// 6) procurStandMgmt.updateCodeName — CODE1~5가 단독 파트에만 쓰이면 procurement_standard.code_name 갱신 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + // 원본 partMngTempList.jsp: dataListJson = getSelectedData() (full rows) + // 하위 호환: objIds 만 오면 rows 는 DB에서 조회 + let rows: Array> = Array.isArray(body.rows) ? body.rows : []; + if (rows.length === 0 && Array.isArray(body.objIds) && body.objIds.length > 0) { + const placeholders = body.objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + const fallback = await pool.query( + `SELECT objid AS "OBJID", part_no AS "PART_NO", revision AS "REVISION", + change_type AS "CHANGE_TYPE", change_option AS "CHANGE_OPTION", + major_category AS "MAJOR_CATEGORY", sub_category AS "SUB_CATEGORY", + maker AS "MAKER", part_name AS "PART_NAME", spec AS "SPEC", + code1 AS "CODE1", code2 AS "CODE2", code3 AS "CODE3", + code4 AS "CODE4", code5 AS "CODE5" + FROM part_mng WHERE objid IN (${placeholders})`, + body.objIds, + ); + rows = fallback.rows; + } + if (rows.length === 0) { + return NextResponse.json({ success: false, message: "선택된 Part가 없습니다." }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + for (const r of rows) { + const objid = String(r.OBJID || ""); + if (!objid) continue; + + // 1) partMngIsLastInit — 같은 part_no의 모든 행 is_last='0' + await client.query( + `UPDATE part_mng + SET is_last = '0', edit_date = NOW() + WHERE part_no = (SELECT part_no FROM part_mng WHERE objid = $1)`, + [objid], + ); + + // 2) partMngDeploy — 대상 행 최종 확정 + await client.query( + `UPDATE part_mng P SET + is_last = '1', + edit_date = NOW(), + deploy_date = NOW(), + status = 'release', + revision = CASE WHEN COALESCE(revision, '') = '' THEN 'RE' ELSE revision END, + eo_date = TO_CHAR(NOW(), 'YYYY-MM-DD'), + eo_no = CASE + WHEN P.is_longd = '1' THEN + 'EOB' || TO_CHAR(NOW(),'YY') || '-' || LPAD( + COALESCE((SELECT MAX(SUBSTRING(SP.eo_no FROM 7))::integer + 1 + FROM part_mng SP + WHERE SP.eo_no IS NOT NULL + AND SP.eo_no LIKE 'EOB' || TO_CHAR(NOW(),'YY') || '-%' + AND SP.part_no != P.part_no + AND SP.revision != COALESCE(P.revision,''))::text, '1'), 4, '0') + ELSE + 'EO' || TO_CHAR(NOW(),'YY') || '-' || LPAD( + COALESCE((SELECT MAX(SUBSTRING(SP.eo_no FROM 6))::integer + 1 + FROM part_mng SP + WHERE SP.eo_no IS NOT NULL + AND SP.eo_no LIKE 'EO' || TO_CHAR(NOW(),'YY') || '-%' + AND SP.part_no != P.part_no + AND SP.revision != COALESCE(P.revision,''))::text, '1'), 4, '0') + END + WHERE P.objid = $1`, + [objid], + ); + + // 3) insertPartMngHistory (HIS_STATUS='DEPLOY') + // 원본: REVISION='RE' 면 CHANGE_TYPE을 '0001602'(신규파트생성)로 덮어씀 + const prevRevision = String(r.REVISION || ""); + const changeTypeForHistory = prevRevision === "RE" ? "0001602" : String(r.CHANGE_TYPE || ""); + const childObjId = String(r.CHILD_OBJID || ""); + const bomReportObjId = String(r.BOM_REPORT_OBJID || ""); + + await client.query( + `INSERT INTO part_mng_history ( + objid, product_mgmt_objid, upg_no, part_no, part_name, unit, qty, + spec, material, weight, part_type, remark, es_spec, ms_spec, + change_option, design_apply_point, management_flag, revision, status, + reg_date, edit_date, writer, is_last, eo_no, eo_temp, + excel_upload_seq, sourcing_code, sub_material, parent_part_no, + design_date, eo_date, deploy_date, + thickness, width, height, out_diameter, in_diameter, length, supply_code, + change_type, contract_objid, maker, qty_temp, + bom_report_objid, parent_part_objid, parent_qty_child_objid, + bom_qty_status, his_reg_date, his_writer, his_status, qty_child_objid, + bom_status, bom_deploy_date, chg_part_objid, chg_part_no, chg_part_rev + ) + SELECT + P.objid::numeric, P.product_mgmt_objid, P.upg_no, P.part_no, P.part_name, P.unit, Q.qty, + P.spec, P.material, P.weight, P.part_type, P.remark, P.es_spec, P.ms_spec, + COALESCE(NULLIF($1::varchar, ''), P.change_option), + P.design_apply_point, P.management_flag, P.revision, P.status, + P.reg_date, NOW(), $2::varchar, P.is_last, P.eo_no, P.eo_temp, + P.excel_upload_seq, P.sourcing_code, P.sub_material, + COALESCE(NULLIF(Q.parent_part_no::varchar, ''), Q.parent_part_no), + P.design_date, P.eo_date, P.deploy_date, + P.thickness, P.width, P.height, P.out_diameter, P.in_diameter, P.length, P.supply_code, + COALESCE(NULLIF($3::varchar, ''), P.change_type), + P.contract_objid, P.maker, Q.qty_temp, + COALESCE(NULLIF($4::varchar, ''), Q.bom_report_objid), + Q.parent_part_no, Q.parent_objid, + Q.status, NOW(), $2::varchar, 'DEPLOY', $5::varchar, + '', NULL, '', '', '' + FROM part_mng P + LEFT OUTER JOIN bom_part_qty Q + ON Q.child_objid = $5::varchar + AND Q.status = 'beforeEdit' + WHERE P.objid = $6::varchar`, + [ + String(r.CHANGE_OPTION || ""), // $1 + user.userId, // $2 + changeTypeForHistory, // $3 + bomReportObjId, // $4 + childObjId, // $5 + objid, // $6 + ], + ); + + // 4) structureQtySave — bom_part_qty 상태 'editing'으로 전환 + if (childObjId) { + await client.query( + `UPDATE bom_part_qty + SET status = CASE WHEN COALESCE($1,'') = '' THEN status ELSE $1 END, + qty_temp = CASE WHEN COALESCE($2,'') = '' THEN qty_temp ELSE $2 END + WHERE child_objid = $3`, + ["editing", "", childObjId], + ); + } + + // 5) changeStatusBomReportByPartObjid — 이 파트를 사용한 BOM 전체 changeDesign + await client.query( + `UPDATE part_bom_report + SET status = 'changeDesign', edit_date = NOW(), editer = $1 + WHERE objid IN ( + SELECT Q.bom_report_objid + FROM bom_part_qty Q, part_mng P, part_mng P2 + WHERE P.objid = $2::varchar + AND P.part_no = P2.part_no + AND P2.objid = Q.part_no + ) + AND status != 'changeDesign'`, + [user.userId, objid], + ); + + // 6) procurStandMgmt.sameStdCodeList + updateCodeName + // CODE1~5 각각, 해당 코드를 쓰는 is_last='1' 파트가 정확히 1건이면 + // procurement_standard.code_name 갱신 (switch: 1→MAJOR_CATEGORY, 2→SUB_CATEGORY, 3→MAKER, 4→PART_NAME, 5→SPEC) + const codeMap: Array<[string, string]> = [ + [String(r.CODE1 || ""), String(r.MAJOR_CATEGORY || "")], + [String(r.CODE2 || ""), String(r.SUB_CATEGORY || "")], + [String(r.CODE3 || ""), String(r.MAKER || "")], + [String(r.CODE4 || ""), String(r.PART_NAME || "")], + [String(r.CODE5 || ""), String(r.SPEC || "")], + ]; + for (const [codeId, codeName] of codeMap) { + if (!codeId) continue; + const cnt = await client.query( + `SELECT COUNT(*)::int AS cnt FROM part_mng + WHERE is_last = '1' + AND ($1 IN (COALESCE(code1,''), COALESCE(code2,''), COALESCE(code3,''), COALESCE(code4,''), COALESCE(code5,'')))`, + [codeId], + ); + if (cnt.rows[0]?.cnt === 1) { + await client.query( + `UPDATE procurement_standard SET code_name = $1 WHERE code_id = $2`, + [codeName, codeId], + ); + } + } + } + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: `${rows.length}건이 확정되었습니다.` }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("Part deploy:", e); + return NextResponse.json({ success: false, message: "확정 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/product/part/detail/route.ts b/src/app/api/product/part/detail/route.ts new file mode 100644 index 0000000..2b8df40 --- /dev/null +++ b/src/app/api/product/part/detail/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + const info = await queryOne( + `SELECT P.objid::text AS "OBJID", + P.part_no AS "PART_NO", P.part_name AS "PART_NAME", + P.spec AS "SPEC", P.material AS "MATERIAL", P.weight AS "WEIGHT", + P.unit AS "UNIT", P.qty AS "QTY", P.part_type AS "PART_TYPE", + P.maker AS "MAKER", P.post_processing AS "POST_PROCESSING", + P.remark AS "REMARK", P.sourcing_code AS "SOURCING_CODE", + P.revision AS "REVISION", P.status AS "STATUS", P.is_last AS "IS_LAST", + P.thickness AS "THICKNESS", P.width AS "WIDTH", P.height AS "HEIGHT", + P.out_diameter AS "OUT_DIAMETER", P.in_diameter AS "IN_DIAMETER", P.length AS "LENGTH", + P.major_category AS "MAJOR_CATEGORY", P.sub_category AS "SUB_CATEGORY", + P.product_mgmt_objid AS "PRODUCT_MGMT_OBJID", P.upg_no AS "UPG_NO", + P.change_type AS "CHANGE_TYPE", P.change_option AS "CHANGE_OPTION", + P.eo_no AS "EO_NO", P.eo_date AS "EO_DATE", + P.design_apply_point AS "DESIGN_APPLY_POINT", + P.design_date AS "DESIGN_DATE", + P.management_flag AS "MANAGEMENT_FLAG", + P.supply_code AS "SUPPLY_CODE", + P.sub_material AS "SUB_MATERIAL", + P.writer AS "WRITER" + FROM part_mng P WHERE P.objid = $1`, [objId] + ); + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return NextResponse.json({ success: true, data: info }); +} diff --git a/src/app/api/product/part/excel-save/route.ts b/src/app/api/product/part/excel-save/route.ts new file mode 100644 index 0000000..1c70c8b --- /dev/null +++ b/src/app/api/product/part/excel-save/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +interface PartRow { + part_no?: string; + part_name?: string; + qty?: string; + material?: string; + spec?: string; + post_processing?: string; + maker?: string; + part_type?: string; + remark?: string; +} + +// PART 엑셀 업로드 저장 (partMng.partUploadSave 대응 단순화) +// - 파싱된 행들을 part_mng에 일괄 INSERT (status='create', is_last='Y') +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const parts: PartRow[] = Array.isArray(body.parts) ? body.parts : []; + if (parts.length === 0) { + return NextResponse.json({ success: false, message: "저장할 데이터가 없습니다." }, { status: 400 }); + } + + // 품번 중복 체크 (문서 내) + const seen = new Set(); + const dups: string[] = []; + parts.forEach((p) => { + const n = String(p.part_no || "").trim(); + if (!n) return; + if (seen.has(n)) dups.push(n); + else seen.add(n); + }); + if (dups.length > 0) { + return NextResponse.json({ + success: false, + message: `문서내 중복 품번: ${Array.from(new Set(dups)).join(", ")}`, + }, { status: 400 }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + let inserted = 0; + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + const partNo = String(p.part_no || "").trim(); + const partName = String(p.part_name || "").trim(); + if (!partNo || !partName) continue; + + await client.query( + `INSERT INTO part_mng (objid, part_no, part_name, qty, material, spec, + post_processing, maker, part_type, remark, + status, revision, is_last, writer, reg_date, excel_upload_seq) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'create','0','Y',$11,now(),$12)`, + [ + createObjectId(), partNo, partName, + String(p.qty || ""), String(p.material || ""), String(p.spec || ""), + String(p.post_processing || ""), String(p.maker || ""), + String(p.part_type || ""), String(p.remark || ""), + user.userId, String(i + 1), + ], + ); + inserted++; + } + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: `${inserted}건이 등록되었습니다.` }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("Part excel save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/product/part/route.ts b/src/app/api/product/part/route.ts new file mode 100644 index 0000000..28f3806 --- /dev/null +++ b/src/app/api/product/part/route.ts @@ -0,0 +1,145 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// product/part - PART 등록(temp)/조회(release) 공용 +// - mode: "register" → partMng.partMngTempList (status IN create/changing) +// - 기본 → partMng.partMngGridList (status = release) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const mode = String(body.mode || "").toLowerCase(); + const isRegister = mode === "register"; + + const conditions: string[] = []; + const params: unknown[] = []; + let idx = 1; + + if (isRegister) { + conditions.push(`P.status IN ('create','changing')`); + } else { + conditions.push(`P.status = 'release'`); + // 원본: SEARCH_REVISION_RELEASE="1" 일 때만 is_last='1' 필터 (기본 "0"/"" 은 전체 조회) + const rev = String(body.SEARCH_REVISION_RELEASE ?? ""); + if (rev === "1" || rev === "Y") { + conditions.push(`P.is_last = '1'`); + } + } + + if (body.SEARCH_PART_NO) { + conditions.push(`UPPER(P.part_no) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.SEARCH_PART_NO); + } + if (body.SEARCH_PART_NAME) { + conditions.push(`UPPER(P.part_name) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.SEARCH_PART_NAME); + } + if (body.SEARCH_MATERIAL) { + conditions.push(`UPPER(P.material) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.SEARCH_MATERIAL); + } + if (body.SEARCH_SPEC) { + conditions.push(`UPPER(P.spec) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.SEARCH_SPEC); + } + if (body.SEARCH_PART_TYPE) { + conditions.push(`P.part_type = $${idx++}`); + params.push(body.SEARCH_PART_TYPE); + } + if (body.SEARCH_YEAR) { + conditions.push(`TO_CHAR(P.reg_date, 'YYYY') = $${idx++}`); + params.push(String(body.SEARCH_YEAR)); + } + if (body.WRITER) { + conditions.push(`P.writer = $${idx++}`); + params.push(body.WRITER); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT + ROW_NUMBER() OVER ( + ORDER BY P.part_no, + (CASE WHEN P.revision LIKE 'RE%' THEN 0 ELSE 1 END) DESC, + P.revision DESC + ) AS "NUM", + ROW_NUMBER() OVER ( + ORDER BY P.excel_upload_seq NULLS LAST, P.part_no + ) AS "RNUM", + P.objid::text AS "OBJID", + P.part_no AS "PART_NO", + P.part_name AS "PART_NAME", + -- partMngTempGridList: PARENT_PART_INFO = Q.PARENT_PART_NO 기반 PART_NO + (SELECT part_no FROM part_mng SP WHERE SP.objid = Q.parent_part_no) AS "PARENT_PART_INFO", + -- BOM_QTY: PART_TYPE='0000063' 이면 '1', 아니면 bom_part_qty 합계 + CASE + WHEN P.part_type = '0000063' THEN '1' + ELSE COALESCE( + (SELECT SUM(CASE WHEN QTY = '' THEN 0 ELSE COALESCE(QTY,'0')::NUMERIC END)::text + FROM bom_part_qty Q2 WHERE Q2.last_part_objid = P.objid), + '' + ) + END AS "BOM_QTY", + -- 원본 Q_QTY 로직 (partMngTempGridList 2250-2255) + CASE + WHEN Q.status = 'deploy' THEN Q.qty + WHEN (Q.qty_temp IS NULL OR Q.qty_temp = '') THEN COALESCE(Q.qty, P.qty::text) + ELSE COALESCE(Q.qty_temp, P.qty::text) + END AS "Q_QTY", + Q.bom_report_objid AS "BOM_REPORT_OBJID", + Q.objid::text AS "OBJID_QTY", + Q.child_objid AS "CHILD_OBJID", + Q.qty AS "QTY", + Q.qty_temp AS "QTY_TEMP", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid = P.objid::text AND doc_type = '3D_CAD') AS "CU01_CNT", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid = P.objid::text AND doc_type = '2D_DRAWING_CAD') AS "CU02_CNT", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid = P.objid::text AND doc_type = '2D_PDF_CAD') AS "CU03_CNT", + P.material AS "MATERIAL", + P.spec AS "SPEC", + P.post_processing AS "POST_PROCESSING", + P.maker AS "MAKER", + P.major_category AS "MAJOR_CATEGORY", + P.sub_category AS "SUB_CATEGORY", + P.revision AS "REVISION", + P.eo_no AS "EO_NO", + P.eo_date AS "EO_DATE", + P.part_type AS "PART_TYPE", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = P.part_type LIMIT 1), '') AS "PART_TYPE_TITLE", + P.remark AS "REMARK", + P.status AS "STATUS", + P.is_last AS "IS_LAST", + P.is_longd AS "IS_LONGD", + P.change_type AS "CHANGE_TYPE", + P.change_option AS "CHANGE_OPTION", + P.code1 AS "CODE1", + P.code2 AS "CODE2", + P.code3 AS "CODE3", + P.code4 AS "CODE4", + P.code5 AS "CODE5", + P.writer AS "WRITER", + COALESCE((SELECT user_name FROM user_info WHERE user_id = P.writer LIMIT 1), '') AS "WRITER_NAME", + TO_CHAR(P.reg_date, 'YYYY-MM-DD') AS "REG_DATE" + FROM part_mng P + -- 원본 partMngTempGridList JOIN: changing 파트의 deployed 형제(같은 PART_NO)가 사용된 BOM_PART_QTY(beforeEdit) 매핑 + LEFT OUTER JOIN bom_part_qty Q + ON P.objid IN ( + SELECT DISTINCT PM1.objid + FROM part_mng PM1, part_mng PM2 + WHERE PM1.status = 'changing' + AND PM2.status != 'changing' + AND PM2.objid = Q.part_no + AND PM1.part_no = PM2.part_no + ) + AND Q.status IN ('beforeEdit') + WHERE ${where} + ORDER BY P.part_no, + (CASE WHEN P.revision LIKE 'RE%' THEN 0 ELSE 1 END) DESC, + P.revision DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/product/part/save/route.ts b/src/app/api/product/part/save/route.ts new file mode 100644 index 0000000..8d4f405 --- /dev/null +++ b/src/app/api/product/part/save/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// partMng save - partMngFormPopUp.jsp + partMngDetailPopUp.jsp fn_save() 대응 +// actionType: "regist" | "update" | "changeDesign" +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const actionType = String(body.actionType || ""); + const isNew = actionType === "regist" || !body.OBJID; + const isChangeDesign = actionType === "changeDesign"; + // 신규 등록도 클라이언트가 OBJID를 미리 만들어 보낸 경우 그대로 사용 (파일 사전 업로드 지원) + const objId = isNew ? (String(body.OBJID || "") || createObjectId()) : String(body.OBJID); + + const params: (string | null)[] = [ + objId, + body.PART_NO || "", body.PART_NAME || "", body.UNIT || "", body.QTY || "", + body.SPEC || "", body.MATERIAL || "", body.WEIGHT || "", + body.PART_TYPE || "", body.MAKER || "", body.POST_PROCESSING || "", body.REMARK || "", + body.THICKNESS || "", body.WIDTH || "", body.HEIGHT || "", + body.OUT_DIAMETER || "", body.IN_DIAMETER || "", body.LENGTH || "", + body.MAJOR_CATEGORY || "", body.SUB_CATEGORY || "", + body.PRODUCT_MGMT_OBJID || null, body.UPG_NO || null, + body.CHANGE_TYPE || null, body.CHANGE_OPTION || null, body.REVISION || "", + body.DESIGN_APPLY_POINT || null, body.DESIGN_DATE || null, + body.MANAGEMENT_FLAG || null, body.SUPPLY_CODE || null, body.SUB_MATERIAL || "", + ]; + + try { + if (isNew) { + await execute( + `INSERT INTO part_mng ( + objid, part_no, part_name, unit, qty, spec, material, weight, + part_type, maker, post_processing, remark, + thickness, width, height, out_diameter, in_diameter, length, + major_category, sub_category, + product_mgmt_objid, upg_no, + change_type, change_option, revision, + design_apply_point, design_date, management_flag, supply_code, sub_material, + status, is_last, writer, reg_date + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8, + $9,$10,$11,$12, + $13,$14,$15,$16,$17,$18, + $19,$20, + $21,$22, + $23,$24,$25, + $26,$27,$28,$29,$30, + 'create','1',$31,NOW() + )`, + [...params, user.userId], + ); + } else if (isChangeDesign) { + // 설계변경: 기존 레코드를 'changing' 상태로 마크하고 신규 OBJID로 새 버전 생성 + const newObjId = createObjectId(); + await execute(`UPDATE part_mng SET is_last = '0', status = 'changing' WHERE objid = $1`, [objId]); + await execute( + `INSERT INTO part_mng ( + objid, part_no, part_name, unit, qty, spec, material, weight, + part_type, maker, post_processing, remark, + thickness, width, height, out_diameter, in_diameter, length, + major_category, sub_category, + product_mgmt_objid, upg_no, + change_type, change_option, revision, + design_apply_point, design_date, management_flag, supply_code, sub_material, + status, is_last, writer, reg_date + ) + SELECT $1, part_no, part_name, $4, $5, $6, $7, $8, + $9, $10, $11, $12, + $13, $14, $15, $16, $17, $18, + $19, $20, $21, $22, $23, $24, + COALESCE(revision, 'RE') || ( + (COALESCE((SELECT COUNT(*) FROM part_mng WHERE part_no = PM.part_no), 0) + 1)::text + ), + $26, $27, $28, $29, $30, + 'changing', '1', $31, NOW() + FROM part_mng PM WHERE objid = $2`, + [ + newObjId, objId, params[1], params[3], params[4], params[5], params[6], params[7], + params[8], params[9], params[10], params[11], params[12], params[13], params[14], + params[15], params[16], params[17], params[18], params[19], params[20], params[21], + params[22], params[23], "auto", + params[25], params[26], params[27], params[28], params[29], + user.userId, + ], + ); + return NextResponse.json({ success: true, objId: newObjId, message: "설계변경 등록되었습니다." }); + } else { + await execute( + `UPDATE part_mng SET + part_no=$2, part_name=$3, unit=$4, qty=$5, + spec=$6, material=$7, weight=$8, + part_type=$9, maker=$10, post_processing=$11, remark=$12, + thickness=$13, width=$14, height=$15, + out_diameter=$16, in_diameter=$17, length=$18, + major_category=$19, sub_category=$20, + product_mgmt_objid=$21, upg_no=$22, + change_type=$23, change_option=$24, revision=$25, + design_apply_point=$26, design_date=$27, + management_flag=$28, supply_code=$29, sub_material=$30, + edit_date=NOW() + WHERE objid=$1`, + params, + ); + } + + return NextResponse.json({ + success: true, + objId, + message: isNew ? "등록되었습니다." : "수정되었습니다.", + }); + } catch (e) { + console.error("Part save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/product/part/search/route.ts b/src/app/api/product/part/search/route.ts new file mode 100644 index 0000000..1860e2e --- /dev/null +++ b/src/app/api/product/part/search/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 구조등록 우측 PART 검색 (원본: partMng/getPartMngList_ajax.do) +// 최신 Part (is_last='1') 기준으로 품번/품명/규격/MAKER 검색 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json([]); + + const body = await request.json(); + const conditions: string[] = ["P.status = 'release'"]; + const params: unknown[] = []; + let idx = 1; + + if (String(body.is_last ?? "1") === "1") { + conditions.push(`P.is_last = '1'`); + } + if (body.search_part_no) { + conditions.push(`UPPER(P.part_no) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.search_part_no); + } + if (body.search_part_name) { + conditions.push(`UPPER(P.part_name) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.search_part_name); + } + if (body.search_spec) { + conditions.push(`UPPER(P.spec) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.search_spec); + } + if (body.search_maker) { + conditions.push(`UPPER(P.maker) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.search_maker); + } + + const where = conditions.join(" AND "); + const rows = await queryRows( + `SELECT P.objid::text AS "OBJID", + P.part_no AS "PART_NO", + P.part_name AS "PART_NAME", + P.spec AS "SPEC", + P.maker AS "MAKER", + P.material AS "MATERIAL", + P.revision AS "REVISION", + P.part_type AS "PART_TYPE" + FROM part_mng P + WHERE ${where} + ORDER BY P.part_no +`, + params, + ); + return NextResponse.json(rows); +} diff --git a/src/app/api/product/spec/route.ts b/src/app/api/product/spec/route.ts new file mode 100644 index 0000000..69726b0 --- /dev/null +++ b/src/app/api/product/spec/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 사양관리 목록 조회 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.project_no) { + conditions.push(`CM.contract_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.spec_name) { + conditions.push(`PS.spec_name LIKE '%' || $${idx++} || '%'`); + params.push(body.spec_name); + } + + const where = conditions.join(" AND "); + + const rows = await queryRows( + `SELECT PS.objid::text AS "OBJID", + CM.contract_no AS "PROJECT_NO", + CM.project_name AS "PROJECT_NAME", + PS.spec_name AS "SPEC_NAME", + PS.spec_value AS "SPEC_VALUE", + PS.unit AS "UNIT", + PS.remark AS "REMARK", + TO_CHAR(PS.reg_date, 'YYYY-MM-DD') AS "REG_DATE", + COALESCE((SELECT user_name FROM user_info WHERE user_id = PS.writer LIMIT 1), '') AS "REG_USER_NAME" + FROM product_spec PS + LEFT JOIN contract_mgmt CM ON CM.objid::text = PS.contract_objid + WHERE ${where} + ORDER BY PS.reg_date DESC +`, + params + ); + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/product/spec/save/route.ts b/src/app/api/product/spec/save/route.ts new file mode 100644 index 0000000..8f1ba2e --- /dev/null +++ b/src/app/api/product/spec/save/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 사양 저장 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const body = await request.json(); + const isNew = !body.objId || body.actionType === "regist"; + const objId = isNew ? createObjectId() : body.objId; + + const client = await pool.connect(); + try { + await client.query( + `INSERT INTO product_spec (objid, contract_objid, spec_name, spec_value, unit, remark, writer, reg_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, now()) + ON CONFLICT (objid) DO UPDATE SET + spec_name=EXCLUDED.spec_name, spec_value=EXCLUDED.spec_value, + unit=EXCLUDED.unit, remark=EXCLUDED.remark`, + [objId, body.contract_objid || body.project_no || "", + body.spec_name || "", body.spec_value || "", + body.unit || "", body.remark || "", user.userId] + ); + return NextResponse.json({ success: true, objId, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (e) { + console.error("Spec save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { client.release(); } +} diff --git a/src/app/api/production/assembly-list/route.ts b/src/app/api/production/assembly-list/route.ts new file mode 100644 index 0000000..81b4248 --- /dev/null +++ b/src/app/api/production/assembly-list/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 조립 리스트 조회 (part_bom_report.objid 기준) +// body: { bomReportObjid } +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const bomObjid = String(body.bomReportObjid || ""); + if (!bomObjid) return NextResponse.json({ RESULTLIST: [], HEADER: {} }); + + // 헤더: 프로젝트 + 유닛 정보 + const header = await queryOne( + `SELECT T.objid::text AS "OBJID", + T.contract_objid AS "CONTRACT_OBJID", + T.unit_code AS "UNIT_CODE", + (SELECT project_no FROM project_mgmt WHERE objid = T.contract_objid) AS "PROJECT_NO", + (SELECT project_name FROM project_mgmt WHERE objid = T.contract_objid) AS "PROJECT_NAME", + (SELECT supply_name FROM supply_mng WHERE objid::varchar = T.customer_objid LIMIT 1) AS "CUSTOMER_NAME", + (SELECT COALESCE(O.unit_no,'') || '-' || COALESCE(O.task_name,'') FROM pms_wbs_task O WHERE O.objid = T.unit_code LIMIT 1) AS "UNIT_NAME" + FROM part_bom_report T + WHERE T.objid = $1`, + [bomObjid], + ); + + // 조립 리스트: bom_part_qty + part_mng + assembly_wbs_task + // + 입고예정일/입고일 (arrival_plan, part_objid) + // + 입고결과 (inventory_mgmt_in × inventory_mgmt) + // + 인수수량 (inventory_mgmt_out × inventory_mgmt) + // + 발주수량 (purchase_order_master × purchase_order_part) + const rows = await queryRows( + `SELECT BP.objid::text AS "BOM_PART_QTY_OBJID", + BP.child_objid AS "CHILD_OBJID", + COALESCE(NULLIF(BP.last_part_objid,''), BP.part_no) AS "PART_OBJID", + PM.part_no AS "PART_NO", + COALESCE(PM.part_name, '') AS "PART_NAME", + COALESCE(PM.material, '') AS "MATERIAL", + COALESCE(PM.spec, '') AS "SPEC", + COALESCE(PM.maker, '') AS "MAKER", + COALESCE(PM.revision, '') AS "REVISION", + COALESCE(PM.eo_no, '') AS "EO_NO", + COALESCE(PM.eo_date, '') AS "EO_DATE", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = PM.part_type LIMIT 1), '') AS "PART_TYPE_TITLE", + COALESCE(PM.part_type, '') AS "PART_TYPE", + COALESCE(NULLIF(BP.qty,''), '') AS "QTY", + A.objid::text AS "ASSEMBLY_OBJID", + COALESCE(A.assembly_user_id, '') AS "ASSEMBLY_USER_ID", + COALESCE((SELECT user_name FROM user_info WHERE user_id = A.assembly_user_id LIMIT 1), A.assembly_user_id, '') AS "ASSEMBLY_USER_NAME", + COALESCE(A.assembly_date, '') AS "ASSEMBLY_DATE", + COALESCE(A.insourcing, '') AS "INSOURCING", + COALESCE(A.outsourcing, '') AS "OUTSOURCING", + -- 입고예정일 (최대 3개) + COALESCE(( + SELECT ARRAY_TO_STRING(ARRAY_AGG(arrival_plan_date ORDER BY arrival_plan_date), ',') + FROM (SELECT DISTINCT arrival_plan_date FROM arrival_plan AP + WHERE AP.part_objid = COALESCE(NULLIF(BP.last_part_objid,''), BP.part_no) + AND COALESCE(arrival_plan_date,'') <> '' + ORDER BY arrival_plan_date LIMIT 3) t + ), '') AS "ARRIVAL_PLAN_DATES", + -- 입고일 (최대 3개) + COALESCE(( + SELECT ARRAY_TO_STRING(ARRAY_AGG(receipt_date ORDER BY receipt_date), ',') + FROM (SELECT DISTINCT receipt_date FROM arrival_plan AP + WHERE AP.part_objid = COALESCE(NULLIF(BP.last_part_objid,''), BP.part_no) + AND COALESCE(receipt_date,'') <> '' + ORDER BY receipt_date LIMIT 3) t + ), '') AS "RECEIPT_DATES", + -- 입고결과 (RECEIPT_QTY - MOVE_QTY 합계) + COALESCE(( + SELECT SUM( + COALESCE(NULLIF(IMI.receipt_qty,'')::integer, 0) + - COALESCE(NULLIF(IMI.move_qty,'')::integer, 0)) + FROM inventory_mgmt_in IMI + JOIN inventory_mgmt IM ON IMI.parent_objid = IM.objid + WHERE IMI.contract_mgmt_objid = (SELECT contract_objid FROM part_bom_report WHERE objid = $1) + AND IM.part_objid = COALESCE(NULLIF(BP.last_part_objid,''), BP.part_no) + ), 0) AS "DELIVERY_QTY", + -- 발주수량 (구매품표준용) + COALESCE(( + SELECT SUM(COALESCE(NULLIF(POP.order_qty,'')::numeric, 0)) + FROM purchase_order_master POM + JOIN purchase_order_part POP ON POM.objid = POP.purchase_order_master_objid + WHERE POM.contract_mgmt_objid = (SELECT contract_objid FROM part_bom_report WHERE objid = $1) + AND POP.part_objid = COALESCE(NULLIF(BP.last_part_objid,''), BP.part_no) + ), 0) AS "ORDER_QTY", + -- 인수수량 (unit 단위) + COALESCE(( + SELECT SUM(COALESCE(NULLIF(IMO.out_qty,'')::integer, 0)) + FROM inventory_mgmt_out IMO + JOIN inventory_mgmt IM ON IMO.parent_objid = IM.objid + WHERE IMO.contract_mgmt_objid = (SELECT contract_objid FROM part_bom_report WHERE objid = $1) + AND IMO.unit = (SELECT unit_code FROM part_bom_report WHERE objid = $1) + AND IM.part_objid = COALESCE(NULLIF(BP.last_part_objid,''), BP.part_no) + ), 0) AS "RECEIVE_QTY" + FROM bom_part_qty BP + LEFT JOIN part_mng PM + ON COALESCE(NULLIF(BP.last_part_objid,''), BP.part_no) = PM.objid::varchar + LEFT JOIN assembly_wbs_task A + ON A.parent_objid = BP.bom_report_objid + AND A.part_objid = COALESCE(NULLIF(BP.last_part_objid,''), BP.part_no) + AND A.bom_qty_child_objid = BP.child_objid + WHERE BP.bom_report_objid = $1 + AND PM.part_type IS NOT NULL AND PM.part_type <> '' + AND BP.status IN ('beforeEdit','editing','deleting','deploy') + ORDER BY PM.part_no`, + [bomObjid], + ); + + return NextResponse.json({ RESULTLIST: rows, HEADER: header || {} }); +} diff --git a/src/app/api/production/assembly-save/route.ts b/src/app/api/production/assembly-save/route.ts new file mode 100644 index 0000000..2e5fc03 --- /dev/null +++ b/src/app/api/production/assembly-save/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 조립 저장 (UPSERT to assembly_wbs_task) +// body: { parentObjid (part_bom_report.objid), rows: [{ ASSEMBLY_OBJID?, PART_OBJID, CHILD_OBJID, ASSEMBLY_USER_ID, ASSEMBLY_DATE, INSOURCING, OUTSOURCING }] } +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const parentObjid = String(body.parentObjid || ""); + const rows: Array> = Array.isArray(body.rows) ? body.rows : []; + if (!parentObjid) return NextResponse.json({ success: false, message: "parentObjid 누락" }, { status: 400 }); + + try { + for (const r of rows) { + const partObjid = String(r.PART_OBJID || ""); + const childObjid = String(r.CHILD_OBJID || ""); + const existingObjid = String(r.ASSEMBLY_OBJID || ""); + const userId = String(r.ASSEMBLY_USER_ID || ""); + const date = String(r.ASSEMBLY_DATE || ""); + const insourcing = String(r.INSOURCING || ""); + const outsourcing = String(r.OUTSOURCING || ""); + + if (!partObjid) continue; + + if (existingObjid) { + await execute( + `UPDATE assembly_wbs_task SET + assembly_user_id = $1, assembly_date = $2, + insourcing = $3, outsourcing = $4 + WHERE objid = $5`, + [userId, date, insourcing, outsourcing, existingObjid], + ); + } else { + // 기존 레코드 재확인 (race condition 대비) + const existing = await queryOne<{ OBJID: string }>( + `SELECT objid AS "OBJID" FROM assembly_wbs_task + WHERE parent_objid = $1 AND part_objid = $2 AND bom_qty_child_objid = $3 + LIMIT 1`, + [parentObjid, partObjid, childObjid], + ); + if (existing) { + await execute( + `UPDATE assembly_wbs_task SET + assembly_user_id = $1, assembly_date = $2, + insourcing = $3, outsourcing = $4 + WHERE objid = $5`, + [userId, date, insourcing, outsourcing, existing.OBJID], + ); + } else { + await execute( + `INSERT INTO assembly_wbs_task + (objid, parent_objid, part_objid, bom_qty_child_objid, + assembly_user_id, assembly_date, insourcing, outsourcing) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [createObjectId(), parentObjid, partObjid, childObjid, userId, date, insourcing, outsourcing], + ); + } + } + } + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + console.error("assembly save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/production/inspection-file-counts/route.ts b/src/app/api/production/inspection-file-counts/route.ts new file mode 100644 index 0000000..3c2f30d --- /dev/null +++ b/src/app/api/production/inspection-file-counts/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 검사 체크리스트 행들의 파일 개수 일괄 조회 +// body: { objIds: string[] } → { counts: { [objId]: number } } +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const objIds: string[] = Array.isArray(body.objIds) ? body.objIds.map(String).filter(Boolean) : []; + if (objIds.length === 0) return NextResponse.json({ counts: {} }); + + const placeholders = objIds.map((_, i) => `$${i + 1}`).join(","); + const rows = await queryRows<{ TARGET: string; CNT: string }>( + `SELECT target_objid AS "TARGET", COUNT(1)::text AS "CNT" + FROM attach_file_info + WHERE target_objid IN (${placeholders}) + AND doc_type = 'INSPECTION_CHECKLIST_FILE' + AND UPPER(COALESCE(status,'')) = 'ACTIVE' + GROUP BY target_objid`, + objIds, + ); + const counts: Record = {}; + for (const id of objIds) counts[id] = 0; + for (const r of rows) counts[String(r.TARGET)] = Number(r.CNT) || 0; + return NextResponse.json({ counts }); +} diff --git a/src/app/api/production/inspection-list/route.ts b/src/app/api/production/inspection-list/route.ts new file mode 100644 index 0000000..d141ad3 --- /dev/null +++ b/src/app/api/production/inspection-list/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 검사 리스트 조회 (project_mgmt.objid 기준) +// body: { contractObjid } +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const contractObjid = String(body.contractObjid || ""); + if (!contractObjid) return NextResponse.json({ RESULTLIST: [], HEADER: {} }); + + const header = await queryOne( + `SELECT objid::text AS "OBJID", + project_no AS "PROJECT_NO", + project_name AS "PROJECT_NAME", + (SELECT supply_name FROM supply_mng WHERE objid::text = customer_objid LIMIT 1) AS "CUSTOMER_NAME" + FROM project_mgmt WHERE objid = $1`, + [contractObjid], + ); + + const rows = await queryRows( + `SELECT I.objid::text AS "OBJID", + I.parent_objid AS "PARENT_OBJID", + I.unit_code AS "UNIT_CODE", + I.internal_inspection_date AS "INTERNAL_INSPECTION_DATE", + I.internal_inspection_result AS "INTERNAL_INSPECTION_RESULT", + I.internal_inspection_id AS "INTERNAL_INSPECTION_ID", + COALESCE((SELECT COUNT(1) FROM attach_file_info + WHERE target_objid = I.objid + AND doc_type = 'INSPECTION_CHECKLIST_FILE' + AND UPPER(COALESCE(status,'')) = 'ACTIVE'), 0) AS "CL01_CNT" + FROM inspection_mgmt I + WHERE I.parent_objid = $1 + ORDER BY I.regdate`, + [contractObjid], + ); + + return NextResponse.json({ RESULTLIST: rows, HEADER: header || {} }); +} diff --git a/src/app/api/production/inspection-save/route.ts b/src/app/api/production/inspection-save/route.ts new file mode 100644 index 0000000..85c3985 --- /dev/null +++ b/src/app/api/production/inspection-save/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 검사 저장 (inspection_mgmt UPSERT + 삭제) +// body: { parentObjid, rows: [{OBJID, UNIT_CODE, INTERNAL_INSPECTION_DATE, INTERNAL_INSPECTION_RESULT, INTERNAL_INSPECTION_ID}], deleteIds: string[] } +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const parentObjid = String(body.parentObjid || ""); + const rows: Array> = Array.isArray(body.rows) ? body.rows : []; + const deleteIds: string[] = Array.isArray(body.deleteIds) ? body.deleteIds.map(String).filter(Boolean) : []; + + if (!parentObjid) return NextResponse.json({ success: false, message: "parentObjid 누락" }, { status: 400 }); + + try { + for (const id of deleteIds) { + await execute(`DELETE FROM inspection_mgmt WHERE objid = $1`, [id]); + // 첨부파일도 제거 + await execute( + `UPDATE attach_file_info SET status = 'inactive' + WHERE target_objid = $1 AND doc_type = 'INSPECTION_CHECKLIST_FILE'`, + [id], + ); + } + + for (const r of rows) { + const objId = String(r.OBJID || ""); + if (!objId) continue; + await execute( + `INSERT INTO inspection_mgmt + (objid, parent_objid, unit_code, + internal_inspection_date, internal_inspection_result, internal_inspection_id, + regdate, writer) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7) + ON CONFLICT (objid) DO UPDATE SET + parent_objid = EXCLUDED.parent_objid, + unit_code = EXCLUDED.unit_code, + internal_inspection_date = EXCLUDED.internal_inspection_date, + internal_inspection_result = EXCLUDED.internal_inspection_result, + internal_inspection_id = EXCLUDED.internal_inspection_id`, + [ + objId, + parentObjid, + String(r.UNIT_CODE || ""), + String(r.INTERNAL_INSPECTION_DATE || ""), + String(r.INTERNAL_INSPECTION_RESULT || ""), + String(r.INTERNAL_INSPECTION_ID || ""), + user.userId, + ], + ); + } + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + console.error("inspection save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/production/inspection/detail/route.ts b/src/app/api/production/inspection/detail/route.ts new file mode 100644 index 0000000..3cfdf16 --- /dev/null +++ b/src/app/api/production/inspection/detail/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + const info = await queryOne( + `SELECT CM.objid::text AS "OBJID", CM.contract_no AS "PROJECT_NO", + CM.project_name AS "PROJECT_NAME", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = CM.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = CM.product LIMIT 1), '') AS "INSPECTION_TYPE_NAME", + CM.contract_del_date AS "INSPECTION_DATE", + CM.product AS "PRODUCT", CM.facility_qty AS "QTY", + COALESCE((SELECT user_name FROM user_info WHERE user_id = CM.pm_user_id LIMIT 1), '') AS "WRITER_NAME" + FROM contract_mgmt CM WHERE CM.objid::text = $1`, [objId] + ); + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return NextResponse.json({ success: true, data: info }); +} diff --git a/src/app/api/production/inspection/route.ts b/src/app/api/production/inspection/route.ts new file mode 100644 index 0000000..d3a9768 --- /dev/null +++ b/src/app/api/production/inspection/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 생산관리_검사관리 (inspectionGridList 원본 1:1 포팅) +// 주 테이블: project_mgmt +// INSPECTION_CNT = inspection_mgmt.parent_objid 카운트 +// ADMISSION_INSPECTION_CNT = attach_file_info(ADMISSION_INSPECTION_FILE) 카운트 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(T.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`T.project_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.customer_objid) { + conditions.push(`T.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.product) { + conditions.push(`T.product = $${idx++}`); + params.push(body.product); + } + if (body.pm_user_id) { + conditions.push(`T.pm_user_id = $${idx++}`); + params.push(body.pm_user_id); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT T.objid::text AS "OBJID", + T.project_no AS "PROJECT_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = T.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + T.project_name AS "PROJECT_NAME", + T.req_del_date AS "REQ_DEL_DATE", + T.setup AS "SETUP", + COALESCE((SELECT user_name FROM user_info WHERE user_id = T.pm_user_id LIMIT 1), '') AS "PM_USER_NAME", + COALESCE((SELECT COUNT(1) FROM inspection_mgmt WHERE parent_objid = T.objid::text), 0) AS "INSPECTION_CNT", + COALESCE((SELECT COUNT(1) FROM attach_file_info + WHERE target_objid = T.objid::text + AND doc_type = 'ADMISSION_INSPECTION_FILE' + AND UPPER(COALESCE(status,'')) = 'ACTIVE'), 0) AS "ADMISSION_INSPECTION_CNT" + FROM project_mgmt T + WHERE ${where} + ORDER BY SUBSTRING(T.project_no, POSITION('-' IN T.project_no)+1) DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/production/inspection/save/route.ts b/src/app/api/production/inspection/save/route.ts new file mode 100644 index 0000000..882a21c --- /dev/null +++ b/src/app/api/production/inspection/save/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 검사결과 저장 (assembly_wbs_task.receive_qty 업데이트) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const body = await request.json(); + const client = await pool.connect(); + try { + // parent_objid(contract)의 assembly_wbs_task들에 receive_qty 업데이트 + if (body.assembly_wbs_objid) { + await client.query( + `UPDATE assembly_wbs_task SET + receive_qty = $1, assembly_date = $2, assembly_user_id = $3 + WHERE objid = $4`, + [body.receive_qty || "", body.assembly_date || null, + body.assembly_user_id || user.userId, body.assembly_wbs_objid] + ); + } else if (body.parent_objid) { + // 프로젝트 단위 검사 완료 처리 — 신규 assembly_wbs_task 레코드 생성 + const objId = createObjectId(); + await client.query( + `INSERT INTO assembly_wbs_task (objid, parent_objid, receive_qty, + assembly_date, assembly_user_id, writer, reg_date) + VALUES ($1, $2, $3, $4, $5, $6, now())`, + [objId, body.parent_objid, body.receive_qty || "", + body.assembly_date || null, body.assembly_user_id || user.userId, + user.userId] + ); + } + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + console.error("Inspection save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { client.release(); } +} diff --git a/src/app/api/production/issue/route.ts b/src/app/api/production/issue/route.ts new file mode 100644 index 0000000..658a2fd --- /dev/null +++ b/src/app/api/production/issue/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 생산관리_이슈관리 (selectPlanningIssueList 원본 1:1 포팅) +// 주 테이블: planning_issue +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year || body.Year) { + conditions.push(`TO_CHAR(T.reg_date, 'YYYY') = $${idx++}`); + params.push(String(body.year || body.Year)); + } + if (body.project_no) { + conditions.push(`(SELECT project_no FROM project_mgmt WHERE objid = T.project_objid) LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.project_objid) { + conditions.push(`T.project_objid = $${idx++}`); + params.push(body.project_objid); + } + if (body.unit_code) { + conditions.push(`T.unit_code = $${idx++}`); + params.push(body.unit_code); + } + if (body.issue_category) { + conditions.push(`T.issue_category = $${idx++}`); + params.push(body.issue_category); + } + if (body.issue_type) { + conditions.push(`T.issue_type = $${idx++}`); + params.push(body.issue_type); + } + // status 파라미터 (현황에서 all/complete/late 호출 호환) + if (body.status) { + const s = String(body.status); + if (s === "all") conditions.push(`T.status <> 'write'`); + else if (s === "complete") conditions.push(`T.status = 'release' AND COALESCE(T.design_result,'') <> '' AND COALESCE(T.design_date,'') <> '' AND COALESCE(T.design_userid,'') <> ''`); + else if (s === "late") conditions.push(`T.status = 'release' AND (COALESCE(T.design_date,'') = '' OR COALESCE(T.design_userid,'') = '' OR COALESCE(T.design_result,'') = '')`); + else if (s === "write") conditions.push(`T.status = 'write'`); + else if (s === "release") conditions.push(`T.status = 'release'`); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT T.objid::text AS "OBJID", + T.issue_no AS "ISSUE_NO", + T.project_objid AS "PROJECT_OBJID", + (SELECT project_no FROM project_mgmt WHERE objid = T.project_objid) AS "PROJECT_NO", + T.unit_code AS "UNIT_CODE", + (SELECT COALESCE(O.unit_no,'') || '-' || COALESCE(O.task_name,'') FROM pms_wbs_task O WHERE O.objid = T.unit_code LIMIT 1) AS "UNIT_CODE_NAME", + T.part_objid AS "PART_OBJID", + (SELECT part_no FROM part_mng WHERE objid::varchar = T.part_objid LIMIT 1) AS "PART_NO", + (SELECT part_name FROM part_mng WHERE objid::varchar = T.part_objid LIMIT 1) AS "PART_NAME", + T.issue_category AS "ISSUE_CATEGORY", + (SELECT code_name FROM comm_code WHERE code_id = T.issue_category LIMIT 1) AS "ISSUE_CATEGORY_NAME", + T.issue_type AS "ISSUE_TYPE", + (SELECT code_name FROM comm_code WHERE code_id = T.issue_type LIMIT 1) AS "ISSUE_TYPE_NAME", + T.content AS "CONTENT", + T.design_userid AS "DESIGN_USERID", + (SELECT user_name FROM user_info WHERE user_id = T.design_userid LIMIT 1) AS "DESIGN_USERID_NAME", + T.design_result AS "DESIGN_RESULT", + (SELECT code_name FROM comm_code WHERE code_id = T.design_result LIMIT 1) AS "DESIGN_RESULT_NAME", + T.design_date AS "DESIGN_DATE", + T.purchase_userid AS "PURCHASE_USERID", + (SELECT user_name FROM user_info WHERE user_id = T.purchase_userid LIMIT 1) AS "PURCHASE_USERID_NAME", + T.purchase_result AS "PURCHASE_RESULT", + T.purchase_date AS "PURCHASE_DATE", + T.quality_userid AS "QUALITY_USERID", + (SELECT user_name FROM user_info WHERE user_id = T.quality_userid LIMIT 1) AS "QUALITY_USERID_NAME", + T.quality_result AS "QUALITY_RESULT", + T.quality_date AS "QUALITY_DATE", + T.production_userid AS "PRODUCTION_USERID", + (SELECT user_name FROM user_info WHERE user_id = T.production_userid LIMIT 1) AS "PRODUCTION_USERID_NAME", + T.production_result AS "PRODUCTION_RESULT", + T.production_date AS "PRODUCTION_DATE", + T.reg_date AS "REG_DATE", + TO_CHAR(T.reg_date, 'YYYY-MM-DD') AS "REG_DATE_TEXT", + T.writer AS "WRITER", + (SELECT user_name FROM user_info WHERE user_id = T.writer LIMIT 1) AS "WRITER_NAME", + T.status AS "STATUS" + FROM planning_issue T + WHERE ${where} + ORDER BY T.issue_no DESC + `; + + const rows = await queryRows(sql, params); + + // STATUS_NAME JS 계산 (원본 CASE 로직 이식) + const now = new Date(); + const result = rows.map((r: Record) => { + const s = String(r.STATUS || ""); + const du = String(r.DESIGN_USERID || ""); + const dd = String(r.DESIGN_DATE || ""); + const pu = String(r.PURCHASE_USERID || ""); + const pd = String(r.PURCHASE_DATE || ""); + const qu = String(r.QUALITY_USERID || ""); + const qd = String(r.QUALITY_DATE || ""); + const ru = String(r.PRODUCTION_USERID || ""); + const rd = String(r.PRODUCTION_DATE || ""); + + const pair = (u: string, d: string) => (u !== "" && d !== "") || (u === "" && d === ""); + const anyUser = du !== "" || pu !== "" || qu !== "" || ru !== ""; + const anyTrace = anyUser || dd !== "" || pd !== "" || qd !== "" || rd !== ""; + + let statusName: string; + if (s === "write") statusName = "등록"; + else if (s === "release" && pair(du, dd) && pair(pu, pd) && pair(qu, qd) && pair(ru, rd) && anyUser) { + statusName = "조치완료"; + } else if (s === "release" && anyTrace) { + statusName = "조치중"; + } else if (s === "release") { + const regDate = r.REG_DATE instanceof Date ? r.REG_DATE : (r.REG_DATE ? new Date(String(r.REG_DATE)) : null); + if (regDate) { + const limit = new Date(regDate); + limit.setDate(limit.getDate() + 5); + statusName = now > limit && dd === "" ? "미조치" : "배포"; + } else statusName = "배포"; + } else statusName = s; + + return { ...r, STATUS_NAME: statusName }; + }); + + return NextResponse.json({ RESULTLIST: result, TOTAL_CNT: result.length }); +} diff --git a/src/app/api/production/issue/save/route.ts b/src/app/api/production/issue/save/route.ts new file mode 100644 index 0000000..d27df73 --- /dev/null +++ b/src/app/api/production/issue/save/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 이슈 저장/삭제/배포 (mergeissueInfo + planningDelete + planningRelease 대응) +// actionType: "regist" | "update" | "delete" | "release" +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const body = await request.json(); + const action = String(body.actionType || ""); + + // 일괄 삭제 / 배포 + if (action === "delete" || action === "release") { + const objIds: string[] = Array.isArray(body.objIds) ? body.objIds.map(String).filter(Boolean) : []; + if (objIds.length === 0) { + return NextResponse.json({ success: false, message: "선택된 대상이 없습니다." }, { status: 400 }); + } + try { + const placeholders = objIds.map((_, i) => `$${i + 1}`).join(","); + if (action === "delete") { + await execute( + `DELETE FROM planning_issue WHERE objid IN (${placeholders}) AND status = 'write'`, + objIds, + ); + return NextResponse.json({ success: true, message: "삭제되었습니다." }); + } + await execute( + `UPDATE planning_issue SET status = 'release' WHERE objid IN (${placeholders}) AND status = 'write'`, + objIds, + ); + return NextResponse.json({ success: true, message: "배포되었습니다." }); + } catch (e) { + console.error("issue bulk action:", e); + return NextResponse.json({ success: false, message: "처리 중 오류가 발생했습니다." }, { status: 500 }); + } + } + + // 단건 저장 (등록/수정) + const isNew = !body.objId || action === "regist"; + const objId = isNew ? createObjectId() : String(body.objId); + + try { + // 신규 시 이슈번호 자동 생성 + let issueNo = body.issue_no || ""; + if (isNew && !issueNo) { + const year = new Date().getFullYear().toString().slice(2); + const r = await queryOne<{ seq: number }>( + `SELECT COALESCE(MAX(SUBSTR(issue_no, 7)::numeric), 0)::int + 1 AS seq + FROM planning_issue WHERE issue_no LIKE $1`, + [`ISU${year}-%`], + ); + issueNo = `ISU${year}-${String(r?.seq || 1).padStart(4, "0")}`; + } + + await execute( + `INSERT INTO planning_issue ( + objid, issue_no, project_objid, unit_code, part_objid, + issue_category, issue_type, content, + design_userid, design_result, design_date, + purchase_userid, purchase_result, purchase_date, + quality_userid, quality_result, quality_date, + production_userid, production_result, production_date, + status, writer, reg_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, NOW()) + ON CONFLICT (objid) DO UPDATE SET + project_objid = EXCLUDED.project_objid, + unit_code = EXCLUDED.unit_code, + part_objid = EXCLUDED.part_objid, + issue_category = EXCLUDED.issue_category, + issue_type = EXCLUDED.issue_type, + content = EXCLUDED.content, + design_userid = EXCLUDED.design_userid, + design_result = EXCLUDED.design_result, + design_date = EXCLUDED.design_date, + purchase_userid = EXCLUDED.purchase_userid, + purchase_result = EXCLUDED.purchase_result, + purchase_date = EXCLUDED.purchase_date, + quality_userid = EXCLUDED.quality_userid, + quality_result = EXCLUDED.quality_result, + quality_date = EXCLUDED.quality_date, + production_userid = EXCLUDED.production_userid, + production_result = EXCLUDED.production_result, + production_date = EXCLUDED.production_date, + status = EXCLUDED.status`, + [ + objId, issueNo, + body.project_objid || "", + body.unit_code || "", + body.part_objid || "", + body.issue_category || "", + body.issue_type || "", + body.content || "", + body.design_userid || "", body.design_result || "", body.design_date || "", + body.purchase_userid || "", body.purchase_result || "", body.purchase_date || "", + body.quality_userid || "", body.quality_result || "", body.quality_date || "", + body.production_userid || "", body.production_result || "", body.production_date || "", + body.status || "write", + user.userId, + ], + ); + return NextResponse.json({ success: true, objId, issueNo, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (e) { + console.error("Issue save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/production/planning/route.ts b/src/app/api/production/planning/route.ts new file mode 100644 index 0000000..584c9ab --- /dev/null +++ b/src/app/api/production/planning/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 생산관리_생산계획수립 (planningGridList 원본 1:1 포팅) +// 주 테이블: project_mgmt +// WBS_CNT: pms_wbs_task에서 produce_plan/act 또는 produce_user_id 하나라도 채워진 것 +// SETUP_WBS_CNT: setup_wbs_task에서 setup_plan/act 하나라도 채워진 것 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(T.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`T.project_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.customer_objid) { + conditions.push(`T.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.product) { + conditions.push(`T.product = $${idx++}`); + params.push(body.product); + } + if (body.pm_user_id) { + conditions.push(`T.pm_user_id = $${idx++}`); + params.push(body.pm_user_id); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT T.objid::text AS "OBJID", + T.project_no AS "PROJECT_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = T.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + T.project_name AS "PROJECT_NAME", + T.req_del_date AS "REQ_DEL_DATE", + T.setup AS "SETUP", + COALESCE((SELECT user_name FROM user_info WHERE user_id = T.pm_user_id LIMIT 1), '') AS "PM_USER_NAME", + -- 조립 WBS (pms_wbs_task) + COALESCE((SELECT COUNT(1) FROM pms_wbs_task + WHERE contract_objid = T.objid::text + AND (COALESCE(produce_plan_start,'') <> '' OR COALESCE(produce_plan_end,'') <> '' + OR COALESCE(produce_act_start,'') <> '' OR COALESCE(produce_act_end,'') <> '' + OR COALESCE(produce_user_id,'') <> '') + ), 0) AS "WBS_CNT", + COALESCE((SELECT MIN(produce_plan_start) FROM pms_wbs_task + WHERE contract_objid = T.objid::text AND COALESCE(produce_plan_start,'') <> ''), '') AS "PRODUCE_PLAN_START", + COALESCE((SELECT MAX(produce_plan_end) FROM pms_wbs_task + WHERE contract_objid = T.objid::text AND COALESCE(produce_plan_end,'') <> ''), '') AS "PRODUCE_PLAN_END", + COALESCE((SELECT MIN(produce_act_start) FROM pms_wbs_task + WHERE contract_objid = T.objid::text AND COALESCE(produce_act_start,'') <> ''), '') AS "PRODUCE_ACT_START", + COALESCE((SELECT MAX(produce_act_end) FROM pms_wbs_task + WHERE contract_objid = T.objid::text AND COALESCE(produce_act_end,'') <> ''), '') AS "PRODUCE_ACT_END", + -- 셋업 WBS (setup_wbs_task) + COALESCE((SELECT COUNT(1) FROM setup_wbs_task + WHERE contract_objid = T.objid::text + AND (COALESCE(setup_plan_start,'') <> '' OR COALESCE(setup_plan_end,'') <> '' + OR COALESCE(setup_act_start,'') <> '' OR COALESCE(setup_act_end,'') <> '') + ), 0) AS "SETUP_WBS_CNT", + COALESCE((SELECT MIN(setup_plan_start) FROM setup_wbs_task + WHERE contract_objid = T.objid::text AND COALESCE(setup_plan_start,'') <> ''), '') AS "SETUP_PLAN_START", + COALESCE((SELECT MAX(setup_plan_end) FROM setup_wbs_task + WHERE contract_objid = T.objid::text AND COALESCE(setup_plan_end,'') <> ''), '') AS "SETUP_PLAN_END", + COALESCE((SELECT MIN(setup_act_start) FROM setup_wbs_task + WHERE contract_objid = T.objid::text AND COALESCE(setup_act_start,'') <> ''), '') AS "SETUP_ACT_START", + COALESCE((SELECT MAX(setup_act_end) FROM setup_wbs_task + WHERE contract_objid = T.objid::text AND COALESCE(setup_act_end,'') <> ''), '') AS "SETUP_ACT_END" + FROM project_mgmt T + WHERE ${where} + ORDER BY SUBSTRING(T.project_no, POSITION('-' IN T.project_no)+1) DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/production/process/route.ts b/src/app/api/production/process/route.ts new file mode 100644 index 0000000..10c3299 --- /dev/null +++ b/src/app/api/production/process/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 생산관리_공정실적관리 (processperformanceGridList 원본 1:1 포팅) +// 주 테이블: part_bom_report (STATUS='deploy') +// 조립 BOM_CNT, 조립품수 ASSING_CNT, 공정율 AS_RATE, 작업공수 WORK_DIARY 기반 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["T.status = 'deploy'"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(T.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`(SELECT project_no FROM project_mgmt WHERE objid = T.contract_objid) LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.customer_objid) { + conditions.push(`T.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.unit_code) { + conditions.push(`T.unit_code = $${idx++}`); + params.push(body.unit_code); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT T.objid::text AS "OBJID", + T.contract_objid AS "CONTRACT_OBJID", + T.customer_objid AS "CUSTOMER_OBJID", + (SELECT supply_name FROM supply_mng WHERE objid::varchar = T.customer_objid LIMIT 1) AS "CUSTOMER_NAME", + (SELECT project_no FROM project_mgmt WHERE objid = T.contract_objid) AS "PROJECT_NO", + (SELECT project_name FROM project_mgmt WHERE objid = T.contract_objid) AS "PROJECT_NAME", + (SELECT due_date FROM project_mgmt WHERE objid = T.contract_objid) AS "DUE_DATE", + (SELECT setup FROM project_mgmt WHERE objid = T.contract_objid) AS "SETUP", + (SELECT user_name FROM user_info WHERE user_id = (SELECT pm_user_id FROM project_mgmt WHERE objid = T.contract_objid) LIMIT 1) AS "PM_USER_NAME", + T.unit_code AS "UNIT_CODE", + (SELECT COALESCE(O.unit_no,'') || '-' || COALESCE(O.task_name,'') FROM pms_wbs_task O WHERE O.objid = T.unit_code LIMIT 1) AS "UNIT_NAME", + -- 조립총수 (BOM_CNT) + (SELECT COUNT(*) FROM bom_part_qty BP + LEFT JOIN part_mng PM ON COALESCE(NULLIF(BP.last_part_objid,''), BP.part_no) = PM.objid::varchar + WHERE BP.bom_report_objid = T.objid + AND PM.part_type IS NOT NULL AND PM.part_type <> '' + AND BP.status IN ('beforeEdit','editing','deleting','deploy') + ) AS "BOM_CNT", + -- 조립품수 (ASSING_CNT) + (SELECT COUNT(1) FROM assembly_wbs_task A + INNER JOIN bom_part_qty BP + ON A.parent_objid = BP.bom_report_objid + AND A.part_objid = COALESCE(NULLIF(BP.last_part_objid,''), BP.part_no) + AND A.bom_qty_child_objid = BP.child_objid + AND BP.status IN ('beforeEdit','editing','deleting','deploy') + WHERE A.parent_objid = T.objid + AND A.assembly_user_id IS NOT NULL AND A.assembly_user_id <> '' + AND A.assembly_date IS NOT NULL AND A.assembly_date <> '' + ) AS "ASSING_CNT", + -- 공정율 (AS_RATE) + CASE WHEN (SELECT COUNT(*) FROM bom_part_qty BP + LEFT JOIN part_mng PM ON COALESCE(NULLIF(BP.last_part_objid,''), BP.part_no) = PM.objid::varchar + WHERE BP.bom_report_objid = T.objid + AND PM.part_type IS NOT NULL AND PM.part_type <> '' + AND BP.status IN ('beforeEdit','editing','deleting','deploy')) = 0 THEN 0 + ELSE ROUND( + ((SELECT COUNT(1) FROM assembly_wbs_task A + INNER JOIN bom_part_qty BP + ON A.parent_objid = BP.bom_report_objid + AND A.part_objid = COALESCE(NULLIF(BP.last_part_objid,''), BP.part_no) + AND A.bom_qty_child_objid = BP.child_objid + AND BP.status IN ('beforeEdit','editing','deleting','deploy') + WHERE A.parent_objid = T.objid + AND A.assembly_user_id IS NOT NULL AND A.assembly_user_id <> '' + AND A.assembly_date IS NOT NULL AND A.assembly_date <> '' + )::numeric + / (SELECT COUNT(*) FROM bom_part_qty BP + LEFT JOIN part_mng PM ON COALESCE(NULLIF(BP.last_part_objid,''), BP.part_no) = PM.objid::varchar + WHERE BP.bom_report_objid = T.objid + AND PM.part_type IS NOT NULL AND PM.part_type <> '' + AND BP.status IN ('beforeEdit','editing','deleting','deploy')) + * 100), 1) + END AS "AS_RATE", + -- 작업공수 + COALESCE(P.work_hour, 0) AS "INSOURCING_SUM", + COALESCE(O.work_hour, 0) AS "OUTSOURCING_SUM", + (COALESCE(P.work_hour, 0) + COALESCE(O.work_hour, 0)) AS "TOTAL_SUM" + FROM part_bom_report T + LEFT JOIN LATERAL ( + SELECT SUM(COALESCE(NULLIF(wd.work_hour,''),'0')::numeric) AS work_hour + FROM work_diary wd + JOIN user_info ui ON ui.user_id = wd.worker_id + WHERE wd.contract_objid = T.contract_objid + AND wd.unit_code = T.unit_code + AND wd.status = 'complete' + AND wd.production_type = 'assemble' + AND ui.dept_code IN ('DPT005','DPT023','DPT013') + ) P ON TRUE + LEFT JOIN LATERAL ( + SELECT SUM(COALESCE(NULLIF(wd.work_hour,''),'0')::numeric) AS work_hour + FROM work_diary wd + WHERE wd.contract_objid = T.contract_objid + AND wd.unit_code = T.unit_code + AND wd.status = 'complete' + AND wd.production_type = 'assemble' + AND wd.sourcing_type = 'outsourcing' + ) O ON TRUE + WHERE ${where} + ORDER BY (SELECT SUBSTRING(project_no, POSITION('-' IN project_no)+1) FROM project_mgmt WHERE objid = T.contract_objid) DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/production/receive-history/route.ts b/src/app/api/production/receive-history/route.ts new file mode 100644 index 0000000..10844a9 --- /dev/null +++ b/src/app/api/production/receive-history/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 인수이력 조회 (inventory_mgmt_out 기준) +// body: { partObjid, contractObjid, unitCode } +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const partObjid = String(body.partObjid || ""); + const contractObjid = String(body.contractObjid || ""); + const unitCode = String(body.unitCode || ""); + + if (!partObjid || !contractObjid) { + return NextResponse.json({ RESULTLIST: [], HEADER: {} }); + } + + // 헤더: 파트 기본 정보 + const header = await queryOne( + `SELECT objid::text AS "OBJID", + part_no AS "PART_NO", + COALESCE(part_name,'') AS "PART_NAME", + COALESCE(spec,'') AS "SPEC" + FROM part_mng + WHERE objid::varchar = $1`, + [partObjid], + ); + + // 인수이력: IMO × IM, part+contract+unit 기준 + const params: unknown[] = [partObjid, contractObjid]; + let unitClause = ""; + if (unitCode) { + params.push(unitCode); + unitClause = `AND IMO.unit = $3`; + } + const rows = await queryRows( + `SELECT IMO.objid::text AS "OBJID", + IMO.out_date AS "OUT_DATE", + IMO.out_qty AS "OUT_QTY", + IMO.unit AS "UNIT", + (SELECT COALESCE(O.unit_no,'') || '-' || COALESCE(O.task_name,'') FROM pms_wbs_task O WHERE O.objid = IMO.unit LIMIT 1) AS "UNIT_NAME", + IMO.acq_user AS "ACQ_USER", + COALESCE((SELECT user_name FROM user_info WHERE user_id = IMO.acq_user LIMIT 1), IMO.acq_user) AS "ACQ_USER_NAME", + IMO.writer AS "WRITER", + COALESCE((SELECT user_name FROM user_info WHERE user_id = IMO.writer LIMIT 1), IMO.writer) AS "WRITER_NAME", + TO_CHAR(IMO.regdate, 'YYYY-MM-DD HH24:MI') AS "REG_DATE" + FROM inventory_mgmt_out IMO + JOIN inventory_mgmt IM ON IM.objid = IMO.parent_objid + WHERE IMO.contract_mgmt_objid = $2 + AND IM.part_objid = $1 + ${unitClause} + ORDER BY IMO.out_date DESC, IMO.regdate DESC`, + params, + ); + + return NextResponse.json({ RESULTLIST: rows, HEADER: header || {} }); +} diff --git a/src/app/api/production/release/detail/route.ts b/src/app/api/production/release/detail/route.ts new file mode 100644 index 0000000..63778a6 --- /dev/null +++ b/src/app/api/production/release/detail/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 출고관리 상세 (release_mgmt.objid 기준) +// body: { objId } — release_mgmt.objid +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + const info = await queryOne( + `SELECT RM.objid::text AS "OBJID", + RM.parent_objid AS "PARENT_OBJID", + RM.release_date AS "RELEASE_DATE", + RM.release_car_no AS "RELEASE_CAR_NO", + RM.writer AS "WRITER", + RM.task_over_user_id AS "TASK_OVER_USER_ID", + RM.task_over_date AS "TASK_OVER_DATE", + RM.task_over_comment AS "TASK_OVER_COMMENT", + RM.install_complete_date AS "INSTALL_COMPLETE_DATE", + RM.install_result AS "INSTALL_RESULT", + RM.product AS "PRODUCT", + PM.project_no AS "PROJECT_NO", + PM.project_name AS "PROJECT_NAME", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = PM.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME" + FROM release_mgmt RM + LEFT JOIN project_mgmt PM ON PM.objid = RM.parent_objid + WHERE RM.objid = $1`, [objId] + ); + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return NextResponse.json({ success: true, data: info }); +} diff --git a/src/app/api/production/release/route.ts b/src/app/api/production/release/route.ts new file mode 100644 index 0000000..2d37bae --- /dev/null +++ b/src/app/api/production/release/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 생산관리_출고관리 (releaseMgmtGridList 원본 1:1 포팅) +// 주 테이블: project_mgmt +// release_mgmt.parent_objid = project_mgmt.objid +// 검사결과(체크리스트) = inspection_mgmt, 입회검사 = attach_file_info(ADMISSION_INSPECTION_FILE) +// 출하지시서 = attach_file_info(RELEASE_ORDER, target_objid = RM.objid) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(T.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`T.project_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.customer_objid) { + conditions.push(`T.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.product) { + conditions.push(`T.product = $${idx++}`); + params.push(body.product); + } + if (body.pm_user_id) { + conditions.push(`T.pm_user_id = $${idx++}`); + params.push(body.pm_user_id); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT T.objid::text AS "OBJID", + T.project_no AS "PROJECT_NO", + T.product AS "PRODUCT", + RM.product_group AS "PRODUCT_GROUP", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = T.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + T.project_name AS "PROJECT_NAME", + T.req_del_date AS "REQ_DEL_DATE", + T.setup AS "SETUP", + COALESCE((SELECT user_name FROM user_info WHERE user_id = T.pm_user_id LIMIT 1), '') AS "PM_USER_NAME", + RM.objid AS "RELEASE_OBJID", + RM.release_date AS "RELEASE_DATE", + COALESCE((SELECT user_name FROM user_info WHERE user_id = RM.writer LIMIT 1), '') AS "RELEASE_WRITER", + -- 검사결과 체크리스트 = inspection_mgmt 건수 + COALESCE((SELECT COUNT(1) FROM inspection_mgmt WHERE parent_objid = T.objid::text), 0) AS "INSPECTION_CNT", + -- 입회검사 = ADMISSION_INSPECTION_FILE 파일 개수 + COALESCE((SELECT COUNT(1) FROM attach_file_info + WHERE target_objid = T.objid::text + AND doc_type = 'ADMISSION_INSPECTION_FILE' + AND UPPER(COALESCE(status,'')) = 'ACTIVE'), 0) AS "ADMISSION_INSPECTION_CNT", + -- 출하지시서 = RELEASE_ORDER 파일 개수 (release_mgmt.objid 기준) + COALESCE((SELECT COUNT(1) FROM attach_file_info + WHERE target_objid::varchar = RM.objid + AND doc_type = 'RELEASE_ORDER' + AND UPPER(COALESCE(status,'')) = 'ACTIVE'), 0) AS "RELEASE_ORDER_CNT", + T.due_date AS "DUE_DATE" + FROM project_mgmt T + LEFT OUTER JOIN release_mgmt RM + ON T.objid::varchar = RM.parent_objid + AND T.product = RM.product + WHERE ${where} + ORDER BY SUBSTRING(T.project_no, POSITION('-' IN T.project_no)+1) DESC + `; + + const rows = await queryRows(sql, params); + const todayStr = new Date().toISOString().slice(0, 10); + const result = rows.map((r: Record) => { + const releaseDate = String(r.RELEASE_DATE || "").trim(); + const dueDate = String(r.DUE_DATE || "").trim(); + let status = "계약"; + if (releaseDate !== "") status = "출고완료"; + else if (/^\d{4}-\d{2}-\d{2}$/.test(dueDate) && todayStr >= dueDate) status = "지연"; + return { ...r, RELEASE_STATUS_TITLE: status }; + }); + return NextResponse.json({ RESULTLIST: result, TOTAL_CNT: result.length }); +} diff --git a/src/app/api/production/release/save/route.ts b/src/app/api/production/release/save/route.ts new file mode 100644 index 0000000..cf6d36d --- /dev/null +++ b/src/app/api/production/release/save/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 출고결과 저장 (release_mgmt UPSERT) +// body: { actionType, release_objid?, parent_objid, product, release_date, writer, +// release_car_no?, task_over_user_id?, task_over_date?, task_over_comment?, +// install_complete_date?, install_result? } +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const isNew = !body.release_objid || body.actionType === "regist"; + const objId = isNew ? createObjectId() : String(body.release_objid); + const writer = String(body.writer || user.userId); + + try { + await execute( + `INSERT INTO release_mgmt ( + objid, parent_objid, release_car_no, release_date, + task_over_user_id, task_over_date, task_over_comment, + status, regdate, writer, product, + install_complete_date, install_result + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, $10, $11, $12) + ON CONFLICT (objid) DO UPDATE SET + parent_objid = EXCLUDED.parent_objid, + release_car_no = EXCLUDED.release_car_no, + release_date = EXCLUDED.release_date, + task_over_user_id = EXCLUDED.task_over_user_id, + task_over_date = EXCLUDED.task_over_date, + task_over_comment = EXCLUDED.task_over_comment, + status = EXCLUDED.status, + writer = EXCLUDED.writer, + product = EXCLUDED.product, + install_complete_date = EXCLUDED.install_complete_date, + install_result = EXCLUDED.install_result`, + [ + objId, + body.parent_objid || "", + body.release_car_no || "", + body.release_date || "", + body.task_over_user_id || "", + body.task_over_date || "", + body.task_over_comment || "", + body.status || "active", + writer, + body.product || "", + body.install_complete_date || "", + body.install_result || "", + ], + ); + return NextResponse.json({ success: true, objId, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (e) { + console.error("Release save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/production/route.ts b/src/app/api/production/route.ts new file mode 100644 index 0000000..11f3ddb --- /dev/null +++ b/src/app/api/production/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 생산관리 메인 목록 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["CM.contract_result = '0000964'"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(TO_DATE(CM.contract_date, 'YYYY-MM-DD'), 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`CM.contract_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + + const where = conditions.join(" AND "); + + const rows = await queryRows( + `SELECT CM.objid::text AS "OBJID", + CM.contract_no AS "PROJECT_NO", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = CM.product LIMIT 1), '') AS "PRODUCT_NAME", + CM.project_name AS "UNIT_NAME", + CM.facility_qty AS "PROD_QTY", + 0 AS "COMPLETE_QTY", + 0 AS "PROGRESS_RATE", + CM.contract_del_date AS "START_DATE", + CM.req_del_date AS "END_DATE", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = CM.contract_result LIMIT 1), '') AS "STATUS_NAME" + FROM contract_mgmt CM + WHERE ${where} + ORDER BY CM.regdate DESC +`, + params + ); + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/production/setup/route.ts b/src/app/api/production/setup/route.ts new file mode 100644 index 0000000..7753424 --- /dev/null +++ b/src/app/api/production/setup/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 생산관리_셋업관리 (selectSetupMgmtList 원본 1:1 포팅) +// 주 테이블: project_mgmt +// TASK_CNT = setup_wbs_task WHERE parent_objid 있는 것만 +// COMPLETE_CNT = TASK_CNT 중 setup_act_end 있는 것 +// SETUP_RATE = COMPLETE_CNT / TASK_CNT × 100 +// EMPLOYEES_IN/OUT/TOTAL = setup_wbs_task.employees_in/out 합계 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(T.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`T.project_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.customer_objid) { + conditions.push(`T.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.product) { + conditions.push(`T.product = $${idx++}`); + params.push(body.product); + } + if (body.pm_user_id) { + conditions.push(`T.pm_user_id = $${idx++}`); + params.push(body.pm_user_id); + } + + const where = conditions.join(" AND "); + + const sql = ` + WITH + setup_cnt AS ( + SELECT contract_objid, + COUNT(1) FILTER (WHERE COALESCE(parent_objid,'') <> '') AS task_cnt, + COUNT(1) FILTER (WHERE COALESCE(parent_objid,'') <> '' AND COALESCE(setup_act_end,'') <> '') AS complete_cnt, + SUM(COALESCE(NULLIF(employees_in,'')::integer, 0)) FILTER (WHERE COALESCE(parent_objid,'') <> '') AS emp_in, + SUM(COALESCE(NULLIF(employees_out,'')::integer, 0)) FILTER (WHERE COALESCE(parent_objid,'') <> '') AS emp_out + FROM setup_wbs_task + GROUP BY contract_objid + ) + SELECT T.objid::text AS "OBJID", + T.project_no AS "PROJECT_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = T.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + T.project_name AS "PROJECT_NAME", + T.req_del_date AS "REQ_DEL_DATE", + T.setup AS "SETUP", + COALESCE((SELECT user_name FROM user_info WHERE user_id = T.pm_user_id LIMIT 1), '') AS "PM_USER_NAME", + COALESCE(SC.task_cnt, 0) AS "TASK_CNT", + COALESCE(SC.complete_cnt, 0) AS "COMPLETE_CNT", + CASE WHEN COALESCE(SC.task_cnt, 0) = 0 THEN 0 + ELSE ROUND((SC.complete_cnt::numeric / SC.task_cnt) * 100, 1) + END AS "SETUP_RATE", + COALESCE(SC.emp_in, 0) AS "EMPLOYEES_IN", + COALESCE(SC.emp_out, 0) AS "EMPLOYEES_OUT", + (COALESCE(SC.emp_in, 0) + COALESCE(SC.emp_out, 0)) AS "EMPLOYEES_TOTAL" + FROM project_mgmt T + LEFT JOIN setup_cnt SC ON SC.contract_objid = T.objid::text + WHERE ${where} + ORDER BY SUBSTRING(T.project_no, POSITION('-' IN T.project_no)+1) DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/production/status/route.ts b/src/app/api/production/status/route.ts new file mode 100644 index 0000000..d901f95 --- /dev/null +++ b/src/app/api/production/status/route.ts @@ -0,0 +1,171 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 생산관리 현황 (planningdashboardGridList 원본 1:1 포팅) +// 주 테이블: project_mgmt +// 이슈: planning_issue (status='release', design_result/design_date 완료 여부) +// 셋업: setup_wbs_task (parent_objid 있는 것만) +// 조립: part_bom_report + bom_part_qty + assembly_wbs_task (BOM 기반 완료율) +// 투입공수: work_diary (production_type='assemble'/'setup', 생산/외주 분리) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(T.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`T.project_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.customer_objid) { + conditions.push(`T.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.product) { + conditions.push(`T.product = $${idx++}`); + params.push(body.product); + } + if (body.pm_user_id) { + conditions.push(`T.pm_user_id = $${idx++}`); + params.push(body.pm_user_id); + } + + const where = conditions.join(" AND "); + + const sql = ` + WITH + assembly_rate AS ( + SELECT pbr.contract_objid, + SUM( + CASE WHEN bom_cnt.denom = 0 THEN 0 + ELSE ROUND((awt_cnt.numer::numeric / bom_cnt.denom) * 100, 1) + END + ) AS as_rate_sum + FROM part_bom_report pbr + LEFT JOIN LATERAL ( + SELECT COUNT(1) AS numer + FROM assembly_wbs_task a + WHERE a.parent_objid = pbr.objid + AND a.assembly_user_id IS NOT NULL AND a.assembly_user_id <> '' + AND a.assembly_date IS NOT NULL AND a.assembly_date <> '' + ) awt_cnt ON TRUE + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS denom + FROM bom_part_qty bp + LEFT JOIN part_mng pm ON bp.part_no = pm.objid::varchar + WHERE bp.bom_report_objid = pbr.objid + AND pm.part_type IS NOT NULL AND pm.part_type <> '' + ) bom_cnt ON TRUE + GROUP BY pbr.contract_objid + ), + assembly_end AS ( + SELECT pbr.contract_objid, + MAX(a.assembly_date) AS assembly_date_end + FROM assembly_wbs_task a + LEFT JOIN part_bom_report pbr ON a.parent_objid = pbr.objid + WHERE pbr.contract_objid IS NOT NULL + GROUP BY pbr.contract_objid + ), + pms_cnt AS ( + SELECT contract_objid, COUNT(1) AS cnt FROM pms_wbs_task GROUP BY contract_objid + ), + wd_prod_assemble AS ( + SELECT wd.contract_objid, SUM(COALESCE(NULLIF(wd.work_hour,''),'0')::numeric) AS hour + FROM work_diary wd + JOIN user_info ui ON ui.user_id = wd.worker_id + WHERE wd.contract_objid IS NOT NULL AND wd.contract_objid <> '' + AND wd.status = 'complete' AND wd.production_type = 'assemble' + AND ui.dept_code IN ('DPT005','DPT023','DPT013') + GROUP BY wd.contract_objid + ), + wd_out_assemble AS ( + SELECT contract_objid, SUM(COALESCE(NULLIF(work_hour,''),'0')::numeric) AS hour + FROM work_diary + WHERE contract_objid IS NOT NULL AND contract_objid <> '' + AND status = 'complete' AND sourcing_type = 'outsourcing' AND production_type = 'assemble' + GROUP BY contract_objid + ), + wd_prod_setup AS ( + SELECT wd.contract_objid, SUM(COALESCE(NULLIF(wd.work_hour,''),'0')::numeric) AS hour + FROM work_diary wd + JOIN user_info ui ON ui.user_id = wd.worker_id + WHERE wd.contract_objid IS NOT NULL AND wd.contract_objid <> '' + AND wd.status = 'complete' AND wd.production_type = 'setup' + AND ui.dept_code IN ('DPT005','DPT023','DPT013') + GROUP BY wd.contract_objid + ), + wd_out_setup AS ( + SELECT contract_objid, SUM(COALESCE(NULLIF(work_hour,''),'0')::numeric) AS hour + FROM work_diary + WHERE contract_objid IS NOT NULL AND contract_objid <> '' + AND status = 'complete' AND sourcing_type = 'outsourcing' AND production_type = 'setup' + GROUP BY contract_objid + ), + issue_agg AS ( + SELECT project_objid, + COUNT(1) AS total, + COUNT(1) FILTER (WHERE COALESCE(design_result,'') <> '' AND COALESCE(design_date,'') <> '') AS comp + FROM planning_issue + WHERE status = 'release' + GROUP BY project_objid + ), + setup_agg AS ( + SELECT contract_objid, + COUNT(1) AS total, + COUNT(1) FILTER (WHERE COALESCE(setup_act_end,'') <> '') AS comp, + MAX(setup_act_end) AS act_end + FROM setup_wbs_task + WHERE COALESCE(parent_objid,'') <> '' + GROUP BY contract_objid + ) + SELECT T.objid::text AS "OBJID", + T.project_no AS "PROJECT_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = T.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + T.project_name AS "PROJECT_NAME", + T.req_del_date AS "REQ_DEL_DATE", + T.setup AS "SETUP", + COALESCE((SELECT user_name FROM user_info WHERE user_id = T.pm_user_id LIMIT 1), '') AS "PM_USER_NAME", + -- 조립 + CASE WHEN COALESCE(PC.cnt, 0) = 0 THEN 0 + ELSE ROUND(COALESCE(AR.as_rate_sum, 0) / PC.cnt, 1) + END AS "ASSEMBLY_RATE", + COALESCE(AE.assembly_date_end, '') AS "ASSEMBLY_DATE_END", + (COALESCE(WPA.hour, 0) + COALESCE(WOA.hour, 0)) AS "ASSEMBLY_EMPLOYEES_TOTAL", + -- 셋업 + CASE WHEN COALESCE(SA.total, 0) = 0 THEN 0 + ELSE ROUND((SA.comp::numeric / SA.total) * 100, 1) + END AS "SETUP_RATE", + COALESCE(SA.act_end, '') AS "SETUP_ACT_END", + (COALESCE(WPS.hour, 0) + COALESCE(WOS.hour, 0)) AS "EMPLOYEES_TOTAL", + -- 이슈 + COALESCE(IA.total, 0) AS "ISSUE_CNT", + COALESCE(IA.comp, 0) AS "COMP_CNT", + (COALESCE(IA.total, 0) - COALESCE(IA.comp, 0)) AS "MISS_CNT", + CASE WHEN COALESCE(IA.total, 0) = 0 THEN 0 + ELSE ROUND((IA.comp::numeric / IA.total) * 100, 1) + END AS "ISSUE_RATE" + FROM project_mgmt T + LEFT JOIN assembly_rate AR ON AR.contract_objid = T.objid::text + LEFT JOIN assembly_end AE ON AE.contract_objid = T.objid::text + LEFT JOIN pms_cnt PC ON PC.contract_objid = T.objid::text + LEFT JOIN wd_prod_assemble WPA ON WPA.contract_objid = T.objid::text + LEFT JOIN wd_out_assemble WOA ON WOA.contract_objid = T.objid::text + LEFT JOIN wd_prod_setup WPS ON WPS.contract_objid = T.objid::text + LEFT JOIN wd_out_setup WOS ON WOS.contract_objid = T.objid::text + LEFT JOIN issue_agg IA ON IA.project_objid = T.objid::text + LEFT JOIN setup_agg SA ON SA.contract_objid = T.objid::text + WHERE ${where} + ORDER BY SUBSTRING(T.project_no, POSITION('-' IN T.project_no)+1) DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/project/invest-cost/route.ts b/src/app/api/project/invest-cost/route.ts new file mode 100644 index 0000000..122a07d --- /dev/null +++ b/src/app/api/project/invest-cost/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 투자비 관리 (project.investCostMngPopup 대응) — invest_cost_mng +// actions: list | save | delete +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const action = String(body.action || "list"); + const targetObjId = String(body.targetObjId || ""); + + if (action === "save") { + const rows: Array> = Array.isArray(body.rows) ? body.rows : []; + try { + for (const r of rows) { + const isNew = !r.objid; + const objId = isNew ? createObjectId() : String(r.objid); + if (isNew) { + await execute( + `INSERT INTO invest_cost_mng (objid, target_objid, seq, title, drafter, duedate, amount, status, writer, reg_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())`, + [objId, targetObjId, String(r.seq || ""), String(r.title || ""), + String(r.drafter || ""), String(r.duedate || ""), + String(r.amount || "").replace(/,/g, ""), String(r.status || "01"), user.userId], + ); + } else { + await execute( + `UPDATE invest_cost_mng SET title=$1, drafter=$2, duedate=$3, amount=$4, status=$5, edit_date=now() + WHERE objid=$6`, + [String(r.title || ""), String(r.drafter || ""), String(r.duedate || ""), + String(r.amount || "").replace(/,/g, ""), String(r.status || "01"), objId], + ); + } + } + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + console.error("invest-cost save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } + } + + if (action === "delete") { + const objIds: string[] = Array.isArray(body.objIds) ? body.objIds : []; + if (objIds.length === 0) return NextResponse.json({ success: false, message: "선택된 항목이 없습니다." }); + try { + const ph = objIds.map((_, i) => `$${i + 1}`).join(","); + await execute(`DELETE FROM invest_cost_mng WHERE objid IN (${ph})`, objIds); + return NextResponse.json({ success: true, message: "삭제되었습니다." }); + } catch (e) { + console.error("invest-cost delete:", e); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } + } + + // list + if (!targetObjId) return NextResponse.json({ RESULTLIST: [] }); + const rows = await queryRows( + `SELECT objid::text AS "OBJID", + seq AS "SEQ", + title AS "TITLE", + drafter AS "DRAFTER", + (SELECT user_name FROM user_info WHERE user_id = drafter LIMIT 1) AS "DRAFTER_NAME", + duedate AS "DUEDATE", + amount AS "AMOUNT", + status AS "STATUS", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid = invest_cost_mng.objid::text AND doc_type = 'INVEST_FILE') AS "INVEST_FILE_CNT" + FROM invest_cost_mng + WHERE target_objid = $1 + ORDER BY seq::integer NULLS LAST`, + [targetObjId], + ); + + return NextResponse.json({ RESULTLIST: rows }); +} diff --git a/src/app/api/project/modify/route.ts b/src/app/api/project/modify/route.ts new file mode 100644 index 0000000..dc936c4 --- /dev/null +++ b/src/app/api/project/modify/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne, execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 프로젝트 수정 조회/저장 (project.getProjectModifyInfo + ModifyProject 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const action = String(body.action || "detail"); + + if (action === "save") { + const { objId, project_name, facility, facility_qty, facility_depth, + contract_del_date, contract_currency, contract_price, contract_price_currency } = body; + if (!objId) return NextResponse.json({ success: false, message: "objId 필요" }, { status: 400 }); + try { + await execute( + `UPDATE project_mgmt SET + project_name = $1, + facility = $2, + facility_qty = $3, + facility_depth = $4, + contract_del_date = $5, + contract_currency = $6, + contract_price = $7, + contract_price_currency = $8 + WHERE objid = $9`, + [ + String(project_name || ""), + String(facility || ""), + String(facility_qty || "").replace(/,/g, ""), + String(facility_depth || "").replace(/,/g, ""), + String(contract_del_date || ""), + String(contract_currency || ""), + String(contract_price || "").replace(/,/g, ""), + String(contract_price_currency || "").replace(/,/g, ""), + String(objId), + ], + ); + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + console.error("Project modify save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } + } + + // detail + const row = await queryOne>( + `SELECT objid::text AS "OBJID", + project_no AS "PROJECT_NO", + project_name AS "PROJECT_NAME", + facility AS "FACILITY", + facility_qty AS "FACILITY_QTY", + facility_depth AS "FACILITY_DEPTH", + contract_del_date AS "CONTRACT_DEL_DATE", + contract_currency AS "CONTRACT_CURRENCY", + contract_price AS "CONTRACT_PRICE", + contract_price_currency AS "CONTRACT_PRICE_CURRENCY" + FROM project_mgmt + WHERE objid = $1`, + [String(body.objId || "")], + ); + return NextResponse.json({ success: true, data: row ?? {} }); +} diff --git a/src/app/api/project/progress/route.ts b/src/app/api/project/progress/route.ts new file mode 100644 index 0000000..3b69db0 --- /dev/null +++ b/src/app/api/project/progress/route.ts @@ -0,0 +1,205 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 프로젝트관리_일정관리(WBS) (project.projectMgmtWbsGridList 대응) +// 원본: PROJECT_MGMT + PMS_WBS_TASK(설계/구매/제작) + SETUP_WBS_TASK(셋업) 카운트 집계 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + // 년도: regdate YYYY + if (body.Year || body.year) { + conditions.push(`TO_CHAR(PM.regdate, 'YYYY') = $${idx++}`); + params.push(String(body.Year || body.year)); + } + // 프로젝트번호 multi (CSV) - project_nos 또는 project_no 모두 지원 + const projectNos = body.project_nos || body.project_no; + if (projectNos) { + const arr = String(projectNos).split(",").map((s) => s.trim()).filter(Boolean); + if (arr.length > 0) { + const ph = arr.map(() => `$${idx++}`).join(","); + conditions.push(`PM.objid::text IN (${ph})`); + arr.forEach((v) => params.push(v)); + } + } + if (body.category_cd) { + conditions.push(`PM.category_cd = $${idx++}`); + params.push(body.category_cd); + } + if (body.customer_objid) { + conditions.push(`PM.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.product) { + conditions.push(`PM.product = $${idx++}`); + params.push(body.product); + } + if (body.pm_user_id) { + conditions.push(`PM.pm_user_id = $${idx++}`); + params.push(body.pm_user_id); + } + if (body.location) { + conditions.push(`PM.location LIKE '%' || $${idx++} || '%'`); + params.push(body.location); + } + if (body.setup) { + conditions.push(`PM.setup LIKE '%' || $${idx++} || '%'`); + params.push(body.setup); + } + // 예상납기일(due_date) 범위 + if (body.contract_start_date) { + conditions.push(`TO_DATE(PM.due_date, 'YYYY-MM-DD') >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(body.contract_start_date); + } + if (body.contract_end_date) { + conditions.push(`TO_DATE(PM.due_date, 'YYYY-MM-DD') <= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(body.contract_end_date); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT + PM.objid::text AS "OBJID", + PM.category_cd AS "CATEGORY_CD", + (SELECT code_name FROM comm_code WHERE code_id = PM.category_cd LIMIT 1) AS "CATEGORY_NAME", + PM.overhaul_order AS "OVERHAUL_ORDER", + PM.customer_objid AS "CUSTOMER_OBJID", + (SELECT supply_name FROM supply_mng WHERE objid::text = PM.customer_objid LIMIT 1) AS "CUSTOMER_NAME", + PM.product AS "PRODUCT", + (SELECT code_name FROM comm_code WHERE code_id = PM.product LIMIT 1) AS "PRODUCT_NAME", + PM.project_no AS "PROJECT_NO", + PM.project_name AS "PROJECT_NAME", + PM.req_del_date AS "REQ_DEL_DATE", + PM.contract_del_date AS "CONTRACT_DEL_DATE", + PM.setup AS "SETUP", + PM.location AS "LOCATION", + PM.facility AS "FACILITY", + (SELECT code_name FROM comm_code WHERE code_id = PM.facility LIMIT 1) AS "FACILITY_NAME", + PM.manufacture_plant AS "MANUFACTURE_PLANT", + (SELECT code_name FROM comm_code WHERE code_id = PM.manufacture_plant LIMIT 1) AS "MANUFACTURE_PLANT_NAME", + PM.pm_user_id AS "PM_USER_ID", + (SELECT user_name FROM user_info WHERE user_id = PM.pm_user_id LIMIT 1) AS "PM_USER_NAME", + PM.mechanical_type AS "MECHANICAL_TYPE", + + -- 설계관리 공정율 (완료/전체) + CASE WHEN (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) = 0 THEN 0 + ELSE ROUND( + ((SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) + - (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text + AND (COALESCE(design_act_end,'') = '')))::numeric + / (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text)::numeric * 100, 1) + END AS "DESIGN_RATETOTAL", + -- 설계 완료 + (SELECT COUNT(1) FROM pms_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(design_act_end,'') != '' AND COALESCE(design_plan_end,'') != '' + AND TO_DATE(design_plan_end,'YYYY-MM-DD') >= TO_DATE(design_act_end,'YYYY-MM-DD')) AS "DESIGN_COMP_CNT", + -- 설계 지연완료 + (SELECT COUNT(1) FROM pms_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(design_act_end,'') != '' AND COALESCE(design_plan_end,'') != '' + AND TO_DATE(design_plan_end,'YYYY-MM-DD') < TO_DATE(design_act_end,'YYYY-MM-DD')) AS "DESIGN_LATE_COMP_CNT", + -- 설계 진행중 + (SELECT COUNT(1) FROM pms_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(design_act_end,'') = '' AND COALESCE(design_plan_end,'') != '' + AND TO_DATE(design_plan_end,'YYYY-MM-DD') >= CURRENT_DATE) AS "DESIGN_ING_CNT", + -- 설계 지연 + (SELECT COUNT(1) FROM pms_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(design_act_end,'') = '' AND COALESCE(design_plan_end,'') != '' + AND TO_DATE(design_plan_end,'YYYY-MM-DD') < CURRENT_DATE) AS "DESIGN_LATE_CNT", + + -- 구매관리 공정율 + CASE WHEN (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) = 0 THEN 0 + ELSE ROUND( + ((SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) + - (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text + AND (COALESCE(purchase_act_end,'') = '')))::numeric + / (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text)::numeric * 100, 1) + END AS "PURCHASE_RATETOTAL", + (SELECT COUNT(1) FROM pms_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(purchase_act_end,'') != '' AND COALESCE(purchase_plan_end,'') != '' + AND TO_DATE(purchase_plan_end,'YYYY-MM-DD') >= TO_DATE(purchase_act_end,'YYYY-MM-DD')) AS "PURCHASE_COMP_CNT", + (SELECT COUNT(1) FROM pms_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(purchase_act_end,'') != '' AND COALESCE(purchase_plan_end,'') != '' + AND TO_DATE(purchase_plan_end,'YYYY-MM-DD') < TO_DATE(purchase_act_end,'YYYY-MM-DD')) AS "PURCHASE_LATE_COMP_CNT", + (SELECT COUNT(1) FROM pms_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(purchase_act_end,'') = '' AND COALESCE(purchase_plan_end,'') != '' + AND TO_DATE(purchase_plan_end,'YYYY-MM-DD') >= CURRENT_DATE) AS "PURCHASE_ING_CNT", + (SELECT COUNT(1) FROM pms_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(purchase_act_end,'') = '' AND COALESCE(purchase_plan_end,'') != '' + AND TO_DATE(purchase_plan_end,'YYYY-MM-DD') < CURRENT_DATE) AS "PURCHASE_LATE_CNT", + + -- 조립(제작)관리 공정율 — 원본은 PRODUCE_RATE 평균 + COALESCE(ROUND( + (SELECT SUM(COALESCE(produce_rate,'0')::numeric) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) + / NULLIF((SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text), 0), 1), 0) AS "PRODUCE_RATETOTAL", + (SELECT COUNT(1) FROM pms_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(produce_act_end,'') != '' AND COALESCE(produce_plan_end,'') != '' + AND TO_DATE(produce_plan_end,'YYYY-MM-DD') >= TO_DATE(produce_act_end,'YYYY-MM-DD')) AS "PRODUCE_COMP_CNT", + (SELECT COUNT(1) FROM pms_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(produce_act_end,'') != '' AND COALESCE(produce_plan_end,'') != '' + AND TO_DATE(produce_plan_end,'YYYY-MM-DD') < TO_DATE(produce_act_end,'YYYY-MM-DD')) AS "PRODUCE_LATE_COMP_CNT", + (SELECT COUNT(1) FROM pms_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(produce_act_end,'') = '' AND COALESCE(produce_plan_end,'') != '' + AND TO_DATE(produce_plan_end,'YYYY-MM-DD') >= CURRENT_DATE) AS "PRODUCE_ING_CNT", + (SELECT COUNT(1) FROM pms_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(produce_act_end,'') = '' AND COALESCE(produce_plan_end,'') != '' + AND TO_DATE(produce_plan_end,'YYYY-MM-DD') < CURRENT_DATE) AS "PRODUCE_LATE_CNT", + + -- 셋업관리 + COALESCE( + CASE WHEN (SELECT COUNT(1) FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(parent_objid,'') != '') = 0 THEN 0 + ELSE ROUND( + (SELECT COUNT(1) FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(setup_act_end,'') != '' AND COALESCE(parent_objid,'') != '')::numeric + / (SELECT COUNT(1) FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(parent_objid,'') != '')::numeric * 100, 1) + END, 0) AS "SETUP_RATETOTAL", + (SELECT COUNT(1) FROM setup_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(parent_objid,'') != '' + AND COALESCE(setup_act_end,'') != '' AND COALESCE(setup_plan_end,'') != '' + AND TO_DATE(setup_plan_end,'YYYY-MM-DD') >= TO_DATE(setup_act_end,'YYYY-MM-DD')) AS "SETUP_COMP_CNT", + (SELECT COUNT(1) FROM setup_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(parent_objid,'') != '' + AND COALESCE(setup_act_end,'') != '' AND COALESCE(setup_plan_end,'') != '' + AND TO_DATE(setup_plan_end,'YYYY-MM-DD') < TO_DATE(setup_act_end,'YYYY-MM-DD')) AS "SETUP_LATE_COMP_CNT", + (SELECT COUNT(1) FROM setup_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(parent_objid,'') != '' + AND COALESCE(setup_act_end,'') = '' AND COALESCE(setup_plan_end,'') != '' + AND TO_DATE(setup_plan_end,'YYYY-MM-DD') >= CURRENT_DATE) AS "SETUP_ING_CNT", + (SELECT COUNT(1) FROM setup_wbs_task + WHERE contract_objid = PM.objid::text + AND COALESCE(parent_objid,'') != '' + AND COALESCE(setup_act_end,'') = '' AND COALESCE(setup_plan_end,'') != '' + AND TO_DATE(setup_plan_end,'YYYY-MM-DD') < CURRENT_DATE) AS "SETUP_LATE_CNT", + + TO_CHAR(PM.regdate, 'YYYY-MM-DD') AS "REGDATE" + FROM project_mgmt PM + WHERE ${where} + ORDER BY PM.regdate DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/project/route.ts b/src/app/api/project/route.ts new file mode 100644 index 0000000..1f64f7e --- /dev/null +++ b/src/app/api/project/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 프로젝트관리 메인 목록 (수주 확정된 계약 기반) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["CM.contract_result = '0000964'"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(TO_DATE(CM.contract_date, 'YYYY-MM-DD'), 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`CM.contract_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.project_name) { + conditions.push(`CM.project_name LIKE '%' || $${idx++} || '%'`); + params.push(body.project_name); + } + + const where = conditions.join(" AND "); + + const rows = await queryRows( + `SELECT CM.objid::text AS "OBJID", + CM.contract_no AS "PROJECT_NO", + CM.project_name AS "PROJECT_NAME", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = CM.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = CM.product LIMIT 1), '') AS "PRODUCT_NAME", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = CM.manufacture_plant LIMIT 1), '') AS "MANUFACTURE_PLANT_NAME", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = CM.contract_result LIMIT 1), '') AS "STATUS", + COALESCE((SELECT user_name FROM user_info WHERE user_id = CM.pm_user_id LIMIT 1), '') AS "PM_NAME", + CM.contract_del_date AS "START_DATE", + CM.req_del_date AS "END_DATE", + 0 AS "TOTAL_RATE" + FROM contract_mgmt CM + WHERE ${where} + ORDER BY CM.regdate DESC +`, + params + ); + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/project/status/route.ts b/src/app/api/project/status/route.ts new file mode 100644 index 0000000..60e3ff2 --- /dev/null +++ b/src/app/api/project/status/route.ts @@ -0,0 +1,176 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 프로젝트관리_진행관리 (project.projectMgmtGridList 대응) +// 원본: project_mgmt(PM) + release_mgmt(RM) + expense_master(EP) + ... +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + // 년도: 계약일(contract_date) 기준 + if (body.Year || body.year) { + conditions.push(`SUBSTR(PM.contract_date, 1, 4) = $${idx++}`); + params.push(String(body.Year || body.year)); + } + // 프로젝트번호 multi (CSV) + if (body.project_no) { + const arr = String(body.project_no).split(",").map((s) => s.trim()).filter(Boolean); + if (arr.length > 0) { + const ph = arr.map(() => `$${idx++}`).join(","); + conditions.push(`PM.objid::text IN (${ph})`); + arr.forEach((v) => params.push(v)); + } + } + if (body.category_cd) { + conditions.push(`PM.category_cd = $${idx++}`); + params.push(body.category_cd); + } + if (body.customer_objid) { + conditions.push(`PM.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.product) { + conditions.push(`PM.product = $${idx++}`); + params.push(body.product); + } + if (body.pm_user_id) { + conditions.push(`PM.pm_user_id = $${idx++}`); + params.push(body.pm_user_id); + } + if (body.location) { + conditions.push(`PM.location LIKE '%' || $${idx++} || '%'`); + params.push(body.location); + } + if (body.setup) { + conditions.push(`PM.setup LIKE '%' || $${idx++} || '%'`); + params.push(body.setup); + } + // 예상납기일(contract_del_date) 범위 + if (body.contract_start_date) { + conditions.push(`PM.contract_del_date >= $${idx++}`); + params.push(body.contract_start_date); + } + if (body.contract_end_date) { + conditions.push(`PM.contract_del_date <= $${idx++}`); + params.push(body.contract_end_date); + } + // 종합현황 모드: 고객사명 LIKE + if (body.customer_name) { + conditions.push(`COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = PM.customer_objid LIMIT 1), '') LIKE '%' || $${idx++} || '%'`); + params.push(body.customer_name); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT + PM.objid::text AS "OBJID", + PM.contract_objid::text AS "CONTRACT_OBJID", + PM.project_no AS "PROJECT_NO", + PM.project_name AS "PROJECT_NAME", + PM.category_cd AS "CATEGORY_CD", + (SELECT code_name FROM comm_code WHERE code_id = PM.category_cd LIMIT 1) AS "CATEGORY_NAME", + PM.overhaul_order AS "OVERHAUL_ORDER", + PM.customer_objid AS "CUSTOMER_OBJID", + (SELECT supply_name FROM supply_mng WHERE objid::text = PM.customer_objid LIMIT 1) AS "CUSTOMER_NAME", + PM.product AS "PRODUCT", + (SELECT code_name FROM comm_code WHERE code_id = PM.product LIMIT 1) AS "PRODUCT_NAME", + PM.mechanical_type AS "MECHANICAL_TYPE", + PM.req_del_date AS "REQ_DEL_DATE", + PM.contract_del_date AS "CONTRACT_DEL_DATE", + PM.location AS "LOCATION", + PM.setup AS "SETUP", + PM.facility AS "FACILITY", + (SELECT code_name FROM comm_code WHERE code_id = PM.facility LIMIT 1) AS "FACILITY_NAME", + PM.facility_qty AS "FACILITY_QTY", + PM.manufacture_plant AS "MANUFACTURE_PLANT", + (SELECT code_name FROM comm_code WHERE code_id = PM.manufacture_plant LIMIT 1) AS "MANUFACTURE_PLANT_NAME", + PM.pm_user_id AS "PM_USER_ID", + (SELECT user_name FROM user_info WHERE user_id = PM.pm_user_id LIMIT 1) AS "PM_USER_NAME", + -- 투입원가 (project.xml projectMgmtGridList 이식) + COALESCE((SELECT (COALESCE(ICG.material_cost_goal,'0')::numeric + COALESCE(ICG.labor_cost_goal,'0')::numeric + COALESCE(ICG.expense_cost_goal,'0')::numeric) + FROM input_cost_goal ICG WHERE ICG.contract_objid::text = PM.objid::text LIMIT 1), 0) AS "TOTAL_COST_GOAL", + ( + COALESCE((SELECT SUM(settle) + FROM (SELECT (SUM(COALESCE(ED.card_used,'0')::numeric) + SUM(COALESCE(ED.cash_used,'0')::numeric) - SUM(COALESCE(ED.payment,'0')::numeric)) AS settle + FROM expense_master EM + LEFT OUTER JOIN expense_detail ED ON ED.expense_master_objid = EM.expense_master_objid + WHERE EM.project_mgmt_objid = PM.objid::text + GROUP BY EM.expense_master_objid) s), 0) + + + COALESCE((SELECT SUM(COALESCE(NULLIF(total_supply_unit_price,'')::numeric, 0)) + FROM purchase_order_master + WHERE contract_mgmt_objid = PM.objid AND status = 'approvalComplete'), 0) + ) AS "TOTAL_COST_ACTUAL", + CASE + WHEN COALESCE((SELECT (COALESCE(ICG.material_cost_goal,'0')::numeric + COALESCE(ICG.labor_cost_goal,'0')::numeric + COALESCE(ICG.expense_cost_goal,'0')::numeric) + FROM input_cost_goal ICG WHERE ICG.contract_objid::text = PM.objid::text LIMIT 1), 0) = 0 THEN 0 + ELSE ROUND( + ( + COALESCE((SELECT SUM(settle) + FROM (SELECT (SUM(COALESCE(ED.card_used,'0')::numeric) + SUM(COALESCE(ED.cash_used,'0')::numeric) - SUM(COALESCE(ED.payment,'0')::numeric)) AS settle + FROM expense_master EM + LEFT OUTER JOIN expense_detail ED ON ED.expense_master_objid = EM.expense_master_objid + WHERE EM.project_mgmt_objid = PM.objid::text + GROUP BY EM.expense_master_objid) s), 0) + + + COALESCE((SELECT SUM(COALESCE(NULLIF(total_supply_unit_price,'')::numeric, 0)) + FROM purchase_order_master + WHERE contract_mgmt_objid = PM.objid AND status = 'approvalComplete'), 0) + ) + / (SELECT (COALESCE(ICG.material_cost_goal,'0')::numeric + COALESCE(ICG.labor_cost_goal,'0')::numeric + COALESCE(ICG.expense_cost_goal,'0')::numeric) + FROM input_cost_goal ICG WHERE ICG.contract_objid::text = PM.objid::text LIMIT 1) * 100, 1) + END AS "TOTAL_INPUT_RATE", + -- 이슈 통계 (planning_issue) + (SELECT COUNT(1) FROM planning_issue WHERE project_objid = PM.objid::text AND status = 'release') AS "ISSUE_CNT", + (SELECT COUNT(1) FROM planning_issue WHERE project_objid = PM.objid::text AND status = 'release' + AND COALESCE(design_result,'') != '' AND COALESCE(design_date,'') != '') AS "COMP_CNT", + ((SELECT COUNT(1) FROM planning_issue WHERE project_objid = PM.objid::text AND status = 'release') + - (SELECT COUNT(1) FROM planning_issue WHERE project_objid = PM.objid::text AND status = 'release' + AND COALESCE(design_result,'') != '' AND COALESCE(design_date,'') != '')) AS "MISS_CNT", + CASE WHEN (SELECT COUNT(1) FROM planning_issue WHERE project_objid = PM.objid::text AND status = 'release') = 0 THEN 0 + ELSE ROUND( + (SELECT COUNT(1) FROM planning_issue WHERE project_objid = PM.objid::text AND status = 'release' + AND COALESCE(design_result,'') != '' AND COALESCE(design_date,'') != '')::numeric + / (SELECT COUNT(1) FROM planning_issue WHERE project_objid = PM.objid::text AND status = 'release')::numeric * 100, 1) + END AS "ISSUE_RATE", + -- 출고 정보 + RM.release_date AS "RELEASE_DATE", + CASE + WHEN COALESCE(TRIM(RM.release_date),'') != '' THEN '출고' + ELSE '미출고' + END AS "RELEASE_STATUS_TITLE", + -- 진척율 (단순화: WBS task 완료/전체) + CASE WHEN (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) = 0 THEN 0 + ELSE ROUND( + ((SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) + - (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text + AND (COALESCE(design_act_end,'') = '')))::numeric + / (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text)::numeric * 100, 1) + END AS "TOTAL_RATE", + -- 셋업 진척율 + CASE WHEN (SELECT COUNT(1) FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(parent_objid,'') != '') = 0 THEN 0 + ELSE ROUND( + (SELECT COUNT(1) FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(setup_act_end,'') != '' AND COALESCE(parent_objid,'') != '')::numeric + / (SELECT COUNT(1) FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(parent_objid,'') != '')::numeric * 100, 1) + END AS "SETUP_RATE", + TO_CHAR(PM.regdate, 'YYYY-MM-DD') AS "REGDATE" + FROM project_mgmt PM + LEFT OUTER JOIN release_mgmt RM + ON RM.parent_objid = PM.objid::text + WHERE ${where} + ORDER BY PM.regdate DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/project/total/route.ts b/src/app/api/project/total/route.ts new file mode 100644 index 0000000..243e6c1 --- /dev/null +++ b/src/app/api/project/total/route.ts @@ -0,0 +1,146 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 프로젝트종합 (Gantt) - dashboard.projectMgmtTimeLineGridList_old 대응 +// 원본: PROJECT_MGMT + CONTRACT_MGMT + WBS act_start/end 집계 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + // 년도: T.REQ_DEL_DATE YYYY 기준 (원본 매퍼) + if (body.Year || body.year) { + conditions.push(`TO_CHAR(TO_DATE(PM.req_del_date, 'YYYY-MM-DD'), 'YYYY') = $${idx++}`); + params.push(String(body.Year || body.year)); + } + const projectNos = body.project_nos || body.project_no; + if (projectNos) { + const arr = String(projectNos).split(",").map((s) => s.trim()).filter(Boolean); + if (arr.length > 0) { + const ph = arr.map(() => `$${idx++}`).join(","); + conditions.push(`PM.objid::text IN (${ph})`); + arr.forEach((v) => params.push(v)); + } + } + if (body.category_cd) { + conditions.push(`PM.category_cd = $${idx++}`); + params.push(body.category_cd); + } + if (body.customer_objid) { + conditions.push(`PM.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.product) { + conditions.push(`PM.product = $${idx++}`); + params.push(body.product); + } + if (body.pm_user_id) { + conditions.push(`CM.pm_user_id = $${idx++}`); + params.push(body.pm_user_id); + } + if (body.location) { + conditions.push(`UPPER(CM.location) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.location); + } + if (body.setup) { + conditions.push(`UPPER(CM.setup) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.setup); + } + if (body.contract_start_date) { + conditions.push(`TO_DATE(CM.due_date, 'YYYY-MM-DD') >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(body.contract_start_date); + } + if (body.contract_end_date) { + conditions.push(`TO_DATE(CM.due_date, 'YYYY-MM-DD') <= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(body.contract_end_date); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT + PM.objid::text AS "OBJID", + PM.category_cd AS "CATEGORY_CD", + (SELECT code_name FROM comm_code WHERE code_id = PM.category_cd LIMIT 1) AS "CATEGORY_NAME", + PM.customer_objid AS "CUSTOMER_OBJID", + (SELECT supply_name FROM supply_mng WHERE objid::text = PM.customer_objid LIMIT 1) AS "CUSTOMER_NAME", + PM.product AS "PRODUCT", + (SELECT code_name FROM comm_code WHERE code_id = PM.product LIMIT 1) AS "PRODUCT_NAME", + PM.project_no AS "PROJECT_NO", + PM.project_name AS "PROJECT_NAME", + PM.manufacture_plant AS "MANUFACTURE_PLANT", + (SELECT code_name FROM comm_code WHERE code_id = PM.manufacture_plant LIMIT 1) AS "MANUFACTURE_PLANT_NAME", + PM.req_del_date AS "REQ_DEL_DATE", + (SELECT user_name FROM user_info WHERE user_id = CM.pm_user_id LIMIT 1) AS "PM_USER_NAME", + + -- 실행 시작/종료 (Gantt 렌더용) + (SELECT MIN(design_act_start) FROM pms_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(design_act_start,'') != '') AS "DESIGN_ACT_START", + (SELECT MAX(design_act_end) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) AS "DESIGN_ACT_END", + (SELECT MIN(purchase_act_start) FROM pms_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(purchase_act_start,'') != '') AS "PURCHASE_ACT_START", + (SELECT MAX(purchase_act_end) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) AS "PURCHASE_ACT_END", + (SELECT MIN(produce_act_start) FROM pms_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(produce_act_start,'') != '') AS "PRODUCE_ACT_START", + (SELECT MAX(produce_act_end) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) AS "PRODUCE_ACT_END", + (SELECT MIN(setup_act_start) FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(setup_act_start,'') != '') AS "SETUP_ACT_START", + (SELECT MAX(setup_act_end) FROM setup_wbs_task WHERE contract_objid = PM.objid::text) AS "SETUP_ACT_END", + + -- 진척율 + CASE WHEN (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) = 0 THEN 0 + ELSE ROUND( + ((SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) + - (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text + AND (COALESCE(design_act_end,'') = '')))::numeric + / (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text)::numeric * 100, 1) + END AS "DESIGN_RATETOTAL", + CASE WHEN (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) = 0 THEN 0 + ELSE ROUND( + ((SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) + - (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text + AND (COALESCE(purchase_act_end,'') = '')))::numeric + / (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text)::numeric * 100, 1) + END AS "PURCHASE_RATETOTAL", + COALESCE(ROUND( + (SELECT SUM(COALESCE(produce_rate,'0')::numeric) FROM pms_wbs_task WHERE contract_objid = PM.objid::text) + / NULLIF((SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text), 0), 1), 0) AS "PRODUCE_RATETOTAL", + COALESCE( + CASE WHEN (SELECT COUNT(1) FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(parent_objid,'') != '') = 0 THEN 0 + ELSE ROUND( + (SELECT COUNT(1) FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(setup_act_end,'') != '' AND COALESCE(parent_objid,'') != '')::numeric + / (SELECT COUNT(1) FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(parent_objid,'') != '')::numeric * 100, 1) + END, 0) AS "SETUP_RATETOTAL", + + -- 지연 카운트 (상태 판정용) + (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(design_act_end,'') = '' AND COALESCE(design_plan_end,'') != '' + AND TO_DATE(design_plan_end,'YYYY-MM-DD') < CURRENT_DATE) AS "DESIGN_LATE_CNT", + (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(purchase_act_end,'') = '' AND COALESCE(purchase_plan_end,'') != '' + AND TO_DATE(purchase_plan_end,'YYYY-MM-DD') < CURRENT_DATE) AS "PURCHASE_LATE_CNT", + (SELECT COUNT(1) FROM pms_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(produce_act_end,'') = '' AND COALESCE(produce_plan_end,'') != '' + AND TO_DATE(produce_plan_end,'YYYY-MM-DD') < CURRENT_DATE) AS "PRODUCE_LATE_CNT", + (SELECT COUNT(1) FROM setup_wbs_task WHERE contract_objid = PM.objid::text + AND COALESCE(parent_objid,'') != '' + AND COALESCE(setup_act_end,'') = '' AND COALESCE(setup_plan_end,'') != '' + AND TO_DATE(setup_plan_end,'YYYY-MM-DD') < CURRENT_DATE) AS "SETUP_LATE_CNT" + + FROM project_mgmt PM + INNER JOIN contract_mgmt CM ON CM.objid = PM.contract_objid + WHERE ${where} + ORDER BY PM.req_del_date NULLS LAST, PM.project_no DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/project/wbs-setup/route.ts b/src/app/api/project/wbs-setup/route.ts new file mode 100644 index 0000000..d59f20c --- /dev/null +++ b/src/app/api/project/wbs-setup/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, queryOne, execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 셋업 WBS 조회/저장 (setup_wbs_task) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const action = String(body.action || "list"); + + if (action === "save") { + const rows: Array> = Array.isArray(body.rows) ? body.rows : []; + try { + for (const r of rows) { + await execute( + `UPDATE setup_wbs_task SET + task_name = $1, + setup_plan_start = $2, setup_plan_end = $3, + setup_act_start = $4, setup_act_end = $5, + setup_rate = $6 + WHERE objid = $7`, + [ + String(r.TASK_NAME || ""), + String(r.SETUP_PLAN_START || ""), String(r.SETUP_PLAN_END || ""), + String(r.SETUP_ACT_START || ""), String(r.SETUP_ACT_END || ""), + String(r.SETUP_RATE || ""), + String(r.OBJID || ""), + ], + ); + } + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + console.error("wbs-setup save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } + } + + const contractObjId = String(body.contractObjId || body.OBJID || ""); + if (!contractObjId) return NextResponse.json({ RESULTLIST: [], HEADER: {} }); + + const header = await queryOne>( + `SELECT project_no AS "PROJECT_NO", project_name AS "PROJECT_NAME", + (SELECT supply_name FROM supply_mng WHERE objid::text = customer_objid LIMIT 1) AS "CUSTOMER_NAME" + FROM project_mgmt WHERE objid = $1`, + [contractObjId], + ); + + const rows = await queryRows( + `SELECT T.objid::text AS "OBJID", + T.parent_objid::text AS "PARENT_OBJID", + T.task_name AS "TASK_NAME", + T.setup_plan_start AS "SETUP_PLAN_START", + T.setup_plan_end AS "SETUP_PLAN_END", + T.setup_act_start AS "SETUP_ACT_START", + T.setup_act_end AS "SETUP_ACT_END", + T.setup_rate AS "SETUP_RATE" + FROM setup_wbs_task T + WHERE T.contract_objid = $1 + AND COALESCE(T.parent_objid, '') != '' + ORDER BY T.task_name`, + [contractObjId], + ); + + return NextResponse.json({ RESULTLIST: rows, HEADER: header ?? {} }); +} diff --git a/src/app/api/project/wbs-task/route.ts b/src/app/api/project/wbs-task/route.ts new file mode 100644 index 0000000..5a04c69 --- /dev/null +++ b/src/app/api/project/wbs-task/route.ts @@ -0,0 +1,133 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, queryOne, execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// WBS Task 조회/저장 (pms_wbs_task) +// - action: list | save +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const action = String(body.action || "list"); + + if (action === "save") { + const rows: Array> = Array.isArray(body.rows) ? body.rows : []; + try { + for (const r of rows) { + await execute( + `UPDATE pms_wbs_task SET + task_name = $1, + design_user_id = $2, design_plan_start = $3, design_plan_end = $4, design_act_start = $5, design_act_end = $6, design_rate = $7, + purchase_user_id = $8, purchase_plan_start = $9, purchase_plan_end = $10, purchase_act_start = $11, purchase_act_end = $12, purchase_rate = $13, + produce_user_id = $14, produce_plan_start = $15, produce_plan_end = $16, produce_act_start = $17, produce_act_end = $18, produce_rate = $19 + WHERE objid = $20`, + [ + String(r.TASK_NAME || ""), + String(r.DESIGN_USER_ID || ""), String(r.DESIGN_PLAN_START || ""), String(r.DESIGN_PLAN_END || ""), String(r.DESIGN_ACT_START || ""), String(r.DESIGN_ACT_END || ""), String(r.DESIGN_RATE || ""), + String(r.PURCHASE_USER_ID || ""), String(r.PURCHASE_PLAN_START || ""), String(r.PURCHASE_PLAN_END || ""), String(r.PURCHASE_ACT_START || ""), String(r.PURCHASE_ACT_END || ""), String(r.PURCHASE_RATE || ""), + String(r.PRODUCE_USER_ID || ""), String(r.PRODUCE_PLAN_START || ""), String(r.PRODUCE_PLAN_END || ""), String(r.PRODUCE_ACT_START || ""), String(r.PRODUCE_ACT_END || ""), String(r.PRODUCE_RATE || ""), + String(r.OBJID || ""), + ], + ); + } + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + console.error("wbs-task save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } + } + + // list + const contractObjId = String(body.contractObjId || body.OBJID || ""); + if (!contractObjId) return NextResponse.json({ RESULTLIST: [], HEADER: {} }); + + const header = await queryOne>( + `SELECT project_no AS "PROJECT_NO", project_name AS "PROJECT_NAME", + (SELECT code_name FROM comm_code WHERE code_id = category_cd LIMIT 1) AS "CATEGORY_NAME", + (SELECT supply_name FROM supply_mng WHERE objid::text = customer_objid LIMIT 1) AS "CUSTOMER_NAME", + contract_del_date AS "CONTRACT_DEL_DATE" + FROM project_mgmt WHERE objid = $1`, + [contractObjId], + ); + + const rows = await queryRows>( + `SELECT T.objid::text AS "OBJID", + T.parent_objid::text AS "PARENT_OBJID", + T.task_name AS "TASK_NAME", + T.unit_no AS "UNIT_NO", + T.design_user_id AS "DESIGN_USER_ID", + (SELECT user_name FROM user_info WHERE user_id = T.design_user_id LIMIT 1) AS "DESIGN_USER_NAME", + T.design_plan_start AS "DESIGN_PLAN_START", + T.design_plan_end AS "DESIGN_PLAN_END", + T.design_act_start AS "DESIGN_ACT_START", + T.design_act_end AS "DESIGN_ACT_END", + T.design_rate AS "DESIGN_RATE", + T.purchase_user_id AS "PURCHASE_USER_ID", + (SELECT user_name FROM user_info WHERE user_id = T.purchase_user_id LIMIT 1) AS "PURCHASE_USER_NAME", + T.purchase_plan_start AS "PURCHASE_PLAN_START", + T.purchase_plan_end AS "PURCHASE_PLAN_END", + T.purchase_act_start AS "PURCHASE_ACT_START", + T.purchase_act_end AS "PURCHASE_ACT_END", + T.purchase_rate AS "PURCHASE_RATE", + T.produce_user_id AS "PRODUCE_USER_ID", + (SELECT user_name FROM user_info WHERE user_id = T.produce_user_id LIMIT 1) AS "PRODUCE_USER_NAME", + T.produce_plan_start AS "PRODUCE_PLAN_START", + T.produce_plan_end AS "PRODUCE_PLAN_END", + T.produce_act_start AS "PRODUCE_ACT_START", + T.produce_act_end AS "PRODUCE_ACT_END", + T.produce_rate AS "PRODUCE_RATE" + FROM pms_wbs_task T + WHERE T.contract_objid = $1 + ORDER BY T.unit_no NULLS LAST, T.task_name`, + [contractObjId], + ); + + // Master 행(집계) 생성 — 원본 project.getProjectProductTaskList UNION 로직 대응 + // 시작일 MIN, 종료일 MAX, 진척율 평균(ROUND 1자리) + const master = buildMasterRow(rows); + const resultList = master ? [master, ...rows.map((r) => ({ ...r, IS_MASTER: "0" }))] : rows; + + return NextResponse.json({ RESULTLIST: resultList, HEADER: header ?? {} }); +} + +function buildMasterRow(rows: Record[]): Record | null { + if (rows.length === 0) return null; + + const minDate = (field: string) => { + const vals = rows.map((r) => String(r[field] ?? "")).filter((v) => v !== ""); + return vals.length === 0 ? "" : vals.reduce((a, b) => (a < b ? a : b)); + }; + const maxDate = (field: string) => { + const vals = rows.map((r) => String(r[field] ?? "")).filter((v) => v !== ""); + return vals.length === 0 ? "" : vals.reduce((a, b) => (a > b ? a : b)); + }; + const avgRate = (field: string) => { + const nums = rows.map((r) => parseFloat(String(r[field] ?? "0")) || 0); + if (nums.length === 0) return ""; + const avg = nums.reduce((a, b) => a + b, 0) / nums.length; + return String(Math.round(avg * 10) / 10); + }; + + return { + IS_MASTER: "1", + OBJID: "", + TASK_NAME: "", + UNIT_NO: "", + DESIGN_PLAN_START: minDate("DESIGN_PLAN_START"), + DESIGN_PLAN_END: maxDate("DESIGN_PLAN_END"), + DESIGN_ACT_START: minDate("DESIGN_ACT_START"), + DESIGN_ACT_END: maxDate("DESIGN_ACT_END"), + DESIGN_RATE: avgRate("DESIGN_RATE"), + PURCHASE_PLAN_START: minDate("PURCHASE_PLAN_START"), + PURCHASE_PLAN_END: maxDate("PURCHASE_PLAN_END"), + PURCHASE_ACT_START: minDate("PURCHASE_ACT_START"), + PURCHASE_ACT_END: maxDate("PURCHASE_ACT_END"), + PURCHASE_RATE: avgRate("PURCHASE_RATE"), + PRODUCE_PLAN_START: minDate("PRODUCE_PLAN_START"), + PRODUCE_PLAN_END: maxDate("PRODUCE_PLAN_END"), + PRODUCE_ACT_START: minDate("PRODUCE_ACT_START"), + PRODUCE_ACT_END: maxDate("PRODUCE_ACT_END"), + PRODUCE_RATE: avgRate("PRODUCE_RATE"), + }; +} diff --git a/src/app/api/project/wbs-template/delete/route.ts b/src/app/api/project/wbs-template/delete/route.ts new file mode 100644 index 0000000..936fb71 --- /dev/null +++ b/src/app/api/project/wbs-template/delete/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// WBS 템플릿 삭제 (project.deleteWBSTemplateTask 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const { objIds } = body; + + if (!objIds || !Array.isArray(objIds) || objIds.length === 0) { + return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }); + } + + try { + const placeholders = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + // 하위 Task 먼저 삭제 + await execute( + `DELETE FROM pms_wbs_task_standard WHERE parent_objid IN (${placeholders})`, + objIds + ); + // 템플릿 삭제 + await execute( + `DELETE FROM pms_wbs_template WHERE objid IN (${placeholders})`, + objIds + ); + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (error) { + console.error("WBS template delete:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/project/wbs-template/detail/route.ts b/src/app/api/project/wbs-template/detail/route.ts new file mode 100644 index 0000000..0c5266a --- /dev/null +++ b/src/app/api/project/wbs-template/detail/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// WBS 템플릿 마스터 상세 (project.getWBSTemplateMasterInfo 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId 필요" }, { status: 400 }); + + const row = await queryOne>( + `SELECT T.objid::text AS "OBJID", + T.product_objid AS "PRODUCT_OBJID", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = T.product_objid LIMIT 1), '') AS "PRODUCT_OBJID_NAME", + T.title AS "TITLE", + T.writer AS "WRITER", + T.customer_product AS "CUSTOMER_PRODUCT" + FROM pms_wbs_template T + WHERE T.objid = $1`, + [objId] + ); + + return NextResponse.json({ success: true, data: row ?? {} }); +} diff --git a/src/app/api/project/wbs-template/excel-save/route.ts b/src/app/api/project/wbs-template/excel-save/route.ts new file mode 100644 index 0000000..5ba7655 --- /dev/null +++ b/src/app/api/project/wbs-template/excel-save/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +interface TaskInput { + task_name?: string; + unit_no?: string; +} + +// Excel import 저장 (project.mergeExcelUploadWBS 대응) +// - 동일 PRODUCT + TITLE 존재 시 오류 +// - pms_wbs_template INSERT + pms_wbs_task_standard 일괄 INSERT (한 트랜잭션) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const product = String(body.product || ""); + const title = String(body.title || ""); + const customerProduct = String(body.customer_product || ""); + const tasks: TaskInput[] = Array.isArray(body.tasks) ? body.tasks : []; + + if (!product) return NextResponse.json({ success: false, message: "제품은 필수값입니다." }, { status: 400 }); + if (!title) return NextResponse.json({ success: false, message: "기계형식을 입력해 주세요." }, { status: 400 }); + if (!customerProduct) return NextResponse.json({ success: false, message: "고객사_장비목적을 입력해 주세요." }, { status: 400 }); + if (tasks.length === 0) return NextResponse.json({ success: false, message: "저장할 정보가 없습니다." }, { status: 400 }); + + const client = await pool.connect(); + try { + // 중복 체크 (project.getWBSTemplateProductList 대응) + const dup = await client.query( + `SELECT 1 FROM pms_wbs_template WHERE product_objid = $1 AND title = $2 LIMIT 1`, + [product, title] + ); + if ((dup.rowCount ?? 0) > 0) { + return NextResponse.json({ success: false, message: "이미 해당 기계형식으로 등록된 정보가 존재합니다." }, { status: 400 }); + } + + await client.query("BEGIN"); + + const masterId = createObjectId(); + await client.query( + `INSERT INTO pms_wbs_template (objid, product_objid, title, customer_product, writer, reg_date) + VALUES ($1, $2, $3, $4, $5, now())`, + [masterId, product, title, customerProduct, user.userId] + ); + + for (let i = 0; i < tasks.length; i++) { + const t = tasks[i]; + const taskName = String(t.task_name || "").trim(); + if (!taskName) continue; + await client.query( + `INSERT INTO pms_wbs_task_standard (objid, parent_objid, task_name, task_seq, user_id, writer, reg_date, unit_no) + VALUES ($1, $2, $3, $4, $5, $6, now(), $7)`, + [createObjectId(), masterId, taskName, String(i + 1), "", user.userId, String(t.unit_no || "")] + ); + } + + await client.query("COMMIT"); + return NextResponse.json({ success: true, objId: masterId, message: "저장하였습니다." }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("WBS excel save:", error); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/project/wbs-template/master-save/route.ts b/src/app/api/project/wbs-template/master-save/route.ts new file mode 100644 index 0000000..6012572 --- /dev/null +++ b/src/app/api/project/wbs-template/master-save/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// WBS 템플릿 마스터 CUSTOMER_PRODUCT 수정 (project.saveWBSTemplateMasterInfo 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const { objId, customer_product } = body; + if (!objId) return NextResponse.json({ success: false, message: "objId 필요" }, { status: 400 }); + + try { + await execute( + `UPDATE pms_wbs_template SET customer_product = $1 WHERE objid = $2`, + [customer_product ?? "", objId] + ); + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (error) { + console.error("WBS template master save:", error); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/project/wbs-template/route.ts b/src/app/api/project/wbs-template/route.ts new file mode 100644 index 0000000..d008465 --- /dev/null +++ b/src/app/api/project/wbs-template/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 프로젝트관리 > 제품구분_UNIT관리 (pms_wbs_template) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + // 원본 JSP: 제품구분(product)만 검색 파라미터로 사용 + if (body.product) { + conditions.push(`T.product_objid = $${idx++}`); + params.push(body.product); + } + + const where = conditions.join(" AND "); + + const rows = await queryRows( + `SELECT T.objid::text AS "OBJID", + T.product_objid AS "PRODUCT_OBJID", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = T.product_objid LIMIT 1), '') AS "PRODUCT_NAME", + T.title AS "TITLE", + T.customer_product AS "CUSTOMER_PRODUCT", + T.writer AS "WRITER", + COALESCE((SELECT COALESCE(dept_name, '') || user_name FROM user_info WHERE user_id = T.writer LIMIT 1), T.writer) AS "WRITER_TITLE", + TO_CHAR(T.reg_date, 'YYYY-MM-DD') AS "REG_DATE_TITLE", + COALESCE((SELECT COUNT(*) FROM pms_wbs_task_standard WHERE parent_objid = T.objid), 0) AS "WBS_TASK_CNT" + FROM pms_wbs_template T + WHERE ${where} + ORDER BY T.product_objid, T.title`, + params + ); + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/project/wbs-template/save/route.ts b/src/app/api/project/wbs-template/save/route.ts new file mode 100644 index 0000000..9f2e4ca --- /dev/null +++ b/src/app/api/project/wbs-template/save/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// WBS 템플릿 저장 (project.saveWBSTemplateTaskInfo 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const isNew = !body.objId || body.actionType === "regist"; + const objId = isNew ? createObjectId() : body.objId; + + const client = await pool.connect(); + try { + await client.query( + `INSERT INTO pms_wbs_template (objid, title, product_objid, customer_product, writer, reg_date) + VALUES ($1, $2, $3, $4, $5, now()) + ON CONFLICT (objid) DO UPDATE SET + title = EXCLUDED.title, + product_objid = EXCLUDED.product_objid, + customer_product = EXCLUDED.customer_product`, + [objId, body.title || "", body.product_objid || "", body.customer_product || "", user.userId] + ); + return NextResponse.json({ success: true, objId, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (error) { + console.error("WBS template save:", error); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/project/wbs-template/task-delete/route.ts b/src/app/api/project/wbs-template/task-delete/route.ts new file mode 100644 index 0000000..450fb79 --- /dev/null +++ b/src/app/api/project/wbs-template/task-delete/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// WBS 템플릿 Task 삭제 (project.deleteWBSTemplateTask 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objIds } = await request.json(); + if (!Array.isArray(objIds) || objIds.length === 0) { + return NextResponse.json({ success: false, message: "선택된 대상이 없습니다." }); + } + + try { + const placeholders = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + await execute(`DELETE FROM pms_wbs_task_standard WHERE objid IN (${placeholders})`, objIds); + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (error) { + console.error("WBS template task delete:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/project/wbs-template/task-detail/route.ts b/src/app/api/project/wbs-template/task-detail/route.ts new file mode 100644 index 0000000..a6e3e73 --- /dev/null +++ b/src/app/api/project/wbs-template/task-detail/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// WBS 템플릿 Task 단건 조회 (project.getWBSTemplateTaskInfo 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: true, data: {} }); + + const row = await queryOne>( + `SELECT T.objid::text AS "OBJID", + T.parent_objid::text AS "PARENT_OBJID", + T.task_name AS "TASK_NAME", + T.task_seq AS "TASK_SEQ", + T.user_id AS "USER_ID", + T.unit_no AS "UNIT_NO" + FROM pms_wbs_task_standard T + WHERE T.objid = $1`, + [objId] + ); + return NextResponse.json({ success: true, data: row ?? {} }); +} diff --git a/src/app/api/project/wbs-template/task-save/route.ts b/src/app/api/project/wbs-template/task-save/route.ts new file mode 100644 index 0000000..59cdf4e --- /dev/null +++ b/src/app/api/project/wbs-template/task-save/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// WBS 템플릿 Task 저장 (project.saveWBSTemplateTaskInfo 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const parentObjId = String(body.parent_objid || ""); + const taskName = String(body.task_name || ""); + const unitNo = String(body.unit_no || ""); + const taskSeq = String(body.task_seq || ""); + + if (!parentObjId) return NextResponse.json({ success: false, message: "PARENT_OBJID 필요" }, { status: 400 }); + if (!taskName) return NextResponse.json({ success: false, message: "UNIT Name을 입력하세요." }, { status: 400 }); + + const isNew = !body.objid; + const objId = isNew ? createObjectId() : String(body.objid); + + try { + await execute( + `INSERT INTO pms_wbs_task_standard (objid, parent_objid, task_name, task_seq, user_id, writer, reg_date, unit_no) + VALUES ($1, $2, $3, $4, $5, $6, now(), $7) + ON CONFLICT (objid) DO UPDATE SET + task_name = EXCLUDED.task_name, + task_seq = EXCLUDED.task_seq, + user_id = EXCLUDED.user_id, + unit_no = EXCLUDED.unit_no`, + [objId, parentObjId, taskName, taskSeq, user.userId, user.userId, unitNo] + ); + return NextResponse.json({ success: true, objId, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (error) { + console.error("WBS template task save:", error); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/project/wbs-template/tasks/route.ts b/src/app/api/project/wbs-template/tasks/route.ts new file mode 100644 index 0000000..0edae24 --- /dev/null +++ b/src/app/api/project/wbs-template/tasks/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// WBS 템플릿 하위 Task 목록 (project.getWBSTemplateTaskList 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { parentObjId } = await request.json(); + if (!parentObjId) return NextResponse.json({ RESULTLIST: [], TOTAL_CNT: 0 }); + + const rows = await queryRows( + `SELECT T.objid::text AS "OBJID", + T.parent_objid::text AS "PARENT_OBJID", + T.task_name AS "TASK_NAME", + T.task_seq AS "TASK_SEQ", + T.user_id AS "USER_ID", + (SELECT user_name FROM user_info WHERE user_id = T.user_id LIMIT 1) AS "USER_ID_TITLE", + T.unit_no AS "UNIT_NO" + FROM pms_wbs_task_standard T + WHERE T.parent_objid = $1 + ORDER BY T.unit_no`, + [parentObjId] + ); + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/purchase-order/delete/route.ts b/src/app/api/purchase-order/delete/route.ts new file mode 100644 index 0000000..19a8aa8 --- /dev/null +++ b/src/app/api/purchase-order/delete/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 발주서 삭제 (purchaseOrder.deletePurchaseOrderMaster 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objIds } = await request.json(); + if (!objIds?.length) return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }); + + try { + const ph = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + await execute(`DELETE FROM purchase_order_part WHERE purchase_order_master_objid IN (${ph})`, objIds); + await execute(`DELETE FROM purchase_order_master WHERE objid IN (${ph})`, objIds); + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (error) { + console.error("Purchase order delete:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/purchase-order/detail/route.ts b/src/app/api/purchase-order/detail/route.ts new file mode 100644 index 0000000..35eccc3 --- /dev/null +++ b/src/app/api/purchase-order/detail/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne, queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 발주서 상세 조회 (purchaseOrder.getPurchaseOrderMasterInfo 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const { objId } = body; + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + // 마스터 조회 + const master = await queryOne( + `SELECT POM.objid::text AS "OBJID", + POM.purchase_order_no AS "PURCHASE_ORDER_NO", + POM.title AS "TITLE", + POM.type AS "TYPE", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = POM.type LIMIT 1), '') AS "TYPE_NAME", + POM.order_type_cd AS "ORDER_TYPE_CD", + POM.purchase_order_no_org AS "PURCHASE_ORDER_NO_ORG", + POM.partner_objid AS "PARTNER_OBJID", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') AS "PARTNER_NAME", + POM.my_company_objid AS "MY_COMPANY_OBJID", + POM.contract_mgmt_objid::text AS "CONTRACT_MGMT_OBJID", + POM.unit_code AS "UNIT_CODE", + POM.delivery_date AS "DELIVERY_DATE", + POM.delivery_place AS "DELIVERY_PLACE", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = POM.delivery_place LIMIT 1), '') AS "DELIVERY_PLACE_NAME", + POM.effective_date AS "EFFECTIVE_DATE", + POM.payment_terms AS "PAYMENT_TERMS", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = POM.payment_terms LIMIT 1), '') AS "PAYMENT_TERMS_NAME", + POM.vat_method AS "VAT_METHOD", + POM.remark AS "REMARK", + POM.sales_mng_user_id AS "SALES_MNG_USER_ID", + COALESCE((SELECT user_name FROM user_info WHERE user_id = POM.sales_mng_user_id LIMIT 1), '') AS "SALES_MNG_USER_NAME", + POM.total_supply_unit_price AS "TOTAL_SUPPLY_UNIT_PRICE", + POM.total_supply_price AS "TOTAL_SUPPLY_PRICE", + POM.total_real_supply_price AS "TOTAL_REAL_SUPPLY_PRICE", + POM.discount_price AS "DISCOUNT_PRICE", + POM.total_price AS "TOTAL_PRICE", + POM.nego_rate AS "NEGO_RATE", + POM.total_price_txt AS "TOTAL_PRICE_TXT", + POM.status AS "STATUS", + CASE POM.status + WHEN 'create' THEN '등록' + WHEN 'approvalRequest' THEN '결재중' + WHEN 'approvalComplete' THEN '결재완료' + WHEN 'reject' THEN '반려' + WHEN 'cancel' THEN '취소' + ELSE '' END AS "STATUS_NAME", + POM.project_no AS "PROJECT_NO", + POM.writer AS "WRITER", + COALESCE((SELECT user_name FROM user_info WHERE user_id = POM.writer LIMIT 1), '') AS "WRITER_NAME", + TO_CHAR(POM.regdate, 'YYYY-MM-DD') AS "REGDATE" + FROM purchase_order_master POM + WHERE POM.objid::text = $1`, + [objId] + ); + + if (!master) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + + // 부품 목록 조회 + const parts = await queryRows( + `SELECT POP.objid::text AS "OBJID", + POP.part_objid AS "PART_OBJID", + POP.part_no AS "PART_NO", + POP.part_name AS "PART_NAME", + POP.spec AS "SPEC", + POP.maker AS "MAKER", + POP.unit AS "UNIT", + POP.bom_qty AS "BOM_QTY", + POP.qty AS "QTY", + POP.order_qty AS "ORDER_QTY", + POP.partner_price AS "PARTNER_PRICE", + POP.price1 AS "PRICE1", + POP.price2 AS "PRICE2", + POP.price3 AS "PRICE3", + POP.price4 AS "PRICE4", + POP.supply_unit_price AS "SUPPLY_UNIT_PRICE", + POP.supply_unit_vat_price AS "SUPPLY_UNIT_VAT_PRICE", + POP.supply_unit_vat_sum_price AS "SUPPLY_UNIT_VAT_SUM_PRICE", + POP.total_order_qty AS "TOTAL_ORDER_QTY", + POP.stock_qty AS "STOCK_QTY", + POP.real_order_qty AS "REAL_ORDER_QTY", + POP.real_supply_price AS "REAL_SUPPLY_PRICE", + POP.remark AS "REMARK" + FROM purchase_order_part POP + WHERE POP.purchase_order_master_objid = $1 + ORDER BY POP.part_no`, + [objId] + ); + + return NextResponse.json({ success: true, data: master, PARTS: parts }); +} diff --git a/src/app/api/purchase-order/parts/route.ts b/src/app/api/purchase-order/parts/route.ts new file mode 100644 index 0000000..22e124a --- /dev/null +++ b/src/app/api/purchase-order/parts/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 발주폼용 부품 검색 (part_mng에서 검색하여 추가) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["P.status = 'release'", "P.is_last = 'Y'"]; + const params: unknown[] = []; + let idx = 1; + + if (body.part_no) { + conditions.push(`P.part_no LIKE '%' || $${idx++} || '%'`); + params.push(body.part_no); + } + if (body.part_name) { + conditions.push(`P.part_name LIKE '%' || $${idx++} || '%'`); + params.push(body.part_name); + } + + const rows = await queryRows( + `SELECT P.objid::text AS "PART_OBJID", + P.part_no AS "PART_NO", + P.part_name AS "PART_NAME", + P.spec AS "SPEC", + P.maker AS "MAKER", + P.unit AS "UNIT", + COALESCE(P.qty::text, '0') AS "BOM_QTY" + FROM part_mng P + WHERE ${conditions.join(" AND ")} + ORDER BY P.part_no +`, + params + ); + + return NextResponse.json({ RESULTLIST: rows }); +} diff --git a/src/app/api/purchase-order/route.ts b/src/app/api/purchase-order/route.ts new file mode 100644 index 0000000..15275e9 --- /dev/null +++ b/src/app/api/purchase-order/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 발주관리 목록 조회 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(POM.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.po_no) { + conditions.push(`POM.purchase_order_no LIKE '%' || $${idx++} || '%'`); + params.push(body.po_no); + } + if (body.supplier_name) { + conditions.push(`COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') LIKE '%' || $${idx++} || '%'`); + params.push(body.supplier_name); + } + + const where = conditions.join(" AND "); + + const rows = await queryRows( + `SELECT POM.objid::text AS "OBJID", + POM.purchase_order_no AS "PO_NO", + POM.project_no AS "PROJECT_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') AS "SUPPLIER_NAME", + POM.title AS "PO_NAME", + POM.delivery_date AS "PO_DATE", + POM.delivery_date AS "DELIVERY_DATE", + COALESCE(POM.total_supply_price, '0') AS "PO_AMOUNT", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = POM.status LIMIT 1), POM.status) AS "STATUS_NAME", + COALESCE((SELECT user_name FROM user_info WHERE user_id = POM.writer LIMIT 1), POM.writer) AS "WRITER_NAME" + FROM purchase_order_master POM + WHERE ${where} + ORDER BY POM.regdate DESC +`, + params + ); + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/purchase-order/save/route.ts b/src/app/api/purchase-order/save/route.ts new file mode 100644 index 0000000..379f720 --- /dev/null +++ b/src/app/api/purchase-order/save/route.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 발주서 저장 (purchaseOrder.mergePurchaseOrderMaster + mergePurchaseOrderPartInfo 대응) +// 발주번호 자동 생성: PO-YYMM-NNN +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const isNew = !body.objId || body.actionType === "regist"; + const masterObjId = isNew ? createObjectId() : body.objId; + const parts: Record[] = body.parts || []; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // === 발주번호 생성 (신규시) === + let purchaseOrderNo = body.purchase_order_no || ""; + if (isNew && !purchaseOrderNo) { + const prefix = `PO-${new Date().toISOString().slice(2, 4)}${String(new Date().getMonth() + 1).padStart(2, "0")}-`; + const maxResult = await client.query( + `SELECT MAX( + CASE WHEN SPLIT_PART(purchase_order_no, '-', 3) = '' OR purchase_order_no IS NULL THEN '0' + ELSE SPLIT_PART(purchase_order_no, '-', 3) END::numeric + )::integer + 1 AS next_seq + FROM purchase_order_master` + ); + const nextSeq = maxResult.rows[0]?.next_seq || 1; + purchaseOrderNo = `${prefix}${nextSeq}`; + } + + // === 마스터 UPSERT === + await client.query( + `INSERT INTO purchase_order_master ( + objid, purchase_order_no, title, type, order_type_cd, purchase_order_no_org, + partner_objid, my_company_objid, contract_mgmt_objid, unit_code, + delivery_date, delivery_place, effective_date, payment_terms, + vat_method, remark, sales_mng_user_id, + total_supply_unit_price, total_supply_price, total_real_supply_price, + discount_price, total_price, nego_rate, total_price_txt, + writer, regdate, status, project_no + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9::numeric, $10, + $11, $12, $13, $14, + $15, $16, $17, + $18, $19, $20, + $21, $22, $23, $24, + $25, now(), $26, $27 + ) + ON CONFLICT (objid) DO UPDATE SET + title = EXCLUDED.title, + type = EXCLUDED.type, + order_type_cd = EXCLUDED.order_type_cd, + purchase_order_no_org = EXCLUDED.purchase_order_no_org, + partner_objid = EXCLUDED.partner_objid, + my_company_objid = EXCLUDED.my_company_objid, + contract_mgmt_objid = EXCLUDED.contract_mgmt_objid, + unit_code = EXCLUDED.unit_code, + delivery_date = EXCLUDED.delivery_date, + delivery_place = EXCLUDED.delivery_place, + effective_date = EXCLUDED.effective_date, + payment_terms = EXCLUDED.payment_terms, + vat_method = EXCLUDED.vat_method, + remark = EXCLUDED.remark, + sales_mng_user_id = EXCLUDED.sales_mng_user_id, + total_supply_unit_price = EXCLUDED.total_supply_unit_price, + total_supply_price = EXCLUDED.total_supply_price, + total_real_supply_price = EXCLUDED.total_real_supply_price, + discount_price = EXCLUDED.discount_price, + total_price = EXCLUDED.total_price, + nego_rate = EXCLUDED.nego_rate, + total_price_txt = EXCLUDED.total_price_txt, + status = EXCLUDED.status, + project_no = EXCLUDED.project_no`, + [ + masterObjId, purchaseOrderNo, body.title || "", body.type || "", + body.order_type_cd || "", body.purchase_order_no_org || "", + body.partner_objid || "", body.my_company_objid || "", + body.contract_mgmt_objid || null, body.unit_code || "", + body.delivery_date || null, body.delivery_place || "", + body.effective_date || null, body.payment_terms || "", + body.vat_method || "", body.remark || "", body.sales_mng_user_id || "", + body.total_supply_unit_price || "0", body.total_supply_price || "0", + body.total_real_supply_price || "0", + body.discount_price || "0", body.total_price || "0", + body.nego_rate || "0", body.total_price_txt || "", + user.userId, body.status || "create", body.project_no || "", + ] + ); + + // === 기존 파트 삭제 후 재입력 (수정 시) === + if (!isNew) { + await client.query( + `DELETE FROM purchase_order_part WHERE purchase_order_master_objid = $1`, + [masterObjId] + ); + } + + // === 파트 INSERT === + for (const part of parts) { + const partObjId = String(part.OBJID || part.objId || "") || createObjectId(); + await client.query( + `INSERT INTO purchase_order_part ( + objid, purchase_order_master_objid, part_objid, + part_no, part_name, spec, maker, unit, + bom_qty, qty, order_qty, partner_price, + price1, price2, price3, price4, + supply_unit_price, supply_unit_vat_price, supply_unit_vat_sum_price, + total_order_qty, stock_qty, real_order_qty, real_supply_price, + remark, writer, regdate, status + ) VALUES ( + $1, $2, $3, + $4, $5, $6, $7, $8, + REPLACE($9::varchar, ',', ''), REPLACE($10::varchar, ',', ''), + REPLACE($11::varchar, ',', ''), REPLACE($12::varchar, ',', ''), + REPLACE($13::varchar, ',', ''), REPLACE($14::varchar, ',', ''), + REPLACE($15::varchar, ',', ''), REPLACE($16::varchar, ',', ''), + REPLACE($17::varchar, ',', ''), REPLACE($18::varchar, ',', ''), + REPLACE($19::varchar, ',', ''), + REPLACE($20::varchar, ',', ''), REPLACE($21::varchar, ',', ''), + REPLACE($22::varchar, ',', ''), REPLACE($23::varchar, ',', ''), + $24, $25, now(), 'active' + )`, + [ + partObjId, masterObjId, String(part.PART_OBJID || ""), + String(part.PART_NO || ""), String(part.PART_NAME || ""), + String(part.SPEC || ""), String(part.MAKER || ""), + String(part.UNIT || ""), + String(part.BOM_QTY || "0"), String(part.QTY || "0"), + String(part.ORDER_QTY || "0"), String(part.PARTNER_PRICE || "0"), + String(part.PRICE1 || "0"), String(part.PRICE2 || "0"), + String(part.PRICE3 || "0"), String(part.PRICE4 || "0"), + String(part.SUPPLY_UNIT_PRICE || "0"), String(part.SUPPLY_UNIT_VAT_PRICE || "0"), + String(part.SUPPLY_UNIT_VAT_SUM_PRICE || "0"), + String(part.TOTAL_ORDER_QTY || "0"), String(part.STOCK_QTY || "0"), + String(part.REAL_ORDER_QTY || "0"), String(part.REAL_SUPPLY_PRICE || "0"), + String(part.REMARK || ""), user.userId, + ] + ); + } + + await client.query("COMMIT"); + + return NextResponse.json({ + success: true, + objId: masterObjId, + purchaseOrderNo, + message: isNew ? "등록되었습니다." : "수정되었습니다.", + }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Purchase order save error:", error); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/purchase/bom/route.ts b/src/app/api/purchase/bom/route.ts new file mode 100644 index 0000000..674d36c --- /dev/null +++ b/src/app/api/purchase/bom/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 구매관리 > 구매BOM관리 — 원본: /salesMng/salesBomReportList.do +// 테이블: part_bom_report + sales_bom_report + project_mgmt + pms_wbs_task +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(CM.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.customer_cd) { + conditions.push(`PBR.customer_objid = $${idx++}`); + params.push(body.customer_cd); + } + if (body.project_no) { + conditions.push(`COALESCE(CM.project_no, CM.contract_no) LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.unit_code) { + conditions.push(`PBR.unit_code = $${idx++}`); + params.push(body.unit_code); + } + if (body.unit_name) { + conditions.push( + `(SELECT COALESCE(O.unit_no,'') || '-' || COALESCE(O.task_name,'') FROM pms_wbs_task O WHERE O.objid = PBR.unit_code) LIKE '%' || $${idx++} || '%'` + ); + params.push(body.unit_name); + } + if (body.writer2_id) { + conditions.push(`SBR.writer = $${idx++}`); + params.push(body.writer2_id); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT PBR.objid::text AS "OBJID", + SBR.objid::text AS "SBR_OBJID", + COALESCE(CM.project_no, CM.contract_no, '') AS "PROJECT_NO", + CM.req_del_date AS "REQ_DEL_DATE", + (SELECT supply_name FROM admin_supply_mng WHERE objid::text = PBR.customer_objid LIMIT 1) AS "CUSTOMER_NAME", + (SELECT code_name FROM comm_code WHERE code_id = CM.contract_company LIMIT 1) AS "CONTRACT_COMPANY_NAME", + (SELECT code_name FROM comm_code WHERE code_id = CM.manufacture_plant LIMIT 1) AS "MANUFACTURE_PLANT_NAME", + PBR.unit_code AS "UNIT_CODE", + (SELECT COALESCE(O.unit_no,'') || '-' || COALESCE(O.task_name,'') FROM pms_wbs_task O WHERE O.objid = PBR.unit_code LIMIT 1) AS "UNIT_NAME", + (SELECT COUNT(*) FROM bom_part_qty A WHERE A.bom_report_objid = PBR.objid) AS "BOM_CNT", + PBR.deploy_date AS "DEPLOY_DATE", + PBR.writer AS "WRITER1", + COALESCE( + (SELECT user_name FROM user_info WHERE user_id = PBR.writer LIMIT 1), + (SELECT user_name FROM user_info_history WHERE user_id = PBR.writer LIMIT 1), + PBR.writer + ) AS "WRITER1_NAME", + (SELECT COUNT(1) FROM sales_bom_report_part SBRP WHERE SBRP.parent_objid = PBR.objid) AS "SALES_PART_CNT", + TO_CHAR(SBR.regdate, 'YYYY-MM-DD') AS "REGDATE2", + SBR.writer AS "WRITER2", + COALESCE( + (SELECT user_name FROM user_info WHERE user_id = SBR.writer LIMIT 1), + (SELECT user_name FROM user_info_history WHERE user_id = SBR.writer LIMIT 1), + SBR.writer + ) AS "WRITER2_NAME" + FROM part_bom_report PBR + LEFT OUTER JOIN sales_bom_report SBR ON PBR.objid = SBR.parent_objid + INNER JOIN project_mgmt CM ON PBR.contract_objid = CM.objid + WHERE ${where} + ORDER BY CM.regdate DESC, PBR.regdate DESC + `; + + try { + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); + } catch (e) { + console.error("purchase/bom list:", e); + return NextResponse.json({ success: false, message: "조회 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/purchase/bom/save/route.ts b/src/app/api/purchase/bom/save/route.ts new file mode 100644 index 0000000..5e3a244 --- /dev/null +++ b/src/app/api/purchase/bom/save/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 원본: /salesMng/saveSalesBomReportInfo.do — sales_bom_report_part UPSERT (공급업체 1~4 + 단가) +interface PartRow { + OBJID?: string; + PARENT_OBJID?: string; + PART_OBJID?: string; + PARENT_PART_NO?: string; + CHILD_OBJID?: string; + PRICE?: string; + SUPPLY_OBJID?: string; + PRICE1?: string; + SUPPLY_OBJID1?: string; + PRICE2?: string; + SUPPLY_OBJID2?: string; + PRICE3?: string; + SUPPLY_OBJID3?: string; + PRICE4?: string; + SUPPLY_OBJID4?: string; + PRICE_SUM?: string; +} + +const stripComma = (v: unknown) => String(v ?? "").replace(/,/g, ""); + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const parentObjId = String(body.parent_objId || body.PARENT_OBJID || ""); + const rows: PartRow[] = Array.isArray(body.rows) ? body.rows : []; + if (!parentObjId) { + return NextResponse.json({ success: false, message: "parent_objId가 필요합니다." }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + for (const r of rows) { + const objid = String(r.OBJID || "").trim() || createObjectId(); + await client.query( + `INSERT INTO sales_bom_report_part ( + objid, parent_objid, part_objid, parent_part_objid, bom_part_qty_objid, + supply_objid, price, supply_objid1, price1, supply_objid2, price2, + supply_objid3, price3, supply_objid4, price4, + price_sum, writer, regdate + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,now()) + ON CONFLICT (objid) DO UPDATE SET + supply_objid = EXCLUDED.supply_objid, + price = EXCLUDED.price, + supply_objid1 = EXCLUDED.supply_objid1, + price1 = EXCLUDED.price1, + supply_objid2 = EXCLUDED.supply_objid2, + price2 = EXCLUDED.price2, + supply_objid3 = EXCLUDED.supply_objid3, + price3 = EXCLUDED.price3, + supply_objid4 = EXCLUDED.supply_objid4, + price4 = EXCLUDED.price4, + price_sum = EXCLUDED.price_sum, + modifier = $17, + update_date = now()`, + [ + objid, + parentObjId, + String(r.PART_OBJID || ""), + String(r.PARENT_PART_NO || ""), + String(r.CHILD_OBJID || ""), + String(r.SUPPLY_OBJID || ""), + stripComma(r.PRICE), + String(r.SUPPLY_OBJID1 || ""), + stripComma(r.PRICE1), + String(r.SUPPLY_OBJID2 || ""), + stripComma(r.PRICE2), + String(r.SUPPLY_OBJID3 || ""), + stripComma(r.PRICE3), + String(r.SUPPLY_OBJID4 || ""), + stripComma(r.PRICE4), + stripComma(r.PRICE_SUM), + user.userId, + ], + ); + } + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("purchase/bom save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/purchase/bom/tree/route.ts b/src/app/api/purchase/bom/tree/route.ts new file mode 100644 index 0000000..aef01c8 --- /dev/null +++ b/src/app/api/purchase/bom/tree/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: salesMng.salesBomReportPartList — BOM 트리(재귀) + 기존 구매BOM(sales_bom_report_part) 매핑 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const parentObjId = String(body.parent_objId || body.bomReportObjId || ""); + if (!parentObjId) return NextResponse.json({ RESULTLIST: [] }); + + const sql = ` + WITH RECURSIVE VIEW_BOM AS ( + SELECT A.bom_report_objid, A.objid, A.parent_objid, A.child_objid, + A.parent_part_no, A.part_no, A.last_part_objid, A.qty, A.regdate, A.seq, + 1 AS lev, + ARRAY[A.child_objid::text] AS path + FROM bom_part_qty A + WHERE (A.parent_objid IS NULL OR A.parent_objid = '') + AND A.bom_report_objid = $1 + UNION ALL + SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, + B.parent_part_no, B.part_no, B.last_part_objid, B.qty, B.regdate, B.seq, + V.lev + 1, + V.path || B.child_objid::text + FROM bom_part_qty B + JOIN VIEW_BOM V ON B.parent_objid = V.child_objid + AND V.bom_report_objid = B.bom_report_objid + WHERE NOT (B.child_objid = ANY(V.path)) + ) + SELECT V.bom_report_objid::text AS "PARENT_OBJID", + V.part_no AS "PART_OBJID", + V.child_objid AS "CHILD_OBJID", + V.parent_part_no AS "PARENT_PART_NO", + V.last_part_objid AS "LAST_PART_OBJID", + V.qty AS "QTY", + V.lev AS "LEV", + V.seq AS "SEQ", + (SELECT MAX(lev) FROM VIEW_BOM) AS "MAX_LEV", + PM.objid::text AS "OBJID_PART", + PM.part_no AS "PART_NO", + PM.part_name AS "PART_NAME", + PM.spec AS "SPEC", + PM.post_processing AS "POST_PROCESSING", + PM.maker AS "MAKER", + PM.remark AS "REMARK", + PM.revision AS "REVISION", + PM.eo_no AS "EO_NO", + (SELECT code_name FROM comm_code WHERE code_id = PM.part_type LIMIT 1) AS "PART_TYPE_NAME", + SP.objid::text AS "OBJID", + SP.price AS "PRICE", + SP.supply_objid AS "SUPPLY_OBJID", + (SELECT supply_name FROM admin_supply_mng WHERE objid::text = SP.supply_objid LIMIT 1) AS "SUPPLY_NAME", + SP.price1 AS "PRICE1", + SP.supply_objid1 AS "SUPPLY_OBJID1", + (SELECT supply_name FROM admin_supply_mng WHERE objid::text = SP.supply_objid1 LIMIT 1) AS "SUPPLY_NAME1", + SP.price2 AS "PRICE2", + SP.supply_objid2 AS "SUPPLY_OBJID2", + (SELECT supply_name FROM admin_supply_mng WHERE objid::text = SP.supply_objid2 LIMIT 1) AS "SUPPLY_NAME2", + SP.price3 AS "PRICE3", + SP.supply_objid3 AS "SUPPLY_OBJID3", + (SELECT supply_name FROM admin_supply_mng WHERE objid::text = SP.supply_objid3 LIMIT 1) AS "SUPPLY_NAME3", + SP.price4 AS "PRICE4", + SP.supply_objid4 AS "SUPPLY_OBJID4", + (SELECT supply_name FROM admin_supply_mng WHERE objid::text = SP.supply_objid4 LIMIT 1) AS "SUPPLY_NAME4", + SP.price_sum AS "PRICE_SUM" + FROM VIEW_BOM V + LEFT OUTER JOIN sales_bom_report_part SP + ON SP.parent_objid = $1 AND SP.bom_part_qty_objid = V.child_objid + LEFT OUTER JOIN part_mng PM + ON COALESCE(V.last_part_objid, V.part_no) = PM.objid::varchar + ORDER BY V.path, V.regdate + `; + + try { + const rows = await queryRows(sql, [parentObjId]); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); + } catch (e) { + console.error("purchase/bom tree:", e); + return NextResponse.json({ success: false, message: "조회 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/purchase/design-change/detail/route.ts b/src/app/api/purchase/design-change/detail/route.ts new file mode 100644 index 0000000..01e7f04 --- /dev/null +++ b/src/app/api/purchase/design-change/detail/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne, queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 조치내역 팝업용 단건 조회 — sales_part_chg + part_mng_history 헤더 정보 함께 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objId, partObjId } = await request.json(); + const pid = String(partObjId || ""); + if (!pid) return NextResponse.json({ success: false, message: "partObjId 누락" }, { status: 400 }); + + // 부품이력 헤더 (읽기전용 표시) + const header = await queryOne( + `SELECT PM.objid::text AS "OBJID", + PM.eo_no AS "EO_NO", + PM.part_no AS "PART_NO", + PM.part_name AS "PART_NAME", + COALESCE(CM.project_no, CM.contract_no) AS "PROJECT_NO", + PM.bom_report_objid AS "BOM_REPORT_OBJID", + PM.qty_child_objid AS "QTY_CHILD_OBJID" + FROM part_mng_history PM + LEFT OUTER JOIN project_mgmt CM ON PM.contract_objid = CM.objid + WHERE PM.objid::text = $1`, + [pid] + ); + + // 조치내역 + const spc = objId + ? await queryOne( + `SELECT objid::text AS "OBJID", + part_objid AS "PART_OBJID", + confirm_date AS "CONFIRM_DATE", + act_cd AS "ACT_CD", + purchase_order_master_objid AS "PURCHASE_ORDER_MASTER_OBJID", + note AS "NOTE", + act_status AS "ACT_STATUS" + FROM sales_part_chg WHERE objid = $1`, + [String(objId)] + ) + : null; + + // 발주서 드롭다운 옵션 (읽기전용으로 고를 수 있게 최신 순) + const orders = await queryRows( + `SELECT objid AS "OBJID", purchase_order_no AS "PURCHASE_ORDER_NO" + FROM purchase_order_master + WHERE COALESCE(status,'') != 'deleted' + ORDER BY regdate DESC` + ); + + return NextResponse.json({ success: true, header, spc, orders }); +} diff --git a/src/app/api/purchase/design-change/part-history/route.ts b/src/app/api/purchase/design-change/part-history/route.ts new file mode 100644 index 0000000..6754239 --- /dev/null +++ b/src/app/api/purchase/design-change/part-history/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne, queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: /partMng/partMngHisDetailPopUp.do — 부품 변경 이력 조회 (550x250) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId 누락" }, { status: 400 }); + + const current = await queryOne( + `SELECT PM.objid::text AS "OBJID", + PM.part_no AS "PART_NO", + PM.part_name AS "PART_NAME", + PM.revision AS "REVISION", + PM.eo_no AS "EO_NO", + PM.eo_date AS "EO_DATE", + (SELECT code_name FROM comm_code WHERE code_id = PM.change_type LIMIT 1) AS "CHANGE_TYPE_NAME", + (SELECT code_name FROM comm_code WHERE code_id = PM.change_option LIMIT 1) AS "CHANGE_OPTION_NAME", + PM.qty AS "QTY", + PM.qty_temp AS "QTY_TEMP", + PM.remark AS "REMARK", + TO_CHAR(PM.reg_date, 'YYYY-MM-DD') AS "REG_DATE", + COALESCE( + (SELECT user_name FROM user_info WHERE user_id = PM.writer LIMIT 1), + (SELECT user_name FROM user_info_history WHERE user_id = PM.writer LIMIT 1), + PM.writer + ) AS "WRITER_NAME" + FROM part_mng_history PM + WHERE PM.objid::text = $1`, + [String(objId)] + ); + + if (!current) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + + // 같은 품번 이력 (같은 project 내) + const history = await queryRows( + `SELECT PM.objid::text AS "OBJID", + PM.revision AS "REVISION", + PM.eo_no AS "EO_NO", + PM.eo_date AS "EO_DATE", + (SELECT code_name FROM comm_code WHERE code_id = PM.change_type LIMIT 1) AS "CHANGE_TYPE_NAME", + TO_CHAR(PM.reg_date, 'YYYY-MM-DD') AS "REG_DATE", + COALESCE( + (SELECT user_name FROM user_info WHERE user_id = PM.writer LIMIT 1), + (SELECT user_name FROM user_info_history WHERE user_id = PM.writer LIMIT 1), + PM.writer + ) AS "WRITER_NAME" + FROM part_mng_history PM + WHERE PM.part_no = (SELECT part_no FROM part_mng_history WHERE objid::text = $1) + AND PM.contract_objid = (SELECT contract_objid FROM part_mng_history WHERE objid::text = $1) + ORDER BY PM.reg_date DESC +`, + [String(objId)] + ); + + return NextResponse.json({ success: true, current, history }); +} diff --git a/src/app/api/purchase/design-change/receipt/route.ts b/src/app/api/purchase/design-change/receipt/route.ts new file mode 100644 index 0000000..694460c --- /dev/null +++ b/src/app/api/purchase/design-change/receipt/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 원본: /salesMng/receiptSalesPartChgInfo.do +// 선택된 설계변경 이력(part_mng_history) 각 행마다 sales_part_chg UPSERT with ACT_STATUS='0001064' (접수) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { rows } = await request.json(); + if (!Array.isArray(rows) || rows.length === 0) { + return NextResponse.json({ success: false, message: "접수할 항목을 선택하세요." }); + } + + const today = new Date().toISOString().slice(0, 10); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + for (const row of rows) { + const partObjId = String(row.OBJID || ""); + if (!partObjId) continue; + + // 기존 SPC가 있으면 ACT_STATUS만 업데이트, 없으면 신규 INSERT + const existing = await client.query( + `SELECT objid FROM sales_part_chg WHERE part_objid = $1 AND COALESCE(qty_child_objid,'') = COALESCE($2,'') LIMIT 1`, + [partObjId, String(row.QTY_CHILD_OBJID || "")] + ); + + if (existing.rowCount && existing.rowCount > 0) { + await client.query( + `UPDATE sales_part_chg + SET act_status = '0001064', + confirm_date = $1, + writer = $2, + regdate = now() + WHERE objid = $3`, + [today, user.userId, existing.rows[0].objid] + ); + } else { + await client.query( + `INSERT INTO sales_part_chg ( + objid, part_objid, confirm_date, act_status, + bom_report_objid, qty_child_objid, writer, regdate + ) VALUES ($1, $2, $3, '0001064', $4, $5, $6, now())`, + [ + createObjectId(), + partObjId, + today, + String(row.BOM_REPORT_OBJID || ""), + String(row.QTY_CHILD_OBJID || ""), + user.userId, + ] + ); + } + } + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: `${rows.length}건 접수되었습니다.` }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("design-change receipt:", e); + return NextResponse.json({ success: false, message: "접수 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/purchase/design-change/route.ts b/src/app/api/purchase/design-change/route.ts new file mode 100644 index 0000000..a778acb --- /dev/null +++ b/src/app/api/purchase/design-change/route.ts @@ -0,0 +1,148 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: /salesMng/salesPartChgList.do — part_mng_history + sales_part_chg + project_mgmt +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = [ + "NOT (PM.his_status = 'DEPLOY' AND PM.change_type IS NULL AND PM.revision = 'RE')", + "PM.revision IS NOT NULL", + "COALESCE(PM.bom_status,'') = 'deploy'", + ]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(CM.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push(`COALESCE(CM.project_no, CM.contract_no) LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.part_no) { + conditions.push(`PM.part_no LIKE '%' || $${idx++} || '%'`); + params.push(body.part_no); + } + if (body.part_name) { + conditions.push(`PM.part_name LIKE '%' || $${idx++} || '%'`); + params.push(body.part_name); + } + if (body.change_type) { + conditions.push(`PM.change_type = $${idx++}`); + params.push(body.change_type); + } + if (body.change_option) { + conditions.push(`PM.change_option = $${idx++}`); + params.push(body.change_option); + } + if (body.part_type) { + conditions.push(`PM.part_type = $${idx++}`); + params.push(body.part_type); + } + if (body.revision) { + conditions.push(`PM.revision = $${idx++}`); + params.push(body.revision); + } + if (body.part_writer) { + conditions.push(`PM.writer = $${idx++}`); + params.push(body.part_writer); + } + if (body.sales_writer) { + conditions.push(`SPC.writer = $${idx++}`); + params.push(body.sales_writer); + } + if (body.act_cd) { + conditions.push(`SPC.act_cd = $${idx++}`); + params.push(body.act_cd); + } + if (body.act_status) { + conditions.push(`COALESCE(SPC.act_status, '0001063') = $${idx++}`); + params.push(body.act_status); + } + if (body.eo_date_start) { + conditions.push(`TO_DATE(PM.eo_date, 'YYYY-MM-DD') >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(body.eo_date_start); + } + if (body.eo_date_end) { + conditions.push(`TO_DATE(PM.eo_date, 'YYYY-MM-DD') <= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(body.eo_date_end); + } + if (body.confirm_date_start) { + conditions.push(`TO_DATE(SPC.confirm_date, 'YYYY-MM-DD') >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(body.confirm_date_start); + } + if (body.confirm_date_end) { + conditions.push(`TO_DATE(SPC.confirm_date, 'YYYY-MM-DD') <= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(body.confirm_date_end); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT PM.objid::text AS "OBJID", + PM.eo_no AS "EO_NO", + COALESCE(CM.project_no, CM.contract_no, '') AS "PROJECT_NO", + CM.customer_project_name AS "CUSTOMER_PROJECT_NAME", + (SELECT part_no || ' ' || part_name FROM part_mng SP WHERE SP.objid::text = PM.parent_part_objid LIMIT 1) AS "PARENT_PART_INFO", + PM.part_no AS "PART_NO", + PM.part_name AS "PART_NAME", + CASE WHEN PM.bom_qty_status = 'adding' THEN PM.qty_temp ELSE PM.qty END AS "QTY", + CASE + WHEN PM.bom_qty_status = 'adding' THEN '' + WHEN PM.bom_qty_status = 'beforeEdit' AND PM.qty = PM.qty_temp THEN '' + ELSE PM.qty_temp + END AS "QTY_TEMP", + PM.change_type AS "CHANGE_TYPE", + (SELECT code_name FROM comm_code WHERE code_id = PM.change_type LIMIT 1) AS "CHANGE_TYPE_NAME", + PM.change_option AS "CHANGE_OPTION", + (SELECT code_name FROM comm_code WHERE code_id = PM.change_option LIMIT 1) AS "CHANGE_OPTION_NAME", + PM.revision AS "REVISION", + PM.eo_date AS "EO_DATE", + PM.part_type AS "PART_TYPE", + (SELECT code_name FROM comm_code WHERE code_id = PM.part_type LIMIT 1) AS "PART_TYPE_NAME", + PM.writer AS "WRITER", + COALESCE( + (SELECT user_name FROM user_info WHERE user_id = PM.writer LIMIT 1), + (SELECT user_name FROM user_info_history WHERE user_id = PM.writer LIMIT 1), + PM.writer + ) AS "WRITER_NAME", + TO_CHAR(PM.reg_date, 'YYYY-MM-DD') AS "HIS_REG_DATE_TITLE", + PM.bom_report_objid AS "BOM_REPORT_OBJID", + PM.qty_child_objid AS "QTY_CHILD_OBJID", + SPC.objid::text AS "SPC_OBJID", + SPC.confirm_date AS "CONFIRM_DATE", + SPC.writer AS "WRITER1", + COALESCE( + (SELECT user_name FROM user_info WHERE user_id = SPC.writer LIMIT 1), + (SELECT user_name FROM user_info_history WHERE user_id = SPC.writer LIMIT 1), + SPC.writer + ) AS "WRITER1_NAME", + SPC.act_cd AS "ACT_CD", + (SELECT code_name FROM comm_code WHERE code_id = SPC.act_cd LIMIT 1) AS "ACT_NAME", + SPC.purchase_order_master_objid AS "PURCHASE_ORDER_MASTER_OBJID", + POM.purchase_order_no AS "PURCHASE_ORDER_NO", + COALESCE(SPC.act_status, '0001063') AS "ACT_STATUS", + (SELECT code_name FROM comm_code WHERE code_id = COALESCE(SPC.act_status, '0001063') LIMIT 1) AS "ACT_STATUS_NAME" + FROM part_mng_history PM + LEFT OUTER JOIN sales_part_chg SPC + ON PM.objid::text = SPC.part_objid + AND COALESCE(PM.qty_child_objid,'') = COALESCE(SPC.qty_child_objid,'') + LEFT OUTER JOIN purchase_order_master POM ON SPC.purchase_order_master_objid = POM.objid + LEFT OUTER JOIN project_mgmt CM ON PM.contract_objid = CM.objid + WHERE ${where} + ORDER BY COALESCE(PM.edit_date, PM.reg_date) DESC, PM.part_no + `; + + try { + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); + } catch (e) { + console.error("purchase/design-change list:", e); + return NextResponse.json({ success: false, message: "조회 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/purchase/design-change/save/route.ts b/src/app/api/purchase/design-change/save/route.ts new file mode 100644 index 0000000..6bce5a4 --- /dev/null +++ b/src/app/api/purchase/design-change/save/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 원본: /salesMng/saveSalesPartChgInfo.do — sales_part_chg UPSERT +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const spcObjId = String(body.objId || "") || createObjectId(); + const isNew = !body.objId; + + const client = await pool.connect(); + try { + await client.query( + `INSERT INTO sales_part_chg ( + objid, part_objid, confirm_date, act_cd, + purchase_order_master_objid, note, writer, regdate, + act_status, bom_report_objid, qty_child_objid + ) VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8, $9, $10) + ON CONFLICT (objid) DO UPDATE SET + part_objid = EXCLUDED.part_objid, + confirm_date = EXCLUDED.confirm_date, + act_cd = EXCLUDED.act_cd, + purchase_order_master_objid = EXCLUDED.purchase_order_master_objid, + note = EXCLUDED.note, + writer = EXCLUDED.writer, + regdate = now(), + act_status = EXCLUDED.act_status`, + [ + spcObjId, + String(body.partObjId || ""), + String(body.confirm_date || new Date().toISOString().slice(0, 10)), + String(body.act_cd || ""), + String(body.purchase_order_master_objid || ""), + String(body.note || ""), + user.userId, + String(body.act_status || "0001064"), + String(body.bom_report_objid || ""), + String(body.qty_child_objid || ""), + ] + ); + return NextResponse.json({ success: true, objId: spcObjId, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (e) { + console.error("design-change save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/purchase/request/bom-parts/route.ts b/src/app/api/purchase/request/bom-parts/route.ts new file mode 100644 index 0000000..aeb7420 --- /dev/null +++ b/src/app/api/purchase/request/bom-parts/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: salesMng.SalesBomPartListByProjectUnit — 프로젝트+유닛에 등록된 구매BOM 부품 목록 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json().catch(() => ({})); + const projectObjId = String(body.project_objid || body.project_no || ""); + const unitCode = String(body.unit_code || body.unit_name || ""); + if (!projectObjId || !unitCode) return NextResponse.json({ RESULTLIST: [] }); + + const rows = await queryRows( + `SELECT T1.parent_objid AS "BOM_REPORT_OBJID", + T1.part_objid AS "PART_OBJID", + T1.objid::text AS "SALES_BOM_REPORT_PART_OBJID", + T1.supply_objid AS "SUPPLY_OBJID", + PM.part_name AS "PART_NAME", + PM.part_no AS "PART_NO", + PM.spec AS "SPEC", + PM.maker AS "MAKER", + PM.unit AS "UNIT", + BPQ.qty AS "ORDER_QTY" + FROM sales_bom_report_part T1 + JOIN part_bom_report PBR ON PBR.objid = T1.parent_objid + JOIN bom_part_qty BPQ ON BPQ.bom_report_objid = PBR.objid AND T1.part_objid = BPQ.part_no + JOIN part_mng PM ON T1.part_objid = PM.objid::varchar + WHERE PBR.contract_objid = $1 AND PBR.unit_code = $2 + ORDER BY BPQ.seq`, + [projectObjId, unitCode], + ); + + return NextResponse.json({ RESULTLIST: rows }); +} diff --git a/src/app/api/purchase/request/delete/route.ts b/src/app/api/purchase/request/delete/route.ts new file mode 100644 index 0000000..46194b8 --- /dev/null +++ b/src/app/api/purchase/request/delete/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: salesMng.deleteSalesRequestPart + salesMng.deleteSalesRequestMaster +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objIds } = await request.json(); + if (!objIds?.length) return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }); + + try { + const ph = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + await execute(`DELETE FROM sales_request_part WHERE sales_request_master_objid IN (${ph})`, objIds); + await execute(`DELETE FROM sales_request_master WHERE objid::text IN (${ph})`, objIds); + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (error) { + console.error("purchase/request delete:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/purchase/request/detail/route.ts b/src/app/api/purchase/request/detail/route.ts new file mode 100644 index 0000000..62150df --- /dev/null +++ b/src/app/api/purchase/request/detail/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne, queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: salesMng.getSalesRequestMasterInfo + salesMngNew.getSalesRequestSavedPartList +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + const info = await queryOne( + `SELECT SRM.objid::text AS "OBJID", + SRM.request_mng_no AS "REQUEST_MNG_NO", + SRM.request_cd AS "REQUEST_CD", + SRM.project_no AS "PROJECT_NO", + (SELECT COALESCE(project_no, contract_no) FROM project_mgmt WHERE objid = SRM.project_no LIMIT 1) AS "PROJECT_NUMBER", + (SELECT customer_project_name FROM project_mgmt WHERE objid = SRM.project_no LIMIT 1) AS "PROJECT_NAME", + (SELECT supply_name FROM supply_mng WHERE objid::text = (SELECT customer_objid FROM project_mgmt WHERE objid = SRM.project_no LIMIT 1)::text LIMIT 1) AS "CUSTOMER_NAME", + (SELECT mechanical_type FROM project_mgmt WHERE objid = SRM.project_no LIMIT 1) AS "MECHANICAL_TYPE", + (SELECT setup FROM project_mgmt WHERE objid = SRM.project_no LIMIT 1) AS "SETUP", + SRM.release_date AS "RELEASE_DATE", + SRM.request_reasons AS "REQUEST_REASONS", + SRM.request_user_id AS "REQUEST_USER_ID", + SRM.delivery_request_date AS "DELIVERY_REQUEST_DATE", + SRM.unit_name AS "UNIT_NAME", + SRM.status AS "STATUS", + CASE SRM.status + WHEN 'create' THEN '등록' + WHEN 'release' THEN '제출 완료' + WHEN 'reception' THEN '접수' + WHEN 'approvalRequest' THEN '결재중' + WHEN 'approvalComplete' THEN '결재완료' + WHEN 'reject' THEN '반려' + ELSE '' + END AS "STATUS_TITLE", + SRM.receipt_user_id AS "RECEIPT_USER_ID", + SRM.receipt_date AS "RECEIPT_DATE", + SRM.writer AS "WRITER", + SRM.remark AS "REMARK", + TO_CHAR(SRM.regdate, 'YYYY-MM-DD') AS "REGDATE_TITLE" + FROM sales_request_master SRM + WHERE SRM.objid::text = $1`, + [objId], + ); + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + + const parts = await queryRows( + `SELECT SRP.objid::text AS "OBJID", + SRP.sales_bom_qty_objid AS "SALES_BOM_QTY_OBJID", + SRP.part_objid AS "PART_OBJID", + (SELECT part_no FROM part_mng WHERE objid::text = SRP.part_objid LIMIT 1) AS "PART_NO", + COALESCE(SRP.part_name, (SELECT part_name FROM part_mng WHERE objid::text = SRP.part_objid LIMIT 1)) AS "PART_NAME", + SRP.qty AS "QTY", + SRP.org_qty AS "ORG_QTY", + SRP.partner_objid AS "PARTNER_OBJID", + SRP.delivery_request_date AS "DELIVERY_REQUEST_DATE", + SRP.remark AS "REMARK" + FROM sales_request_part SRP + WHERE SRP.sales_request_master_objid = $1 + ORDER BY SRP.regdate`, + [objId], + ); + + return NextResponse.json({ success: true, data: info, PARTS: parts }); +} diff --git a/src/app/api/purchase/request/receipt/route.ts b/src/app/api/purchase/request/receipt/route.ts new file mode 100644 index 0000000..9d9fd0e --- /dev/null +++ b/src/app/api/purchase/request/receipt/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: /salesMng/receiptSalesRequestInfo.do — sales_request_master.status='reception' +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objIds } = await request.json(); + if (!Array.isArray(objIds) || objIds.length === 0) { + return NextResponse.json({ success: false, message: "접수할 항목을 선택하세요." }); + } + + const today = new Date().toISOString().slice(0, 10); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + for (const objId of objIds) { + await client.query( + `UPDATE sales_request_master + SET status = 'reception', + receipt_date = $1, + receipt_user_id = $2 + WHERE objid::text = $3`, + [today, user.userId, String(objId)], + ); + } + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: `${objIds.length}건 접수되었습니다.` }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("purchase/request receipt:", e); + return NextResponse.json({ success: false, message: "접수 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/purchase/request/route.ts b/src/app/api/purchase/request/route.ts new file mode 100644 index 0000000..6438a5d --- /dev/null +++ b/src/app/api/purchase/request/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: /salesMng/salesRequestMngRegList.do — sales_request_master + sales_request_part +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(SRM.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.project_no) { + conditions.push( + `SRM.project_no IN (SELECT objid FROM project_mgmt WHERE COALESCE(project_no, contract_no) LIKE '%' || $${idx++} || '%')` + ); + params.push(body.project_no); + } + if (body.request_cd) { + conditions.push(`SRM.request_cd = $${idx++}`); + params.push(body.request_cd); + } + if (body.receipt_writer) { + conditions.push(`SRM.receipt_user_id = $${idx++}`); + params.push(body.receipt_writer); + } + if (body.status) { + conditions.push(`SRM.status = $${idx++}`); + params.push(body.status); + } + if (body.receipt_date_start) { + conditions.push(`TO_DATE(SRM.receipt_date,'YYYY-MM-DD') >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(body.receipt_date_start); + } + if (body.receipt_date_end) { + conditions.push(`TO_DATE(SRM.receipt_date,'YYYY-MM-DD') <= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(body.receipt_date_end); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT SRM.objid::text AS "OBJID", + SRM.request_mng_no AS "REQUEST_MNG_NO", + SRM.request_cd AS "REQUEST_CD", + (SELECT code_name FROM comm_code WHERE code_id = SRM.request_cd LIMIT 1) AS "REQUEST_CD_NAME", + SRM.project_no AS "PROJECT_OBJID", + (SELECT COALESCE(project_no, contract_no) FROM project_mgmt WHERE objid = SRM.project_no LIMIT 1) AS "PROJECT_NUMBER", + (SELECT customer_project_name FROM project_mgmt WHERE objid = SRM.project_no LIMIT 1) AS "PROJECT_NAME", + SRM.unit_name AS "UNIT_CODE", + (SELECT COALESCE(O.unit_no,'') || '-' || COALESCE(O.task_name,'') FROM pms_wbs_task O WHERE O.objid = SRM.unit_name LIMIT 1) AS "UNIT_CODE_NAME", + SRM.request_reasons AS "REQUEST_REASONS", + (SELECT code_name FROM comm_code WHERE code_id = SRM.request_reasons LIMIT 1) AS "REQUEST_REASONS_NAME", + SRM.request_user_id AS "REQUEST_USER_ID", + COALESCE( + (SELECT user_name FROM user_info WHERE user_id = SRM.request_user_id LIMIT 1), + (SELECT user_name FROM user_info_history WHERE user_id = SRM.request_user_id LIMIT 1), + SRM.request_user_id + ) AS "REQUEST_USER_NAME", + SRM.delivery_request_date AS "DELIVERY_REQUEST_DATE", + SRM.status AS "STATUS", + CASE + WHEN ((SELECT COUNT(1) FROM purchase_order_master P WHERE P.sales_request_objid = SRM.objid) + >= (SELECT COUNT(1) FROM sales_request_part SRP WHERE SRP.sales_request_master_objid = SRM.objid)) + AND (SELECT COUNT(1) FROM purchase_order_master P WHERE P.sales_request_objid = SRM.objid) > 0 + THEN '발주완료' + WHEN (SELECT COUNT(1) FROM purchase_order_master P WHERE P.sales_request_objid = SRM.objid) > 0 + THEN '발주부분완료' + WHEN SRM.status = 'create' THEN '등록' + WHEN SRM.status = 'release' THEN '제출 완료' + WHEN SRM.status = 'reception' THEN '접수' + WHEN SRM.status = 'approvalRequest' THEN '결재중' + WHEN SRM.status = 'approvalComplete' THEN '결재완료' + WHEN SRM.status = 'reject' THEN '반려' + ELSE '' + END AS "STATUS_TITLE", + SRM.receipt_user_id AS "RECEIPT_USER_ID", + COALESCE( + (SELECT user_name FROM user_info WHERE user_id = SRM.receipt_user_id LIMIT 1), + (SELECT user_name FROM user_info_history WHERE user_id = SRM.receipt_user_id LIMIT 1), + SRM.receipt_user_id + ) AS "RECEIPT_USER_NAME", + SRM.receipt_date AS "RECEIPT_DATE", + TO_CHAR(SRM.regdate, 'YYYY-MM-DD') AS "REGDATE_TITLE", + SRP.TOTAL_QTY AS "TOTAL_QTY", + SRP.ITEMS_QTY AS "ITEMS_QTY", + (SELECT STRING_AGG(purchase_order_no, ',') + FROM purchase_order_master P WHERE P.sales_request_objid = SRM.objid) AS "PURCHASE_ORDER_NO_ARR" + FROM sales_request_master SRM + LEFT OUTER JOIN ( + SELECT sales_request_master_objid, + SUM(COALESCE(qty,'0')::numeric) AS TOTAL_QTY, + COUNT(1) AS ITEMS_QTY + FROM sales_request_part + GROUP BY sales_request_master_objid + ) SRP ON SRP.sales_request_master_objid = SRM.objid + WHERE ${where} + ORDER BY SRM.regdate DESC + `; + + try { + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); + } catch (e) { + console.error("purchase/request list:", e); + return NextResponse.json({ success: false, message: "조회 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/purchase/request/save/route.ts b/src/app/api/purchase/request/save/route.ts new file mode 100644 index 0000000..1c7081e --- /dev/null +++ b/src/app/api/purchase/request/save/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 원본: salesMng.mergeSalesRequestMasterInfo + salesMng.initSalesRequestPart + salesMng.mergeSalesRequestPartInfo +interface PartRow { + OBJID?: string; + SALES_BOM_QTY_OBJID?: string; + PART_OBJID?: string; + PART_NAME?: string; + QTY?: string | number; + ORG_QTY?: string | number; + PARTNER_OBJID?: string; + DELIVERY_REQUEST_DATE?: string; + REMARK?: string; +} + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const isNew = !body.objId || body.actionType === "regist"; + const masterObjId = isNew ? createObjectId() : String(body.objId); + const status = String(body.status || "create"); + const parts: PartRow[] = Array.isArray(body.parts) ? body.parts : []; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + await client.query( + `INSERT INTO sales_request_master ( + objid, request_mng_no, request_cd, project_no, release_date, + request_reasons, request_user_id, delivery_request_date, unit_name, + status, writer, remark, regdate + ) VALUES ( + $1, + (SELECT 'R' || TO_CHAR(NOW(),'YYYYMMDD') || '-' || LPAD((COALESCE(MAX(SUBSTR(request_mng_no, 11, 13))::integer, 0) + 1)::text, 3, '0') + FROM sales_request_master + WHERE request_mng_no LIKE 'R' || TO_CHAR(NOW(),'YYYYMMDD') || '-%'), + $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now() + ) + ON CONFLICT (objid) DO UPDATE SET + request_cd = EXCLUDED.request_cd, + project_no = EXCLUDED.project_no, + release_date = EXCLUDED.release_date, + request_reasons = EXCLUDED.request_reasons, + request_user_id = EXCLUDED.request_user_id, + delivery_request_date = EXCLUDED.delivery_request_date, + unit_name = EXCLUDED.unit_name, + status = EXCLUDED.status, + remark = EXCLUDED.remark`, + [ + masterObjId, + String(body.request_cd || ""), + String(body.project_no || ""), + String(body.release_date || ""), + String(body.request_reasons || ""), + String(body.request_user_id || user.userId), + String(body.delivery_request_date || ""), + String(body.unit_name || ""), + status, + user.userId, + String(body.remark || ""), + ], + ); + + await client.query(`DELETE FROM sales_request_part WHERE sales_request_master_objid = $1`, [masterObjId]); + + for (const p of parts) { + if (!p.PART_OBJID) continue; + await client.query( + `INSERT INTO sales_request_part ( + objid, sales_bom_qty_objid, part_objid, sales_request_master_objid, + part_name, qty, org_qty, partner_objid, + delivery_request_date, writer, regdate, status, remark + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now(), $11, $12)`, + [ + createObjectId(), + String(p.SALES_BOM_QTY_OBJID || ""), + String(p.PART_OBJID || ""), + masterObjId, + String(p.PART_NAME || ""), + String(p.QTY || "0"), + String(p.ORG_QTY || p.QTY || "0"), + String(p.PARTNER_OBJID || ""), + String(p.DELIVERY_REQUEST_DATE || body.delivery_request_date || ""), + status, + String(p.REMARK || ""), + ], + ); + } + + await client.query("COMMIT"); + return NextResponse.json({ + success: true, + objId: masterObjId, + message: isNew ? "등록되었습니다." : "수정되었습니다.", + }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("purchase/request save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/purchase/stock/delete/route.ts b/src/app/api/purchase/stock/delete/route.ts new file mode 100644 index 0000000..25373b7 --- /dev/null +++ b/src/app/api/purchase/stock/delete/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: deleteSalesLongDelivery — 자식 테이블 + 메인 모두 삭제 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objIds } = await request.json(); + if (!Array.isArray(objIds) || objIds.length === 0) { + return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }); + } + + const ph = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query(`DELETE FROM sales_long_delivery_input WHERE parent_objid IN (${ph})`, objIds); + await client.query(`DELETE FROM sales_long_delivery_predict WHERE parent_objid IN (${ph})`, objIds); + await client.query(`DELETE FROM sales_long_delivery WHERE objid IN (${ph})`, objIds); + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("stock delete:", e); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/purchase/stock/detail/route.ts b/src/app/api/purchase/stock/detail/route.ts new file mode 100644 index 0000000..87f637f --- /dev/null +++ b/src/app/api/purchase/stock/detail/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne, queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 재고 단건 조회 — 기본정보 + 자재투입이력 + 예측수량 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId 누락" }, { status: 400 }); + + const info = await queryOne( + `SELECT objid::text AS "OBJID", + ld_part_name AS "LD_PART_NAME", + spec AS "SPEC", + form_no AS "FORM_NO", + maker AS "MAKER", + material_code AS "MATERIAL_CODE", + supply_objid AS "SUPPLY_OBJID", + (SELECT supply_name FROM admin_supply_mng WHERE objid::text = T1.supply_objid LIMIT 1) AS "SUPPLY_NAME", + location AS "LOCATION", + price AS "PRICE" + FROM sales_long_delivery T1 + WHERE objid = $1`, + [String(objId)] + ); + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + + const inputs = await queryRows( + `SELECT objid AS "OBJID", + contract_objid AS "CONTRACT_OBJID", + (SELECT COALESCE(project_no, contract_no) FROM project_mgmt WHERE objid = I.contract_objid LIMIT 1) AS "PROJECT_NO", + input_qty AS "INPUT_QTY", + input_date AS "INPUT_DATE" + FROM sales_long_delivery_input I + WHERE parent_objid = $1 + ORDER BY input_date DESC, objid`, + [String(objId)] + ); + + const predicts = await queryRows( + `SELECT objid AS "OBJID", + month AS "MONTH", + use_place AS "USE_PLACE", + contract_objid AS "CONTRACT_OBJID", + (SELECT COALESCE(project_no, contract_no) FROM project_mgmt WHERE objid = P.contract_objid LIMIT 1) AS "PROJECT_NO", + qty AS "QTY", + note AS "NOTE" + FROM sales_long_delivery_predict P + WHERE parent_objid = $1 + ORDER BY month::numeric, objid`, + [String(objId)] + ); + + return NextResponse.json({ success: true, info, inputs, predicts }); +} diff --git a/src/app/api/purchase/stock/input-save/route.ts b/src/app/api/purchase/stock/input-save/route.ts new file mode 100644 index 0000000..828d889 --- /dev/null +++ b/src/app/api/purchase/stock/input-save/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 자재투입이력 일괄 저장 (기존 행 delete 후 re-insert — 원본 merge 방식) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { parentObjId, rows } = await request.json(); + if (!parentObjId) return NextResponse.json({ success: false, message: "parentObjId 누락" }, { status: 400 }); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query(`DELETE FROM sales_long_delivery_input WHERE parent_objid = $1`, [String(parentObjId)]); + + const list = Array.isArray(rows) ? rows : []; + for (const r of list) { + const qty = String(r.INPUT_QTY ?? r.input_qty ?? ""); + if (!qty) continue; + await client.query( + `INSERT INTO sales_long_delivery_input ( + objid, parent_objid, contract_objid, input_qty, input_date, + admin_editor, admin_edit_date + ) VALUES ($1, $2, $3, $4, $5, $6, now())`, + [ + String(r.OBJID || r.objid || "") || createObjectId(), + String(parentObjId), + String(r.CONTRACT_OBJID ?? r.contract_objid ?? ""), + qty, + String(r.INPUT_DATE ?? r.input_date ?? ""), + user.userId, + ] + ); + } + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("stock input-save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/purchase/stock/predict-save/route.ts b/src/app/api/purchase/stock/predict-save/route.ts new file mode 100644 index 0000000..c96c3b1 --- /dev/null +++ b/src/app/api/purchase/stock/predict-save/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 예측수량 일괄 저장 (기존 행 delete 후 re-insert) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { parentObjId, rows } = await request.json(); + if (!parentObjId) return NextResponse.json({ success: false, message: "parentObjId 누락" }, { status: 400 }); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query(`DELETE FROM sales_long_delivery_predict WHERE parent_objid = $1`, [String(parentObjId)]); + + const list = Array.isArray(rows) ? rows : []; + for (const r of list) { + const qty = String(r.QTY ?? r.qty ?? ""); + if (!qty) continue; + await client.query( + `INSERT INTO sales_long_delivery_predict ( + objid, parent_objid, month, use_place, qty, note, contract_objid + ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + String(r.OBJID || r.objid || "") || createObjectId(), + String(parentObjId), + String(r.MONTH ?? r.month ?? ""), + String(r.USE_PLACE ?? r.use_place ?? ""), + qty, + String(r.NOTE ?? r.note ?? ""), + String(r.CONTRACT_OBJID ?? r.contract_objid ?? ""), + ] + ); + } + + await client.query("COMMIT"); + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + await client.query("ROLLBACK"); + console.error("stock predict-save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/purchase/stock/route.ts b/src/app/api/purchase/stock/route.ts new file mode 100644 index 0000000..687be46 --- /dev/null +++ b/src/app/api/purchase/stock/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 원본: /salesMng/salesLongDeliveryList.do — sales_long_delivery + _input + _predict +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.ld_part_name || body.part_name) { + conditions.push(`T1.ld_part_name LIKE '%' || $${idx++} || '%'`); + params.push(body.ld_part_name || body.part_name); + } + if (body.Location || body.location) { + conditions.push(`T1.location = $${idx++}`); + params.push(body.Location || body.location); + } + if (body.spec) { + conditions.push(`T1.spec LIKE '%' || $${idx++} || '%'`); + params.push(body.spec); + } + if (body.maker) { + conditions.push(`T1.maker LIKE '%' || $${idx++} || '%'`); + params.push(body.maker); + } + if (body.material_code) { + conditions.push(`T1.material_code LIKE '%' || $${idx++} || '%'`); + params.push(body.material_code); + } + if (body.admin_supply) { + conditions.push(`T1.supply_objid = $${idx++}`); + params.push(body.admin_supply); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT T1.objid::text AS "OBJID", + T1.ld_part_name AS "LD_PART_NAME", + T1.spec AS "SPEC", + T1.form_no AS "FORM_NO", + T1.maker AS "MAKER", + T1.material_code AS "MATERIAL_CODE", + T1.supply_objid AS "SUPPLY_OBJID", + (SELECT supply_name FROM admin_supply_mng S WHERE S.objid::text = T1.supply_objid LIMIT 1) AS "SUPPLY_NAME", + T1.location AS "LOCATION", + (SELECT code_name FROM comm_code WHERE code_id = T1.location LIMIT 1) AS "LOCATION_NAME", + T1.price AS "PRICE", + (COALESCE(T1.price,'0')::numeric * + COALESCE((SELECT SUM(COALESCE(input_qty,'0')::numeric) FROM sales_long_delivery_input T2 WHERE T2.parent_objid = T1.objid), 0) + )::text AS "PRICE_SUM", + COALESCE( + (SELECT SUM(COALESCE(input_qty,'0')::numeric) FROM sales_long_delivery_input T2 + WHERE T2.parent_objid = T1.objid AND (contract_objid IS NULL OR contract_objid = '')), 0 + ) - + COALESCE( + (SELECT SUM(COALESCE(input_qty,'0')::numeric) FROM sales_long_delivery_input T2 + WHERE T2.parent_objid = T1.objid AND contract_objid IS NOT NULL AND contract_objid != ''), 0 + ) AS "INPUT_QTY", + COALESCE( + (SELECT COUNT(1) FROM sales_long_delivery_input T2 WHERE T2.parent_objid = T1.objid), 0 + ) AS "INPUT_CNT", + T3.M_TOTAL AS "M_TOTAL", + T3.M01 AS "M01", T3.M02 AS "M02", T3.M03 AS "M03", + T3.M04 AS "M04", T3.M05 AS "M05", T3.M06 AS "M06", + T3.M07 AS "M07", T3.M08 AS "M08", T3.M09 AS "M09", + T3.M10 AS "M10", T3.M11 AS "M11", T3.M12 AS "M12" + FROM sales_long_delivery T1 + LEFT OUTER JOIN ( + SELECT parent_objid, + SUM(COALESCE(qty,'0')::numeric) AS M_TOTAL, + SUM(CASE WHEN month::numeric = 1 THEN qty::numeric END) AS M01, + SUM(CASE WHEN month::numeric = 2 THEN qty::numeric END) AS M02, + SUM(CASE WHEN month::numeric = 3 THEN qty::numeric END) AS M03, + SUM(CASE WHEN month::numeric = 4 THEN qty::numeric END) AS M04, + SUM(CASE WHEN month::numeric = 5 THEN qty::numeric END) AS M05, + SUM(CASE WHEN month::numeric = 6 THEN qty::numeric END) AS M06, + SUM(CASE WHEN month::numeric = 7 THEN qty::numeric END) AS M07, + SUM(CASE WHEN month::numeric = 8 THEN qty::numeric END) AS M08, + SUM(CASE WHEN month::numeric = 9 THEN qty::numeric END) AS M09, + SUM(CASE WHEN month::numeric = 10 THEN qty::numeric END) AS M10, + SUM(CASE WHEN month::numeric = 11 THEN qty::numeric END) AS M11, + SUM(CASE WHEN month::numeric = 12 THEN qty::numeric END) AS M12 + FROM sales_long_delivery_predict + GROUP BY parent_objid + ) T3 ON T3.parent_objid = T1.objid + WHERE ${where} + ORDER BY T1.ld_part_name, "SUPPLY_NAME" + `; + + try { + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); + } catch (e) { + console.error("purchase/stock list:", e); + return NextResponse.json({ success: false, message: "조회 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/purchase/stock/save/route.ts b/src/app/api/purchase/stock/save/route.ts new file mode 100644 index 0000000..ec256af --- /dev/null +++ b/src/app/api/purchase/stock/save/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 원본: mergeSalesLongDelivery +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const isNew = !body.objId; + const objId = isNew ? createObjectId() : String(body.objId); + + const client = await pool.connect(); + try { + await client.query( + `INSERT INTO sales_long_delivery ( + objid, ld_part_name, spec, form_no, maker, material_code, + supply_objid, regdate, writer, location, price + ) VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8, $9, $10) + ON CONFLICT (objid) DO UPDATE SET + ld_part_name = EXCLUDED.ld_part_name, + spec = EXCLUDED.spec, + form_no = EXCLUDED.form_no, + maker = EXCLUDED.maker, + material_code = EXCLUDED.material_code, + supply_objid = EXCLUDED.supply_objid, + regdate = now(), + writer = EXCLUDED.writer, + location = EXCLUDED.location, + price = EXCLUDED.price`, + [ + objId, + String(body.ld_part_name || ""), + String(body.spec || ""), + String(body.form_no || ""), + String(body.maker || ""), + String(body.material_code || ""), + String(body.supply_objid || ""), + user.userId, + String(body.location || ""), + String(body.price || "0"), + ] + ); + return NextResponse.json({ success: true, objId, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (e) { + console.error("stock save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/sales/contract-dashboard/customer-stats/route.ts b/src/app/api/sales/contract-dashboard/customer-stats/route.ts new file mode 100644 index 0000000..a990ab0 --- /dev/null +++ b/src/app/api/sales/contract-dashboard/customer-stats/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 고객사별 수주 건수 (파이차트용) +// 원본: contractMgmt.xml getContractCNTBySupply +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const year = String(body.Year || body.year || new Date().getFullYear()); + + const rows = await queryRows( + `SELECT + ASM.objid::text AS "OBJID", + ASM.supply_name AS "SUPPLY_NAME", + S1.total_cnt AS "TOTAL_SUPPLY_UNIT_CNT" + FROM supply_mng ASM + JOIN ( + SELECT CM.customer_objid, COUNT(1) AS total_cnt + FROM contract_mgmt CM + WHERE CM.contract_result = '0000964' + AND TO_CHAR(TO_DATE(CM.contract_date,'YYYY-MM-DD'),'YYYY') = $1 + GROUP BY CM.customer_objid + ) S1 ON ASM.objid::varchar = S1.customer_objid + ORDER BY S1.total_cnt DESC, ASM.supply_name`, + [year] + ); + + return NextResponse.json({ success: true, rows }); +} diff --git a/src/app/api/sales/contract-dashboard/route.ts b/src/app/api/sales/contract-dashboard/route.ts new file mode 100644 index 0000000..fc62bd6 --- /dev/null +++ b/src/app/api/sales/contract-dashboard/route.ts @@ -0,0 +1,146 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 영업관리 > 계약현황 대시보드 메인 테이블 +// 원본: contractMgmt.xml getContractDashBoard +// - 12개월 × 제품구분(0000001 자식) 매트릭스 +// - 각 셀은 해당 월 × 제품의 수주 건수 +// - 계 row (연간 합계), 월별 row 반환 +// - 각 row에 연간 매출액(원화 환산), 출고 수량 포함 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const year = String(body.Year || body.year || new Date().getFullYear()); + + // 제품구분 종류 (CODE list) + const products = await queryRows<{ CODE: string; NAME: string }>( + `SELECT code_id AS "CODE", code_name AS "NAME" + FROM comm_code + WHERE parent_code_id = '0000001' + AND COALESCE(status,'active') = 'active' + ORDER BY code_id` + ); + + // 동적 필터 + const extraConditions: string[] = []; + const extraParams: unknown[] = []; + let idx = 2; // $1 은 year + if (body.category_cd) { + extraConditions.push(`CM.category_cd = $${idx++}`); + extraParams.push(body.category_cd); + } + if (body.customer_objid) { + extraConditions.push(`CM.customer_objid = $${idx++}`); + extraParams.push(body.customer_objid); + } + if (body.product) { + extraConditions.push(`CM.product = $${idx++}`); + extraParams.push(body.product); + } + const extraWhere = extraConditions.length > 0 ? " AND " + extraConditions.join(" AND ") : ""; + + // 월별 × 제품 cross-tab 데이터 집계 + const monthly = await queryRows>( + ` + WITH base AS ( + SELECT + TO_CHAR(TO_DATE(CM.contract_date, 'YYYY-MM-DD'), 'MM') AS mm, + CM.product, + COALESCE(NULLIF( + CASE + WHEN COALESCE(NULLIF(CM.contract_price,''), '0')::float != 0 THEN CM.contract_price + WHEN CM.contract_currency = '0001566' AND COALESCE(NULLIF(CM.contract_price_currency,''), '0')::float != 0 THEN CM.contract_price_currency + ELSE '0' + END, '' + )::float, 0) AS contract_price, + (SELECT COUNT(*) FROM project_mgmt PM RIGHT JOIN release_mgmt RM ON PM.objid = RM.parent_objid WHERE CM.objid = PM.contract_objid) AS release_qty + FROM contract_mgmt CM + WHERE CM.contract_date IS NOT NULL + AND CM.contract_result = '0000964' + AND TO_CHAR(TO_DATE(CM.contract_date, 'YYYY-MM-DD'), 'YYYY') = $1 + ${extraWhere} + ) + SELECT mm AS "MM", + product AS "PRODUCT", + COUNT(*) AS "CNT", + SUM(contract_price) AS "COST", + SUM(release_qty) AS "RELEASE_QTY" + FROM base + GROUP BY mm, product + ORDER BY mm + `, + [year, ...extraParams] + ); + + // row-per-month 로 재구성: { MM: '01', total_cnt, total_cost, release, byProduct: { : cnt } } + type MonthRow = { + MM: string; + CONTRACT_CNT_YEAR: number; + CONTRACT_COST_YEAR_ORG: number; + CONTRACT_COST_YEAR: number; // /100000000 + RELEASE_CNT_YEAR: number; + [key: string]: number | string; // CONTRACT_CNT_MONTH_ + }; + + const months: MonthRow[] = []; + for (let i = 1; i <= 12; i++) { + const mm = String(i).padStart(2, "0"); + const row: MonthRow = { + MM: mm, + CONTRACT_CNT_YEAR: 0, + CONTRACT_COST_YEAR_ORG: 0, + CONTRACT_COST_YEAR: 0, + RELEASE_CNT_YEAR: 0, + }; + for (const p of products) row[`CONTRACT_CNT_MONTH_${p.CODE}`] = 0; + months.push(row); + } + + for (const r of monthly) { + const mm = String(r.MM || "").padStart(2, "0"); + const m = months.find((x) => x.MM === mm); + if (!m) continue; + const cnt = Number(r.CNT || 0); + const cost = Number(r.COST || 0); + const rel = Number(r.RELEASE_QTY || 0); + const pcode = String(r.PRODUCT || ""); + m.CONTRACT_CNT_YEAR += cnt; + m.CONTRACT_COST_YEAR_ORG += cost; + m.CONTRACT_COST_YEAR = m.CONTRACT_COST_YEAR_ORG / 100000000; + m.RELEASE_CNT_YEAR += rel; + if (pcode) { + const key = `CONTRACT_CNT_MONTH_${pcode}`; + m[key] = (Number(m[key] || 0) + cnt); + } + } + + // 계(합계) row + const total: MonthRow = { + MM: "", + CONTRACT_CNT_YEAR: 0, + CONTRACT_COST_YEAR_ORG: 0, + CONTRACT_COST_YEAR: 0, + RELEASE_CNT_YEAR: 0, + }; + for (const p of products) total[`CONTRACT_CNT_MONTH_${p.CODE}`] = 0; + for (const m of months) { + total.CONTRACT_CNT_YEAR += m.CONTRACT_CNT_YEAR; + total.CONTRACT_COST_YEAR_ORG += m.CONTRACT_COST_YEAR_ORG; + total.RELEASE_CNT_YEAR += m.RELEASE_CNT_YEAR; + for (const p of products) { + const key = `CONTRACT_CNT_MONTH_${p.CODE}`; + total[key] = Number(total[key] || 0) + Number(m[key] || 0); + } + } + total.CONTRACT_COST_YEAR = total.CONTRACT_COST_YEAR_ORG / 100000000; + + return NextResponse.json({ + products, + total, + months, + year, + }); +} diff --git a/src/app/api/sales/contract-dashboard/year-goal/route.ts b/src/app/api/sales/contract-dashboard/year-goal/route.ts new file mode 100644 index 0000000..cc506d2 --- /dev/null +++ b/src/app/api/sales/contract-dashboard/year-goal/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 년도별 영업목표 + 실적 조회 +// 원본: contractMgmt.xml getYearGoalInfo +// body.Year 기준으로 Year, Year-1, Year-2 3개 반환 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const baseYear = parseInt(String(body.Year || body.year || new Date().getFullYear()), 10) || new Date().getFullYear(); + + const fetchYear = async (yr: string) => { + const row = await queryOne>( + ` + WITH W_CM AS ( + SELECT + CM.area_cd, + COALESCE(NULLIF( + CASE + WHEN COALESCE(NULLIF(CM.contract_price,''), '0') != '0' THEN COALESCE(NULLIF(CM.contract_price,''), '0') + WHEN CM.contract_currency = '0001566' AND COALESCE(NULLIF(CM.contract_price_currency,''), '0') != '0' THEN COALESCE(NULLIF(CM.contract_price_currency,''), '0') + ELSE COALESCE(NULLIF(CM.contract_price,''), '0') + END, '' + ), '0') AS contract_price + FROM contract_mgmt CM + WHERE CM.contract_result = '0000964' + AND TO_CHAR(TO_DATE(CM.contract_date, 'YYYY-MM-DD'), 'YYYY') = $1 + ) + SELECT + $1 AS "YEAR", + (SELECT COALESCE(MAX(price::integer), 0) FROM pms_pjt_year_goal WHERE year = $1) AS "PRICE", + (SELECT objid FROM pms_pjt_year_goal WHERE year = $1 LIMIT 1) AS "YEAR_GOAL_OBJID", + (SELECT COUNT(1) FROM contract_mgmt WHERE TO_CHAR(TO_DATE(contract_date,'YYYY-MM-DD'),'YYYY') = $1) AS "CONTRACT_CNT_YEAR_ALL", + (SELECT COUNT(1) FROM W_CM) AS "CONTRACT_CNT_YEAR", + (SELECT COUNT(1) FROM W_CM WHERE area_cd = '0001220') AS "CONTRACT_CNT_YEAR_IN", + (SELECT COUNT(1) FROM W_CM WHERE area_cd = '0001221') AS "CONTRACT_CNT_YEAR_OUT", + COALESCE((SELECT (SUM(COALESCE(contract_price::float, 0)))/100000000 FROM W_CM), 0) AS "CONTRACT_COST_YEAR", + CASE + WHEN (SELECT COALESCE(MAX(price::integer), 0) FROM pms_pjt_year_goal WHERE year = $1) = 0 THEN 0 + ELSE COALESCE( + ( + (SELECT (SUM(COALESCE(contract_price::float, 0)))/100000000 FROM W_CM) + / NULLIF((SELECT MAX(price::integer) FROM pms_pjt_year_goal WHERE year = $1), 0) + ) * 100, + 0 + ) + END AS "GOAL_RATE" + `, + [yr] + ); + return row || { YEAR: yr, PRICE: 0, CONTRACT_CNT_YEAR: 0, CONTRACT_COST_YEAR: 0, GOAL_RATE: 0 }; + }; + + const years = await Promise.all([ + fetchYear(String(baseYear - 2)), + fetchYear(String(baseYear - 1)), + fetchYear(String(baseYear)), + ]); + + return NextResponse.json({ success: true, years }); +} diff --git a/src/app/api/sales/contract-dashboard/year-goal/save/route.ts b/src/app/api/sales/contract-dashboard/year-goal/save/route.ts new file mode 100644 index 0000000..4772f10 --- /dev/null +++ b/src/app/api/sales/contract-dashboard/year-goal/save/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 영업목표 등록/수정 (원본: saveYearGoalInfo) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const year = String(body.Year || body.year || ""); + const price = String(body.PRICE || body.price || "0"); + const objid = String(body.YEAR_GOAL_OBJID || body.objid || ""); + + if (!year) return NextResponse.json({ success: false, message: "YEAR 누락" }, { status: 400 }); + + const finalObjid = objid || createObjectId(); + + const client = await pool.connect(); + try { + await client.query( + `INSERT INTO pms_pjt_year_goal (objid, year, operation_division_code, price, writer, regdate) + VALUES ($1, $2, '', $3, $4, now()) + ON CONFLICT (objid) DO UPDATE SET + year = EXCLUDED.year, + price = EXCLUDED.price`, + [finalObjid, year, price, user.userId] + ); + return NextResponse.json({ success: true, message: "저장되었습니다.", objid: finalObjid }); + } catch (e) { + console.error("YearGoal save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/sales/contract/delete/route.ts b/src/app/api/sales/contract/delete/route.ts new file mode 100644 index 0000000..50d32d4 --- /dev/null +++ b/src/app/api/sales/contract/delete/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 계약 삭제 (contractMgmt.deleteContractMngInfo 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const { objIds } = body; + + if (!objIds || !Array.isArray(objIds) || objIds.length === 0) { + return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }); + } + + try { + // 수주 상태인 건은 삭제 불가 + const placeholders = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + + // 첨부파일도 함께 삭제 + await execute( + `DELETE FROM attach_file_info WHERE target_objid IN (${objIds.map((_: string, i: number) => `$${i + 1}`).join(",")})`, + objIds + ); + + // 계약 삭제 + await execute( + `DELETE FROM contract_mgmt WHERE objid IN (${placeholders})`, + objIds + ); + + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (error) { + console.error("Contract delete error:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }); + } +} diff --git a/src/app/api/sales/contract/detail/route.ts b/src/app/api/sales/contract/detail/route.ts new file mode 100644 index 0000000..c4d9057 --- /dev/null +++ b/src/app/api/sales/contract/detail/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 계약 상세 조회 (contracMgmtFormPopup.do 대응) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const { objId } = body; + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + const info = await queryOne( + `SELECT CM.objid::text AS "OBJID", CM.contract_no AS "CONTRACT_NO", + CM.category_cd AS "CATEGORY_CD", CM.area_cd AS "AREA_CD", + CM.customer_objid AS "CUSTOMER_OBJID", CM.product AS "PRODUCT", + CM.mechanical_type AS "MECHANICAL_TYPE", + CM.customer_project_name AS "CUSTOMER_PROJECT_NAME", + CM.due_date AS "DUE_DATE", CM.location AS "LOCATION", CM.setup AS "SETUP", + CM.facility AS "FACILITY", CM.facility_qty AS "FACILITY_QTY", + CM.facility_type AS "FACILITY_TYPE", CM.facility_depth AS "FACILITY_DEPTH", + CM.contract_result AS "CONTRACT_RESULT", + CM.contract_date AS "CONTRACT_DATE", CM.po_no AS "PO_NO", + CM.pm_user_id AS "PM_USER_ID", + CM.contract_currency AS "CONTRACT_CURRENCY", + CM.contract_price_currency AS "CONTRACT_PRICE_CURRENCY", + CM.contract_price AS "CONTRACT_PRICE", + CM.project_name AS "PROJECT_NAME", + CM.contract_del_date AS "CONTRACT_DEL_DATE", + CM.req_del_date AS "REQ_DEL_DATE", + CM.contract_company AS "CONTRACT_COMPANY", + CM.manufacture_plant AS "MANUFACTURE_PLANT", + CM.target_project_no AS "TARGET_PROJECT_NO", + CM.target_project_no_direct AS "TARGET_PROJECT_NO_DIRECT", + CM.customer_production_no AS "CUSTOMER_PRODUCTION_NO", + CM.overhaul_order AS "OVERHAUL_ORDER", + CM.writer AS "WRITER", + COALESCE((SELECT user_name FROM user_info WHERE user_id = CM.writer LIMIT 1), '') AS "WRITER_NAME", + TO_CHAR(CM.regdate, 'YYYY-MM-DD') AS "REGDATE" + FROM contract_mgmt CM + WHERE CM.objid = $1`, + [objId] + ); + + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return NextResponse.json({ success: true, data: info }); +} diff --git a/src/app/api/sales/contract/next-no/route.ts b/src/app/api/sales/contract/next-no/route.ts new file mode 100644 index 0000000..3906074 --- /dev/null +++ b/src/app/api/sales/contract/next-no/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 다음 영업번호 생성 (YYC-NNNN 형식) +export async function GET() { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const yearPrefix = new Date().getFullYear().toString().slice(2); // "26" + const prefix = `${yearPrefix}C-`; + + const result = await queryOne<{ MAX_NO: string }>( + `SELECT MAX(contract_no) AS "MAX_NO" + FROM contract_mgmt + WHERE contract_no LIKE $1 || '%'`, + [prefix] + ); + + let nextSeq = 1; + if (result?.MAX_NO) { + const lastSeq = parseInt(result.MAX_NO.replace(prefix, ""), 10); + if (!isNaN(lastSeq)) nextSeq = lastSeq + 1; + } + + const nextNo = `${prefix}${String(nextSeq).padStart(4, "0")}`; + return NextResponse.json({ success: true, contractNo: nextNo }); +} diff --git a/src/app/api/sales/contract/projects/route.ts b/src/app/api/sales/contract/projects/route.ts new file mode 100644 index 0000000..46f612f --- /dev/null +++ b/src/app/api/sales/contract/projects/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 대상프로젝트번호 드롭다운용 프로젝트 목록 +// 원본: common.xml getCusProjectNoList +export async function POST() { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const rows = await queryRows( + `SELECT objid::varchar AS "CODE", + project_no AS "NAME" + FROM project_mgmt + WHERE COALESCE(project_no,'') != '' + ORDER BY project_no` + ); + return NextResponse.json({ success: true, rows }); +} diff --git a/src/app/api/sales/contract/route.ts b/src/app/api/sales/contract/route.ts new file mode 100644 index 0000000..cfb3504 --- /dev/null +++ b/src/app/api/sales/contract/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 영업관리 > 계약관리 목록 (원본: contractMgmt.xml contractGridList + contractBase) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.Year || body.year) { + conditions.push(`SUBSTR(CM.contract_date, 1, 4) = $${idx++}`); + params.push(String(body.Year || body.year)); + } + if (body.category_cd) { + conditions.push(`CM.category_cd = $${idx++}`); + params.push(body.category_cd); + } + if (body.customer_objid) { + conditions.push(`CM.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.product) { + conditions.push(`CM.product = $${idx++}`); + params.push(body.product); + } + if (body.contract_result) { + conditions.push(`CM.contract_result = $${idx++}`); + params.push(body.contract_result); + } + if (body.pm_user_id) { + conditions.push(`CM.pm_user_id = $${idx++}`); + params.push(body.pm_user_id); + } + if (body.contract_start_date) { + conditions.push(`TO_DATE(CM.contract_date,'YYYY-MM-DD') >= TO_DATE($${idx++},'YYYY-MM-DD')`); + params.push(body.contract_start_date); + } + if (body.contract_end_date) { + conditions.push(`TO_DATE(CM.contract_date,'YYYY-MM-DD') <= TO_DATE($${idx++},'YYYY-MM-DD')`); + params.push(body.contract_end_date); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT CM.objid::text AS "OBJID", + CM.contract_no AS "CONTRACT_NO", + CM.category_cd AS "CATEGORY_CD", + (SELECT code_name FROM comm_code WHERE code_id = CM.category_cd LIMIT 1) AS "CATEGORY_NAME", + CM.overhaul_order AS "OVERHAUL_ORDER", + CM.area_cd AS "AREA_CD", + (SELECT code_name FROM comm_code WHERE code_id = CM.area_cd LIMIT 1) AS "AREA_NAME", + CM.customer_objid AS "CUSTOMER_OBJID", + (SELECT supply_name FROM supply_mng WHERE objid::text = CM.customer_objid LIMIT 1) AS "CUSTOMER_NAME", + CM.product AS "PRODUCT", + (SELECT code_name FROM comm_code WHERE code_id = CM.product LIMIT 1) AS "PRODUCT_NAME", + CM.mechanical_type AS "MECHANICAL_TYPE", + CM.customer_project_name AS "CUSTOMER_PROJECT_NAME", + CM.due_date AS "DUE_DATE", + CM.location AS "LOCATION", + CM.setup AS "SETUP", + CM.facility AS "FACILITY", + (SELECT code_name FROM comm_code WHERE code_id = CM.facility LIMIT 1) AS "FACILITY_NAME", + CM.facility_qty AS "FACILITY_QTY", + CM.facility_type AS "FACILITY_TYPE", + CM.facility_depth AS "FACILITY_DEPTH", + CM.writer AS "WRITER", + (SELECT user_name FROM user_info WHERE user_id = CM.writer LIMIT 1) AS "WRITER_NAME", + TO_CHAR(CM.regdate,'YYYY-MM-DD') AS "REG_DATE", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid = CM.objid::text AND doc_type='contractMgmt01' AND UPPER(COALESCE(status,'active'))='ACTIVE')::text AS "CU01_CNT", + (CASE WHEN (CM.result_cd IS NULL OR CM.result_cd='') AND (CM.spec_result_cd IS NULL OR CM.spec_result_cd='') AND (CM.est_result_cd IS NULL OR CM.est_result_cd='') THEN '0' ELSE '1' END) AS "CU03_CNT", + CM.contract_result AS "CONTRACT_RESULT", + (SELECT code_name FROM comm_code WHERE code_id = CM.contract_result LIMIT 1) AS "CONTRACT_RESULT_NAME", + CM.contract_date AS "CONTRACT_DATE", + CM.po_no AS "PO_NO", + CM.pm_user_id AS "PM_USER_ID", + (SELECT user_name FROM user_info WHERE user_id = CM.pm_user_id LIMIT 1) AS "PM_USER_NAME", + CM.contract_currency AS "CONTRACT_CURRENCY", + (SELECT code_name FROM comm_code WHERE code_id = CM.contract_currency LIMIT 1) AS "CONTRACT_CURRENCY_NAME", + CM.contract_price_currency AS "CONTRACT_PRICE_CURRENCY", + CM.contract_price AS "CONTRACT_PRICE", + CM.project_name AS "PROJECT_NAME", + CM.contract_del_date AS "CONTRACT_DEL_DATE", + CM.req_del_date AS "REQ_DEL_DATE", + CM.contract_company AS "CONTRACT_COMPANY", + (SELECT code_name FROM comm_code WHERE code_id = CM.contract_company LIMIT 1) AS "CONTRACT_COMPANY_NAME", + CM.manufacture_plant AS "MANUFACTURE_PLANT", + (SELECT code_name FROM comm_code WHERE code_id = CM.manufacture_plant LIMIT 1) AS "MANUFACTURE_PLANT_NAME" + FROM contract_mgmt CM + WHERE ${where} + ORDER BY CM.regdate DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/sales/contract/save/route.ts b/src/app/api/sales/contract/save/route.ts new file mode 100644 index 0000000..45f1901 --- /dev/null +++ b/src/app/api/sales/contract/save/route.ts @@ -0,0 +1,294 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 영업관리 > 계약 저장 + 수주(0000964) 전환 시 프로젝트 자동 생성 + WBS 자동 복사 +// 원본: ContractMgmtService.saveContractMgmtInfo (Java) + +// contractMgmt.xml saveContractMgmtInfo / insertProjectTask / insertProjectSetupTask + +// project.xml createProject / ModifyProjectByContract +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const objId = String(body.objId || createObjectId()); + + // 오버홀/개조/MRO 카테고리 + const OVERHAUL_CATEGORIES = new Set(["0000170", "0000171", "0001790"]); + const CONTRACT_RESULT_AWARDED = "0000964"; // 수주 + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // objid로 DB 존재 여부 확인 — 있으면 update, 없으면 insert + const existing = await client.query( + `SELECT contract_no FROM contract_mgmt WHERE objid = $1`, + [objId] + ); + const isNew = existing.rows.length === 0; + + // 1) CONTRACT_MGMT upsert + let contractNo = isNew ? "" : String(existing.rows[0].contract_no || ""); + if (!contractNo) { + // 시퀀스 기반 채번 (원본: (SELECT TO_CHAR(NOW(),'yy')::VARCHAR ||'C-'|| LPAD(NEXTVAL('contract_mgmt_seq'),4,'0'))) + const seq = await client.query(`SELECT NEXTVAL('contract_mgmt_seq') AS n`); + const n = Number(seq.rows[0].n); + const yy = new Date().getFullYear().toString().slice(2); + contractNo = `${yy}C-${String(n).padStart(4, "0")}`; + } + + await client.query( + `INSERT INTO contract_mgmt ( + objid, contract_no, category_cd, area_cd, customer_objid, product, + mechanical_type, customer_project_name, due_date, location, setup, + facility, facility_qty, facility_type, facility_depth, + contract_result, contract_date, po_no, pm_user_id, + contract_currency, contract_price_currency, contract_price, + project_name, contract_del_date, req_del_date, + contract_company, manufacture_plant, + target_project_no, target_project_no_direct, customer_production_no, + overhaul_order, writer, regdate + ) VALUES ( + $1,$2,$3,$4,$5,$6, + $7,$8,$9,$10,$11, + $12,$13,$14,$15, + $16,$17,$18,$19, + $20,$21,$22, + $23,$24,$25, + $26,$27, + $28,$29,$30, + $31,$32,now() + ) + ON CONFLICT (objid) DO UPDATE SET + category_cd = EXCLUDED.category_cd, + area_cd = EXCLUDED.area_cd, + customer_objid = EXCLUDED.customer_objid, + product = EXCLUDED.product, + mechanical_type = EXCLUDED.mechanical_type, + customer_project_name = EXCLUDED.customer_project_name, + due_date = EXCLUDED.due_date, + location = EXCLUDED.location, + setup = EXCLUDED.setup, + facility = EXCLUDED.facility, + facility_qty = EXCLUDED.facility_qty, + facility_type = EXCLUDED.facility_type, + facility_depth = EXCLUDED.facility_depth, + contract_result = EXCLUDED.contract_result, + contract_date = EXCLUDED.contract_date, + po_no = EXCLUDED.po_no, + pm_user_id = EXCLUDED.pm_user_id, + contract_currency = EXCLUDED.contract_currency, + contract_price_currency = EXCLUDED.contract_price_currency, + contract_price = EXCLUDED.contract_price, + project_name = EXCLUDED.project_name, + contract_del_date = EXCLUDED.contract_del_date, + req_del_date = EXCLUDED.req_del_date, + contract_company = EXCLUDED.contract_company, + manufacture_plant = EXCLUDED.manufacture_plant, + target_project_no = EXCLUDED.target_project_no, + target_project_no_direct = EXCLUDED.target_project_no_direct, + customer_production_no = EXCLUDED.customer_production_no, + overhaul_order = EXCLUDED.overhaul_order`, + [ + objId, contractNo, + body.category_cd || "", body.area_cd || "", + body.customer_objid || "", body.product || "", + body.mechanical_type || "", body.customer_project_name || "", + body.due_date || "", body.location || "", body.setup || "", + body.facility || "", String(body.facility_qty || "1"), + body.facility_type || "", body.facility_depth || "", + body.contract_result || "", body.contract_date || "", + body.po_no || "", body.pm_user_id || "", + body.contract_currency || "", String(body.contract_price_currency || "0").replace(/,/g, ""), + String(body.contract_price || "0").replace(/,/g, ""), + body.project_name || "", body.contract_del_date || "", + body.req_del_date || "", + body.contract_company || "", body.manufacture_plant || "", + body.target_project_no || "", body.target_project_no_direct || "", + body.customer_production_no || "", + body.overhaul_order || "", user.userId, + ] + ); + + // 2) 수주(0000964)인 경우 프로젝트 자동 생성 + WBS 복사 + let projectsCreated = 0; + if (body.contract_result === CONTRACT_RESULT_AWARDED) { + const facilityQty = Math.max(1, parseInt(String(body.facility_qty || "1"), 10)); + const pricePer = Math.floor((parseFloat(String(body.contract_price || "0").replace(/,/g, "")) || 0) / facilityQty); + const pricePerCurrency = Math.floor((parseFloat(String(body.contract_price_currency || "0").replace(/,/g, "")) || 0) / facilityQty); + + // 이미 연결된 프로젝트가 있는지 확인 — 있으면 업데이트, 없으면 신규 생성 + const existCheck = await client.query( + `SELECT project_name FROM project_mgmt WHERE contract_objid = $1 LIMIT 1`, + [objId] + ); + + if (existCheck.rows.length === 0) { + // 신규 — facility_qty 만큼 반복 생성 + const isOverhaul = OVERHAUL_CATEGORIES.has(body.category_cd || ""); + const targetProjectNo = body.target_project_no_direct || body.target_project_no || ""; + + for (let i = 0; i < facilityQty; i++) { + const projectObjId = createObjectId(); + const overhaulOrder = isOverhaul ? String((parseInt(body.overhaul_order || "0", 10) || 0) + i) : (body.overhaul_order || ""); + + // 프로젝트 번호: 오버홀이면 target_project_no, 아니면 "{mechanical_type}-{autoincrement}" + // 파라미터: $1=projectObjId, $2=objId(contract_objid INSERT용), $3=facility, $4=facility_depth + // $5=projectNo seed, $6=pricePer, $7=pricePerCurrency, $8=project_name, $9=overhaul_order + // $10=objId(WHERE절용 — $2와 동일값이지만 타입 추론 충돌 방지 위해 분리) + let projectNoSql: string; + const projectNoParams: unknown[] = []; + if (isOverhaul && targetProjectNo) { + projectNoSql = `$5`; + projectNoParams.push(targetProjectNo); + } else { + // MECHANICAL_TYPE || '-' || nextNum + projectNoSql = ` + COALESCE($5, '') || '-' || COALESCE( + (SELECT CASE WHEN project_no ~ '[-\\s][0-9]+$' + THEN (REGEXP_REPLACE(project_no, '.*[-\\s]([0-9]+)$', '\\1')::integer + 1)::text + ELSE NULL END + FROM project_mgmt + WHERE project_no NOT LIKE '%\\_%' ESCAPE '\\' + ORDER BY CASE WHEN project_no ~ '[-\\s][0-9]+$' + THEN (REGEXP_REPLACE(project_no, '.*[-\\s]([0-9]+)$', '\\1')::integer + 1) + ELSE NULL END DESC NULLS LAST + LIMIT 1), + '1')`; + projectNoParams.push(body.mechanical_type || ""); + } + + // createProject (원본 project.xml) 단순화한 형태 — CONTRACT_MGMT 복사 + 일부 override + await client.query( + `INSERT INTO project_mgmt ( + objid, contract_objid, category_cd, customer_objid, product, + customer_project_name, status_cd, due_date, location, setup, + facility, facility_qty, facility_type, facility_depth, + production_no, bus_cal_cd, category1_cd, chg_user_id, + plan_date, complete_date, result_cd, project_no, + pm_user_id, contract_price, contract_price_currency, contract_currency, + regdate, writer, contract_no, customer_equip_name, + req_del_date, contract_del_date, contract_company, contract_date, + po_no, manufacture_plant, contract_result, project_name, + spec_user_id, spec_plan_date, spec_comp_date, spec_result_cd, + est_plan_date, est_user_id, est_comp_date, est_result_cd, + area_cd, mechanical_type, overhaul_order, is_temp + ) + SELECT + $1, $2, category_cd, customer_objid, product, + customer_project_name, status_cd, due_date, location, setup, + $3, '1', facility_type, $4, + production_no, bus_cal_cd, category1_cd, chg_user_id, + plan_date, complete_date, result_cd, ${projectNoSql}, + pm_user_id, $6, $7, contract_currency, + now(), writer, contract_no, customer_equip_name, + req_del_date, contract_del_date, contract_company, contract_date, + po_no, manufacture_plant, contract_result, $8, + spec_user_id, spec_plan_date, spec_comp_date, spec_result_cd, + est_plan_date, est_user_id, est_comp_date, est_result_cd, + area_cd, mechanical_type, $9, '1' + FROM contract_mgmt WHERE objid = $10`, + [ + projectObjId, objId, + body.facility || "", body.facility_depth || "", + ...projectNoParams, + String(pricePer), String(pricePerCurrency), body.project_name || "", + overhaulOrder, + objId, + ] + ); + + // WBS Task 복사: pms_wbs_task_standard JOIN pms_wbs_template WHERE title = mechanical_type + // contractMgmt.xml insertProjectTask 참고. task의 contract_objid에는 신규 프로젝트 OBJID가 들어감(원본 설계) + await client.query( + `INSERT INTO pms_wbs_task (objid, contract_objid, task_name, task_seq, unit_no, writer, reg_date) + SELECT + MD5(gen_random_uuid()::text), + $1, + T.task_name, + T.task_seq, + T.unit_no, + $2, + now() + FROM pms_wbs_task_standard T + LEFT JOIN pms_wbs_template T1 ON T.parent_objid = T1.objid + WHERE T1.title = $3`, + [projectObjId, user.userId, body.mechanical_type || ""] + ); + + // Setup WBS Task 복사 (원본은 전체 standard 복사 — 필터 없음) + await client.query( + `INSERT INTO setup_wbs_task (objid, contract_objid, parent_objid, task_category, task_name, standard_objid, task_seq, proj_step) + SELECT + MD5(gen_random_uuid()::text), + $1, + T.parent_objid, + T.task_category, + T.task_name, + T.objid, + T.task_seq, + T.proj_step + FROM setup_wbs_task_standard T`, + [projectObjId] + ); + + projectsCreated++; + } + } else { + // 이미 있는 프로젝트 — 업데이트 (ModifyProjectByContract 대응) + await client.query( + `UPDATE project_mgmt SET + due_date = $2, + customer_project_name = $3, + location = $4, + setup = $5, + facility = $6, + facility_type = $7, + facility_depth = $8, + contract_date = $9, + po_no = $10, + pm_user_id = $11, + contract_currency = $12, + contract_price_currency = $13, + contract_price = $14, + project_name = $15, + contract_del_date = $16, + req_del_date = $17, + contract_company = $18, + manufacture_plant = $19 + WHERE contract_objid = $1`, + [ + objId, + body.due_date || "", body.customer_project_name || "", + body.location || "", body.setup || "", + body.facility || "", body.facility_type || "", body.facility_depth || "", + body.contract_date || "", body.po_no || "", body.pm_user_id || "", + body.contract_currency || "", String(pricePerCurrency), String(pricePer), + body.project_name || "", body.contract_del_date || "", body.req_del_date || "", + body.contract_company || "", body.manufacture_plant || "", + ] + ); + } + } + + await client.query("COMMIT"); + + const msg = isNew ? "등록되었습니다." : "수정되었습니다."; + const extra = projectsCreated > 0 ? ` (프로젝트 ${projectsCreated}건 자동 생성 + WBS 연결됨)` : ""; + return NextResponse.json({ + success: true, objId, contractNo, projectsCreated, + message: msg + extra, + }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Contract save error:", error); + return NextResponse.json({ + success: false, + message: "저장 중 오류가 발생했습니다: " + (error instanceof Error ? error.message : String(error)), + }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/sales/customer/delete/route.ts b/src/app/api/sales/customer/delete/route.ts new file mode 100644 index 0000000..907879c --- /dev/null +++ b/src/app/api/sales/customer/delete/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 고객 삭제 (원본: contractMgmt.xml deletesupplyMngInfo — 실제 DELETE) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const objIds: string[] = Array.isArray(body.objIds) ? body.objIds : body.objId ? [body.objId] : []; + + if (objIds.length === 0) { + return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }); + } + + try { + const placeholders = objIds.map((_, i) => `$${i + 1}::numeric`).join(","); + await execute( + `DELETE FROM supply_mng WHERE objid IN (${placeholders})`, + objIds + ); + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (error) { + console.error("Customer delete:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/sales/customer/detail/route.ts b/src/app/api/sales/customer/detail/route.ts new file mode 100644 index 0000000..6c59a9e --- /dev/null +++ b/src/app/api/sales/customer/detail/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 고객 상세 (원본: contractMgmt.xml getSupMngInfo) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const objid = String(body.objid || body.objId || ""); + if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 }); + + const info = await queryOne( + `SELECT objid::text AS "OBJID", + cus_no AS "CUS_NO", + supply_code AS "SUPPLY_CODE", + supply_name AS "SUPPLY_NAME", + reg_no AS "REG_NO", + REGEXP_REPLACE(COALESCE(reg_no,''), '([0-9]{7})', '*******') AS "REG_NO2", + supply_address AS "SUPPLY_ADDRESS", + supply_busname AS "SUPPLY_BUSNAME", + supply_stockname AS "SUPPLY_STOCKNAME", + supply_tel_no AS "SUPPLY_TEL_NO", + supply_fax_no AS "SUPPLY_FAX_NO", + charge_user_name AS "CHARGE_USER_NAME", + payment_method AS "PAYMENT_METHOD", + reg_id AS "REG_ID", + TO_CHAR(reg_date,'YYYY-MM-DD') AS "REGDATE", + COALESCE(status,'active') AS "STATUS", + area_cd AS "AREA_CD", + bus_reg_no AS "BUS_REG_NO", + office_no AS "OFFICE_NO", + email AS "EMAIL" + FROM supply_mng WHERE objid::text = $1`, + [objid] + ); + + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return NextResponse.json({ success: true, data: info }); +} diff --git a/src/app/api/sales/customer/route.ts b/src/app/api/sales/customer/route.ts new file mode 100644 index 0000000..03b724a --- /dev/null +++ b/src/app/api/sales/customer/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 영업관리 > 고객관리 목록 (원본: contractMgmt.xml supplyMngGridPagingList) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.supply_name) { + conditions.push(`UPPER(S.supply_name) LIKE UPPER('%' || $${idx++} || '%')`); + params.push(body.supply_name); + } + if (body.supply_code) { + conditions.push(`S.supply_code = $${idx++}`); + params.push(body.supply_code); + } + if (body.area_cd) { + conditions.push(`S.area_cd = $${idx++}`); + params.push(body.area_cd); + } + if (body.searchStatus) { + conditions.push(`UPPER(COALESCE(S.status,'active')) = UPPER($${idx++})`); + params.push(body.searchStatus); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT S.objid::text AS "OBJID", + S.cus_no AS "CUS_NO", + S.supply_code AS "SUPPLY_CODE", + (SELECT code_name FROM comm_code WHERE code_id = S.supply_code LIMIT 1) AS "SUPPLY_CODE_NAME", + S.area_cd AS "AREA_CD", + (SELECT code_name FROM comm_code WHERE code_id = S.area_cd LIMIT 1) AS "AREA_CD_NAME", + S.supply_name AS "SUPPLY_NAME", + S.charge_user_name AS "CHARGE_USER_NAME", + S.bus_reg_no AS "BUS_REG_NO", + S.supply_address AS "SUPPLY_ADDRESS", + S.supply_tel_no AS "SUPPLY_TEL_NO", + S.email AS "EMAIL", + TO_CHAR(S.reg_date,'YYYY-MM-DD') AS "REGDATE", + COALESCE(S.status,'active') AS "STATUS" + FROM supply_mng S + WHERE ${where} + ORDER BY S.cus_no DESC NULLS LAST, S.reg_date DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/sales/customer/save/route.ts b/src/app/api/sales/customer/save/route.ts new file mode 100644 index 0000000..ec17541 --- /dev/null +++ b/src/app/api/sales/customer/save/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 고객 등록/수정 (원본: contractMgmt.xml mergeSupMgmtInfo) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const isNew = !body.objid; + const objid = isNew ? createObjectId() : String(body.objid); + + const client = await pool.connect(); + try { + if (isNew) { + // 원본과 동일하게 CUS-XXXX 자동 채번 + await client.query( + `INSERT INTO supply_mng + (objid, supply_code, supply_name, reg_no, supply_address, supply_busname, + supply_stockname, supply_tel_no, supply_fax_no, charge_user_name, payment_method, + reg_id, reg_date, status, area_cd, bus_reg_no, office_no, email, cus_no) + VALUES ($1::numeric, $2,$3,$4,$5,$6, $7,$8,$9,$10,$11, + $12, now(), 'active', $13,$14,$15,$16, + (SELECT 'CUS-' || LPAD((COALESCE(MAX(SUBSTR(cus_no,5,8))::INTEGER,0)+1)::VARCHAR, 4, '0') + FROM supply_mng))`, + [ + objid, + body.supply_code || "", + body.supply_name || "", + body.reg_no || "", + body.supply_address || "", + body.supply_busname || "", + body.supply_stockname || "", + body.supply_tel_no || "", + body.supply_fax_no || "", + body.charge_user_name || "", + body.payment_method || "", + body.reg_id || user.userId, + body.area_cd || "", + body.bus_reg_no || "", + body.office_no || "", + body.email || "", + ] + ); + } else { + await client.query( + `UPDATE supply_mng SET + supply_code = $2, + supply_name = $3, + reg_no = $4, + supply_address = $5, + supply_busname = $6, + supply_stockname = $7, + supply_tel_no = $8, + supply_fax_no = $9, + charge_user_name = $10, + payment_method = $11, + reg_id = $12, + area_cd = $13, + bus_reg_no = $14, + office_no = $15, + email = $16 + WHERE objid::text = $1`, + [ + objid, + body.supply_code || "", + body.supply_name || "", + body.reg_no || "", + body.supply_address || "", + body.supply_busname || "", + body.supply_stockname || "", + body.supply_tel_no || "", + body.supply_fax_no || "", + body.charge_user_name || "", + body.payment_method || "", + body.reg_id || "", + body.area_cd || "", + body.bus_reg_no || "", + body.office_no || "", + body.email || "", + ] + ); + } + + return NextResponse.json({ success: true, message: isNew ? "등록되었습니다." : "수정되었습니다.", objid }); + } catch (e) { + console.error("Customer save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/sales/release/delete/route.ts b/src/app/api/sales/release/delete/route.ts new file mode 100644 index 0000000..3456666 --- /dev/null +++ b/src/app/api/sales/release/delete/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 출고 삭제 (원본: releaseMgmt.xml deleteReleaseMgmtInfo — 실제 DELETE) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const objIds: string[] = Array.isArray(body.objIds) ? body.objIds : body.objId ? [body.objId] : []; + + if (objIds.length === 0) { + return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }); + } + + try { + const ph = objIds.map((_, i) => `$${i + 1}`).join(","); + await execute(`DELETE FROM release_mgmt WHERE objid IN (${ph})`, objIds); + return NextResponse.json({ success: true, message: `${objIds.length}건이 삭제되었습니다.` }); + } catch (error) { + console.error("Release delete:", error); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/sales/release/detail/route.ts b/src/app/api/sales/release/detail/route.ts new file mode 100644 index 0000000..143bd4e --- /dev/null +++ b/src/app/api/sales/release/detail/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 출고관리 단건 조회 — project_objid(접수 프로젝트)로 조회 +// release_mgmt는 없을 수 있음(신규 등록 케이스), 그 경우 프로젝트/계약 정보만 반환 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const projectObjId = String(body.projectObjId || body.objId || ""); + if (!projectObjId) { + return NextResponse.json({ success: false, message: "projectObjId 누락" }, { status: 400 }); + } + + const info = await queryOne( + `SELECT + PM.objid::text AS "PROJECT_OBJID", + PM.contract_objid::text AS "CONTRACT_OBJID", + PM.project_no AS "PROJECT_NO", + PM.project_name AS "PROJECT_NAME", + PM.product AS "PRODUCT", + (SELECT code_name FROM comm_code WHERE code_id = PM.product LIMIT 1) AS "PRODUCT_NAME", + PM.mechanical_type AS "MECHANICAL_TYPE", + CM.customer_objid AS "CUSTOMER_OBJID", + (SELECT supply_name FROM supply_mng WHERE objid::text = CM.customer_objid LIMIT 1) AS "CUSTOMER_NAME", + (SELECT code_name FROM comm_code WHERE code_id = CM.category_cd LIMIT 1) AS "CATEGORY_NAME", + CM.contract_del_date AS "CONTRACT_DEL_DATE", + CM.req_del_date AS "REQ_DEL_DATE", + CM.location AS "LOCATION", + CM.setup AS "SETUP", + CM.facility_type AS "FACILITY_TYPE", + RM.objid AS "RELEASE_OBJID", + RM.release_car_no AS "RELEASE_CAR_NO", + RM.release_date AS "RELEASE_DATE", + RM.task_over_user_id AS "TASK_OVER_USER_ID", + RM.task_over_date AS "TASK_OVER_DATE", + RM.task_over_comment AS "TASK_OVER_COMMENT", + RM.install_complete_date AS "INSTALL_COMPLETE_DATE", + RM.install_result AS "INSTALL_RESULT" + FROM project_mgmt PM + JOIN contract_mgmt CM ON CM.objid = PM.contract_objid + LEFT JOIN release_mgmt RM + ON RM.parent_objid = PM.contract_objid AND COALESCE(RM.product,'') = COALESCE(PM.product,'') + WHERE PM.objid::text = $1`, + [projectObjId] + ); + + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return NextResponse.json({ success: true, data: info }); +} diff --git a/src/app/api/sales/release/route.ts b/src/app/api/sales/release/route.ts new file mode 100644 index 0000000..5e83d9c --- /dev/null +++ b/src/app/api/sales/release/route.ts @@ -0,0 +1,115 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 영업관리 > 출고관리 목록 +// 원본: productionplanning.xml releaseMgmtGridList +// 접근: 수주(0000964)된 contract_mgmt ← project_mgmt 연결 ← release_mgmt(OUTER JOIN) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const conditions: string[] = [ + "COALESCE(CM.contract_result,'') = '0000964'", // 수주된 계약만 + ]; + const params: unknown[] = []; + let idx = 1; + + if (body.Year || body.year) { + conditions.push(`SUBSTR(CM.contract_date,1,4) = $${idx++}`); + params.push(String(body.Year || body.year)); + } + if (body.project_nos) { + const arr = String(body.project_nos).split(",").filter(Boolean); + if (arr.length > 0) { + const ph = arr.map(() => `$${idx++}`).join(","); + conditions.push(`PM.objid IN (${ph})`); + arr.forEach((v) => params.push(v)); + } + } + if (body.category_cd) { + conditions.push(`CM.category_cd = $${idx++}`); + params.push(body.category_cd); + } + if (body.customer_objid) { + conditions.push(`CM.customer_objid = $${idx++}`); + params.push(body.customer_objid); + } + if (body.product) { + conditions.push(`CM.product = $${idx++}`); + params.push(body.product); + } + if (body.pm_user_id) { + conditions.push(`CM.pm_user_id = $${idx++}`); + params.push(body.pm_user_id); + } + if (body.release_start_date) { + conditions.push(`RM.release_date >= $${idx++}`); + params.push(body.release_start_date); + } + if (body.release_end_date) { + conditions.push(`RM.release_date <= $${idx++}`); + params.push(body.release_end_date); + } + if (body.install_result) { + conditions.push(`RM.install_result = $${idx++}`); + params.push(body.install_result); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT + PM.objid::text AS "OBJID", + PM.contract_objid::text AS "CONTRACT_OBJID", + PM.project_no AS "PROJECT_NO", + PM.project_name AS "PROJECT_NAME", + CM.category_cd AS "CATEGORY_CD", + (SELECT code_name FROM comm_code WHERE code_id = CM.category_cd LIMIT 1) AS "CATEGORY_NAME", + CM.overhaul_order AS "OVERHAUL_ORDER", + CM.area_cd AS "AREA_CD", + (SELECT code_name FROM comm_code WHERE code_id = CM.area_cd LIMIT 1) AS "AREA_NAME", + CM.customer_objid AS "CUSTOMER_OBJID", + (SELECT supply_name FROM supply_mng WHERE objid::text = CM.customer_objid LIMIT 1) AS "CUSTOMER_NAME", + CM.product AS "PRODUCT", + (SELECT code_name FROM comm_code WHERE code_id = CM.product LIMIT 1) AS "PRODUCT_NAME", + CM.mechanical_type AS "MECHANICAL_TYPE", + CM.req_del_date AS "REQ_DEL_DATE", + CM.contract_del_date AS "CONTRACT_DEL_DATE", + CM.location AS "LOCATION", + CM.setup AS "SETUP", + CM.facility AS "FACILITY", + (SELECT code_name FROM comm_code WHERE code_id = CM.facility LIMIT 1) AS "FACILITY_NAME", + PM.facility_qty AS "FACILITY_QTY", + CM.facility_type AS "FACILITY_TYPE", + CM.facility_depth AS "FACILITY_DEPTH", + (SELECT user_name FROM user_info WHERE user_id = CM.pm_user_id LIMIT 1) AS "PM_USER_NAME", + RM.objid AS "RELEASE_OBJID", + RM.release_car_no AS "RELEASE_CAR_NO", + RM.release_date AS "RELEASE_DATE", + RM.task_over_user_id AS "TASK_OVER_USER_ID", + (SELECT user_name FROM user_info WHERE user_id = RM.task_over_user_id LIMIT 1) AS "TASK_OVER_USER_NAME", + RM.task_over_date AS "TASK_OVER_DATE", + RM.install_complete_date AS "INSTALL_COMPLETE_DATE", + RM.install_result AS "INSTALL_RESULT", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid::varchar = RM.objid AND doc_type = 'RELEASE_CHECK' AND UPPER(COALESCE(status,'active'))='ACTIVE')::text AS "RELEASE_CHECK_CNT", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid::varchar = RM.objid AND doc_type = 'RELEASE_ORDER' AND UPPER(COALESCE(status,'active'))='ACTIVE')::text AS "RELEASE_ORDER_CNT", + (SELECT COUNT(*) FROM attach_file_info WHERE target_objid::varchar = RM.objid AND doc_type = 'RELEASE_TAKING_OVER' AND UPPER(COALESCE(status,'active'))='ACTIVE')::text AS "RELEASE_TAKING_OVER_CNT", + CASE + WHEN COALESCE(TRIM(RM.release_date),'') != '' THEN '출고완료' + WHEN COALESCE(TRIM(CM.contract_date),'') != '' THEN '수주' + WHEN TO_CHAR(NOW(),'YYYYMMDD') >= REPLACE(COALESCE(CM.due_date,''), '-', '') THEN '지연' + ELSE '계약' + END AS "RELEASE_STATUS_TITLE" + FROM project_mgmt PM + JOIN contract_mgmt CM ON CM.objid = PM.contract_objid + LEFT OUTER JOIN release_mgmt RM + ON RM.parent_objid = PM.contract_objid AND COALESCE(RM.product,'') = COALESCE(PM.product,'') + WHERE ${where} + ORDER BY PM.project_no DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/sales/release/save/route.ts b/src/app/api/sales/release/save/route.ts new file mode 100644 index 0000000..ec52f2f --- /dev/null +++ b/src/app/api/sales/release/save/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 출고 등록/수정 (원본: releaseMgmt.xml saveReleaseMgmtInfo) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const releaseObjId = String(body.RELEASE_OBJID || body.release_objid || ""); + const parentObjId = String(body.PARENT_OBJID || body.parent_objid || ""); + const product = String(body.PRODUCT || body.product || ""); + + if (!releaseObjId) { + return NextResponse.json({ success: false, message: "RELEASE_OBJID 누락" }, { status: 400 }); + } + if (!parentObjId) { + return NextResponse.json({ success: false, message: "계약 OBJID 누락" }, { status: 400 }); + } + + const releaseCarNo = String(body.RELEASE_CAR_NO || ""); + const releaseDate = String(body.RELEASE_DATE || ""); + const taskOverUserId = String(body.TASK_OVER_USER_ID || ""); + const taskOverDate = String(body.TASK_OVER_DATE || ""); + const taskOverComment = String(body.TASK_OVER_COMMENT || ""); + const installCompleteDate = String(body.INSTALL_COMPLETE_DATE || ""); + const installResult = String(body.INSTALL_RESULT || ""); + const productGroup = String(body.PRODUCT_GROUP || body.product_group || ""); + const status = String(body.STATUS || "0000201"); + + const client = await pool.connect(); + try { + await client.query( + `INSERT INTO release_mgmt + (objid, parent_objid, release_car_no, release_date, task_over_user_id, + task_over_date, task_over_comment, status, regdate, writer, + product_group, product, install_complete_date, install_result) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now(), $9, $10, $11, $12, $13) + ON CONFLICT (objid) DO UPDATE SET + parent_objid = EXCLUDED.parent_objid, + release_car_no = EXCLUDED.release_car_no, + release_date = EXCLUDED.release_date, + task_over_user_id = EXCLUDED.task_over_user_id, + task_over_date = EXCLUDED.task_over_date, + task_over_comment = EXCLUDED.task_over_comment, + status = EXCLUDED.status, + writer = EXCLUDED.writer, + product_group = EXCLUDED.product_group, + product = EXCLUDED.product, + install_complete_date = EXCLUDED.install_complete_date, + install_result = EXCLUDED.install_result`, + [ + releaseObjId, parentObjId, releaseCarNo, releaseDate, taskOverUserId, + taskOverDate, taskOverComment, status, user.userId, + productGroup, product, installCompleteDate, installResult, + ] + ); + + return NextResponse.json({ success: true, message: "저장되었습니다.", releaseObjId }); + } catch (e) { + console.error("Release save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/src/app/api/sales/year-goal/route.ts b/src/app/api/sales/year-goal/route.ts new file mode 100644 index 0000000..8a31f2a --- /dev/null +++ b/src/app/api/sales/year-goal/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows, execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 영업목표 조회/저장 (contractMgmt.getYearGoalInfo + saveYearGoalInfo 이식) +// 테이블: PMS_PJT_YEAR_GOAL (OBJID, YEAR, OPERATION_DIVISION_CODE, PRICE, WRITER, REGDATE) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + const action = String(body.action || "list"); + + if (action === "save") { + const year = String(body.year || ""); + const price = String(body.price || "0").replace(/,/g, ""); + if (!year) return NextResponse.json({ success: false, message: "year 필요" }, { status: 400 }); + try { + const objId = String(body.objId || createObjectId()); + await execute( + `INSERT INTO pms_pjt_year_goal (objid, year, operation_division_code, price, writer, regdate) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (objid) DO UPDATE SET price = EXCLUDED.price`, + [objId, year, String(body.operation_division_code || ""), price, user.userId], + ); + return NextResponse.json({ success: true, message: "저장되었습니다." }); + } catch (e) { + console.error("year-goal save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }, { status: 500 }); + } + } + + if (action === "delete") { + const objIds: string[] = Array.isArray(body.objIds) ? body.objIds : []; + if (objIds.length === 0) return NextResponse.json({ success: false, message: "선택된 항목 없음" }); + try { + const ph = objIds.map((_, i) => `$${i + 1}`).join(","); + await execute(`DELETE FROM pms_pjt_year_goal WHERE objid IN (${ph})`, objIds); + return NextResponse.json({ success: true, message: "삭제되었습니다." }); + } catch (e) { + console.error("year-goal delete:", e); + return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }, { status: 500 }); + } + } + + // list + const rows = await queryRows( + `SELECT objid::text AS "OBJID", + year AS "YEAR", + operation_division_code AS "OPERATION_DIVISION_CODE", + price AS "PRICE", + writer AS "WRITER", + TO_CHAR(regdate, 'YYYY-MM-DD') AS "REGDATE" + FROM pms_pjt_year_goal + ORDER BY year DESC`, + ); + return NextResponse.json({ RESULTLIST: rows }); +} diff --git a/src/app/api/scm/defect/detail/route.ts b/src/app/api/scm/defect/detail/route.ts new file mode 100644 index 0000000..a6124a1 --- /dev/null +++ b/src/app/api/scm/defect/detail/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + const info = await queryOne( + `SELECT AP.objid::text AS "OBJID", + POM.purchase_order_no AS "PURCHASE_ORDER_NO", + CM.contract_no AS "PROJECT_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') AS "SUPPLIER_NAME", + POP.part_no AS "PART_NO", POP.part_name AS "PART_NAME", + AP.error_qty AS "DEFECT_QTY", AP.error_reason AS "ERROR_REASON", + AP.attribution AS "ATTRIBUTION", + AP.receipt_date AS "DEFECT_DATE", + AP.re_arrival_plan_date AS "RE_ARRIVAL_PLAN_DATE", + AP.group_seq AS "GROUP_SEQ", AP.seq AS "SEQ" + FROM arrival_plan AP + JOIN purchase_order_part POP ON POP.objid::text = AP.order_part_objid + JOIN purchase_order_master POM ON POM.objid = POP.purchase_order_master_objid + LEFT JOIN contract_mgmt CM ON CM.objid = POM.contract_mgmt_objid + WHERE AP.objid::text = $1`, [objId] + ); + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return NextResponse.json({ success: true, data: info }); +} diff --git a/src/app/api/scm/defect/route.ts b/src/app/api/scm/defect/route.ts new file mode 100644 index 0000000..6f2dda6 --- /dev/null +++ b/src/app/api/scm/defect/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// SCM 부적합품관리 목록 조회 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + + const conditions: string[] = []; + const params: unknown[] = []; + let idx = 1; + + // SCM: filter by partner unless admin + if (!user.isAdmin) { + conditions.push( + `POM.partner_objid = (SELECT objid::text FROM supply_mng WHERE UPPER(supply_code) = UPPER($${idx++}) LIMIT 1)` + ); + params.push(user.partnerCd); + } + + // invalidMgmtGridList 대응 — arrival_plan.error_qty 기반 + if (body.year) { + conditions.push(`TO_CHAR(CM.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.supplier_name) { + conditions.push(`COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') LIKE '%' || $${idx++} || '%'`); + params.push(body.supplier_name); + } + if (body.status_code) { + conditions.push(`CASE WHEN COALESCE(AP.assembly_status, '') != '' THEN 'complete' ELSE 'pending' END = $${idx++}`); + params.push(body.status_code); + } + + const where = conditions.length > 0 ? conditions.join(" AND ") : "1=1"; + + const sql = ` + SELECT AP.objid::text AS "OBJID", + POM.purchase_order_no AS "DEFECT_NO", + CM.contract_no AS "PROJECT_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') AS "SUPPLIER_NAME", + POP.part_no AS "PART_NO", + POP.part_name AS "PART_NAME", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = AP.error_reason LIMIT 1), '') AS "DEFECT_TYPE_NAME", + AP.receipt_date AS "DEFECT_DATE", + AP.error_qty AS "DEFECT_QTY", + '' AS "ACTION_CONTENT", + CASE WHEN COALESCE(AP.assembly_status, '') != '' THEN '처리완료' ELSE '미처리' END AS "STATUS_NAME" + FROM arrival_plan AP + JOIN purchase_order_part POP ON POP.objid::text = AP.order_part_objid + JOIN purchase_order_master POM ON POM.objid = POP.purchase_order_master_objid + LEFT JOIN contract_mgmt CM ON CM.objid = POM.contract_mgmt_objid + WHERE AP.error_qty IS NOT NULL AND AP.error_qty != '' AND AP.error_qty != '0' AND ${where} + ORDER BY AP.receipt_date DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/scm/defect/save/route.ts b/src/app/api/scm/defect/save/route.ts new file mode 100644 index 0000000..dc98bea --- /dev/null +++ b/src/app/api/scm/defect/save/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const body = await request.json(); + const client = await pool.connect(); + // arrival_plan의 error_qty/error_reason/attribution 업데이트 + try { + if (body.objId) { + await client.query( + `UPDATE arrival_plan SET + error_qty = $1, error_reason = $2, attribution = $3 + WHERE objid = $4`, + [body.error_qty || body.defect_qty || "", + body.error_reason || body.defect_reason || "", + body.attribution || "", + body.objId] + ); + } + return NextResponse.json({ success: true, message: "불량 정보가 저장되었습니다." }); + } catch (e) { + console.error("SCM defect save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }); + } finally { client.release(); } +} diff --git a/src/app/api/scm/invoice/route.ts b/src/app/api/scm/invoice/route.ts new file mode 100644 index 0000000..f26e631 --- /dev/null +++ b/src/app/api/scm/invoice/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// SCM 거래명세서 목록 조회 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + // SCM: filter by partner unless admin + if (!user.isAdmin) { + conditions.push( + `POM.partner_objid = (SELECT objid::text FROM supply_mng WHERE UPPER(supply_code) = UPPER($${idx++}) LIMIT 1)` + ); + params.push(user.partnerCd); + } + + // invoiceMgmtGridList 대응 + if (body.year) { + conditions.push(`TO_CHAR(IM.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.supplier_name) { + conditions.push(`COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') LIKE '%' || $${idx++} || '%'`); + params.push(body.supplier_name); + } + if (body.invoice_date_from) { + conditions.push(`IM.issuance_date >= $${idx++}`); + params.push(body.invoice_date_from); + } + if (body.invoice_date_to) { + conditions.push(`IM.issuance_date <= $${idx++}`); + params.push(body.invoice_date_to); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT IM.objid::text AS "OBJID", + IM.objid::text AS "INVOICE_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') AS "SUPPLIER_NAME", + CM.contract_no AS "PROJECT_NO", + IM.issuance_date AS "INVOICE_DATE", + COALESCE(IM.price_sum::numeric, 0) AS "SUPPLY_AMOUNT", + ROUND(COALESCE(IM.price_sum::numeric, 0) * 0.1) AS "TAX_AMOUNT", + ROUND(COALESCE(IM.price_sum::numeric, 0) * 1.1) AS "TOTAL_AMOUNT", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = IM.status LIMIT 1), IM.status) AS "STATUS_NAME", + COALESCE((SELECT user_name FROM user_info WHERE user_id = IM.writer LIMIT 1), '') AS "WRITER_NAME" + FROM invoice_mgmt IM + LEFT JOIN purchase_order_master POM ON POM.objid::text = IM.parent_objid + LEFT JOIN contract_mgmt CM ON CM.objid = POM.contract_mgmt_objid + WHERE ${where} + ORDER BY IM.regdate DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/scm/order/receipt/route.ts b/src/app/api/scm/order/receipt/route.ts new file mode 100644 index 0000000..cdf9ad7 --- /dev/null +++ b/src/app/api/scm/order/receipt/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// SCM 발주 접수 처리 — reception_status='reception' 업데이트 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objIds } = await request.json(); + if (!objIds?.length) return NextResponse.json({ success: false, message: "접수할 항목을 선택하세요." }); + try { + const ph = objIds.map((_: string, i: number) => `$${i + 1}`).join(","); + await execute( + `UPDATE purchase_order_master SET reception_status = 'reception' WHERE objid::text IN (${ph})`, + objIds + ); + return NextResponse.json({ success: true, message: `${objIds.length}건 접수 완료되었습니다.` }); + } catch (error) { + console.error("SCM receipt:", error); + return NextResponse.json({ success: false, message: "접수 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/scm/order/route.ts b/src/app/api/scm/order/route.ts new file mode 100644 index 0000000..dbc2b9c --- /dev/null +++ b/src/app/api/scm/order/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// SCM 발주관리 목록 조회 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + + const conditions: string[] = []; + const params: unknown[] = []; + let idx = 1; + + // SCM: filter by partner unless admin + if (!user.isAdmin) { + conditions.push( + `POM.partner_objid = (SELECT objid::text FROM supply_mng WHERE UPPER(supply_code) = UPPER($${idx++}) LIMIT 1)` + ); + params.push(user.partnerCd); + } + + if (body.year) { + conditions.push(`TO_CHAR(POM.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.purchase_order_no) { + conditions.push(`POM.purchase_order_no LIKE '%' || $${idx++} || '%'`); + params.push(body.purchase_order_no); + } + if (body.customer_name) { + conditions.push(`COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = CM.customer_objid LIMIT 1), '') LIKE '%' || $${idx++} || '%'`); + params.push(body.customer_name); + } + if (body.project_no) { + conditions.push(`CM.contract_no LIKE '%' || $${idx++} || '%'`); + params.push(body.project_no); + } + if (body.status_code || body.status) { + conditions.push(`POM.status = $${idx++}`); + params.push(body.status_code || body.status); + } + + const where = conditions.length > 0 ? conditions.join(" AND ") : "1=1"; + + const sql = ` + SELECT POM.objid::text AS "OBJID", POM.purchase_order_no AS "PURCHASE_ORDER_NO", + POM.multi_master_yn AS "MULTI_MASTER_YN", + COALESCE((SELECT user_name FROM user_info WHERE user_id = POM.sales_mng_user_id LIMIT 1), '') AS "SALES_MNG_USER_NAME", + TO_CHAR(POM.regdate, 'YYYY-MM-DD') AS "REGDATE", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = CM.customer_objid LIMIT 1), '') AS "CUSTOMER_NAME", + CM.contract_no AS "PROJECT_NO", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = POM.unit_code LIMIT 1), POM.unit_code) AS "UNIT_NAME", + POM.title AS "TITLE", POM.delivery_date AS "DELIVERY_DATE", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = POM.delivery_place LIMIT 1), '') AS "DELIVERY_PLACE_NAME", + COALESCE((SELECT COUNT(*) FROM purchase_order_part WHERE purchase_order_master_objid = POM.objid), 0) AS "PART_CNT", + COALESCE((SELECT SUM(COALESCE(real_order_qty, order_qty, '0')::numeric) FROM purchase_order_part WHERE purchase_order_master_objid = POM.objid), 0) AS "REAL_ORDER_CNT", + POM.status AS "STATUS_NAME", + COALESCE((SELECT COUNT(*) FROM arrival_plan WHERE parent_objid = POM.objid::text), 0) AS "ARRIVAL_CNT", + POM.sales_status AS "SALES_STATUS", + POM.reception_status AS "RECEPTION_STATUS", + -- arrival_plan 기반 입고/미납/불량 + COALESCE((SELECT SUM(COALESCE(NULLIF(AP.receipt_qty,'')::numeric, 0)) FROM arrival_plan AP WHERE AP.parent_objid = POM.objid::text), 0) AS "RECEIPT_QTY", + COALESCE((SELECT SUM(COALESCE(real_order_qty, order_qty, '0')::numeric) FROM purchase_order_part WHERE purchase_order_master_objid = POM.objid), 0) + - COALESCE((SELECT SUM(COALESCE(NULLIF(AP2.receipt_qty,'')::numeric, 0)) FROM arrival_plan AP2 WHERE AP2.parent_objid = POM.objid::text), 0) AS "NON_DELIVERY_QTY", + COALESCE((SELECT SUM(COALESCE(NULLIF(AP3.error_qty,'')::numeric, 0)) FROM arrival_plan AP3 WHERE AP3.parent_objid = POM.objid::text AND AP3.error_qty IS NOT NULL AND AP3.error_qty != '' AND AP3.error_qty != '0'), 0) AS "ERROR_QTY", + -- 거래명세서 발행일 + COALESCE((SELECT MAX(IM.issuance_date) FROM invoice_mgmt IM WHERE IM.parent_objid = POM.objid::text), '') AS "ISSUANCE_DATE" + FROM purchase_order_master POM + LEFT JOIN contract_mgmt CM ON CM.objid = POM.contract_mgmt_objid + WHERE ${where} + ORDER BY POM.regdate DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/scm/payment/detail/route.ts b/src/app/api/scm/payment/detail/route.ts new file mode 100644 index 0000000..a77b0b7 --- /dev/null +++ b/src/app/api/scm/payment/detail/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const { objId } = await request.json(); + if (!objId) return NextResponse.json({ success: false, message: "objId required" }); + + const info = await queryOne( + `SELECT IM.objid::text AS "OBJID", + IM.objid::text AS "PAYMENT_NO", IM.parent_objid AS "PARENT_OBJID", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') AS "SUPPLIER_NAME", + CM.contract_no AS "PROJECT_NO", + IM.issuance_date AS "PAYMENT_DATE", + COALESCE(IM.price_sum::numeric, 0) AS "PAYMENT_AMOUNT", + POM.payment_terms AS "PAYMENT_TYPE", IM.status AS "STATUS", + COALESCE((SELECT user_name FROM user_info WHERE user_id = IM.writer LIMIT 1), '') AS "WRITER_NAME" + FROM invoice_mgmt IM + LEFT JOIN purchase_order_master POM ON POM.objid::text = IM.parent_objid + LEFT JOIN contract_mgmt CM ON CM.objid = POM.contract_mgmt_objid + WHERE IM.objid::text = $1`, [objId] + ); + if (!info) return NextResponse.json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return NextResponse.json({ success: true, data: info }); +} diff --git a/src/app/api/scm/payment/route.ts b/src/app/api/scm/payment/route.ts new file mode 100644 index 0000000..544afc0 --- /dev/null +++ b/src/app/api/scm/payment/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// SCM 자금지급관리 목록 조회 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + // SCM: filter by partner unless admin + if (!user.isAdmin) { + conditions.push( + `POM.partner_objid = (SELECT objid::text FROM supply_mng WHERE UPPER(supply_code) = UPPER($${idx++}) LIMIT 1)` + ); + params.push(user.partnerCd); + } + + // fundPaymentMgmtGridList 대응 + if (body.year) { + conditions.push(`TO_CHAR(IM.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.supplier_name) { + conditions.push(`COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') LIKE '%' || $${idx++} || '%'`); + params.push(body.supplier_name); + } + if (body.payment_date_from) { + conditions.push(`IM.issuance_date >= $${idx++}`); + params.push(body.payment_date_from); + } + if (body.payment_date_to) { + conditions.push(`IM.issuance_date <= $${idx++}`); + params.push(body.payment_date_to); + } + if (body.status_code || body.status) { + conditions.push(`IM.status = $${idx++}`); + params.push(body.status_code || body.status); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT IM.objid::text AS "OBJID", + IM.objid::text AS "PAYMENT_NO", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') AS "SUPPLIER_NAME", + CM.contract_no AS "PROJECT_NO", + IM.issuance_date AS "PAYMENT_DATE", + COALESCE(IM.price_sum::numeric, 0) AS "PAYMENT_AMOUNT", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = POM.payment_terms LIMIT 1), '') AS "PAYMENT_TYPE_NAME", + '' AS "ACCOUNT_NO", + COALESCE((SELECT code_name FROM comm_code WHERE code_id = IM.status LIMIT 1), IM.status) AS "STATUS_NAME", + COALESCE((SELECT user_name FROM user_info WHERE user_id = IM.writer LIMIT 1), IM.writer) AS "WRITER_NAME" + FROM invoice_mgmt IM + LEFT JOIN purchase_order_master POM ON POM.objid::text = IM.parent_objid + LEFT JOIN contract_mgmt CM ON CM.objid = POM.contract_mgmt_objid + WHERE ${where} + ORDER BY IM.regdate DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/scm/payment/save/route.ts b/src/app/api/scm/payment/save/route.ts new file mode 100644 index 0000000..cf3d70e --- /dev/null +++ b/src/app/api/scm/payment/save/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + const body = await request.json(); + const isNew = body.actionType === "regist" || !body.objId; + const objId = isNew ? createObjectId() : body.objId; + const client = await pool.connect(); + try { + await client.query( + `INSERT INTO invoice_mgmt (objid, parent_objid, group_seq, price_sum, issuance_date, status, regdate, writer) + VALUES ($1, $2, 1, $3, $4, 'created', now(), $5) + ON CONFLICT (objid) DO UPDATE SET + parent_objid=EXCLUDED.parent_objid, + price_sum=EXCLUDED.price_sum, + issuance_date=EXCLUDED.issuance_date`, + [objId, body.parent_objid || "", body.price_sum || "", body.issuance_date || null, user.userId] + ); + return NextResponse.json({ success: true, message: isNew ? "등록되었습니다." : "수정되었습니다." }); + } catch (e) { + console.error("SCM payment save:", e); + return NextResponse.json({ success: false, message: "저장 중 오류가 발생했습니다." }); + } finally { client.release(); } +} diff --git a/src/app/api/scm/quality/route.ts b/src/app/api/scm/quality/route.ts new file mode 100644 index 0000000..f3215a0 --- /dev/null +++ b/src/app/api/scm/quality/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// SCM 공급업체품질관리 목록 조회 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json(); + + const conditions: string[] = []; + const params: unknown[] = []; + let idx = 1; + + // SCM: filter by partner unless admin + if (!user.isAdmin) { + conditions.push( + `POM.partner_objid = (SELECT objid::text FROM supply_mng WHERE UPPER(supply_code) = UPPER($${idx++}) LIMIT 1)` + ); + params.push(user.partnerCd); + } + + // supplyQCMgmtGridList 대응 — 공급업체별 품질 집계 (arrival_plan 기반) + if (body.year) { + conditions.push(`TO_CHAR(POM.regdate, 'YYYY') = $${idx++}`); + params.push(body.year); + } + if (body.supplier_name || body.partner_name) { + conditions.push( + `(SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1) LIKE '%' || $${idx++} || '%'` + ); + params.push(body.supplier_name || body.partner_name); + } + + const where = conditions.length > 0 ? conditions.join(" AND ") : "1=1"; + + // 공급업체별 집계 + const sql = ` + SELECT POM.partner_objid AS "OBJID", + COALESCE((SELECT supply_name FROM supply_mng WHERE objid::text = POM.partner_objid LIMIT 1), '') AS "SUPPLIER_NAME", + TO_CHAR(POM.regdate, 'YYYY') AS "EVALUATION_PERIOD", + -- 납품건수 = 입고된 arrival_plan 건수 + COALESCE(SUM((SELECT COUNT(*) FROM arrival_plan AP WHERE AP.parent_objid = POM.objid::text AND COALESCE(AP.receipt_qty,'') != '')), 0) AS "DELIVERY_CNT", + -- 불량건수 = error_qty > 0 건수 + COALESCE(SUM((SELECT COUNT(*) FROM arrival_plan AP2 WHERE AP2.parent_objid = POM.objid::text AND COALESCE(NULLIF(AP2.error_qty,'')::numeric, 0) > 0)), 0) AS "DEFECT_CNT", + -- 불량율 + CASE WHEN SUM(COALESCE((SELECT SUM(COALESCE(NULLIF(AP3.receipt_qty,'')::numeric, 0)) FROM arrival_plan AP3 WHERE AP3.parent_objid = POM.objid::text), 0)) > 0 + THEN ROUND(SUM(COALESCE((SELECT SUM(COALESCE(NULLIF(AP4.error_qty,'')::numeric, 0)) FROM arrival_plan AP4 WHERE AP4.parent_objid = POM.objid::text), 0))::numeric * 100 + / NULLIF(SUM(COALESCE((SELECT SUM(COALESCE(NULLIF(AP5.receipt_qty,'')::numeric, 0)) FROM arrival_plan AP5 WHERE AP5.parent_objid = POM.objid::text), 0)), 1), 1) + ELSE 0 END AS "DEFECT_RATE", + -- 납기준수율 (placeholder — 실제 납기 지연 계산은 별도 로직 필요) + 100 AS "DELIVERY_RATE", + 0 AS "QUALITY_SCORE", + 'A' AS "TOTAL_GRADE", + '' AS "EVALUATION_DATE", + '' AS "EVALUATOR_NAME" + FROM purchase_order_master POM + WHERE POM.status = 'approvalComplete' AND ${where} + GROUP BY POM.partner_objid, TO_CHAR(POM.regdate, 'YYYY') + ORDER BY "SUPPLIER_NAME" + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/work/diary/confirm/route.ts b/src/app/api/work/diary/confirm/route.ts new file mode 100644 index 0000000..9d5489b --- /dev/null +++ b/src/app/api/work/diary/confirm/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// productionplanning.xml::workDiaryConfirm 대응 - 작업일지 팀장확인 +// STATUS = 'write' 인 항목만 'complete'로 변경 가능 (JSP 측 제약) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json().catch(() => ({})); + const checkArr: string[] = Array.isArray(body.checkArr) + ? body.checkArr.filter(Boolean).map(String) + : typeof body.checkArr === "string" && body.checkArr + ? body.checkArr.split(",").map((s: string) => s.trim()).filter(Boolean) + : []; + + if (checkArr.length === 0) { + return NextResponse.json({ success: false, msg: "선택된 데이터가 없습니다." }, { status: 400 }); + } + + try { + const placeholders = checkArr.map((_, i) => `$${i + 1}`).join(","); + const count = await execute( + `UPDATE work_diary SET status = 'complete' WHERE objid IN (${placeholders}) AND status = 'write'`, + checkArr, + ); + return NextResponse.json({ success: true, msg: `${count}건 확정되었습니다.` }); + } catch (error) { + console.error("작업일지 팀장확인:", error); + return NextResponse.json( + { success: false, msg: "확정 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/work/diary/delete/route.ts b/src/app/api/work/diary/delete/route.ts new file mode 100644 index 0000000..dcc2ce0 --- /dev/null +++ b/src/app/api/work/diary/delete/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// productionplanning.xml::workDiaryDelete 대응 - 작업일지 삭제 +// 원본 규칙: STATUS = 'write' 인 항목만 삭제 가능 (JSP 측 제약) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json().catch(() => ({})); + const checkArr: string[] = Array.isArray(body.checkArr) + ? body.checkArr.filter(Boolean).map(String) + : typeof body.checkArr === "string" && body.checkArr + ? body.checkArr.split(",").map((s: string) => s.trim()).filter(Boolean) + : []; + + if (checkArr.length === 0) { + return NextResponse.json({ success: false, msg: "선택된 데이터가 없습니다." }, { status: 400 }); + } + + try { + const placeholders = checkArr.map((_, i) => `$${i + 1}`).join(","); + const count = await execute( + `DELETE FROM work_diary WHERE objid IN (${placeholders}) AND status = 'write'`, + checkArr, + ); + return NextResponse.json({ success: true, msg: `${count}건 삭제되었습니다.` }); + } catch (error) { + console.error("작업일지 삭제:", error); + return NextResponse.json( + { success: false, msg: "삭제 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/work/diary/detail/route.ts b/src/app/api/work/diary/detail/route.ts new file mode 100644 index 0000000..073339d --- /dev/null +++ b/src/app/api/work/diary/detail/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// productionplanning.xml::selectWorkDiaryInfo 대응 - 작업일지 상세 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json().catch(() => ({})); + const objId = String(body.objId || ""); + if (!objId) { + return NextResponse.json({ success: false, message: "objId가 필요합니다." }, { status: 400 }); + } + + const row = await queryOne( + `SELECT + T.objid::text AS "OBJID", + T.contract_objid AS "CONTRACT_OBJID", + (SELECT project_no FROM project_mgmt AS O WHERE O.objid = T.contract_objid) AS "PROJECT_NO", + T.unit_code AS "UNIT_CODE", + (SELECT O.unit_no || '-' || O.task_name FROM pms_wbs_task AS O WHERE O.objid = T.unit_code) AS "UNIT_CODE_NAME", + T.division AS "DIVISION", + T.task_name AS "TASK_NAME", + T.worker_id AS "WORKER_ID", + COALESCE( + (SELECT user_name FROM user_info WHERE user_id = T.worker_id), + (SELECT user_name FROM user_info_history WHERE user_id = T.worker_id ORDER BY regdate DESC LIMIT 1), + T.worker_id + ) AS "WORKER_NAME", + T.work_start_date AS "WORK_START_DATE", + T.work_end_date AS "WORK_END_DATE", + T.work_hour AS "WORK_HOUR", + T.remark AS "REMARK", + T.regdate AS "REGDATE", + T.writer AS "WRITER", + COALESCE( + (SELECT user_name FROM user_info WHERE user_id = T.writer), + (SELECT user_name FROM user_info_history WHERE user_id = T.writer ORDER BY regdate DESC LIMIT 1), + T.writer + ) AS "WRITER_NAME", + TO_CHAR(T.regdate, 'YYYY-MM-DD') AS "REG_DATE_TEXT", + T.status AS "STATUS", + CASE WHEN T.status = 'write' THEN '작성중' + WHEN T.status = 'complete' THEN '확인완료' + ELSE '' END AS "STATUS_TITLE", + T.sourcing_type AS "SOURCING_TYPE", + T.production_type AS "PRODUCTION_TYPE" + FROM work_diary AS T + WHERE T.objid = $1`, + [objId], + ); + + return NextResponse.json({ success: true, data: row || null }); +} diff --git a/src/app/api/work/diary/route.ts b/src/app/api/work/diary/route.ts new file mode 100644 index 0000000..b3cfca7 --- /dev/null +++ b/src/app/api/work/diary/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// productionplanning.xml::workDiaryGridList 대응 - 작업일지 목록 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json().catch(() => ({})); + const projectNos: string[] = Array.isArray(body.project_nos) + ? body.project_nos.filter(Boolean).map(String) + : []; + + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(T.regdate, 'YYYY') = $${idx++}`); + params.push(String(body.year)); + } + if (body.search_division) { + conditions.push(`T.division = $${idx++}`); + params.push(String(body.search_division)); + } + if (projectNos.length > 0) { + const placeholders = projectNos.map(() => `$${idx++}`).join(","); + conditions.push(`T.contract_objid IN (${placeholders})`); + params.push(...projectNos); + } + if (body.unit_code) { + conditions.push(`T.unit_code = $${idx++}`); + params.push(String(body.unit_code)); + } + if (body.busUsersDeptId) { + conditions.push(`COALESCE( + (SELECT dept_code FROM user_info WHERE user_id = T.worker_id), + (SELECT dept_code FROM user_info_history WHERE user_id = T.worker_id ORDER BY regdate DESC LIMIT 1) + ) = $${idx++}`); + params.push(String(body.busUsersDeptId)); + } + if (body.worker) { + conditions.push(`T.worker_id = $${idx++}`); + params.push(String(body.worker)); + } + if (body.search_status) { + conditions.push(`T.status = $${idx++}`); + params.push(String(body.search_status)); + } + + const where = conditions.join(" AND "); + + const sql = ` + SELECT + T.objid::text AS "OBJID", + T.contract_objid AS "CONTRACT_OBJID", + (SELECT project_no FROM project_mgmt AS O WHERE O.objid = T.contract_objid) AS "PROJECT_NO", + T.unit_code AS "UNIT_CODE", + (SELECT O.unit_no || '-' || O.task_name FROM pms_wbs_task AS O WHERE O.objid = T.unit_code) AS "UNIT_CODE_NAME", + T.division AS "DIVISION", + T.task_name AS "TASK_NAME", + T.worker_id AS "WORKER_ID", + COALESCE( + (SELECT dept_name FROM user_info WHERE user_id = T.worker_id), + (SELECT dept_name FROM user_info_history WHERE user_id = T.worker_id ORDER BY regdate DESC LIMIT 1), + '' + ) AS "DEPT_NAME", + COALESCE( + (SELECT user_name FROM user_info WHERE user_id = T.worker_id), + (SELECT user_name FROM user_info_history WHERE user_id = T.worker_id ORDER BY regdate DESC LIMIT 1), + T.worker_id + ) AS "WORKER_NAME", + T.work_start_date AS "WORK_START_DATE", + T.work_end_date AS "WORK_END_DATE", + T.work_hour AS "WORK_HOUR", + T.remark AS "REMARK", + T.regdate AS "REGDATE", + T.writer AS "WRITER", + T.sourcing_type AS "SOURCING_TYPE", + T.production_type AS "PRODUCTION_TYPE", + COALESCE( + (SELECT user_name FROM user_info WHERE user_id = T.writer), + (SELECT user_name FROM user_info_history WHERE user_id = T.writer ORDER BY regdate DESC LIMIT 1), + T.writer + ) AS "WRITER_NAME", + TO_CHAR(T.regdate, 'YYYY-MM-DD') AS "REG_DATE_TEXT", + T.status AS "STATUS", + CASE WHEN T.status = 'write' THEN '작성중' + WHEN T.status = 'complete' THEN '확인완료' + ELSE '' END AS "STATUS_TITLE" + FROM work_diary AS T + WHERE ${where} + ORDER BY T.regdate DESC + `; + + const rows = await queryRows(sql, params); + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length }); +} diff --git a/src/app/api/work/diary/save/route.ts b/src/app/api/work/diary/save/route.ts new file mode 100644 index 0000000..e788182 --- /dev/null +++ b/src/app/api/work/diary/save/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// productionplanning.xml::mergeWorkDiaryInfo 대응 - 작업일지 등록/수정 +// INSERT ... ON CONFLICT (objid) DO UPDATE. STATUS는 신규 시 'write' 고정. +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json().catch(() => ({})); + const isNew = body.actionType === "regist" || !body.objId; + const objId = isNew ? createObjectId() : String(body.objId); + const contractObjid = String(body.project_objid || body.contract_objid || ""); + + try { + await execute( + `INSERT INTO work_diary ( + objid, contract_objid, unit_code, division, task_name, + worker_id, work_start_date, work_end_date, work_hour, remark, + status, writer, regdate, sourcing_type, production_type + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, $10, + 'write', $11, now(), $12, $13 + ) + ON CONFLICT (objid) DO UPDATE SET + contract_objid = EXCLUDED.contract_objid, + unit_code = EXCLUDED.unit_code, + division = EXCLUDED.division, + task_name = EXCLUDED.task_name, + worker_id = EXCLUDED.worker_id, + work_start_date = EXCLUDED.work_start_date, + work_end_date = EXCLUDED.work_end_date, + work_hour = EXCLUDED.work_hour, + remark = EXCLUDED.remark, + sourcing_type = EXCLUDED.sourcing_type, + production_type = EXCLUDED.production_type`, + [ + objId, + contractObjid, + String(body.unit_code || ""), + String(body.division || ""), + String(body.task_name || ""), + String(body.worker_id || user.userId), + String(body.work_start_date || ""), + String(body.work_end_date || ""), + String(body.work_hour || ""), + String(body.remark || ""), + user.userId, + String(body.sourcing_type || ""), + String(body.production_type || ""), + ], + ); + return NextResponse.json({ + success: true, + objId, + message: isNew ? "등록되었습니다." : "수정되었습니다.", + }); + } catch (error) { + console.error("작업일지 저장:", error); + return NextResponse.json( + { success: false, message: "저장 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/work/status/employee/route.ts b/src/app/api/work/status/employee/route.ts new file mode 100644 index 0000000..72f940b --- /dev/null +++ b/src/app/api/work/status/employee/route.ts @@ -0,0 +1,145 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// productionplanning.xml::workStatusByImployeeList / workStatusByImployeeNPList 대응 +// 담당자별 작업현황 (프로젝트 + 비프로젝트 두 섹션) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json().catch(() => ({})); + const projectNos: string[] = Array.isArray(body.project_nos) + ? body.project_nos.filter(Boolean).map(String) + : []; + const year = body.year ? String(body.year) : ""; + const deptCode = body.busUsersDeptId ? String(body.busUsersDeptId) : ""; + const worker = body.worker ? String(body.worker) : ""; + + // 프로젝트 섹션 쿼리 + const projConditions: string[] = ["1=1"]; + const projParams: unknown[] = []; + let pIdx = 1; + + if (projectNos.length > 0) { + const placeholders = projectNos.map(() => `$${pIdx++}`).join(","); + projConditions.push(`T.objid IN (${placeholders})`); + projParams.push(...projectNos); + } + if (deptCode) { + projConditions.push(`COALESCE( + (SELECT dept_code FROM user_info WHERE user_id = WD.worker_id), + (SELECT dept_code FROM user_info_history WHERE user_id = WD.worker_id ORDER BY regdate DESC LIMIT 1) + ) = $${pIdx++}`); + projParams.push(deptCode); + } + if (worker) { + projConditions.push(`WD.worker_id = $${pIdx++}`); + projParams.push(worker); + } + + // 연도 필터는 work_diary 서브쿼리 내부에 들어가야 함 + const projYearFilter = year ? `AND TO_CHAR(regdate, 'YYYY') = $${pIdx++}` : ""; + if (year) projParams.push(year); + + const projSql = ` + SELECT + T.objid::text AS "OBJID", + T.project_no AS "PROJECT_NO", + T.project_name AS "PROJECT_NAME", + WD.worker_id AS "WORKER_ID", + COALESCE( + (SELECT dept_name FROM user_info WHERE user_id = WD.worker_id), + (SELECT dept_name FROM user_info_history WHERE user_id = WD.worker_id ORDER BY regdate DESC LIMIT 1), + '' + ) AS "WORKER_DEPT_NAME", + COALESCE( + (SELECT user_name FROM user_info WHERE user_id = WD.worker_id), + (SELECT user_name FROM user_info_history WHERE user_id = WD.worker_id ORDER BY regdate DESC LIMIT 1), + WD.worker_id + ) AS "WORKER_USER_NAME", + WD.work_hour AS "WORK_HOUR", + ROUND((WD.work_hour::float / 8)::numeric, 1) AS "MAN_DAY", + ROUND((WD.work_hour::float / 8 / 22)::numeric, 1) AS "MAN_MONTH" + FROM project_mgmt AS T + LEFT OUTER JOIN ( + SELECT contract_objid, worker_id, SUM(work_hour::numeric) AS work_hour + FROM work_diary WD + WHERE WD.contract_objid != '' AND WD.contract_objid IS NOT NULL + AND status = 'complete' + ${projYearFilter} + GROUP BY contract_objid, worker_id + ) WD ON WD.contract_objid = T.objid + WHERE ${projConditions.join(" AND ")} + ORDER BY SUBSTRING(T.project_no, POSITION('-' IN T.project_no) + 1) DESC + `; + + // 비프로젝트 섹션 쿼리 + const npConditions: string[] = ["1=1"]; + const npParams: unknown[] = []; + let nIdx = 1; + if (deptCode) { + npConditions.push(`COALESCE( + (SELECT dept_code FROM user_info WHERE user_id = WD.worker_id), + (SELECT dept_code FROM user_info_history WHERE user_id = WD.worker_id ORDER BY regdate DESC LIMIT 1) + ) = $${nIdx++}`); + npParams.push(deptCode); + } + if (worker) { + npConditions.push(`WD.worker_id = $${nIdx++}`); + npParams.push(worker); + } + const npYearFilter = year ? `AND TO_CHAR(regdate, 'YYYY') = $${nIdx++}` : ""; + if (year) npParams.push(year); + + const npSql = ` + SELECT + WD.worker_id AS "WORKER_ID", + COALESCE( + (SELECT dept_name FROM user_info WHERE user_id = WD.worker_id), + (SELECT dept_name FROM user_info_history WHERE user_id = WD.worker_id ORDER BY regdate DESC LIMIT 1), + '' + ) AS "WORKER_DEPT_NAME", + COALESCE( + (SELECT user_name FROM user_info WHERE user_id = WD.worker_id), + (SELECT user_name FROM user_info_history WHERE user_id = WD.worker_id ORDER BY regdate DESC LIMIT 1), + WD.worker_id + ) AS "WORKER_USER_NAME", + WD.work_hour AS "WORK_HOUR", + ROUND((WD.work_hour::float / 8)::numeric, 1) AS "MAN_DAY", + ROUND((WD.work_hour::float / 8 / 22)::numeric, 1) AS "MAN_MONTH" + FROM ( + SELECT worker_id, SUM(work_hour::numeric) AS work_hour + FROM work_diary WD + WHERE (WD.contract_objid = '' OR WD.contract_objid IS NULL) + AND status = 'complete' + ${npYearFilter} + GROUP BY worker_id + ) WD + WHERE ${npConditions.join(" AND ")} + `; + + const [listRows, npRows] = await Promise.all([ + queryRows(projSql, projParams), + queryRows(npSql, npParams), + ]); + + const num = (v: unknown) => Number(v ?? 0) || 0; + const round1 = (n: number) => Math.round(n * 10) / 10; + let sumHour = 0; + let sumDay = 0; + let sumMonth = 0; + for (const r of listRows) { + const row = r as Record; + sumHour += num(row.WORK_HOUR); + sumDay += num(row.MAN_DAY); + sumMonth += num(row.MAN_MONTH); + } + const SUM_PRICE_MAP = { + SUM_WORK_HOUR: round1(sumHour), + SUM_MAN_DAY: round1(sumDay), + SUM_MAN_MONTH: round1(sumMonth), + }; + + return NextResponse.json({ LIST: listRows, NP_LIST: npRows, SUM_PRICE_MAP }); +} diff --git a/src/app/api/work/status/project/route.ts b/src/app/api/work/status/project/route.ts new file mode 100644 index 0000000..b5be2bb --- /dev/null +++ b/src/app/api/work/status/project/route.ts @@ -0,0 +1,188 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// productionplanning.xml::workStatusByProjectList 대응 - 프로젝트 작업현황 +// part_bom_report 단위로 부서별 공수(설계/구매/영업/생관/생산/관리/외주) 집계 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json().catch(() => ({})); + const projectNos: string[] = Array.isArray(body.project_nos) + ? body.project_nos.filter(Boolean).map(String) + : []; + + if (projectNos.length === 0) { + return NextResponse.json({ RESULTLIST: [], SUM_PRICE_MAP: {} }); + } + + const conditions: string[] = ["1=1"]; + const params: unknown[] = []; + let idx = 1; + + if (body.year) { + conditions.push(`TO_CHAR(T.REGDATE,'YYYY') = $${idx++}`); + params.push(String(body.year)); + } + + const placeholders = projectNos.map(() => `$${idx++}`).join(","); + conditions.push(`T.CONTRACT_OBJID IN (${placeholders})`); + params.push(...projectNos); + + if (body.unit_code) { + conditions.push(`T.UNIT_CODE = $${idx++}`); + params.push(String(body.unit_code)); + } + + const where = conditions.join(" AND "); + + // 부서 그룹 매핑 (원본 partMng/productionplanning.xml 기준) + // D=설계(DPT014,DPT006), PC=구매(DPT012,DPT004), S=영업(DPT016), + // PM=생산관리(DPT003,DPT018), P=생산(DPT005,DPT023,DPT013), M=관리(DPT019,DPT001,DPT002) + // O=외주(sourcing_type='outsourcing') + const sql = ` + SELECT T.*, + ROUND((T."WORK_HOUR"::float / 8)::numeric, 1) AS "MAN_DAY", + ROUND((T."WORK_HOUR"::float / 8 / 22)::numeric, 1) AS "MAN_MONTH" + FROM ( + SELECT + T.objid::text AS "OBJID", + T.customer_objid AS "CUSTOMER_OBJID", + (SELECT supply_name FROM supply_mng AS O WHERE O.objid::varchar = T.customer_objid) AS "CUSTOMER_NAME", + T.contract_objid AS "CONTRACT_OBJID", + (SELECT customer_project_name FROM project_mgmt AS O WHERE O.objid = T.contract_objid) AS "CUSTOMER_PROJECT_NAME", + (SELECT project_name FROM project_mgmt AS O WHERE O.objid = T.contract_objid) AS "PROJECT_NAME", + (SELECT project_no FROM project_mgmt AS O WHERE O.objid = T.contract_objid) AS "PROJECT_NO", + T.unit_code AS "UNIT_CODE", + (SELECT O.unit_no || '-' || O.task_name FROM pms_wbs_task AS O WHERE O.objid = T.unit_code) AS "UNIT_NAME", + T.status AS "STATUS", + CASE UPPER(T.status) + WHEN 'CREATE' THEN '등록중' + WHEN 'CHANGEDESIGN' THEN '설계변경미배포' + WHEN 'DEPLOY' THEN '배포완료' + ELSE '' + END AS "STATUS_TITLE", + T.regdate AS "REGDATE", + TO_CHAR(T.regdate, 'YYYY-MM-DD') AS "REG_DATE", + COALESCE(D.work_hour, '0') AS "DESIGN_INPUT", + COALESCE(PC.work_hour, '0') AS "PURCHASE_INPUT", + COALESCE(S.work_hour, '0') AS "SALES_INPUT", + COALESCE(PM.work_hour, '0') AS "PRODUCTION_MGMT_INPUT", + COALESCE(P.work_hour, '0') AS "PRODUCTION_INPUT", + COALESCE(M.work_hour, '0') AS "MGMT_INPUT", + COALESCE(O.work_hour, '0') AS "OUTSOURCING", + (COALESCE(D.work_hour,'0')::numeric + + COALESCE(PC.work_hour,'0')::numeric + + COALESCE(S.work_hour,'0')::numeric + + COALESCE(PM.work_hour,'0')::numeric + + COALESCE(P.work_hour,'0')::numeric + + COALESCE(M.work_hour,'0')::numeric + + COALESCE(O.work_hour,'0')::numeric + ) AS "WORK_HOUR" + FROM part_bom_report AS T + LEFT OUTER JOIN ( + SELECT contract_objid, unit_code, SUM(work_hour::numeric) AS work_hour + FROM work_diary WD + WHERE WD.contract_objid != '' AND WD.contract_objid IS NOT NULL + AND status = 'complete' + AND (SELECT dept_code FROM user_info WHERE user_id = WD.worker_id) IN ('DPT014','DPT006') + GROUP BY contract_objid, unit_code + ) D ON D.unit_code = T.unit_code AND D.contract_objid = T.contract_objid + LEFT OUTER JOIN ( + SELECT contract_objid, unit_code, SUM(work_hour::numeric) AS work_hour + FROM work_diary WD + WHERE WD.contract_objid != '' AND WD.contract_objid IS NOT NULL + AND status = 'complete' + AND (SELECT dept_code FROM user_info WHERE user_id = WD.worker_id) IN ('DPT012','DPT004') + GROUP BY contract_objid, unit_code + ) PC ON PC.unit_code = T.unit_code AND PC.contract_objid = T.contract_objid + LEFT OUTER JOIN ( + SELECT contract_objid, unit_code, SUM(work_hour::numeric) AS work_hour + FROM work_diary WD + WHERE WD.contract_objid != '' AND WD.contract_objid IS NOT NULL + AND status = 'complete' + AND (SELECT dept_code FROM user_info WHERE user_id = WD.worker_id) = 'DPT016' + GROUP BY contract_objid, unit_code + ) S ON S.unit_code = T.unit_code AND S.contract_objid = T.contract_objid + LEFT OUTER JOIN ( + SELECT contract_objid, unit_code, SUM(work_hour::numeric) AS work_hour + FROM work_diary WD + WHERE WD.contract_objid != '' AND WD.contract_objid IS NOT NULL + AND status = 'complete' + AND (SELECT dept_code FROM user_info WHERE user_id = WD.worker_id) IN ('DPT003','DPT018') + GROUP BY contract_objid, unit_code + ) PM ON PM.unit_code = T.unit_code AND PM.contract_objid = T.contract_objid + LEFT OUTER JOIN ( + SELECT contract_objid, unit_code, SUM(work_hour::numeric) AS work_hour + FROM work_diary WD + WHERE WD.contract_objid != '' AND WD.contract_objid IS NOT NULL + AND status = 'complete' + AND (SELECT dept_code FROM user_info WHERE user_id = WD.worker_id) IN ('DPT005','DPT023','DPT013') + GROUP BY contract_objid, unit_code + ) P ON P.unit_code = T.unit_code AND P.contract_objid = T.contract_objid + LEFT OUTER JOIN ( + SELECT contract_objid, unit_code, SUM(work_hour::numeric) AS work_hour + FROM work_diary WD + WHERE WD.contract_objid != '' AND WD.contract_objid IS NOT NULL + AND status = 'complete' + AND (SELECT dept_code FROM user_info WHERE user_id = WD.worker_id) IN ('DPT019','DPT001','DPT002') + GROUP BY contract_objid, unit_code + ) M ON M.unit_code = T.unit_code AND M.contract_objid = T.contract_objid + LEFT OUTER JOIN ( + SELECT contract_objid, unit_code, SUM(work_hour::numeric) AS work_hour + FROM work_diary WD + WHERE WD.contract_objid != '' AND WD.contract_objid IS NOT NULL + AND status = 'complete' + AND sourcing_type = 'outsourcing' + GROUP BY contract_objid, unit_code + ) O ON O.unit_code = T.unit_code AND O.contract_objid = T.contract_objid + ) T + WHERE ${where} + ORDER BY T."UNIT_CODE" + `; + + const rows = await queryRows(sql, params); + + const num = (v: unknown) => Number(v ?? 0) || 0; + const sum = { + SUM_DESIGN_INPUT: 0, + SUM_PURCHASE_INPUT: 0, + SUM_SALES_INPUT: 0, + SUM_PRODUCTION_MGMT_INPUT: 0, + SUM_PRODUCTION_INPUT: 0, + SUM_MGMT_INPUT: 0, + SUM_OUTSOURCING: 0, + SUM_WORK_HOUR: 0, + SUM_MAN_DAY: 0, + SUM_MAN_MONTH: 0, + }; + for (const r of rows) { + const row = r as Record; + sum.SUM_DESIGN_INPUT += num(row.DESIGN_INPUT); + sum.SUM_PURCHASE_INPUT += num(row.PURCHASE_INPUT); + sum.SUM_SALES_INPUT += num(row.SALES_INPUT); + sum.SUM_PRODUCTION_MGMT_INPUT += num(row.PRODUCTION_MGMT_INPUT); + sum.SUM_PRODUCTION_INPUT += num(row.PRODUCTION_INPUT); + sum.SUM_MGMT_INPUT += num(row.MGMT_INPUT); + sum.SUM_OUTSOURCING += num(row.OUTSOURCING); + sum.SUM_WORK_HOUR += num(row.WORK_HOUR); + sum.SUM_MAN_DAY += num(row.MAN_DAY); + sum.SUM_MAN_MONTH += num(row.MAN_MONTH); + } + const round1 = (n: number) => Math.round(n * 10) / 10; + const SUM_PRICE_MAP = { + SUM_DESIGN_INPUT: round1(sum.SUM_DESIGN_INPUT), + SUM_PURCHASE_INPUT: round1(sum.SUM_PURCHASE_INPUT), + SUM_SALES_INPUT: round1(sum.SUM_SALES_INPUT), + SUM_PRODUCTION_MGMT_INPUT: round1(sum.SUM_PRODUCTION_MGMT_INPUT), + SUM_PRODUCTION_INPUT: round1(sum.SUM_PRODUCTION_INPUT), + SUM_MGMT_INPUT: round1(sum.SUM_MGMT_INPUT), + SUM_OUTSOURCING: round1(sum.SUM_OUTSOURCING), + SUM_WORK_HOUR: round1(sum.SUM_WORK_HOUR), + SUM_MAN_DAY: round1(sum.SUM_MAN_DAY), + SUM_MAN_MONTH: round1(sum.SUM_MAN_MONTH), + }; + + return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length, SUM_PRICE_MAP }); +}