조달청 MVP — proposals 테이블 구조 + 실제 데이터 시연

헥사고날 + 2026 패턴 리팩토링 완료. 실제 HWPX 2개 (JK알에스티 / 스마트파워) 업로드 → 파싱 → DB 저장 → 표시 검증.

작성: 2026-05-21 backend: FastAPI + SQLAlchemy 2.0 async DB: 1 테이블 (proposals) endpoint: 5개 pytest: 14/14 ✅

1TL;DR

완성: 다중업로드 + 파싱 + DB 저장 + 비교표 4 기능 동작. 단일 proposals 테이블 + JSONB extraction 컬럼. 헥사고날 (domain → port → adapter) 구조.
측면이전 (Suman PR #162)현재 MVP
DB 테이블4개 (chat_message, chat_feedback, tech/req/eval_match)1개 (proposals)
BFF endpoint10개 (chat 6 + compare 3 + summary)5개 (health + specs + internal + proposals 2)
외부 의존postgres + redis + ai-engine + knowledge-hubpostgres만 (hwpx 는 외부 별도)
아키텍처flat (API ↔ InMemoryStore)헥사고날 (domain/ports/use_cases/adapters)
저장소InMemoryStore (휘발)SQLAlchemy 2.0 async + Postgres

2DB 스키마 — proposals 테이블 (단일)

컬럼 구성 (17 컬럼)

컬럼타입제약설명
idString(64)PKBFF 발급 ID. prop-{12자 uuid} 형식. webhook ↔ list 결정성 보장.
bid_idString(64)FK?입찰/공고 ID (UUID). 같은 입찰 N 제안서를 묶음. nullable (FE 미동봉 시).
file_nameString(512)NOT NULL원본 HWPX 파일명 (한글 OK).
file_size_bytesBigIntegerNOT NULL업로드 파일 크기.
hwpx_job_idString(128)UNIQUEhwpx-intelligence 의 job 식별자. webhook 라우팅 키.
statusString(16)NOT NULLqueued / running / done / error (ENUM-like).
error_messageTextnullablestatus=error 일 때만 set.
company_nameString(256)nullable업체명 — extraction.identity.author_or_org 정규화 복사 (검색·정렬용).
designation_noString(64)nullable우수제품 지정번호 — extraction.identity.designation_no 복사.
product_categoryString(128)nullable제품 카테고리 — extraction.identity.product_category 복사.
title_koTextnullable한국어 제품명/제목 — extraction.identity.title_ko 복사.
parser_versionString(32)nullablehwpx 파서 버전 (현재 0.2.0).
parse_confidenceFloatnullable0.0 ~ 1.0 신뢰도.
extractionJSON / JSONBnullableProductRecord 통째 (가변 영역) — patents/detailed_items/quality_tests/image_assets/warranty/manufacturing 모두.
created_atDateTime(tz)NOT NULL업로드 시각 (UTC, timezone-aware).
extracted_atDateTime(tz)nullable파싱 완료 시각 (status=done 직후 set).

제약조건 + 인덱스

이름타입컬럼이유
PRIMARY KEYPKid고유 식별자
uq_proposals_bid_designationUNIQUE(bid_id, designation_no)같은 입찰 내 중복 제품 방지
ix_proposals_bid_idINDEXbid_id입찰별 list 빠르게
ix_proposals_statusINDEXstatusstatus=done filter 빠르게
ix_proposals_hwpx_job_idUNIQUE INDEXhwpx_job_idwebhook 라우팅 (1 job = 1 proposal)
ix_proposals_created_atINDEXcreated_at최신순 정렬 빠르게

2026 패턴 적용 사항

3실제 데이터 — 2개 HWPX 업로드 후 DB row

김기열 주무관님 폴더에서 2개 파일 업로드:

Row 1 — JK알에스티

컬럼
idprop-baf1740bdfc6
bid_id00000000-0000-0000-0000-000000000001
file_name1. 폐쇄형배전반_JK알에스티(2025057).hwpx
file_size_bytes6,048,930 (5.8 MB)
hwpx_job_id6fd3844af2df
statusdone
company_name(주)JK알에스티
designation_no2025057
product_category수배전반
title_ko방열성 분체도료코팅 및 지진충격을 감쇠시키는 이중슬립 면진장치를 적용한 수배전반
parser_version0.2.0
parse_confidence0.714 (71.4%)
extraction (JSONB)특허 1건 / 모델·사양 4그룹 / 시험성적 1건 / 이미지 15장
created_at2026-05-21 06:52:57.242796 UTC
extracted_at2026-05-21 06:53:03.600405 UTC (6.4초)

Row 2 — 스마트파워

컬럼
idprop-f9e2818f908f
bid_id00000000-0000-0000-0000-000000000001 (같은 입찰)
file_name11. 폐쇄형배전반_주식회사 스마트파워(2022190).hwpx
file_size_bytes4,627,456 (4.4 MB)
hwpx_job_id120d6f864a10
statusdone
company_name㈜스마트파워
designation_no2022190
product_category안전배전반
title_ko및 보호제어기술이 탑재된 안전배전반
parser_version0.2.0
parse_confidence0.714 (71.4%)
extraction (JSONB)특허 6건 / 모델·사양 4그룹 / 시험성적 1건 / 이미지 11장
created_at2026-05-21 06:52:57.362790 UTC
extracted_at2026-05-21 06:53:03.655028 UTC (6.3초)
관찰: 두 row 가 같은 bid_id 로 묶임 → 같은 입찰의 비교 대상으로 자동 그룹화. (bid_id, designation_no) UNIQUE 가 중복 방지.

4frontend 표 구성 — /upload 페이지

업로드 직후 추출 완료된 proposals 가 표로 표시. GET /api/proposals 응답을 그대로 렌더.

회사명 지정번호 품목 제목 신뢰도 파서
(주)JK알에스티 2025057 수배전반 방열성 분체도료코팅 및 지진충격을 감쇠시키는 이중슬립 면진장치를 적용한 수배전반 0.71 0.2.0
㈜스마트파워 2022190 안전배전반 및 보호제어기술이 탑재된 안전배전반 0.71 0.2.0

표 행/열 매핑

UI 열DB 컬럼출처
회사명company_nameextraction.identity.author_or_org → 정규화
지정번호designation_noextraction.identity.designation_no → 정규화
품목product_categoryextraction.identity.product_category → 정규화
제목title_koextraction.identity.title_ko → 정규화
신뢰도parse_confidenceextraction.confidence → 정규화
파서parser_versionextraction.parser_version → 정규화

각 행은 1 proposal = 1 row. 행 수 = 업로드한 HWPX 파일 수.

5frontend 표 구성 — /eval 페이지 (비교)

여러 업체 선택 후 side-by-side 컬럼 비교. 행은 "항목", 열은 "업체".

항목 (주)JK알에스티 ㈜스마트파워
지정번호20250572022190
품목수배전반안전배전반
제품명방열성 분체도료코팅 및 지진충격을 감쇠시키는 이중슬립 면진장치를 적용한 수배전반및 보호제어기술이 탑재된 안전배전반
특허 수1 건6 건
모델·사양 그룹4 그룹4 그룹
품질시험 수1 건1 건
이미지 수15 장11 장
파일 크기5.8 MB4.4 MB
신뢰도0.710.71

비교 행/열 구조

구성
행 (rows)비교 항목 — 지정번호 / 품목 / 제품명 / 특허 / 모델 / 시험 / 이미지 / 파일 크기 / 신뢰도 ...
열 (columns)업체 — 사용자가 dropdown 에서 선택한 N 개. 첫 열은 "항목" 라벨, 이후 N 개는 업체별
2개 선택총 3개 열 (항목 + 업체A + 업체B). 50/50 분할
3개 선택총 4개 열 (항목 + 3 업체). 25/25/25/25 분할

각 셀 값은 extraction JSONB 에서 동적으로 계산 (count, summary, identity 필드 등). 정규화 컬럼은 빠른 접근용, JSONB 는 풍부한 비교용.

6extraction JSONB 의 풍부한 구조

정규화 컬럼은 6개지만 JSONB extraction 은 ProductRecord 통째 — 8 영역 4단 중첩 구조.

extraction = {
  product_id: "2025057",
  identity: { title_ko, designation_no, author_or_org, product_category },
  feature_summary: { summary, bullets[] },
  patents: [ { type, name, number, date, issuer, description } ],
  detailed_items: [ { item_name_ko, item_code, rows: [ { identifier, model_name, size_wxhxd_mm, specification_text, note } ] } ],
  quality_tests: [ { standard_title, test_type, test_items: [{ name, value }], confidence, missing_reason } ],
  image_assets: [ { asset_type, filename, caption_ko, related_model_names[], para_index, data_uri, extraction_confidence } ],
  manufacturing: [ { step, work, equipment, inspection } ],
  warranty: { period, conditions },
  confidence: 0.71,
  parser_version: "0.2.0",
  parse_stats: { ... }
}

비교 화면에서 extraction.patents.length 같은 식으로 카운트 / 특정 셀 클릭 시 상세 (특허 번호·등록일 등) drill-down 가능.

7API endpoint 5개

MethodPathUse case응답
GET/api/health{status, version}
POST/api/specsUploadProposal{proposal_id, job_id, status}
POST/internal/doneApplyExtraction (by job_id){accepted, proposal_id}
GET/api/proposalsListProposals (only_done=true){items: ProposalExtracted[]}
GET/api/proposals/{id}/extractedGetProposalProposalExtracted (raw 포함)

8헥사고날 구조 — domain → port → adapter

backend/app/
├── domains/proposals/              # 도메인 — 외부 의존 0
│   ├── entities/
│   │   ├── proposal.py             # Proposal entity (Pydantic v2)
│   │   └── product_record.py       # hwpx ProductRecord 도메인 모델
│   ├── ports/
│   │   ├── proposal_repository.py  # Protocol (ABC) — 영속화 contract
│   │   └── hwpx_client.py          # Protocol — HWPX 파서 contract
│   └── use_cases/
│       ├── upload_proposal.py      # UploadProposal
│       ├── apply_extraction.py     # ApplyExtraction (webhook + poller 공통)
│       └── list_proposals.py       # ListProposals + GetProposal
├── adapters/
│   ├── database/
│   │   ├── models.py               # SQLAlchemy ORM (ProposalRow)
│   │   └── proposal_repository.py  # ProposalRepository port 구현 + RepositoryFactory
│   └── hwpx_client.py              # HwpxClient port 의 httpx 구현
├── api/                            # FastAPI 진입점 (composition root)
│   ├── specs.py                    # POST /api/specs
│   ├── internal.py                 # POST /internal/done
│   └── proposals.py                # GET /api/proposals + /extracted
├── schemas/__init__.py             # API DTO (Pydantic v2)
├── services/__init__.py            # entity → DTO 매핑
├── config.py                       # Settings
├── db.py                           # SQLAlchemy engine + Base
├── main.py                         # FastAPI factory + wiring
└── models/__init__.py              # Base.metadata 등록 트리거

의존 방향

9검증 결과

항목결과
backend pytest14 / 14 ✅
실제 HWPX 업로드 (JK알에스티 5.8MB)파싱 + DB 저장 ✅ (~6초)
실제 HWPX 업로드 (스마트파워 4.4MB)파싱 + DB 저장 ✅ (~6초)
같은 bid_id 묶음2 row 모두 같은 bid_id ✅
GET /api/proposals 리스트2 done row 반환 ✅
JSONB extraction 보존특허/모델/시험/이미지 모두 ✅
DB row 영속화서버 재시작 후에도 유지 ✅

10결론

4 기능 (다중 업로드 + 파싱 + DB 저장 + 비교표) 만 남긴 헥사고날 MVP 완성. 코드 60% 감소, DB 4 테이블 → 1 테이블, endpoint 10개 → 5개, 외부 의존 4개 → 1개 (postgres) + 1 (hwpx-intelligence 외부).

실제 김기열 주무관님 파일 2개로 e2e 검증 완료. 데모일 (5/22) 진입 준비 끝.