# PR-A : PART 등록·조회 묶음 구현 명세

> 작성: 2026-05-12 / 범위: 개발관리 M1(PART 등록) + M2(PART 조회) — 같은 `part_mng` 테이블 R/W, 매퍼 공유.

---

## 1. 매퍼 쿼리 1:1 매핑

원본 `wace_plm/src/com/pms/mapper/partMng.xml`:

| Query id | Line | 본 PR 매핑 | 용도 |
|---|---:|---|---|
| `partMngBaseSimple` (sql) | 78 | (서비스 측 공통 SELECT fragment) | PART_MNG 메인 88+ 컬럼 SELECT |
| `partMngTempGridList` | 2,354 | `GET /api/development/part-temp/list` | M1 그리드 (status != 'release') + ORDER_SPEC_MNG·ADMIN_SUPPLY_MNG JOIN |
| `partMngGridList` | 1,903 | `GET /api/development/part/list` | M2 그리드 (status = 'release' 고정) |
| `partMngInfo` | 2,699 | `GET /api/development/part/:objid` | 상세 단건 (편집 팝업 진입) |
| `insertpartInfo` | 7,625 | `POST /api/development/part` | 신규 등록 (38 컬럼 INSERT) |
| `updatePartDetail` | 2,711 | `PUT /api/development/part/:objid` | 상세 수정 (21 컬럼 UPDATE) |
| `partMngDeploy` | 4,190 | `POST /api/development/part-temp/deploy` | 확정 (M1→M2) STATUS='release', EO_NO 채번 |
| `partMngIsLastInit` | 4,230 | (deploy 트랜잭션 내부) | 동일 PART_NO 이전 IS_LAST='0' |
| `insertPartMngHistory` | 4,244 | (deploy 트랜잭션 내부) | PART_MNG_HISTORY 이력 INSERT |
| `partMngDelete` | 4,486 | `DELETE /api/development/part` (body: `objids: string[]`) | 다중 삭제 |

`partMngBaseSimple` SELECT 핵심: `PART_MNG P` + `COMM_CODE CC_UNIT`(UNIT) + `COMM_CODE CC_PART`(PART_TYPE) + `admin_supply_mng SUP`(SUPPLY_CODE) + LATERAL `BOM_PART_QTY`(LAST_PART_OBJID·status='deploy'·최신 1행) + LATERAL `COMM_CODE`(CHANGE_OPTION 다중 라벨) + `ATTACH_FILE_INFO`(3D/2D/PDF 파일 카운트). 23개 그리드 컬럼 + CODE_NAME 라벨 + Y/N flag CASE 변환 자체 처리.

---

## 2. API 엔드포인트 명세

### 2.1 M1 그리드 — `GET /api/development/part-temp/list`

**Query**:
```
search_part_no?: string
search_part_name?: string
search_material?: string
search_spec?: string
search_part_type?: string  (PART_TYPE_CODE comm_code id)
writer?: string
status?: string             // 단일: 'create'/'changing'/'editing'
status_arr?: string[]       // 다중 (둘 중 하나만 사용)
product_code?: string
upg_no?: string
page?: number               // 기본 1
page_size?: number          // 기본 20
```

**SQL** (요약):
```sql
SELECT T.*, SORT (REVISION), O.PARTNER_TITLE, Q.OBJID/CHILD_OBJID/QTY/QTY_TEMP, Q_QTY (CASE), 
       (SELECT PART_NO FROM PART_MNG SP WHERE SP.OBJID = Q.PARENT_PART_NO) PARENT_PART_INFO
FROM <partMngBaseSimple> T
LEFT JOIN (ORDER_SPEC_MNG OSM JOIN ADMIN_SUPPLY_MNG SUP) O ON T.OBJID::VARCHAR = O.PART_OBJID::VARCHAR
LEFT JOIN BOM_PART_QTY Q ON (
    T.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 = 'beforeEdit'
)
WHERE 1=1 + 동적 (SEARCH_PART_NO/NAME/MATERIAL/SPEC/PART_TYPE, WRITER, STATUS, STATUS_ARR)
ORDER BY PARENT_PART_INFO, T.PART_NO
```

