영업관리 PartSelect·JOIN을 part_mng 전용 테이블로 분리 (item_info 혼재 데이터 해소)
배경: item_info에 wace 마이그레이션(numeric id 8,179건) + RPS 자체 등록(UUID 21k건)이 섞여 있어 PartSelect/그리드 JOIN에서 잡 데이터(-20260126-, 00 등)와 RPS 자체 등록 데이터가 노출됨. wace 도메인 메뉴는 part_mng만 참조하도록 분리. 개발관리 등 다른 wace 메뉴도 동일 테이블 활용. 변경: - DDL: docs/migration/sales/ddl-extracted/105_create_part_mng.sql (item_info의 numeric id 데이터를 part_mng로 컬럼 매핑 INSERT, 8,176건 적재) - backend service JOIN 6곳 교체: item_info IT → part_mng PM (PM.objid::varchar = CI.part_objid) · salesEstimateService.ts (getList LATERAL JOIN, getById 라인 조회 — 2곳) · salesOrderMgmtService.ts (getList, getById, getOrderFormView, createProjectsFromContract — 4곳) - /sales/parts endpoint: part_mng SELECT + status active/release/활성 필터. 반환 키는 기존 호환을 위해 id/item_number/item_name으로 alias (objid::varchar/part_no/part_name). - 자동 검증: scripts/verify-part-mng.sql (카운트·JOIN·그리드 SQL·잡 데이터 제거 검증) 영향: - item_info는 그대로 — RPS 자체 메뉴(/sales/sales-item 등) 전용으로 유지 - ecrMngService, wacePlmDataImportService 등 RPS 다른 모듈은 이미 part_mng 사용 가정 → 자연 활성화 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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) => {
|
router.get("/parts", async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
// wace 이식 데이터(company_code 빈 값/별표) 우선, COMPANY_16 데이터 추가
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT id, item_number, item_name
|
`SELECT objid::varchar AS id,
|
||||||
FROM item_info
|
part_no AS item_number,
|
||||||
WHERE COALESCE(company_code, '') IN ('', '*', 'COMPANY_16')
|
part_name AS item_name
|
||||||
AND (item_number IS NOT NULL OR item_name IS NOT NULL)
|
FROM part_mng
|
||||||
ORDER BY item_number NULLS LAST, item_name NULLS LAST`,
|
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 });
|
res.json({ success: true, data: result.rows });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -297,14 +297,14 @@ export async function getList(filter: EstimateListFilter) {
|
|||||||
SELECT
|
SELECT
|
||||||
CI.contract_objid,
|
CI.contract_objid,
|
||||||
COUNT(*) AS item_count,
|
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(PM.part_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_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,
|
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,
|
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_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
|
STRING_AGG(DISTINCT CC_PRDI.code_name, ', ') FILTER (WHERE CC_PRDI.code_name IS NOT NULL) AS product_summary
|
||||||
FROM contract_item CI
|
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_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'
|
LEFT JOIN comm_code CC_PRDI ON CC_PRDI.code_id = CI.product AND CC_PRDI.status='active'
|
||||||
WHERE CI.status = 'ACTIVE'
|
WHERE CI.status = 'ACTIVE'
|
||||||
@@ -365,10 +365,10 @@ export async function getById(objid: string) {
|
|||||||
|
|
||||||
const itemsRes = await pool.query(
|
const itemsRes = await pool.query(
|
||||||
`SELECT CI.*,
|
`SELECT CI.*,
|
||||||
COALESCE(IT.item_number, CI.part_no) AS master_part_no,
|
COALESCE(PM.part_no, CI.part_no) AS master_part_no,
|
||||||
COALESCE(IT.item_name, CI.part_name) AS master_part_name
|
COALESCE(PM.part_name, CI.part_name) AS master_part_name
|
||||||
FROM contract_item CI
|
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
|
WHERE CI.contract_objid = $1
|
||||||
AND CI.status = 'ACTIVE'
|
AND CI.status = 'ACTIVE'
|
||||||
ORDER BY CI.seq`,
|
ORDER BY CI.seq`,
|
||||||
|
|||||||
@@ -197,8 +197,8 @@ export async function getList(filter: OrderListFilter) {
|
|||||||
SELECT
|
SELECT
|
||||||
CI.contract_objid,
|
CI.contract_objid,
|
||||||
COUNT(*) AS item_count,
|
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(PM.part_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_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,
|
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,
|
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,
|
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,
|
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
|
STRING_AGG(DISTINCT CC_PRDI.code_name, ', ') FILTER (WHERE CC_PRDI.code_name IS NOT NULL) AS product_summary
|
||||||
FROM contract_item CI
|
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'
|
LEFT JOIN comm_code CC_PRDI ON CC_PRDI.code_id = CI.product AND CC_PRDI.status='active'
|
||||||
WHERE CI.status='ACTIVE'
|
WHERE CI.status='ACTIVE'
|
||||||
GROUP BY CI.contract_objid
|
GROUP BY CI.contract_objid
|
||||||
@@ -246,9 +246,9 @@ export async function getById(objid: string) {
|
|||||||
if (headerRes.rowCount === 0) return null;
|
if (headerRes.rowCount === 0) return null;
|
||||||
|
|
||||||
const itemsRes = await pool.query(
|
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
|
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'
|
WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE'
|
||||||
ORDER BY CI.seq`, [objid],
|
ORDER BY CI.seq`, [objid],
|
||||||
);
|
);
|
||||||
@@ -323,8 +323,8 @@ export async function getOrderFormView(objid: string) {
|
|||||||
const itemsSql = `
|
const itemsSql = `
|
||||||
SELECT
|
SELECT
|
||||||
CI.seq,
|
CI.seq,
|
||||||
COALESCE(IT.item_number, CI.part_no) AS part_no,
|
COALESCE(PM.part_no, CI.part_no) AS part_no,
|
||||||
COALESCE(IT.item_name, CI.part_name) AS part_name,
|
COALESCE(PM.part_name, CI.part_name) AS part_name,
|
||||||
IT.size AS spec,
|
IT.size AS spec,
|
||||||
CC_UNIT.code_name AS unit_name,
|
CC_UNIT.code_name AS unit_name,
|
||||||
IT.unit AS unit_code,
|
IT.unit AS unit_code,
|
||||||
@@ -335,7 +335,7 @@ export async function getOrderFormView(objid: string) {
|
|||||||
CI.order_vat AS order_vat,
|
CI.order_vat AS order_vat,
|
||||||
CI.order_total_amount AS order_total_amount
|
CI.order_total_amount AS order_total_amount
|
||||||
FROM contract_item CI
|
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'
|
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'
|
WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE'
|
||||||
ORDER BY CI.seq
|
ORDER BY CI.seq
|
||||||
@@ -670,11 +670,11 @@ async function createProjectsFromContract(client: any, contractObjid: string, us
|
|||||||
const itemsRes = await client.query(
|
const itemsRes = await client.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
CI.objid, CI.part_objid,
|
CI.objid, CI.part_objid,
|
||||||
COALESCE(IT.item_number, CI.part_no) AS part_no,
|
COALESCE(PM.part_no, CI.part_no) AS part_no,
|
||||||
COALESCE(IT.item_name, CI.part_name) AS part_name,
|
COALESCE(PM.part_name, CI.part_name) AS part_name,
|
||||||
CI.quantity, CI.order_quantity, CI.due_date, CI.product
|
CI.quantity, CI.order_quantity, CI.due_date, CI.product
|
||||||
FROM contract_item CI
|
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'
|
WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE'
|
||||||
ORDER BY CI.seq`,
|
ORDER BY CI.seq`,
|
||||||
[contractObjid],
|
[contractObjid],
|
||||||
|
|||||||
@@ -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 데이터 분리';
|
||||||
@@ -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-____');
|
||||||
Reference in New Issue
Block a user