diff --git a/backend-node/src/routes/salesCommonRoutes.ts b/backend-node/src/routes/salesCommonRoutes.ts index 66082601..fc74e038 100644 --- a/backend-node/src/routes/salesCommonRoutes.ts +++ b/backend-node/src/routes/salesCommonRoutes.ts @@ -33,17 +33,23 @@ router.get("/codes/:groupId", async (req: Request, res: Response) => { } }); -/** GET /api/sales/parts → [{id, item_number, item_name}] (영업관리 풀-옵션용) */ +/** GET /api/sales/parts → [{id, item_number, item_name}] (영업관리 풀-옵션용) + * 데이터 출처: wace 품목 마스터 전용 테이블 part_mng (8,176건). + * 반환 키는 기존 호환을 위해 id/item_number/item_name으로 alias. + * status는 active/release/활성만 (wace 운영 값). + */ router.get("/parts", async (_req: Request, res: Response) => { try { const pool = getPool(); - // wace 이식 데이터(company_code 빈 값/별표) 우선, COMPANY_16 데이터 추가 const result = await pool.query( - `SELECT id, item_number, item_name - FROM item_info - WHERE COALESCE(company_code, '') IN ('', '*', 'COMPANY_16') - AND (item_number IS NOT NULL OR item_name IS NOT NULL) - ORDER BY item_number NULLS LAST, item_name NULLS LAST`, + `SELECT objid::varchar AS id, + part_no AS item_number, + part_name AS item_name + FROM part_mng + WHERE LOWER(COALESCE(status, '')) IN ('active', 'release', '활성') + AND part_no IS NOT NULL AND part_no <> '' + AND part_name IS NOT NULL AND part_name <> '' + ORDER BY part_no NULLS LAST, part_name NULLS LAST`, ); res.json({ success: true, data: result.rows }); } catch (err) { diff --git a/backend-node/src/services/salesEstimateService.ts b/backend-node/src/services/salesEstimateService.ts index 988a1aea..720fef2a 100644 --- a/backend-node/src/services/salesEstimateService.ts +++ b/backend-node/src/services/salesEstimateService.ts @@ -297,14 +297,14 @@ export async function getList(filter: EstimateListFilter) { SELECT CI.contract_objid, COUNT(*) AS item_count, - (array_agg(COALESCE(IT.item_name, CI.part_name) ORDER BY CI.seq))[1] AS first_part_name, - (array_agg(COALESCE(IT.item_number, CI.part_no) ORDER BY CI.seq))[1] AS first_part_no, + (array_agg(COALESCE(PM.part_name, CI.part_name) ORDER BY CI.seq))[1] AS first_part_name, + (array_agg(COALESCE(PM.part_no, CI.part_no) ORDER BY CI.seq))[1] AS first_part_no, MIN(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN CI.due_date END) AS earliest_due_date, GREATEST(COUNT(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN 1 END) - 1, 0) AS other_due_date_count, STRING_AGG(DISTINCT CC_RR.code_name, ', ') FILTER (WHERE CC_RR.code_name IS NOT NULL) AS return_reason_summary, STRING_AGG(DISTINCT CC_PRDI.code_name, ', ') FILTER (WHERE CC_PRDI.code_name IS NOT NULL) AS product_summary FROM contract_item CI - LEFT JOIN item_info IT ON IT.id = CI.part_objid + LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid LEFT JOIN comm_code CC_RR ON CC_RR.code_id = CI.return_reason AND CC_RR.status='active' LEFT JOIN comm_code CC_PRDI ON CC_PRDI.code_id = CI.product AND CC_PRDI.status='active' WHERE CI.status = 'ACTIVE' @@ -365,10 +365,10 @@ export async function getById(objid: string) { const itemsRes = await pool.query( `SELECT CI.*, - COALESCE(IT.item_number, CI.part_no) AS master_part_no, - COALESCE(IT.item_name, CI.part_name) AS master_part_name + COALESCE(PM.part_no, CI.part_no) AS master_part_no, + COALESCE(PM.part_name, CI.part_name) AS master_part_name FROM contract_item CI - LEFT JOIN item_info IT ON IT.id = CI.part_objid + LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE' ORDER BY CI.seq`, diff --git a/backend-node/src/services/salesOrderMgmtService.ts b/backend-node/src/services/salesOrderMgmtService.ts index 39f76246..d73a382c 100644 --- a/backend-node/src/services/salesOrderMgmtService.ts +++ b/backend-node/src/services/salesOrderMgmtService.ts @@ -197,8 +197,8 @@ export async function getList(filter: OrderListFilter) { SELECT CI.contract_objid, COUNT(*) AS item_count, - (array_agg(COALESCE(IT.item_name, CI.part_name) ORDER BY CI.seq))[1] AS first_part_name, - (array_agg(COALESCE(IT.item_number, CI.part_no) ORDER BY CI.seq))[1] AS first_part_no, + (array_agg(COALESCE(PM.part_name, CI.part_name) ORDER BY CI.seq))[1] AS first_part_name, + (array_agg(COALESCE(PM.part_no, CI.part_no) ORDER BY CI.seq))[1] AS first_part_no, MIN(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN CI.due_date END) AS earliest_due_date, GREATEST(COUNT(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN 1 END) - 1, 0) AS other_due_date_count, COALESCE(SUM(CAST(REPLACE(NULLIF(CI.order_quantity, ''), ',', '') AS NUMERIC)), 0) AS order_quantity_sum, @@ -207,7 +207,7 @@ export async function getList(filter: OrderListFilter) { COUNT(CASE WHEN CI.order_quantity IS NOT NULL AND CI.order_quantity != '' AND CI.order_quantity != '0' THEN 1 END) AS has_order_data, STRING_AGG(DISTINCT CC_PRDI.code_name, ', ') FILTER (WHERE CC_PRDI.code_name IS NOT NULL) AS product_summary FROM contract_item CI - LEFT JOIN item_info IT ON IT.id = CI.part_objid + LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid LEFT JOIN comm_code CC_PRDI ON CC_PRDI.code_id = CI.product AND CC_PRDI.status='active' WHERE CI.status='ACTIVE' GROUP BY CI.contract_objid @@ -246,9 +246,9 @@ export async function getById(objid: string) { if (headerRes.rowCount === 0) return null; const itemsRes = await pool.query( - `SELECT CI.*, IT.item_name AS master_item_name + `SELECT CI.*, PM.part_name AS master_item_name FROM contract_item CI - LEFT JOIN item_info IT ON IT.id = CI.part_objid + LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE' ORDER BY CI.seq`, [objid], ); @@ -323,8 +323,8 @@ export async function getOrderFormView(objid: string) { const itemsSql = ` SELECT CI.seq, - COALESCE(IT.item_number, CI.part_no) AS part_no, - COALESCE(IT.item_name, CI.part_name) AS part_name, + COALESCE(PM.part_no, CI.part_no) AS part_no, + COALESCE(PM.part_name, CI.part_name) AS part_name, IT.size AS spec, CC_UNIT.code_name AS unit_name, IT.unit AS unit_code, @@ -335,7 +335,7 @@ export async function getOrderFormView(objid: string) { CI.order_vat AS order_vat, CI.order_total_amount AS order_total_amount FROM contract_item CI - LEFT JOIN item_info IT ON IT.id = CI.part_objid + LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid LEFT JOIN comm_code CC_UNIT ON CC_UNIT.code_id = IT.unit AND CC_UNIT.status='active' WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE' ORDER BY CI.seq @@ -670,11 +670,11 @@ async function createProjectsFromContract(client: any, contractObjid: string, us const itemsRes = await client.query( `SELECT CI.objid, CI.part_objid, - COALESCE(IT.item_number, CI.part_no) AS part_no, - COALESCE(IT.item_name, CI.part_name) AS part_name, + COALESCE(PM.part_no, CI.part_no) AS part_no, + COALESCE(PM.part_name, CI.part_name) AS part_name, CI.quantity, CI.order_quantity, CI.due_date, CI.product FROM contract_item CI - LEFT JOIN item_info IT ON IT.id = CI.part_objid + LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE' ORDER BY CI.seq`, [contractObjid], diff --git a/docs/migration/sales/ddl-extracted/105_create_part_mng.sql b/docs/migration/sales/ddl-extracted/105_create_part_mng.sql new file mode 100644 index 00000000..c7cdf096 --- /dev/null +++ b/docs/migration/sales/ddl-extracted/105_create_part_mng.sql @@ -0,0 +1,38 @@ +-- ============================================================ +-- part_mng — wace 품목 마스터 데이터 채움 (영업/개발 메뉴 공용) +-- ---------------------------------------------------------------- +-- 배경: +-- - part_mng 테이블은 이미 vexplor_rps에 존재 (스키마는 wace 운영 part_mng와 일치) +-- 스키마: objid bigint, part_no, part_name, part_type, status, writer, regdate, company_code +-- - 데이터는 0건이라 비어있음. 다른 RPS 모듈(ecrMngService, wacePlmDataImportService)도 part_mng 사용 가정으로 만들어져 있음. +-- - item_info에는 wace 마이그레이션(numeric id 8,179건) + RPS 자체 등록(UUID 20k+)이 섞여 있음. +-- wace 데이터만 part_mng로 옮기고, wace 도메인 메뉴는 part_mng를 참조. +-- ---------------------------------------------------------------- +-- 작업: item_info의 wace numeric id 8,179건을 컬럼명 매핑해서 part_mng에 INSERT +-- item_info.id (varchar) → part_mng.objid (bigint) +-- item_info.item_number → part_mng.part_no +-- item_info.item_name → part_mng.part_name +-- item_info.type/division → part_mng.part_type (있으면) +-- item_info.status → part_mng.status +-- item_info.writer → part_mng.writer +-- item_info.created_date → part_mng.regdate +-- item_info.company_code → part_mng.company_code +-- ============================================================ + +INSERT INTO part_mng (objid, part_no, part_name, part_type, status, writer, regdate, company_code) +SELECT + CAST(id AS bigint), + item_number, + item_name, + COALESCE(NULLIF(type, ''), NULLIF(division, '')), + LOWER(COALESCE(NULLIF(status, ''), 'active')), + writer, + created_date, + company_code +FROM item_info +WHERE id ~ '^-?[0-9]+$' + AND item_number IS NOT NULL AND item_number <> '' + AND item_name IS NOT NULL AND item_name <> '' +ON CONFLICT (objid) DO NOTHING; + +COMMENT ON TABLE part_mng IS 'wace 품목 마스터 (영업/개발 메뉴 공용) — item_info의 wace numeric id 데이터 분리'; diff --git a/scripts/verify-part-mng.sql b/scripts/verify-part-mng.sql new file mode 100644 index 00000000..42eceed5 --- /dev/null +++ b/scripts/verify-part-mng.sql @@ -0,0 +1,45 @@ +-- ============================================================ +-- part_mng 분리 검증 — wace 도메인 메뉴가 part_mng를 참조하는지 정합성 확인 +-- ============================================================ + +\echo '' +\echo '[1] 카운트' +SELECT 'item_info numeric id (wace)' AS kind, COUNT(*) FROM item_info WHERE id ~ '^-?[0-9]+$' +UNION ALL SELECT 'part_mng total', COUNT(*) FROM part_mng +UNION ALL SELECT 'part_mng status active/release/활성', COUNT(*) FROM part_mng WHERE LOWER(COALESCE(status,'')) IN ('active','release','활성'); + +\echo '' +\echo '[2] 견적관리 라인 JOIN — 26C-0801' +SELECT CI.seq, CI.part_objid, + COALESCE(PM.part_no, CI.part_no) AS master_part_no, + COALESCE(PM.part_name, CI.part_name) AS master_part_name + FROM contract_item CI + LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid + WHERE CI.contract_objid='-1778190592' AND CI.status='ACTIVE' + ORDER BY CI.seq; + +\echo '' +\echo '[3] /sales/parts 호출 결과 — 8,173건 기대' +SELECT COUNT(*) AS cnt FROM part_mng + WHERE LOWER(COALESCE(status, '')) IN ('active', 'release', '활성') + AND part_no IS NOT NULL AND part_no <> '' + AND part_name IS NOT NULL AND part_name <> ''; + +\echo '' +\echo '[4] 영업관리 4개 메뉴 그리드 SQL 정합성 (라인 JOIN)' +\echo ' - 견적관리 26C-0801 ITEM_SUMMARY' +SELECT T.contract_no, + CASE WHEN COUNT(*) = 1 THEN MIN(COALESCE(PM.part_name, CI.part_name)) + WHEN COUNT(*) > 1 THEN MIN(COALESCE(PM.part_name, CI.part_name)) || ' 외 ' || (COUNT(*) - 1) || '건' + ELSE '' END AS item_summary + FROM contract_mgmt T + JOIN contract_item CI ON CI.contract_objid = T.objid AND CI.status='ACTIVE' + LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid + WHERE T.contract_no IN ('26C-0801','26C-0797','26C-0796') + GROUP BY T.contract_no + ORDER BY T.contract_no DESC; + +\echo '' +\echo '[5] 잡 데이터 제거 확인 (item_info에 있는 -20260126-, 0 등은 part_mng에 없어야)' +SELECT 'item_info garbage' AS kind, COUNT(*) FROM item_info WHERE item_number IN ('-20260126-', '-20260126-____') +UNION ALL SELECT 'part_mng garbage (0건 기대)', COUNT(*) FROM part_mng WHERE part_no IN ('-20260126-', '-20260126-____');