**Response**:
```ts
{
  rows: PartTempRow[];   // 그리드 23셀 + 추가 필드
  total: number;
  page: number;
  pageSize: number;
}
```

### 2.2 M2 그리드 — `GET /api/development/part/list`

**Query**: 위 + `search_year?` `search_hardness?` `search_method?` `search_surface?` `customer_objid?` `customer_cd?` `project_name?` `unit_code?` `search_design_date_from?` `search_design_date_to?` `is_last?` `eo?`

**SQL** (요약):
```sql
SELECT NUM (ROW_NUMBER), T.*,
       DECODE(PART_TYPE, '0000063', '1',
              (SELECT SUM(...) FROM BOM_PART_QTY Q WHERE Q.LAST_PART_OBJID=T.OBJID)::CHARACTER) BOM_QTY
FROM <partMngBaseSimple> T
WHERE 1=1 AND T.status='release'   -- M1 vs M2 핵심 차이
  + 동적 (M1 검색 필드 + 추가 5종)
```

**Response**: `{ rows: PartRow[]; total, page, pageSize }`

### 2.3 단건 상세 — `GET /api/development/part/:objid`

```sql
SELECT T.* FROM <partMngBaseSimple> T WHERE T.OBJID = #{OBJID}
```

→ `PartRow` 단일 반환. 404 시 `{ error: 'not_found' }`.

### 2.4 신규 등록 — `POST /api/development/part`

**Body** (38 컬럼, 핵심):
```ts
{
  part_objid: string;     // numeric, 클라이언트 채번 (nanoid-based) 또는 서버 채번
  part_no: string;
  part_name: string;
  unit?: string;          // comm_code
  qty?: string;
  spec?: string;
  material?: string;
  thickness?: string; width?: string; height?: string;
  out_diameter?: string; in_diameter?: string; length?: string;
  remark?: string;
  part_type: string;      // comm_code (PART_TYPE_CODE)
  product_mgmt_objid?: string;
  supply_code?: string;
  maker?: string;
  contract_objid?: string;
  post_processing?: string;
  heat_treatment_hardness?: string;
  heat_treatment_method?: string;
  surface_treatment?: string;
  acctfg?: string;        // comm_code 계정구분
  odrfg?: string;         // 0=구매/1=생산/8=Phantom
  unit_dc?: string; unitmang_dc?: string;
  unitchng_nb?: string;
  lot_fg?: '0'|'1';
  use_yn?: '0'|'1';
  qc_fg?: '0'|'1';
  setitem_fg?: '0'|'1';
  req_fg?: '0'|'1';
  unit_length?: string;
  unit_qty?: string;
}
```

**SQL**: `insertpartInfo` (7,625) 그대로. `STATUS='create'`, `REG_DATE=now()`, `IS_LAST='1'`, `WRITER=#{CONNECTUSERID}` (서버에서 `req.user.user_id` 주입).

**채번 정책**: `part_mng.objid` 는 **`bigint`** 타입(다른 영업관리 테이블 `contract_mgmt.objid` 등은 varchar — `genObjid("CM")` 패턴 사용). bigint 컬럼은 prefix-string 못 쓰므로 **wace `CommonUtils.createObjId()` 1:1 구현** 사용:

```typescript
// backend-node/src/utils/objidUtil.ts (신규)
import { randomUUID } from 'crypto';

function javaStringHashCode(s: string): number {
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
  return h;
}

/** wace CommonUtils.createObjId() 1:1 — UUID v4 → 하이픈 제거(32 hex) → Java String.hashCode (int32) → String. 결과: -2,147,483,648 ~ 2,147,483,647. */
export function createObjId(): string {
  return String(javaStringHashCode(randomUUID().replaceAll('-', '')));
}
```

INSERT 시 `body.part_objid` 가 비어 있으면 서버에서 `createObjId()` 호출(클라이언트 채번도 허용하되 권장 X).

### 2.5 상세 수정 — `PUT /api/development/part/:objid`

**Body** (21 컬럼, `updatePartDetail` 1:1):
`part_name, material, heat_treatment_hardness, heat_treatment_method, surface_treatment, maker, part_type, acctfg, odrfg, spec, unit_dc, unitmang_dc, unitchng_nb, lot_fg, use_yn, qc_fg, setitem_fg, req_fg, unit_length, unit_qty, remark`

→ `EDIT_DATE = NOW()` 자동.

### 2.6 확정 — `POST /api/development/part-temp/deploy`

**Body**: `{ objids: string[] }` — 다중 선택 확정.

**트랜잭션 (각 objid에 대해 순차 처리)**:
1. `partMngIsLastInit`: 같은 PART_NO 모든 행 `IS_LAST='0'`
2. `insertPartMngHistory`: 현재 행을 `PART_MNG_HISTORY`로 복사 (이력 보존)
3. `partMngDeploy`: 본 행 `IS_LAST='1'`, `STATUS='release'`, `DEPLOY_DATE=NOW()`, `REVISION=COALESCE(REVISION,'RE')`, `EO_DATE=...`, `EO_NO=` 채번 (IS_LONGD에 따라 `EOB{yy}-{seq}` or `EO{yy}-{seq}`)

**EO_NO 채번 SQL** (wace 운영판 그대로):
```sql
CASE WHEN P.IS_LONGD = '1' THEN
       'EOB' || TO_CHAR(NOW(),'yy') || '-' || LPAD(
         (SELECT COALESCE(SUBSTR(MAX(EO_NO),7,8)::INTEGER+1, 1)
          FROM PART_MNG SP
          WHERE SP.EO_NO LIKE 'EOB' || TO_CHAR(NOW(),'yy') || '-%'
            AND SP.PART_NO != P.PART_NO
            AND SP.REVISION != P.REVISION
         )||'', 4, '0')
     ELSE
       'EO' || TO_CHAR(NOW(),'yy') || '-' || LPAD(... 'EO{yy}-{seq}' ...)
END
```

**Response**: `{ deployed: number, eo_nos: Record<objid, eo_no> }`

### 2.7 다중 삭제 — `DELETE /api/development/part`

**Body**: `{ objids: string[] }`

**SQL** (wace 그대로 POSITION 트릭):
```sql
DELETE FROM PART_MNG WHERE POSITION(OBJID||',' IN #{checkArr}||',') > 0
```

→ backend-node에서는 PostgreSQL 표준인 `WHERE OBJID = ANY($1::numeric[])` 로 정리(동일 효과 + 인덱스 활용 가능).

---

## 3. Backend 파일 구조

```
backend-node/src/
  routes/
    devPartRoutes.ts          // Express Router — 7 endpoint
  controllers/
    devPartController.ts      // req/res 처리, validation
  services/
    devPartService.ts         // SQL 실행 (pg 트랜잭션 처리 포함)
    devPartSqlFragments.ts    // partMngBaseSimple SELECT fragment 재사용
```

`app.ts`에 `app.use('/api/development', devPartRoutes)` 추가 (또는 메뉴 묶음 라우터 도입 시 그쪽).

---

## 4. Frontend 파일 구조

```
frontend/
  app/(main)/COMPANY_16/development/
    part-regist/
      page.tsx                // M1 그리드 + 상단 액션 + 페이징
    part-search/
      page.tsx                // M2 그리드 + 상단 액션 + 페이징
  components/development/
    PartFormDialog.tsx        // 신규/수정 통합 (mode prop)
    PartDetailDialog.tsx      // 읽기 전용 상세
  lib/api/
    devPart.ts                // 7 endpoint 호출 함수 + 타입
```

### 4.1 그리드 23셀 (M1·M2 공통)

| key | 라벨 | 정렬 | 너비 |
|---|---|---|---:|
| part_no | 품번 | left | 140 |
| part_name | 품명 | left | 220 |
| cu01_cnt | 3D | right | 60 |
| cu02_cnt | 2D | right | 60 |
| cu03_cnt | PDF | right | 60 |
| material | 재료 | left | 100 |
| heat_treatment_hardness | 열처리경도 | left | 110 |
| heat_treatment_method | 열처리방법 | left | 110 |
| surface_treatment | 표면처리 | left | 100 |
| maker | 메이커 | left | 100 |
| part_type_title | 범주이름 | left | 100 |
| spec | 규격 | left | 140 |
| acctfg_nm | 계정구분 | center | 80 |
| odrfg_nm | 조달구분 | center | 80 |
| unit_dc_nm | 재고단위 | center | 80 |
| unitmang_dc_nm | 관리단위 | center | 80 |
| unitchng_nb | 환산수량 | right | 90 |
| lot_fg_nm | LOT구분 | center | 80 |
| use_yn_nm | 사용여부 | center | 80 |
| qc_fg_nm | 검사여부 | center | 80 |
| setitem_fg_nm | SET품여부 | center | 90 |
| req_fg_nm | 의뢰여부 | center | 80 |
| unit_length / unit_qty | 개당길이/수량 | right | 100 |

추가 (M1만): `partner_title`, `q_qty`, `parent_part_info`
추가 (M2만): `bom_qty`

### 4.2 검색 폼

**M1 (PART 등록)** — 2 필드: SEARCH_PART_NO · SEARCH_PART_NAME (둘 다 PartSelect autocomplete)
**M2 (PART 조회)** — 메인 조회 화면 (별도 검색 폼 없음, 그리드 헤더 inline 필터로 처리하거나 상단 간소화 검색바 1줄로 통합 — 본 PR 우선 `<Input>` 2개로 시작, 추후 보강)

### 4.3 액션 버튼 (각 page 상단)

**M1**: 등록 · 수정 · 삭제 · 확정 · 조회
**M2**: 등록 · 수정 · 삭제 · 조회 (도면연동/ERP업로드/Excel은 본 PR 제외)

### 4.4 PartFormDialog (신규/수정 통합)

- mode: `'create' | 'edit'`
- 38 필드 — `<Input>` + `<CommCodeSelect>` 조합
- 검증: part_no/part_name 필수, comm_code 필드는 SmartSelect
- 신규: POST → 신규 행 추가
- 수정: PUT → 21 필드만 전송 (insertpartInfo는 38, updatePartDetail는 21 — wace 그대로)

### 4.5 PartDetailDialog (읽기 전용)

행 더블클릭 시 진입. 모든 필드 disabled. "수정" 버튼 → PartFormDialog(mode='edit') 전환.

---

## 5. 본 PR 제외 항목

| 항목 | 사유 / 후속 |
|---|---|
| 도면 다중 업로드 (M1) | `ATTACH_FILE_INFO` 다파일 업로드 — 별 PR |
| ERP 업로드 (M2) | wace 외부 시스템 연동 — 별 PR |
| Excel Upload (M1·M2) | `openPartExcelImportPopUp.jsp` 별도 — 별 PR |
| BOM_PART_QTY R/W (M3 영역) | PR-B 에서 다룸 |
| EO_NO 채번 분기 일부 (`IS_LONGD` flag) | 본 PR 포함 — 운영판 그대로 |

---

## 6. 검증 시나리오 (verify.md 기준)

1. M1 페이지 진입 → 그리드 표시(status != 'release') 확인
2. "등록" → PartFormDialog 신규 → POST → M1 그리드에 새 행
3. M1 행 선택 → "확정" → POST deploy → STATUS='release', EO_NO 채번 확인
4. M2 페이지 진입 → deploy된 행이 M2 그리드에 표시
5. M2 행 선택 → "수정" → PartFormDialog 수정 → PUT
6. M2 행 다중 선택 → "삭제" → DELETE → 그리드에서 제거
7. 검색 (SEARCH_PART_NO/NAME) → 필터 적용 확인
8. 운영DB 11133/waceplm 의 동일 SQL 결과와 vexplor_rps 결과 행 수 비교 (sanity)
