- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes) - 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에 안내용 README.md 추가 - CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가 - docs/README.md 문서 지도 갱신
3554 lines
177 KiB
Markdown
3554 lines
177 KiB
Markdown
# xlsx 리포트 완전 스펙 (시트·연동·서식·시각화)
|
||
|
||
> **이 문서의 역할**: DMF Crawler 가 매일 06:00 에 산출하는 **최종 결과물** `DMF_리포트_YYYY-MM-DD.xlsx` 를 셀 단위로 확정한다. 시트 8종의 컬럼·너비·표시형식·조건부서식·수식, 대시보드의 셀 주소별 레이아웃, 시트 간 하이퍼링크·정의된 이름, 디자인 토큰(HEX·폰트·행높이), 그리고 `src/dmf_crawler/report/` 전 모듈의 구현 골격을 담는다. 이 문서를 읽고 구현하면 리포트가 그대로 나와야 한다. 상위 SSOT 는 `docs/design/01-architecture.md`(구조·모듈명·설정키)와 `docs/design/00-DATA-SOURCE-DECISION.md`(원천 7필드)이며, 근거는 `docs/research/06-xlsx-linking-and-formatting.md`·`07-pharma-excel-dashboard-design.md` 에 있다.
|
||
|
||
---
|
||
|
||
## 0. 한눈에 보기
|
||
|
||
이 문서가 확정하는 것:
|
||
|
||
- **쓰기 엔진은 `XlsxWriter` 단독.** openpyxl 은 런타임 의존성에서 **완전히 제외**한다. 스파크라인 API 가 openpyxl 에 없고, diff 원본은 xlsx 가 아니라 SQLite 이므로 "어제 파일 읽기" 요구 자체가 소멸했다. 산출물 검증은 stdlib `zipfile` 로 한다.
|
||
- **시트 8종, 이 순서로 고정**: `00_대시보드` → `01_오늘변경분` → `02_전체현황` → `03_성분별` → `04_업체별` → `05_워치리스트`(빈 경우 생략) → `06_추이` → `99_메타`. 아키텍처 SSOT §3.13 과 1:1 일치한다.
|
||
- **대시보드는 스크롤 없는 1화면.** 총 폭 ≈1,257px / 총 높이 ≈794px, `set_zoom(90)` 적용 시 1,131 × 715px → 1366×768 노트북에서 가로·세로 스크롤 모두 없음.
|
||
- **모든 숫자는 파이썬이 미리 계산해 넣는다.** 수식을 쓰는 곳은 전부 `write_formula(..., value=캐시값)` 로 **캐시값을 함께 기록**한다. 파일을 Excel 로 한 번도 열지 않아도 값이 보인다.
|
||
- **색은 Okabe-Ito 고정, 상태는 4중 코딩**(배경색 + 폰트색 + 텍스트 라벨 + 기호). 신호등 아이콘셋은 CVD 위험으로 금지, `3_arrows_gray` 만 허용. 모든 상태 배경/폰트 조합은 WCAG 대비비 7.2 이상(AAA).
|
||
- **상태 도메인은 3종뿐**: `신규` / `변경` / `취하`. 원천 API 에 등록구분 필드가 없으므로 리서치 07 의 `연차보고`·`사전등록` 상태는 채택하지 않는다(부록에 기록).
|
||
- **원자적 저장 + 폴백**: 같은 볼륨 임시파일 → `zipfile` 검증 → `os.replace`. 잠김 시 3회 지수 백오프 후 `DMF_리포트_YYYY-MM-DD_HHMMSS.xlsx` 로 폴백하고 WARN 알림을 남긴다. 최신본 고정 링크는 심볼릭 링크가 아니라 **원자적 복사**다(Windows 심볼릭 링크는 권한이 필요).
|
||
- **AI 산출물은 리포트를 막지 못한다.** `enrichment` 조인이 비면 헤드라인은 `(AI 요약 없음)`, 메모 열은 `-` 로 렌더링하고 리포트는 항상 완주한다.
|
||
- **`src/report/workbook.py` 는 존재하지 않는다.** 아키텍처 SSOT 가 확정한 분할 — `report/{build,data,theme,widgets,atomic}.py` + `report/sheets/s00~s99` — 을 따른다(§8.0 에서 매핑을 명시).
|
||
- **인쇄는 A4 가로 1페이지 너비 맞춤**, 데이터 시트는 헤더 3행을 매 페이지 반복(`repeat_rows(0, 2)`).
|
||
|
||
---
|
||
|
||
## 1. 라이브러리 확정
|
||
|
||
### 1.1 결론
|
||
|
||
> **쓰기 = `XlsxWriter` 단독. openpyxl·pandas 는 이 프로젝트의 런타임 의존성이 아니다.**
|
||
|
||
아키텍처 SSOT §8.1 의 런타임 의존성 3개(`httpx`, `XlsxWriter`, `jsonschema`)와 정확히 일치한다.
|
||
|
||
### 1.2 요구 대조표
|
||
|
||
| 리포트 요구 | XlsxWriter | openpyxl | 판정 |
|
||
|---|---|---|---|
|
||
| **스파크라인**(KPI 타일 6개 하단) | ✅ `add_sparkline()` — line/column/win_loss | ❌ **API 자체가 없음** | **XlsxWriter 필수** |
|
||
| 누적 세로 막대 차트(3계열) | ✅ `add_chart({'type':'column','subtype':'stacked'})` | ✅ | 둘 다 가능 |
|
||
| 가로 막대 차트 + 데이터 라벨 | ✅ `data_labels`, `set_size` | ✅(라벨 옵션 빈약) | XlsxWriter 우위 |
|
||
| Excel 표(ListObject) + 합계행 | ✅ `add_table(total_row=True, total_function, total_value)` | 수동 구현 | XlsxWriter 우위 |
|
||
| **수식 캐시값 지정** | ✅ `write_formula(cell, f, fmt, value)` | ❌ 불가 | **XlsxWriter 필수** |
|
||
| 조건부서식 18종 | ✅ `conditional_format()` | ✅ | 둘 다 가능 |
|
||
| 내부 하이퍼링크 | ✅ `write_url("internal:'시트'!A1")` | ✅ | 둘 다 가능 |
|
||
| 정의된 이름 | ✅ `define_name()` | ✅ | 둘 다 가능 |
|
||
| 인쇄·탭색·틀고정 | ✅ | ✅ | 둘 다 가능 |
|
||
| 기존 파일 수정 | ❌ 신규 생성 전용 | ✅(단 차트·스파크라인 **손실**) | 해당 없음 — §1.3 |
|
||
|
||
### 1.3 openpyxl 을 뺀 근거 3가지
|
||
|
||
1. **스파크라인이 요구다.** 요구 R3.3(시각화) 과 대시보드 KPI 타일 설계가 스파크라인을 전제한다. openpyxl 에는 API 가 없다. 이 한 줄로 엔진은 결정된다.
|
||
2. **"어제 파일을 읽어 diff" 요구가 소멸했다.** 아키텍처 ADR 이 diff 원본을 **SQLite 이벤트 소싱**으로 확정했다(`repo.load_snapshot`). 리포트 xlsx 는 사람이 열어 편집·오염시킬 수 있는 **파생물**이지 정본이 아니다. 따라서 xlsx 읽기 기능이 필요 없다.
|
||
3. **차트·스파크라인 손실 함정을 원천 차단한다.** openpyxl 공식 문서: *"openpyxl does currently not read all possible items in an Excel file so shapes will be lost from existing files if they are opened and saved with the same name."* 매일 새 파일을 만드는 우리 정책에서는 애초에 열고 저장할 일이 없다. 의존성을 두면 언젠가 누군가 `load_workbook → save` 를 쓴다. **없애는 것이 가장 확실한 방어다.**
|
||
|
||
### 1.4 openpyxl 이 담당했을 두 역할의 대체안
|
||
|
||
| 원래 역할 | 대체 |
|
||
|---|---|
|
||
| 산출물 xlsx 무결성 검증 | stdlib `zipfile` 로 `testzip()` + `xl/workbook.xml` 존재 확인 (`report/atomic.py::verify_xlsx`) |
|
||
| 워치리스트 사용자 편집 왕복(이월) | **SSOT 를 SQLite `watchlist` 테이블로 확정**. `05_워치리스트` 시트는 **읽기 전용 미러**이며 시트 보호를 건다. 편집은 설정 GUI(`gui/app.py`)에서 한다 |
|
||
|
||
### 1.5 금지 사항 (구현 시 위반하면 리뷰에서 반려)
|
||
|
||
- `constant_memory: True` **금지**. XlsxWriter 문서 원문: *"tables aren't available in XlsxWriter when Workbook 'constant_memory' mode is enabled."* `02_전체현황` 이 Excel 표를 쓰므로 켤 수 없다. 현재 규모(수천~수만 행)에서 메모리 문제는 없다.
|
||
- `worksheet.autofit()` **금지**. Calibri 11 메트릭 추정이라 한글에서 좁게 나온다. §8.3 의 `display_width()` 로 직접 계산한다.
|
||
- 수식을 캐시값 없이 쓰는 것 **금지**. 예외는 `add_table` 의 `total_function` 인데, 이때는 반드시 `total_value` 를 함께 준다.
|
||
- LibreOffice headless 재계산 **금지**(아키텍처 부록에서 기각). 외부 프로그램 의존을 늘리지 않는다.
|
||
|
||
---
|
||
|
||
## 2. 워크북 전체 구성
|
||
|
||
### 2.1 시트 목록·순서·탭 색
|
||
|
||
| # | 시트명 | 탭 색 | 목적 1줄 | 생략 조건 |
|
||
|---|---|---|---|---|
|
||
| 1 | `00_대시보드` | `#0072B2` | 5초 안에 오늘 상황을 파악하는 1화면 요약. 활성 시트 | 없음 |
|
||
| 2 | `01_오늘변경분` | `#D55E00` | 전일 스냅샷 대비 신규/변경/취하만 모은 리포트 본체 | 없음(0건이면 안내행 1줄) |
|
||
| 3 | `02_전체현황` | `#5B6770` | 유효 등록 전체 원장. 사용자가 필터로 파고드는 시트 | 없음 |
|
||
| 4 | `03_성분별` | `#009E73` | 성분(원료명) 기준 Top N 집계 + 30일 추이 | 없음 |
|
||
| 5 | `04_업체별` | `#009E73` | 업체(신청인)·제조국 기준 Top N 집계 | 없음 |
|
||
| 6 | `05_워치리스트` | `#CC79A7` | 관심 키워드 히트 현황(읽기 전용 미러) | `watchlist` 활성 항목 0건이면 **시트 자체를 만들지 않음** |
|
||
| 7 | `06_추이` | `#0072B2` | 일자별 건수 시계열. **모든 스파크라인·차트의 원본 데이터** | 없음 |
|
||
| 8 | `99_메타` | `#888888` | 수집 시각·소스·건수 검증·해시·로그 경로·AI 상태 | 없음 |
|
||
|
||
- 탭 색 `#0072B2` 가 `00_대시보드` 와 `06_추이` 에 중복되는 것은 의도다. **파란색 = 워크북의 구조/강조 축**(대시보드와 그 데이터 원본), 나머지는 의미색이다.
|
||
- 리서치 07 의 `스냅샷·로그` 시트는 **채택하지 않는다.** 그 내용(append-only 감사 로그)은 SQLite `events`/`fetch_stats` 테이블이 정본이고, xlsx 에는 `99_메타` 의 요약·경로 포인터만 둔다. 5만 행 롤오프 규칙도 함께 소멸.
|
||
|
||
### 2.2 워크북 전역 설정
|
||
|
||
```python
|
||
wb = xlsxwriter.Workbook(str(tmp_path), {
|
||
"constant_memory": False, # 표(ListObject) 사용을 위해 반드시 False
|
||
"strings_to_numbers": False, # 등록번호 '20121228-168-I-169-04' 훼손 방지
|
||
"strings_to_urls": False, # 제조소 소재지에 든 문자열이 링크로 오인되지 않게
|
||
"nan_inf_to_errors": True,
|
||
"use_future_functions": True, # _xlfn 접두 자동 처리
|
||
"default_date_format": "yyyy-mm-dd",
|
||
"remove_timezone": True,
|
||
})
|
||
wb.set_size(1500, 900) # 최초 열림 창 크기
|
||
wb.set_properties({
|
||
"title": f"DMF 일일 모니터링 리포트 {report_date}",
|
||
"subject": "원료의약품 등록(DMF) 신규·변경·취하 현황",
|
||
"author": "DMF Crawler",
|
||
"manager": "",
|
||
"company": "",
|
||
"category": "규제 인텔리전스",
|
||
"keywords": "DMF, 원료의약품, 식약처, 등록현황",
|
||
"comments": f"run_id={run_id} / 자동 생성 / 원본: 공공데이터포털 15057075",
|
||
"created": datetime_of_run, # datetime 객체
|
||
})
|
||
```
|
||
|
||
| 항목 | 값 | 근거 |
|
||
|---|---|---|
|
||
| 기본 폰트 | 맑은 고딕 10pt `#1F2933` | `report.font` 설정키. Windows Vista+ 기본 탑재 |
|
||
| 최초 활성 시트 | `00_대시보드` (`activate()` + `set_first_sheet()`) | 5초 규칙 |
|
||
| 눈금선 | `00_대시보드` 만 `hide_gridlines(2)`, 나머지 유지 | 대시보드는 카드 UI, 데이터 시트는 표 |
|
||
| 확대 | `00_대시보드` 90%, 나머지 100% | 1화면 수납 |
|
||
| 값 없음 표기 | 빈 문자열이 아니라 `-` | 정렬·필터 일관성 |
|
||
| 표 스타일 | `Table Style Light 11` | 줄무늬 약함 = 데이터 잉크 비율 우선 |
|
||
| 표 이름 | `T_LEDGER` / `T_INGREDIENT` / `T_COMPANY` / `T_WATCH` / `T_TREND` | Excel 표 이름 규칙(공백·숫자 시작 불가) |
|
||
|
||
### 2.3 시트 공통 레이아웃 규약 (`01`~`06`, `99`)
|
||
|
||
| 행 | 내용 |
|
||
|---|---|
|
||
| 1행 | `A1` = `← 대시보드` 역링크, `B1` = 시트 제목(14pt bold), `우측 끝` = `기준일 YYYY-MM-DD · 생성 HH:MM` |
|
||
| 2행 | 공백(높이 6) |
|
||
| **3행** | **컬럼 헤더** |
|
||
| 4행~ | 데이터 |
|
||
|
||
- `freeze_panes(3, k)` — 0-index 로 "3행까지 고정". `k` 는 시트별로 다르다(§3).
|
||
- 헤더 셀에는 `write_comment()` 로 컬럼 정의 툴팁을 단다.
|
||
- 데이터가 0건이면 4행에 `(해당 없음)` 안내 1줄을 회색으로 쓰고 표/조건부서식은 걸지 않는다.
|
||
|
||
---
|
||
|
||
## 3. 시트별 완전 명세
|
||
|
||
표기 규약: **정렬** = 셀 가로 정렬, **형식** = `num_format` 문자열, **폭** = `set_column` 문자 단위 폭.
|
||
|
||
### 3.1 `00_대시보드`
|
||
|
||
셀 단위 레이아웃은 §4 에서 별도로 다룬다. 여기서는 시트 속성만 확정한다.
|
||
|
||
| 항목 | 값 |
|
||
|---|---|
|
||
| 탭 색 | `#0072B2` |
|
||
| 눈금선 | `hide_gridlines(2)` (화면·인쇄 모두 숨김) |
|
||
| 확대 | `set_zoom(90)` |
|
||
| 틀 고정 | **없음** (1화면이라 불필요) |
|
||
| 자동 필터 | 없음 |
|
||
| 활성화 | `activate()`, `set_first_sheet()` |
|
||
| 인쇄 | A4 가로, `fit_to_pages(1, 1)`, 인쇄 영역 `A1:S47` |
|
||
|
||
### 3.2 `01_오늘변경분` — 리포트 본체
|
||
|
||
| 항목 | 값 |
|
||
|---|---|
|
||
| 헤더 행 | 3행 (0-index 2) |
|
||
| 데이터 시작 | 4행 (0-index 3) |
|
||
| `freeze_panes` | `(3, 5)` — 3행 + A~E열 고정 (정렬키·기호·상태·등록번호·성분명까지 따라다님) |
|
||
| `autofilter` | `(2, 0, last_row, 18)` = `A3:S{last}` — Excel 표를 쓰지 않고 명시 지정(행 전체 조건부서식이 표 줄무늬와 충돌하지 않게) |
|
||
| 정렬 기준 | `A(정렬키)` 오름차순 → `N(워치히트)` 내림차순 → `E(성분명)` 오름차순. **파이썬에서 미리 정렬해 기록**한다 |
|
||
| 필터 기본값 | 없음(전량 표시) |
|
||
| 행 높이 | 데이터 행 30(줄바꿈 2줄 수용), 헤더 32 |
|
||
|
||
정렬키 값 도메인: `취하=1`, `신규=2`, `변경=3`. 사용자가 임의 정렬한 뒤에도 A열 오름차순으로 원복할 수 있다.
|
||
|
||
| 열 | 컬럼명 | 타입 | 폭 | 정렬 | 형식 | 조건부서식 / 수식 | 비고 |
|
||
|---|---|---|---|---|---|---|---|
|
||
| A | `정렬키` | int | 4 | 가운데 | `0` | — | **히든** `{'hidden': 1}` |
|
||
| B | `기호` | str | 4 | 가운데 | `@` | 행 규칙에 포함 | `+` `◆` `✕` |
|
||
| C | `상태` | str | 8 | 가운데 | `@` | **행 전체 규칙의 판정 열** | `신규`/`변경`/`취하` |
|
||
| D | `등록번호` | str | 24 | 왼쪽 | `@` | 내부 링크 → `02_전체현황` 해당 행 | `write_url` |
|
||
| E | `성분명` | str | 30 | 왼쪽 | `@` | 워치 매칭 시 bold(`CF-01-06`) | 줄바꿈 |
|
||
| F | `업체명` | str | 26 | 왼쪽 | `@` | 워치 매칭 시 bold | `ENTP_NAME` |
|
||
| G | `제조소명` | str | 26 | 왼쪽 | `@` | — | `MNFCTR_NAME` |
|
||
| H | `제조소 소재지` | str | 40 | 왼쪽 | `@` | — | `MNFCTR_PLACE`, 줄바꿈 |
|
||
| I | `제조국가` | str | 16 | 가운데 | `@` | `대한민국` 포함 → `#E3F3EA`(`CF-01-05`) | 다중값은 `, ` 결합 |
|
||
| J | `발급일자` | date | 12 | 가운데 | `yyyy-mm-dd` | — | `DMF_PERMIT_DATE` |
|
||
| K | `수리일자(파생)` | date | 12 | 가운데 | `yyyy-mm-dd` | — | 등록번호 앞 8자리 |
|
||
| L | `변경필드` | str | 24 | 왼쪽 | `@` | 줄바꿈 | `성분명, 제조국가` |
|
||
| M | `변경내용(전→후)` | str | 52 | 왼쪽 | `@` | 줄바꿈, 9pt | `제조국가: 이탈리아 → 이탈리아, 스위스` |
|
||
| N | `워치히트` | int | 8 | 가운데 | `0;;-` | `>0` → `#F7E9F0` + bold(`CF-01-04`) | 매칭 개수 |
|
||
| O | `워치키워드` | str | 20 | 왼쪽 | `@` | — | 쉼표 구분 |
|
||
| P | `AI 메모` | str | 40 | 왼쪽 | `@` | 줄바꿈, 9pt `#5B6770` | 없으면 `-` |
|
||
| Q | `원문검색` | url | 10 | 가운데 | `@` | `write_url(외부, string='조회')` | nedrug 성분명 검색 URL |
|
||
| R | `dmf_key` | str | 26 | 왼쪽 | `@` | — | **히든**. 재현·추적용 |
|
||
| S | `content_hash` | str | 18 | 왼쪽 | `@` | — | **히든** |
|
||
|
||
**Q열 외부 링크 URL 템플릿** (성분명 검색):
|
||
|
||
```
|
||
https://nedrug.mfds.go.kr/pbp/CCBAC03/getItem?totalPages=1&limit=10&page=1&searchYn=true&itemName={성분명 URL 인코딩}
|
||
```
|
||
|
||
> ⚠️ **미검증**: 위 쿼리스트링이 성분명 검색으로 정확히 동작하는지는 실측하지 않았다. 실패 시 파라미터 없는 목록 URL `https://nedrug.mfds.go.kr/pbp/CCBAC03` 로 폴백하도록 구현한다.
|
||
|
||
**행 전체 조건부서식은 `B4:S{last}` 에 건다**(A열 히든 제외). 판정 열은 `$C`.
|
||
|
||
### 3.3 `02_전체현황` — 누적 원장
|
||
|
||
| 항목 | 값 |
|
||
|---|---|
|
||
| 헤더 행 | 3행 |
|
||
| Excel 표 | `add_table(2, 0, last_row, 16, {'name':'T_LEDGER','style':'Table Style Light 11','banded_rows':True,'autofilter':True})` |
|
||
| `freeze_panes` | `(3, 3)` — 3행 + A~C열(등록번호·성분명·업체명) |
|
||
| 정렬 기준 | `I(수리일자)` 내림차순 → `A(등록번호)` 오름차순 |
|
||
| 행 높이 | 15 고정, 줄바꿈 없음(수천~수만 행 성능) |
|
||
| 열 그룹화 | `P:Q` 를 `{'level': 1, 'hidden': True}` 로 묶어 접기 가능하게 |
|
||
|
||
| 열 | 컬럼명 | 타입 | 폭 | 정렬 | 형식 | 조건부서식 / 수식 |
|
||
|---|---|---|---|---|---|---|
|
||
| A | `등록번호` | str | 24 | 왼쪽 | `@` | `duplicate` 강조(`CF-02-01`) |
|
||
| B | `성분명` | str | 30 | 왼쪽 | `@` | 워치 매칭 → `#F7E9F0`(`CF-02-05`) |
|
||
| C | `업체명` | str | 26 | 왼쪽 | `@` | 워치 매칭 → `#F7E9F0` |
|
||
| D | `제조소명` | str | 26 | 왼쪽 | `@` | — |
|
||
| E | `제조소 소재지` | str | 44 | 왼쪽 | `@` | 헤더 주석(툴팁) |
|
||
| F | `제조국가` | str | 18 | 가운데 | `@` | `대한민국` 포함 → `#E3F3EA`(`CF-02-04`) |
|
||
| G | `제조국 수` | int | 8 | 가운데 | `0` | `>=2` → bold (`CF-02-06`) |
|
||
| H | `발급일자` | date | 12 | 가운데 | `yyyy-mm-dd` | — |
|
||
| I | `수리일자` | date | 12 | 가운데 | `yyyy-mm-dd` | `data_bar` `#0072B2`(`CF-02-07`) |
|
||
| J | `최초관측일` | date | 12 | 가운데 | `yyyy-mm-dd` | — |
|
||
| K | `최종관측일` | date | 12 | 가운데 | `yyyy-mm-dd` | — |
|
||
| L | `최종변경일` | date | 12 | 가운데 | `yyyy-mm-dd` | **오늘이면** `#FDF0DC`(`CF-02-02`) |
|
||
| M | `상태` | str | 8 | 가운데 | `@` | `취하` → 행 전체 `#FBE5DC`+취소선(`CF-02-03`) |
|
||
| N | `경과일` | int | 8 | 가운데 | `#,##0;;-` | **수식**(아래) + `3_arrows_gray` 아이콘셋(`CF-02-08`) |
|
||
| O | `원문검색` | url | 10 | 가운데 | `@` | 외부 링크 `조회` |
|
||
| P | `dmf_key` | str | 26 | 왼쪽 | `@` | **히든**, 그룹 level 1 |
|
||
| Q | `content_hash` | str | 18 | 왼쪽 | `@` | **히든**, 그룹 level 1 |
|
||
|
||
**N열 수식 전문** (행 4 기준, 아래로 상대 복사):
|
||
|
||
```excel
|
||
=IF($I4="","-",INT(REPORT_DATE-$I4))
|
||
```
|
||
|
||
`REPORT_DATE` 는 §5.4 의 정의된 이름(`='99_메타'!$B$3`). 캐시값은 파이썬이 `(기준일 - 수리일자).days` 로 계산해 `write_formula(..., value=n)` 로 함께 기록한다.
|
||
|
||
> **주의**: Excel 표(ListObject) 안에서는 컬럼 수식이 구조적 참조로 자동 확장된다. 우리는 캐시값을 넣어야 하므로 `add_table` 의 `formula` 옵션을 쓰지 않고 **셀마다 `write_formula` 로 직접 기록**한 뒤 표를 씌운다. 표는 서식·필터만 담당한다.
|
||
|
||
### 3.4 `03_성분별`
|
||
|
||
| 항목 | 값 |
|
||
|---|---|
|
||
| 헤더 행 | 3행 |
|
||
| Excel 표 | `add_table(2, 0, last_row+1, 15, {'name':'T_INGREDIENT','style':'Table Style Light 11','total_row':True})` — 마지막 1행은 합계행 |
|
||
| `freeze_panes` | `(3, 2)` — 3행 + A~B열(순위·성분명) |
|
||
| 정렬 기준 | `F(오늘 합계)` 내림차순 → `I(누적 유효등록)` 내림차순 → `B(성분명)` 오름차순 |
|
||
| 행 수 | `report.top_n`(기본 10). 단 **오늘 변경분이 있는 성분은 top_n 을 넘어도 전부 포함**한다(합집합) |
|
||
| 행 높이 | 18(스파크라인 수용) |
|
||
|
||
| 열 | 컬럼명 | 타입 | 폭 | 정렬 | 형식 | 조건부서식 / 합계행 |
|
||
|---|---|---|---|---|---|---|
|
||
| A | `순위` | int | 6 | 가운데 | `0` | `total_string: '합계'` |
|
||
| B | `성분명` | str | 34 | 왼쪽 | `@` | 워치 매칭 → `#F7E9F0` + bold(`CF-03-05`) |
|
||
| C | `오늘 신규` | int | 9 | 가운데 | `#,##0;;-` | `>0` → `#E3F3EA`(`CF-03-01`), `total_function:'sum'` |
|
||
| D | `오늘 변경` | int | 9 | 가운데 | `#,##0;;-` | `>0` → `#FDF0DC`(`CF-03-02`), `total_function:'sum'` |
|
||
| E | `오늘 취하` | int | 9 | 가운데 | `#,##0;;-` | `>0` → `#FBE5DC`(`CF-03-03`), `total_function:'sum'` |
|
||
| F | `오늘 합계` | int | 9 | 가운데 | `#,##0;;-` | **수식** `=SUM($C4:$E4)` + 캐시값, `total_function:'sum'` |
|
||
| G | `최근 7일` | int | 9 | 가운데 | `#,##0;;-` | `data_bar` `#0072B2`(`CF-03-06`), `total_function:'sum'` |
|
||
| H | `최근 30일` | int | 9 | 가운데 | `#,##0;;-` | `data_bar` `#0072B2`, `total_function:'sum'` |
|
||
| I | `누적 유효등록` | int | 11 | 가운데 | `#,##0` | `data_bar` `#0072B2`, `total_function:'sum'` |
|
||
| J | `등록업체 수` | int | 9 | 가운데 | `#,##0` | `>=5` → bold(`CF-03-07`, 공급처 다변화 신호) |
|
||
| K | `제조국 수` | int | 9 | 가운데 | `#,##0` | — |
|
||
| L | `주요 제조국` | str | 14 | 가운데 | `@` | — |
|
||
| M | `국산 보유` | str | 8 | 가운데 | `@` | `Y` → `#E3F3EA`(`CF-03-08`) |
|
||
| N | `전일 대비` | int | 9 | 가운데 | `▲ #,##0;▼ -#,##0;– 0` | `icon_set` `3_arrows_gray`(`CF-03-04`) |
|
||
| O | `30일 추이` | — | 14 | 가운데 | — | **스파크라인**(column). 원본 `'06_추이'` 히든 블록 |
|
||
| P | `상세` | url | 8 | 가운데 | `@` | 내부 링크 → `02_전체현황!A3` |
|
||
|
||
**O열 스파크라인**: 성분별 30일 시계열은 `06_추이` 시트의 히든 블록 `AD:BG`(행 = 성분, 열 = 일자 30개)에 가로로 적재한다. 성분 i 의 범위는 `'06_추이'!$AE${4+i}:$BH${4+i}`. 설정은 `{'type':'column','style':None,'series_color':'#0072B2','high_point':True,'empty_cells':'zero'}`.
|
||
|
||
### 3.5 `04_업체별`
|
||
|
||
`03_성분별` 과 대칭 구조.
|
||
|
||
| 항목 | 값 |
|
||
|---|---|
|
||
| Excel 표 | `add_table(2, 0, last_row+1, 16, {'name':'T_COMPANY','style':'Table Style Light 11','total_row':True})` |
|
||
| `freeze_panes` | `(3, 2)` |
|
||
| 정렬 기준 | `G(오늘 합계)` 내림차순 → `I(누적 유효등록)` 내림차순 → `B(업체명)` 오름차순 |
|
||
| 행 수 | `report.top_n` ∪ (오늘 변경분이 있는 업체 전체) |
|
||
|
||
| 열 | 컬럼명 | 타입 | 폭 | 정렬 | 형식 | 조건부서식 / 합계행 |
|
||
|---|---|---|---|---|---|---|
|
||
| A | `순위` | int | 6 | 가운데 | `0` | `total_string: '합계'` |
|
||
| B | `업체명` | str | 30 | 왼쪽 | `@` | 워치 매칭 → `#F7E9F0` + bold |
|
||
| C | `업체키` | str | 26 | 왼쪽 | `@` | **히든**(정규화 키) |
|
||
| D | `오늘 신규` | int | 9 | 가운데 | `#,##0;;-` | `>0` → `#E3F3EA`, `sum` |
|
||
| E | `오늘 변경` | int | 9 | 가운데 | `#,##0;;-` | `>0` → `#FDF0DC`, `sum` |
|
||
| F | `오늘 취하` | int | 9 | 가운데 | `#,##0;;-` | `>0` → `#FBE5DC`, `sum` |
|
||
| G | `오늘 합계` | int | 9 | 가운데 | `#,##0;;-` | **수식** `=SUM($D4:$F4)` + 캐시값, `sum` |
|
||
| H | `최근 30일` | int | 9 | 가운데 | `#,##0;;-` | `data_bar`, `sum` |
|
||
| I | `누적 유효등록` | int | 11 | 가운데 | `#,##0` | `data_bar`, `sum` |
|
||
| J | `보유 성분 수` | int | 9 | 가운데 | `#,##0` | — |
|
||
| K | `제조소 수` | int | 9 | 가운데 | `#,##0` | — |
|
||
| L | `주요 제조국` | str | 14 | 가운데 | `@` | — |
|
||
| M | `국산여부` | str | 8 | 가운데 | `@` | `국산` → `#E3F3EA` |
|
||
| N | `최근 등록일` | date | 12 | 가운데 | `yyyy-mm-dd` | 90일 이상 경과 → `#EFEFEF`(`CF-04-05`) |
|
||
| O | `워치리스트` | str | 8 | 가운데 | `@` | `★` → `#F7E9F0` |
|
||
| P | `30일 추이` | — | 14 | 가운데 | — | 스파크라인(column) |
|
||
| Q | `상세` | url | 8 | 가운데 | `@` | 내부 링크 → `02_전체현황!A3` |
|
||
|
||
### 3.6 `05_워치리스트` (조건부 생성)
|
||
|
||
- **생성 조건**: `watchlist` 테이블에 `active='Y'` 인 행이 1건 이상. 0건이면 시트를 만들지 않고, 대시보드 내비게이션의 해당 슬롯은 회색 비활성 텍스트 `(워치리스트 없음)` 로 대체한다.
|
||
- **시트 보호**: `protect("", {"objects": True, "scenarios": True, "select_locked_cells": True, "select_unlocked_cells": True, "sort": True, "autofilter": True})`. 읽기 전용 미러임을 물리적으로 표시한다. 비밀번호 없음 — 해제 자체를 막는 것이 목적이 아니라 **실수로 편집하고 저장했다가 다음 날 덮어써지는 착각을 막는 것**이 목적이다.
|
||
- `freeze_panes(3, 3)`
|
||
|
||
**블록 A — 키워드 정의(3행 헤더, 4행~)**
|
||
|
||
| 열 | 컬럼명 | 타입 | 폭 | 정렬 | 형식 | 유효성 / 규칙 |
|
||
|---|---|---|---|---|---|---|
|
||
| A | `번호` | int | 6 | 가운데 | `0` | — |
|
||
| B | `대상유형` | str | 12 | 가운데 | `@` | `data_validation` list: `성분명`,`업체명`,`제조소명`,`제조국가`,`등록번호` |
|
||
| C | `키워드` | str | 30 | 왼쪽 | `@` | 빈 값 → `#FBE5DC`(`CF-05-03`) |
|
||
| D | `매칭방식` | str | 12 | 가운데 | `@` | list: `부분일치`,`정확일치`,`정규식` |
|
||
| E | `우선순위` | int | 8 | 가운데 | `0` | list: `1`,`2`,`3` |
|
||
| F | `활성` | str | 6 | 가운데 | `@` | list: `Y`,`N` |
|
||
| G | `메모` | str | 26 | 왼쪽 | `@` | 자유 |
|
||
|
||
**블록 B — 히트 결과(같은 행, I~N)**
|
||
|
||
| 열 | 컬럼명 | 타입 | 폭 | 정렬 | 형식 | 조건부서식 / 수식 |
|
||
|---|---|---|---|---|---|---|
|
||
| I | `오늘 매칭` | int | 9 | 가운데 | `#,##0;;-` | **수식**(아래) + `>0` → `#F7E9F0`·bold(`CF-05-01`) |
|
||
| J | `최근 7일` | int | 9 | 가운데 | `#,##0;;-` | `data_bar` `#CC79A7` |
|
||
| K | `최근 30일` | int | 9 | 가운데 | `#,##0;;-` | `data_bar` `#CC79A7` |
|
||
| L | `누적 매칭` | int | 9 | 가운데 | `#,##0` | — |
|
||
| M | `마지막 매칭일` | date | 12 | 가운데 | `yyyy-mm-dd` | 오늘 → `#F7E9F0`(`CF-05-02`) |
|
||
| N | `상세` | url | 8 | 가운데 | `@` | 내부 링크 → `01_오늘변경분!A3` |
|
||
|
||
**I열 수식 전문** (행 4, 아래로 상대 복사):
|
||
|
||
```excel
|
||
=IF($C4="",0,COUNTIF('01_오늘변경분'!$O$4:$O$100000,"*"&$C4&"*"))
|
||
```
|
||
|
||
캐시값은 파이썬이 실제 매칭 개수로 채운다.
|
||
|
||
**블록 C — 매칭 상세표** (`A{blockC}` 이하, 블록 A 마지막 행 + 3행부터)
|
||
|
||
헤더 7컬럼: `키워드` / `상태` / `등록번호` / `성분명` / `업체명` / `제조국가` / `발급일자`.
|
||
행 전체에 `01_오늘변경분` 과 동일한 상태 조건부서식(`CF-01-01~03`)을 재사용한다.
|
||
|
||
### 3.7 `06_추이` — 시각화 원본
|
||
|
||
| 항목 | 값 |
|
||
|---|---|
|
||
| 헤더 행 | 3행 |
|
||
| 데이터 | 4행 ~ `3 + report.trend_days`(기본 90). **오래된 날짜가 위**, 최신이 아래 → 스파크라인이 왼→오 시간순으로 읽힘 |
|
||
| Excel 표 | `add_table(2, 0, last_row, 8, {'name':'T_TREND','style':'Table Style Light 11'})` |
|
||
| `freeze_panes` | `(3, 1)` |
|
||
| 정렬 기준 | `A(일자)` 오름차순 |
|
||
|
||
| 열 | 컬럼명 | 타입 | 폭 | 정렬 | 형식 | 조건부서식 |
|
||
|---|---|---|---|---|---|---|
|
||
| A | `일자` | date | 12 | 가운데 | `yyyy-mm-dd` | 오늘 → bold + `#E5F1F8`(`CF-06-01`) |
|
||
| B | `신규` | int | 9 | 가운데 | `#,##0;;-` | `data_bar` `#009E73`(`CF-06-02`) |
|
||
| C | `변경` | int | 9 | 가운데 | `#,##0;;-` | `data_bar` `#E69F00` |
|
||
| D | `취하` | int | 9 | 가운데 | `#,##0;;-` | `data_bar` `#D55E00` |
|
||
| E | `합계` | int | 9 | 가운데 | `#,##0;;-` | **수식** `=SUM($B4:$D4)` + 캐시값 |
|
||
| F | `워치 히트` | int | 9 | 가운데 | `#,##0;;-` | `>0` → `#F7E9F0` |
|
||
| G | `누적 유효등록` | int | 12 | 가운데 | `#,##0` | — |
|
||
| H | `수집 건수` | int | 10 | 가운데 | `#,##0` | 전일 대비 5% 이상 급감 → `#FBE5DC`(`CF-06-03`) |
|
||
| I | `실행 상태` | str | 10 | 가운데 | `@` | `SUCCESS`→`#E3F3EA` / `PARTIAL`→`#FDF0DC` / `FAILED`→`#FBE5DC`(`CF-06-04`) |
|
||
|
||
**히든 블록 (차트·스파크라인 원본)** — 열 폭 0, `{'hidden': 1}`
|
||
|
||
| 범위 | 내용 |
|
||
|---|---|
|
||
| `AA3:AB3` | 헤더 `제조국` / `건수` |
|
||
| `AA4:AB11` | 제조국 Top 8 (최근 30일 기준, 건수 내림차순) — **차트2 원본** |
|
||
| `AD3` | 헤더 `성분명` |
|
||
| `AE3:BH3` | 최근 30일 일자 헤더 |
|
||
| `AD4:BH{3+n_ing}` | 성분별 30일 시계열 (행 = 성분, 열 = 일자) — `03_성분별` O열 스파크라인 원본 |
|
||
| `BJ3` | 헤더 `업체명` |
|
||
| `BK3:CN3` | 최근 30일 일자 헤더 |
|
||
| `BJ4:CN{3+n_co}` | 업체별 30일 시계열 — `04_업체별` P열 스파크라인 원본 |
|
||
|
||
### 3.8 `99_메타`
|
||
|
||
| 항목 | 값 |
|
||
|---|---|
|
||
| 헤더 | 블록마다 별도 |
|
||
| `freeze_panes` | `(3, 0)` |
|
||
| 자동 필터 | 없음 |
|
||
| 열 폭 | `A`=28, `B`=46, `C`=16, `D`=16, `E`=30 |
|
||
|
||
**블록 1 — 실행 메타 (`A3:B18`)** — A열 항목명(bold, `#5B6770`), B열 값
|
||
|
||
| 행 | 항목 | 값 예 | 형식 |
|
||
|---|---|---|---|
|
||
| 3 | `리포트 기준일` | `2026-09-02` | `yyyy-mm-dd` |
|
||
| 4 | `수집 시작 시각` | `2026-09-02 06:02:14` | `yyyy-mm-dd hh:mm:ss` |
|
||
| 5 | `수집 종료 시각` | `2026-09-02 06:06:41` | `yyyy-mm-dd hh:mm:ss` |
|
||
| 6 | `실행 ID` | `20260902_060214` | `@` |
|
||
| 7 | `실행 상태` | `SUCCESS` | `@` (`CF-99-02`) |
|
||
| 8 | `실행 트리거` | `scheduled` | `@` |
|
||
| 9 | `실행 호스트` | `DESKTOP-XXXX` | `@` |
|
||
| 10 | `파이썬 버전` | `3.12.7` | `@` |
|
||
| 11 | `XlsxWriter 버전` | `3.2.9` | `@` |
|
||
| 12 | `직전 성공 실행` | `20260901_060119` | `@` |
|
||
| 13 | `실행 로그 경로` | `logs\run_20260902_060214\pipeline.log` | `@` (외부 링크) |
|
||
| 14 | `이벤트 로그 경로` | `logs\run_20260902_060214\events.jsonl` | `@` (외부 링크) |
|
||
| 15 | `원문 아카이브` | `data\raw\2026-09-02\` | `@` (외부 링크) |
|
||
| 16 | `DB 백업` | `backup\dmf_2026-09-02.sqlite3` | `@` |
|
||
| 17 | `스냅샷 해시` | `9f2c1a7be40d5c11` | `@` |
|
||
| 18 | `직전 리포트` | `reports\DMF_리포트_2026-09-01.xlsx` | `@` (외부 링크) |
|
||
|
||
**블록 2 — 소스 (`A21:E23`)**: 헤더 `소스명 / URL / HTTP / 수집행수 / 비고`. 최소 1행(공식 OpenAPI `getMdcDmfList01`). URL 은 외부 하이퍼링크.
|
||
|
||
**블록 3 — 무결성 게이트 5종 (`A26:E32`)**: 헤더 `게이트 / 기대 / 실제 / 판정 / 상세`. 아키텍처 §3.6 의 5게이트와 1:1.
|
||
|
||
| 행 | 게이트 | 기대 |
|
||
|---|---|---|
|
||
| 27 | `1. 응답 정상성` | 전 페이지 200 + `resultCode=00` + 본문 시그니처 |
|
||
| 28 | `2. 건수 일치` | `수집 = totalCount` |
|
||
| 29 | `3. 급감 방지` | 전일 대비 감소율 ≤ `max_drop_ratio` |
|
||
| 30 | `4. 널 비율` | 필수 3필드 널 비율 ≤ `max_null_ratio` |
|
||
| 31 | `5. 중복 비율` | 중복 등록번호 비율 ≤ `max_duplicate_ratio` |
|
||
| 32 | `종합` | 전부 통과 |
|
||
|
||
**D열(판정) 수식 전문** (행 27, 아래로 복사):
|
||
|
||
```excel
|
||
=IF($C27=$B27,"OK","불일치")
|
||
```
|
||
|
||
수치 비교가 불가능한 게이트 1은 파이썬이 `OK`/`불일치` 문자열을 직접 쓴다. 조건부서식 `CF-99-01`.
|
||
|
||
**블록 4 — AI 계층 상태 (`A35:B41`)**
|
||
|
||
| 항목 | 값 예 |
|
||
|---|---|
|
||
| `agy 사용 여부` | `사용` / `건너뜀(변화 없음)` / `건너뜀(서킷 OPEN)` / `건너뜀(토큰 상한)` / `실패` |
|
||
| `모델` | `gemini-3.7-flash-medium` |
|
||
| `노력 수준` | `medium` |
|
||
| `입력 토큰` / `출력 토큰` | `28,412` / `1,180` |
|
||
| `소요 시간(초)` | `33.4` |
|
||
| `오늘 누적 토큰` | `29,592 / 300,000` |
|
||
| `봉투 원문 경로` | `logs\run_...\agy.stdout.json` (외부 링크) |
|
||
|
||
**블록 5 — 코드북 (`A44:C60`)**: `구분 / 코드 / 설명`. 상태 3종, 등록번호 5구획 해부(`발급일자8-성분일련-시행군-접수일련-성분내일련`), 제조국 표기 정규화 매핑.
|
||
|
||
**블록 6 — 출처·면책 (`A63:E66`)**: 9pt `#5B6770`. 출처 URL, 이용약관 준수 문구, "본 리포트는 공공데이터를 자동 수집·가공한 참고 자료이며 법적 효력이 없다" 면책.
|
||
|
||
---
|
||
|
||
## 4. `00_대시보드` 셀 단위 레이아웃
|
||
|
||
### 4.1 열 폭 그리드
|
||
|
||
| 열 | 폭 | ≈px | 역할 |
|
||
|---|---|---|---|
|
||
| `A` | 1.5 | 15 | 좌 여백 |
|
||
| `B` `C` | 13 / 13 | 96 / 96 | KPI 타일 1 · 차트1 · Top 성분표 |
|
||
| `D` | 1.5 | 15 | 거터 |
|
||
| `E` `F` | 13 / 13 | 96 / 96 | KPI 타일 2 |
|
||
| `G` | 1.5 | 15 | 거터 |
|
||
| `H` `I` | 13 / 13 | 96 / 96 | KPI 타일 3 · Top 업체표 |
|
||
| `J` | 1.5 | 15 | 거터 |
|
||
| `K` `L` | 13 / 13 | 96 / 96 | KPI 타일 4 · 차트2 |
|
||
| `M` | 1.5 | 15 | 거터 |
|
||
| `N` `O` | 13 / 13 | 96 / 96 | KPI 타일 5 · 워치 표 |
|
||
| `P` | 1.5 | 15 | 거터 |
|
||
| `Q` `R` | 13 / 13 | 96 / 96 | KPI 타일 6 |
|
||
| `S` | 1.5 | 15 | 우 여백 |
|
||
|
||
총 폭 = 15 + 96×12 + 15×6 + 15 = **1,257px**. `set_zoom(90)` → **1,131px** → 1366px 화면에서 가로 스크롤 없음.
|
||
|
||
### 4.2 행 높이 그리드
|
||
|
||
| 행 | 높이 | 역할 |
|
||
|---|---|---|
|
||
| 1 | 6 | 상단 여백 |
|
||
| 2 | 30 | 제목 |
|
||
| 3 | 18 | 헤드라인 문장(AI 또는 규칙 기반) |
|
||
| 4 | 8 (배너 시 **22**) | 여백 / 상태 배너 |
|
||
| 5 | 18 | KPI 라벨 |
|
||
| 6 | 34 | KPI 값 (28pt) |
|
||
| 7 | 16 | KPI 델타 |
|
||
| 8 | 14 | KPI 스파크라인 |
|
||
| 9 | 10 | 여백 |
|
||
| 10–24 | 20 ×15 = 300 | 차트 밴드 |
|
||
| 25 | 10 | 여백 |
|
||
| 26 | 18 | 표 헤더 |
|
||
| 27–36 | 16 ×10 = 160 | 표 본문(10행) |
|
||
| 37–40 | 16 ×4 = 64 | 표 예비행 |
|
||
| 41 | 10 | 여백 |
|
||
| 42 | 20 | 내비게이션 1행 |
|
||
| 43 | 20 | 내비게이션 2행 |
|
||
| 44–46 | 8 ×3 = 24 | 여백 |
|
||
| 47 | 14 | 출처·면책 |
|
||
|
||
총 높이 = **794px**. `set_zoom(90)` → **715px** → 768px 화면에 수납.
|
||
|
||
### 4.3 텍스트 와이어프레임 (실제 셀 주소 표기)
|
||
|
||
```
|
||
A B C D E F G H I J K L M N O P Q R S
|
||
┌────┬─────────────────┬───┬─────────────────┬───┬─────────────────┬───┬─────────────────┬───┬─────────────────┬───┬─────────────────┬────┐
|
||
1 │ (상단 여백 h=6) │
|
||
2 │ │ B2:R2 (merge) DMF 일일 모니터링 리포트 — 2026-09-02(수) 18pt bold #1F2933 좌측정렬 │ │
|
||
3 │ │ B3:R3 (merge) 오늘 신규 12건 · 변경 3건 · 취하 1건 — 워치리스트 2건 적중(피타바스타틴칼슘, 다파글리플로진) 11pt #5B6770 │ │
|
||
4 │ │ B4:R4 (merge) [배너] ⚠ 오늘 수집 실패 — 마지막 성공: 2026-09-01. 아래 수치는 그날 기준이다. 11pt bold #8A1D00 / #FBE5DC│ │
|
||
5 │ │ B5:C5 │ │ E5:F5 │ │ H5:I5 │ │ K5:L5 │ │ N5:O5 │ │ Q5:R5 │ │
|
||
│ │ 오늘 신규 │ │ 오늘 변경 │ │ 오늘 취하 │ │ 오늘 총 변경분 │ │ 워치리스트 히트 │ │ 전체 누적 등록 │ │
|
||
│ │ top=5 #009E73 │ │ top=5 #E69F00 │ │ top=5 #D55E00 │ │ top=5 #0072B2 │ │ top=5 #CC79A7 │ │ top=5 #1F2933 │ │
|
||
6 │ │ B6:C6 │ │ E6:F6 │ │ H6:I6 │ │ K6:L6 │ │ N6:O6 │ │ Q6:R6 │ │
|
||
│ │ 12 │ │ 3 │ │ 1 │ │ 16 │ │ 2 │ │ 13,842 │ │
|
||
│ │ 28pt bold 상태색│ │ 28pt bold │ │ 28pt bold │ │ 28pt bold │ │ 28pt bold │ │ 28pt bold │ │
|
||
7 │ │ B7:C7 ▲ 5 │ │ E7:F7 ▼ -2 │ │ H7:I7 – 0 │ │ K7:L7 ▲ 3 │ │ N7:O7 ▲ 2 │ │ Q7:R7 ▲ 11 │ │
|
||
8 │ │ B8 ▁▂▅▁▃█▂▁ │ │ E8 ▁▁▂▁▁▃▁▁ │ │ H8 ▁▁▁▁█▁▁▁ │ │ K8 ▂▃▅▂▄█▃▂ │ │ N8 ▁▁█▁▁█▁▁ │ │ Q8 ╱╱╱╱╱╱╱╱ │ │
|
||
│ │ sparkline column│ │ sparkline column│ │ sparkline column│ │ sparkline column│ │ sparkline winloss│ │ sparkline line │ │
|
||
9 │ (여백 h=10) │
|
||
10 │ ┌──────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────┐ │
|
||
│ │ 앵커 B10 · 600×300px │ │ 앵커 K10 · 600×300px │ │
|
||
│ │ [차트1] 최근 30일 일별 등록 동향 (누적 세로 막대) │ │ [차트2] 제조국 Top 8 (가로 막대, 단색 #0072B2) │ │
|
||
│ │ x=일자(30) y=건수 │ │ x=건수 y=국가명 (직접 라벨링, 색 구분 없음) │ │
|
||
│ │ 계열: 신규#009E73 / 변경#E69F00 / 취하#D55E00 │ │ 인도 ████████████████ 120 │ │
|
||
24 │ │ 범례: top │ │ 범례: none (단색이라 불필요) │ │
|
||
│ └──────────────────────────────────────────────────┘ └──────────────────────────────────────────────────┘ │
|
||
25 │ (여백 h=10) │
|
||
26 │ │ B26:F26 Top 10 성분 │ │ H26:L26 Top 10 업체 │ │ N26:R26 워치리스트 히트 │ │
|
||
│ │ 순위│성분명│오늘│30일│누적 │ │ 순위│업체명│오늘│30일│누적 │ │ 키워드│매칭│성분명│업체명│상태 │ │
|
||
27 │ │ 1 │아토르바…│ 3 │ 11 │ 128│ │ 1 │㈜○○제약│ 4 │15 │ 342 │ │ 다파글…│ 1 │… │… │ 신규 │ │
|
||
36 │ │ … 10행, I열 data_bar #0072B2│ │ … 10행 │ │ … 최대 10행 │ │
|
||
37 │ │ (예비 4행 — 데이터가 적으면 공백) │
|
||
41 │ (여백 h=10) │
|
||
42 │ │ B42:C42 [오늘 변경분] │ E42:F42 [전체 현황] │ H42:I42 [성분별] │ K42:L42 [업체별] │ N42:O42 [워치리스트] │ Q42:R42 [추이] │
|
||
43 │ │ B43:C43 [메타] │
|
||
44 │ (여백 h=8 ×3) │
|
||
47 │ │ B47:R47 출처: 공공데이터포털 의약품 원료의약품 등록 정보(15057075) · 식품의약품안전처 │ 수집 2026-09-02 06:02 KST │ 참고용 │
|
||
└────┴─────────────────┴───┴─────────────────┴───┴─────────────────┴───┴─────────────────┴───┴─────────────────┴───┴─────────────────┴────┘
|
||
```
|
||
|
||
### 4.4 KPI 타일 6종 — 셀 주소·값·델타·스파크라인
|
||
|
||
| # | 라벨 | 라벨셀 | 값셀 | 델타셀 | 스파크라인셀 | 액센트 | 스파크 타입 | 스파크 원본 |
|
||
|---|---|---|---|---|---|---|---|---|
|
||
| 1 | 오늘 신규 | `B5:C5` | `B6:C6` | `B7:C7` | `B8:C8` | `#009E73` | `column` | `'06_추이'!$B${s}:$B${e}` |
|
||
| 2 | 오늘 변경 | `E5:F5` | `E6:F6` | `E7:F7` | `E8:F8` | `#E69F00` | `column` | `'06_추이'!$C${s}:$C${e}` |
|
||
| 3 | 오늘 취하 | `H5:I5` | `H6:I6` | `H7:I7` | `H8:I8` | `#D55E00` | `column` | `'06_추이'!$D${s}:$D${e}` |
|
||
| 4 | 오늘 총 변경분 | `K5:L5` | `K6:L6` | `K7:L7` | `K8:L8` | `#0072B2` | `column` | `'06_추이'!$E${s}:$E${e}` |
|
||
| 5 | 워치리스트 히트 | `N5:O5` | `N6:O6` | `N7:O7` | `N8:O8` | `#CC79A7` | `win_loss` | `'06_추이'!$F${s}:$F${e}` |
|
||
| 6 | 전체 누적 등록 | `Q5:R5` | `Q6:R6` | `Q7:R7` | `Q8:R8` | `#1F2933` | `line` | `'06_추이'!$G${s}:$G${e}` |
|
||
|
||
`e` = `06_추이` 마지막 데이터 행, `s` = `max(4, e - 29)` (최근 30일).
|
||
|
||
- 스파크라인은 **병합 범위의 좌상단 셀**(`B8`, `E8`, …)에 `add_sparkline` 한다. 병합해도 좌상단 기준으로 그려진다.
|
||
- 공통 옵션: `{"series_color": accent, "high_point": True, "last_point": True, "empty_cells": "zero"}`. `win_loss` 만 `{"negative_points": True}` 추가.
|
||
- 스파크라인 데이터 포인트는 30개 — 실무 권고 상한(12~24)을 넘으므로 `column` 타입으로 개별 값을 구분 가능하게 한다. `line` 은 단조 증가하는 누적 KPI(#6)에만 쓴다.
|
||
|
||
**KPI 값 셀 수식 전문** (예: 타일 1) — 캐시값 동봉:
|
||
|
||
```excel
|
||
='06_추이'!$B$93
|
||
```
|
||
|
||
**KPI 델타 셀 수식 전문** (예: 타일 1):
|
||
|
||
```excel
|
||
=IF(ROW('06_추이'!$B$93)<=4,0,'06_추이'!$B$93-'06_추이'!$B$92)
|
||
```
|
||
|
||
실제로는 `93`/`92` 가 파이썬이 계산한 절대 행 번호로 치환된다. 데이터가 1일치뿐이면 델타는 `0` 을 직접 쓰고 수식을 걸지 않는다.
|
||
|
||
### 4.5 차트 2종 사양
|
||
|
||
| 항목 | 차트1 | 차트2 |
|
||
|---|---|---|
|
||
| 앵커 셀 | `B10` | `K10` |
|
||
| 크기 | `set_size({'width': 600, 'height': 300})` | 동일 |
|
||
| 타입 | `column` / `stacked` | `bar` |
|
||
| 제목 | `최근 30일 일별 등록 동향` | `제조국 Top 8 (최근 30일)` |
|
||
| 카테고리 | `['06_추이', s-1, 0, e-1, 0]` (A열 일자) | `['06_추이', 3, 26, 10, 26]` (AA4:AA11) |
|
||
| 계열 | 3개: 신규(B, `#009E73`) / 변경(C, `#E69F00`) / 취하(D, `#D55E00`) | 1개: 건수(AB, `#0072B2` 단색) |
|
||
| 범례 | `{'position': 'top', 'font': {'name':'맑은 고딕','size':9}}` | `{'none': True}` |
|
||
| x축 | 라벨 8pt `#5B6770`, `major_tick_mark: 'none'`, 축선 `#D0D7DE` | `{'visible': False}` — 데이터 라벨로 대체 |
|
||
| y축 | 8pt, `major_gridlines` `#EFEFEF` 0.75pt, 축선 없음 | 9pt `#1F2933`, `reverse: True`(1위가 위) |
|
||
| 데이터 라벨 | 없음(누적 막대라 혼잡) | `{'value': True, 'position': 'outside_end', 'font': 8pt}` |
|
||
| gap | 40 | 50 |
|
||
| 차트영역 | 테두리 `#D0D7DE`, 배경 `#FFFFFF` | 동일 |
|
||
| 플롯영역 | 테두리 없음, 배경 없음 | 동일 |
|
||
|
||
**색 구분 금지 근거**: 차트2 는 카테고리 8개다. Claus Wilke — *"Use direct labeling instead of colors when you need to distinguish between more than about eight categorical items."* → 단색 + 축 라벨 직접 표기.
|
||
|
||
### 4.6 Top 표 3종
|
||
|
||
| 표 | 헤더 범위 | 데이터 범위 | 컬럼 | 데이터 원본 |
|
||
|---|---|---|---|---|
|
||
| Top 10 성분 | `B26:F26` | `B27:F36` | `순위`(6) / `성분명`(20) / `오늘`(6) / `30일`(6) / `누적`(8) | `03_성분별` 상위 10행 |
|
||
| Top 10 업체 | `H26:L26` | `H27:L36` | `순위` / `업체명` / `오늘` / `30일` / `누적` | `04_업체별` 상위 10행 |
|
||
| 워치 히트 | `N26:R26` | `N27:R36` | `키워드`(10) / `매칭`(5) / `성분명`(14) / `업체명`(12) / `상태`(6) | `05_워치리스트` 블록 C 상위 10행 |
|
||
|
||
- 표 헤더: 10pt bold `#FFFFFF` on `#1F2933`, 가운데.
|
||
- 표 본문: 10pt `#1F2933`, 아래선 `#D0D7DE` hair(스타일 7).
|
||
- `누적` 열에 `data_bar` `#0072B2`(`CF-00-01`).
|
||
- 워치 히트 표의 `상태` 열에 상태 조건부서식(`CF-00-02~04`).
|
||
- **`누적` 열은 시트 간 수식**으로 검증 가능하게 한다 — §5.3 참조.
|
||
|
||
### 4.7 상태 배너 (`B4:R4`)
|
||
|
||
| 조건 | 표시 | 서식 |
|
||
|---|---|---|
|
||
| 정상(`SUCCESS`) | 배너 없음. 행 4 높이 8, 내용 없음 | — |
|
||
| 무결성 차단(`PARTIAL`) | `⚠ 오늘 수집 실패 — 마지막 성공: {날짜}. 아래 수치는 그날 기준이다. 사유: {blocking_reason}` | bold `#8A1D00` on `#FBE5DC`, 높이 22 |
|
||
| 기준선 수립일(첫 실행) | `ℹ 기준선 수립일입니다. 변경 탐지는 내일 실행부터 시작됩니다.` | bold `#00456B` on `#E5F1F8`, 높이 22 |
|
||
| AI 실패/스킵 | `ℹ AI 요약을 생성하지 못했습니다({사유}). 수치는 모두 정상입니다.` | `#5B6770` on `#EFEFEF`, 높이 22 |
|
||
|
||
배너가 2개 이상 해당하면 **심각도 순(PARTIAL > 기준선 > AI)으로 1개만** 표시하고 나머지는 `99_메타` 에 기록한다. 대시보드에 경고를 쌓지 않는다.
|
||
|
||
### 4.8 내비게이션 (`42~43행`)
|
||
|
||
| 셀(병합) | 표시 문자열 | 링크 대상 |
|
||
|---|---|---|
|
||
| `B42:C42` | `오늘 변경분 →` | `internal:'01_오늘변경분'!A1` |
|
||
| `E42:F42` | `전체 현황 →` | `internal:'02_전체현황'!A1` |
|
||
| `H42:I42` | `성분별 →` | `internal:'03_성분별'!A1` |
|
||
| `K42:L42` | `업체별 →` | `internal:'04_업체별'!A1` |
|
||
| `N42:O42` | `워치리스트 →` | `internal:'05_워치리스트'!A1` (없으면 회색 `(워치리스트 없음)`) |
|
||
| `Q42:R42` | `추이 →` | `internal:'06_추이'!A1` |
|
||
| `B43:C43` | `메타·로그 →` | `internal:'99_메타'!A1` |
|
||
|
||
서식: 10pt bold `#0072B2`, 밑줄 없음, 배경 `#F7F7F7`, 테두리 1px `#D0D7DE`, 가운데 정렬. `tip=` 로 툴팁을 단다.
|
||
|
||
---
|
||
|
||
## 5. 시트 간 연동 명세
|
||
|
||
### 5.1 하이퍼링크 전집
|
||
|
||
**시트명 인용 규칙**: 이 워크북의 모든 시트명은 숫자로 시작한다(`00_대시보드` …). Excel 은 숫자로 시작하는 시트명을 참조할 때 **작은따옴표 인용을 요구**한다. 따라서 **예외 없이 `'시트명'!셀`** 로 쓴다. 내부 링크는 `internal:'00_대시보드'!A1` 형태다.
|
||
|
||
| # | 출발 시트 | 출발 셀 | 링크 종류 | 대상 | 표시 문자열 | 툴팁 |
|
||
|---|---|---|---|---|---|---|
|
||
| L01 | `00_대시보드` | `B42:C42` | internal | `'01_오늘변경분'!A1` | `오늘 변경분 →` | 오늘 신규·변경·취하 전체 |
|
||
| L02 | `00_대시보드` | `E42:F42` | internal | `'02_전체현황'!A1` | `전체 현황 →` | 유효 등록 전체 원장 |
|
||
| L03 | `00_대시보드` | `H42:I42` | internal | `'03_성분별'!A1` | `성분별 →` | 성분 기준 집계 |
|
||
| L04 | `00_대시보드` | `K42:L42` | internal | `'04_업체별'!A1` | `업체별 →` | 업체 기준 집계 |
|
||
| L05 | `00_대시보드` | `N42:O42` | internal | `'05_워치리스트'!A1` | `워치리스트 →` | 관심 키워드 히트 |
|
||
| L06 | `00_대시보드` | `Q42:R42` | internal | `'06_추이'!A1` | `추이 →` | 일자별 시계열 |
|
||
| L07 | `00_대시보드` | `B43:C43` | internal | `'99_메타'!A1` | `메타·로그 →` | 수집 메타·무결성·AI 상태 |
|
||
| L08 | `00_대시보드` | `B26` | internal | `'03_성분별'!A1` | `Top 10 성분` (헤더가 링크) | 성분별 시트로 |
|
||
| L09 | `00_대시보드` | `H26` | internal | `'04_업체별'!A1` | `Top 10 업체` | 업체별 시트로 |
|
||
| L10 | `00_대시보드` | `N26` | internal | `'05_워치리스트'!A1` | `워치리스트 히트` | 워치리스트 시트로 |
|
||
| L11 | `01`~`06`,`99` | `A1` | internal | `'00_대시보드'!A1` | `← 대시보드` | 대시보드로 돌아가기 |
|
||
| L12 | `01_오늘변경분` | `D4:D{last}` | internal | `'02_전체현황'!A{행}` | 등록번호 원문 | 전체 현황의 해당 행으로 |
|
||
| L13 | `01_오늘변경분` | `Q4:Q{last}` | external | nedrug 검색 URL | `조회` | 의약품안전나라에서 검색 |
|
||
| L14 | `02_전체현황` | `O4:O{last}` | external | nedrug 검색 URL | `조회` | 의약품안전나라에서 검색 |
|
||
| L15 | `03_성분별` | `P4:P{last}` | internal | `'02_전체현황'!A3` | `보기` | 전체 현황에서 필터 |
|
||
| L16 | `04_업체별` | `Q4:Q{last}` | internal | `'02_전체현황'!A3` | `보기` | 전체 현황에서 필터 |
|
||
| L17 | `05_워치리스트` | `N4:N{last}` | internal | `'01_오늘변경분'!A3` | `보기` | 오늘 변경분에서 확인 |
|
||
| L18 | `99_메타` | `B13`,`B14`,`B15`,`B18` | external | `file:///…` 로컬 경로 | 경로 문자열 | 파일 열기 |
|
||
| L19 | `99_메타` | `B22` | external | OpenAPI 엔드포인트 URL | URL | 원본 API 문서 |
|
||
|
||
**L12 의 대상 행 계산**: `02_전체현황` 은 `dmf_key → 행번호` 사전을 빌드 순서대로 만들어 두고, `01_오늘변경분` 을 쓸 때 그 사전을 조회한다. `취하` 이벤트는 `02_전체현황` 에 행이 없을 수 있으므로 그때는 링크 없이 일반 텍스트로 쓴다.
|
||
|
||
**링크 시각 스타일** — Excel 기본 파랑 밑줄을 쓰지 않는다.
|
||
|
||
| 링크 종류 | 서식 |
|
||
|---|---|
|
||
| 내비게이션 버튼(L01~L07) | 10pt bold `#0072B2`, 밑줄 없음, 배경 `#F7F7F7`, 테두리 `#D0D7DE` |
|
||
| 역링크(L11) | 10pt `#0072B2`, 밑줄 없음, 배경 없음 |
|
||
| 표 안 내부 링크(L12, L15~L17) | 10pt `#0072B2`, 밑줄 없음 |
|
||
| 외부 링크(L13, L14, L18, L19) | 10pt `#0072B2`, **밑줄 있음** — 워크북 밖으로 나간다는 신호를 시각적으로 구분 |
|
||
|
||
### 5.2 목차 기능
|
||
|
||
**별도 목차 시트를 만들지 않는다.** `00_대시보드` 가 목차를 겸한다(§4.8 내비게이션 + §4.6 Top 표 헤더 링크). 근거:
|
||
|
||
- 시트가 8개뿐이고 탭 자체가 목차 역할을 한다. 목차 전용 시트는 클릭 1회를 더 요구한다.
|
||
- 5초 규칙 — 첫 화면은 "지금 무슨 일이 일어났는가"여야 하며 "어디로 갈 수 있는가"는 그 아래여야 한다.
|
||
- 모든 데이터 시트 `A1` 의 역링크(L11)가 항상 대시보드로 되돌린다 → 왕복 구조가 완결된다.
|
||
|
||
### 5.3 시트 간 수식 전집
|
||
|
||
모두 `write_formula(row, col, formula, fmt, value=캐시값)` 로 기록한다.
|
||
|
||
| # | 위치 | 수식 전문 | 캐시값 산출 |
|
||
|---|---|---|---|
|
||
| F01 | `00_대시보드!B6` | `='06_추이'!$B${e}` | 오늘 NEW 건수 |
|
||
| F02 | `00_대시보드!E6` | `='06_추이'!$C${e}` | 오늘 CHANGED 건수 |
|
||
| F03 | `00_대시보드!H6` | `='06_추이'!$D${e}` | 오늘 WITHDRAWN 건수 |
|
||
| F04 | `00_대시보드!K6` | `='06_추이'!$E${e}` | 세 값의 합 |
|
||
| F05 | `00_대시보드!N6` | `='06_추이'!$F${e}` | 오늘 워치 히트 |
|
||
| F06 | `00_대시보드!Q6` | `='06_추이'!$G${e}` | 누적 유효 등록 |
|
||
| F07 | `00_대시보드!B7` | `='06_추이'!$B${e}-'06_추이'!$B${e-1}` | 전일 대비 델타 |
|
||
| F08 | `00_대시보드!E7` | `='06_추이'!$C${e}-'06_추이'!$C${e-1}` | 〃 |
|
||
| F09 | `00_대시보드!H7` | `='06_추이'!$D${e}-'06_추이'!$D${e-1}` | 〃 |
|
||
| F10 | `00_대시보드!K7` | `='06_추이'!$E${e}-'06_추이'!$E${e-1}` | 〃 |
|
||
| F11 | `00_대시보드!N7` | `='06_추이'!$F${e}-'06_추이'!$F${e-1}` | 〃 |
|
||
| F12 | `00_대시보드!Q7` | `='06_추이'!$G${e}-'06_추이'!$G${e-1}` | 〃 |
|
||
| F13 | `00_대시보드!F27:F36` (Top 성분 누적) | `=COUNTIFS('02_전체현황'!$B:$B,$C27,'02_전체현황'!$M:$M,"유효")` | SQL `COUNT(*)` |
|
||
| F14 | `00_대시보드!L27:L36` (Top 업체 누적) | `=COUNTIFS('02_전체현황'!$C:$C,$I27,'02_전체현황'!$M:$M,"유효")` | SQL `COUNT(*)` |
|
||
| F15 | `02_전체현황!N4:N{last}` | `=IF($I4="","-",INT(REPORT_DATE-$I4))` | `(기준일-수리일자).days` |
|
||
| F16 | `03_성분별!F4:F{last}` | `=SUM($C4:$E4)` | 세 값의 합 |
|
||
| F17 | `04_업체별!G4:G{last}` | `=SUM($D4:$F4)` | 세 값의 합 |
|
||
| F18 | `05_워치리스트!I4:I{last}` | `=IF($C4="",0,COUNTIF('01_오늘변경분'!$O$4:$O$100000,"*"&$C4&"*"))` | 실제 매칭 개수 |
|
||
| F19 | `06_추이!E4:E{last}` | `=SUM($B4:$D4)` | 세 값의 합 |
|
||
| F20 | `99_메타!D27:D31` | `=IF($C27=$B27,"OK","불일치")` | 게이트 판정 |
|
||
| F21 | `99_메타!D32` (종합) | `=IF(COUNTIF($D$27:$D$31,"불일치")=0,"OK","불일치")` | `verdict.ok` |
|
||
|
||
> **XlsxWriter 수식 작성 규칙 2가지** (문서 명시):
|
||
> 1. 인자 구분자는 **쉼표**다(세미콜론 아님). 한국어 Excel 에서 화면에는 쉼표로 보이지만 파일 포맷은 항상 US 스타일이다.
|
||
> 2. `_xlfn` 접두가 필요한 미래 함수는 워크북 옵션 `use_future_functions: True` 로 자동 처리된다. 위 수식들은 모두 레거시 함수라 해당 없음.
|
||
|
||
### 5.4 정의된 이름 (Defined Names)
|
||
|
||
**ASCII 이름을 정본으로 한다.** Excel 은 유니코드 정의 이름을 허용하지만 XlsxWriter 의 유효성 검사 통과 여부를 실측하지 않았다(부록 기록). ASCII 는 100% 안전하고, 이름 상자에 뜨는 문자열이 짧아 오히려 쓰기 편하다.
|
||
|
||
| 이름 | 스코프 | 수식 | 용도 |
|
||
|---|---|---|---|
|
||
| `REPORT_DATE` | 통합문서 | `='99_메타'!$B$3` | F15 경과일 계산의 기준일 |
|
||
| `RUN_ID` | 통합문서 | `='99_메타'!$B$6` | 추적 |
|
||
| `KPI_NEW` | 통합문서 | `='00_대시보드'!$B$6` | 외부 참조용 |
|
||
| `KPI_CHANGED` | 통합문서 | `='00_대시보드'!$E$6` | 〃 |
|
||
| `KPI_WITHDRAWN` | 통합문서 | `='00_대시보드'!$H$6` | 〃 |
|
||
| `KPI_TOTAL` | 통합문서 | `='00_대시보드'!$K$6` | 〃 |
|
||
| `KPI_WATCH` | 통합문서 | `='00_대시보드'!$N$6` | 〃 |
|
||
| `KPI_ACTIVE` | 통합문서 | `='00_대시보드'!$Q$6` | 〃 |
|
||
| `TREND_DATE` | 통합문서 | `='06_추이'!$A$4:$A${last}` | 차트1 카테고리 |
|
||
| `TREND_NEW` | 통합문서 | `='06_추이'!$B$4:$B${last}` | 차트1 계열 |
|
||
| `TREND_CHANGED` | 통합문서 | `='06_추이'!$C$4:$C${last}` | 〃 |
|
||
| `TREND_WITHDRAWN` | 통합문서 | `='06_추이'!$D$4:$D${last}` | 〃 |
|
||
| `LEDGER` | 통합문서 | `='02_전체현황'!$A$3:$Q${last}` | 사용자 임의 수식용 |
|
||
| `TODAY_CHANGES` | 통합문서 | `='01_오늘변경분'!$A$3:$S${last}` | 〃 |
|
||
| `_xlnm.Print_Titles` | 시트별 | `repeat_rows(0, 2)` 가 자동 생성 | 인쇄 제목 행 |
|
||
| `_xlnm.Print_Area` | 시트별 | `print_area(...)` 가 자동 생성 | 인쇄 영역 |
|
||
|
||
```python
|
||
# report/build.py 에서 시트를 모두 만든 뒤 마지막에 호출
|
||
wb.define_name("REPORT_DATE", "='99_메타'!$B$3")
|
||
wb.define_name("TREND_NEW", f"='06_추이'!$B$4:$B${trend_last}")
|
||
# … 표의 나머지도 같은 형태
|
||
```
|
||
|
||
> **주의**: `define_name` 은 워크북 스코프에서 **시트를 모두 생성한 뒤** 호출해야 한다. 존재하지 않는 시트를 참조하면 Excel 이 열 때 `#REF!` 가 된다.
|
||
|
||
### 5.5 연동 구조 요약 다이어그램
|
||
|
||
```
|
||
┌───────────────────────────────┐
|
||
│ 00_대시보드 │
|
||
│ KPI 6 · 차트 2 · Top 표 3 │
|
||
└───┬───────────────────────┬───┘
|
||
L01~L07 (내비 링크) │ │ F01~F14 (수식 참조)
|
||
┌──────┬──────┬────┴─┬──────┬──────┬───────┴──┐
|
||
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
||
01_오늘 02_전체 03_성분 04_업체 05_워치 06_추이 99_메타
|
||
변경분 현황 별 별 리스트 (원본) (기준일)
|
||
│ ▲ │ │ │ ▲ ▲
|
||
│ │ │ │ │ │ │
|
||
└──────┘ └──────┴──────┘ │ │
|
||
L12 등록번호 L15/L16 상세 │ │
|
||
└──────────────────────────── L17 ───┘ │
|
||
│
|
||
모든 데이터 시트 A1 ──── L11 (← 대시보드) ─────────┘
|
||
스파크라인 원본: 06_추이 B~G열 + 히든 AD:CN
|
||
차트 원본: 06_추이 A~D열(차트1), AA:AB(차트2)
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 디자인 토큰
|
||
|
||
### 6.1 색 팔레트 — Okabe-Ito 기반
|
||
|
||
**채택 근거**: Masataka Okabe · Kei Ito 의 Color Universal Design 팔레트. Nature Methods 권장, Claus Wilke *Fundamentals of Data Visualization* 의 기본 범주형 스케일. **순수 red 와 순수 green 을 아예 쓰지 않는다** — Orange(`#E69F00`)는 1형 색각(protanopia)에서도 황등색으로 지각되고, Bluish Green(`#009E73`)은 청색 채널이 충분해 적록 병합에서 살아남는다. Tableau 10 은 인접한 빨강·초록이 2형 색각에서 충돌하므로 **기각**.
|
||
|
||
| 토큰 | HEX | 원 팔레트 | 흰 배경 대비비 | 용도 |
|
||
|---|---|---|---|---|
|
||
| `ACCENT` | `#0072B2` | Okabe-Ito Blue | **5.19** (AA) | 유일한 강조색. 링크·데이터바·단색 차트·합계 |
|
||
| `NEW` | `#009E73` | Bluish Green | 3.42 | 신규 액센트(KPI 값·차트 계열) |
|
||
| `CHANGED` | `#E69F00` | Orange | 2.25 | 변경 액센트 |
|
||
| `WITHDRAWN` | `#D55E00` | Vermillion | 3.87 | 취하 액센트 |
|
||
| `WATCH` | `#CC79A7` | Reddish Purple | 3.06 | 워치리스트 액센트 |
|
||
| `SKY` | `#56B4E9` | Sky Blue | 2.31 | 보조 계열(예비) |
|
||
| `INK` | `#1F2933` | (파생) | **14.76** (AAA) | 본문 텍스트·표 헤더 배경·누적 KPI |
|
||
| `MUTED` | `#5B6770` | (파생) | 5.80 (AA) | 보조 텍스트·라벨·각주 |
|
||
| `NEUTRAL` | `#888888` | Paul Tol bad-data grey | 3.54 | 결측·무효·비활성 |
|
||
| `RULE` | `#D0D7DE` | (파생) | 1.45 | 테두리·구분선(장식 전용, 텍스트 아님) |
|
||
| `GRID` | `#EFEFEF` | (파생) | — | 차트 격자선 |
|
||
| `TILE_BG` | `#F7F7F7` | (파생) | — | KPI 타일·내비 버튼 배경 |
|
||
| `PAPER` | `#FFFFFF` | — | — | 시트 배경 |
|
||
|
||
**사용 금지**: Okabe-Ito Yellow(`#F0E442`) — 흰 배경 대비 1.32 로 텍스트·선에 부적합. 배경으로도 쓰지 않는다(우리 연한 톤이 이미 충분).
|
||
|
||
### 6.2 상태색 매핑 — 4중 코딩 (배경 + 폰트 + 라벨 + 기호)
|
||
|
||
WCAG 1.4.1 은 색 단독 전달을 금지한다. 남성 약 8%, 여성 약 0.5% 가 색각 이상이다. 따라서 **모든 상태는 배경색·폰트색·텍스트 라벨·기호 4개 채널로 동시에** 표현한다.
|
||
|
||
| 상태 | 라벨 | 기호 | 배경 HEX | 폰트 HEX | 대비비 | 등급 | 액센트 HEX |
|
||
|---|---|---|---|---|---|---|---|
|
||
| 신규 | `신규` | `+` | `#E3F3EA` | `#005A32` | **7.28** | AAA | `#009E73` |
|
||
| 변경 | `변경` | `◆` | `#FDF0DC` | `#7A3E00` | **7.42** | AAA | `#E69F00` |
|
||
| 취하 | `취하` | `✕` | `#FBE5DC` | `#8A1D00` | **7.69** | AAA | `#D55E00` |
|
||
| 워치 히트 | `★` | `★` | `#F7E9F0` | `#7A2E55` | **7.61** | AAA | `#CC79A7` |
|
||
| 정보/정상 | `OK` | `✓` | `#E5F1F8` | `#00456B` | **8.85** | AAA | `#0072B2` |
|
||
| 변동 없음 | `-` | `·` | `#FFFFFF` | `#5B6770` | 5.80 | AA | `#888888` |
|
||
| 오류/결측 | `?` | `⚠` | `#EFEFEF` | `#3A3A3A` | **9.89** | AAA | `#888888` |
|
||
|
||
- 취하 행에는 배경·폰트·라벨·기호에 더해 **취소선**(`font_strikeout: True`)까지 5번째 채널을 얹는다.
|
||
- Excel 조건부서식 기본색(`#FFC7CE`/`#9C0006`, `#C6EFCE`/`#006100`)은 **red/green 쌍이라 CUD 금지 조합**이고 대비도 낮다 → 사용하지 않는다.
|
||
|
||
### 6.3 타이포그래피 계층
|
||
|
||
폰트는 `report.font` 설정키(기본 `맑은 고딕`). 폰트명에 공백이 있어 반드시 문자열로 전달한다. Excel 은 해당 PC 에 설치된 폰트만 렌더링하며, 배포 대상이 Windows 로 한정된다는 전제에서 안전하다.
|
||
|
||
| 요소 | 크기 | 굵기 | 색 | 비고 |
|
||
|---|---|---|---|---|
|
||
| 대시보드 제목 | 18pt | bold | `#1F2933` | `B2:R2` |
|
||
| 대시보드 헤드라인 | 11pt | regular | `#5B6770` | `B3:R3` |
|
||
| 상태 배너 | 11pt | bold | 상태 폰트색 | `B4:R4` |
|
||
| **KPI 값** | **28pt** | bold | 상태 액센트색 | 카드 내 단독 최대 요소. 44pt 는 5자리에서 넘침 |
|
||
| KPI 라벨 | 10pt | bold | `#5B6770` | |
|
||
| KPI 델타 | 10pt | bold | `#5B6770` | 색이 아니라 ▲▼ 기호로 방향 전달 |
|
||
| 시트 제목 (`B1`) | 14pt | bold | `#1F2933` | |
|
||
| 표 헤더 | 10pt | bold | `#FFFFFF` on `#1F2933` | 줄바꿈 허용 |
|
||
| 표 본문 | 10pt | regular | `#1F2933` | |
|
||
| 표 본문(긴 텍스트: 변경내용·AI 메모) | 9pt | regular | `#1F2933` / `#5B6770` | 줄바꿈 |
|
||
| 하이퍼링크 | 10pt | bold(내비)/regular | `#0072B2` | 외부만 밑줄 |
|
||
| 각주·출처·면책 | 9pt | regular | `#5B6770` | |
|
||
| 차트 제목 | 11pt | bold | `#1F2933` | |
|
||
| 차트 축 라벨 | 8~9pt | regular | `#5B6770` / `#1F2933` | |
|
||
|
||
### 6.4 행 높이 · 열 너비 규칙
|
||
|
||
| 대상 | 값 | 근거 |
|
||
|---|---|---|
|
||
| 대시보드 행 높이 | §4.2 표 그대로 | 1화면 수납 계산 |
|
||
| 데이터 시트 1행(제목) | 24 | |
|
||
| 데이터 시트 2행(여백) | 6 | |
|
||
| 데이터 시트 3행(헤더) | 32 | 2줄 줄바꿈 헤더 수용 |
|
||
| `01_오늘변경분` 데이터 행 | 30 | 변경내용 2줄 수용 |
|
||
| `02_전체현황` 데이터 행 | 15 | 수천~수만 행 성능. 줄바꿈 없음 |
|
||
| `03`/`04` 데이터 행 | 18 | 스파크라인 수용 |
|
||
| `05`/`06`/`99` 데이터 행 | 16 | |
|
||
| 열 너비 최소 / 최대 / 여백 | 8.0 / 48.0 / +2.0 | `compute_col_widths` 파라미터 |
|
||
| 한글 폭 계수 | W·F = **1.8**, A = 1.2, 그 외 = 1.0 | `east_asian_width` 의 2.0 은 맑은 고딕에서 과대 추정. 실무 경험치 1.8 채택 |
|
||
|
||
### 6.5 테두리 규칙 (데이터 잉크 비율 우선)
|
||
|
||
| 대상 | 테두리 |
|
||
|---|---|
|
||
| 표 본문 셀 | **세로선 없음.** 아래선만 `hair`(스타일 7) `#D0D7DE` |
|
||
| 표 헤더 | 아래선 `medium`(스타일 2) `#1F2933` |
|
||
| 표 합계행 | 위선 `double`(스타일 6) `#1F2933` |
|
||
| KPI 타일 | 좌·우 `thin`(1) `#D0D7DE`, **상단 `thick`(5) 상태 액센트색**, 하단 `thin` `#D0D7DE` |
|
||
| 내비 버튼 | 사방 `thin`(1) `#D0D7DE` |
|
||
| 상태 배너 | 좌측 `thick`(5) 상태 폰트색 |
|
||
| 차트 영역 | `#D0D7DE` 1px |
|
||
| 플롯 영역 | 없음 |
|
||
|
||
XlsxWriter 테두리 스타일 번호: `1=thin`, `2=medium`, `3=dashed`, `4=dotted`, `5=thick`, `6=double`, `7=hair`.
|
||
|
||
### 6.6 여백·정렬
|
||
|
||
| 항목 | 값 |
|
||
|---|---|
|
||
| 인쇄 여백 | `set_margins(left=0.4, right=0.4, top=0.6, bottom=0.6)` (인치) |
|
||
| 머리글/바닥글 여백 | 0.3 / 0.3 |
|
||
| 문자열 열 정렬 | 왼쪽 + `valign: vcenter` |
|
||
| 숫자·날짜 열 정렬 | 가운데 (자릿수 비교가 아니라 스캔이 목적) |
|
||
| 합계·금액성 큰 수 | 오른쪽 (`02_전체현황` 에는 해당 없음) |
|
||
| 헤더 정렬 | 가운데 + `text_wrap: True` |
|
||
| 긴 텍스트 셀 | 왼쪽 + `text_wrap: True` + `valign: top` |
|
||
|
||
### 6.7 표시 형식(number_format) 사전
|
||
|
||
| 토큰 | 문자열 | 적용 |
|
||
|---|---|---|
|
||
| `INT` | `#,##0` | 누적 건수 |
|
||
| `INT_DASH` | `#,##0;;-` | 0을 `-` 로 (일일 건수) |
|
||
| `INT_UNIT` | `#,##0"건"` | 대시보드 표 예비 |
|
||
| `DELTA` | `▲ #,##0;▼ -#,##0;– 0` | KPI 델타 |
|
||
| `DELTA_PCT` | `▲ 0.0%;▼ -0.0%;– 0.0%` | 비율 델타(예비) |
|
||
| `DATE` | `yyyy-mm-dd` | 모든 날짜 |
|
||
| `DATETIME` | `yyyy-mm-dd hh:mm:ss` | 메타 시각 |
|
||
| `TEXT` | `@` | 등록번호·해시 등 **숫자 변환 금지 문자열** |
|
||
| `RATIO` | `0.0%` | 널 비율·감소율 |
|
||
| `SEC` | `0.0"초"` | agy 소요 시간 |
|
||
|
||
> `▲ #,##0;▼ -#,##0;– 0` 의 3구획은 각각 양수/음수/0 이다. 음수 구획에 `-` 를 남겨 두는 이유는 `▼ -2` 처럼 부호를 함께 보여 스크린 리더와 흑백 인쇄에서도 방향이 전달되게 하기 위함이다.
|
||
|
||
---
|
||
|
||
## 7. 조건부 서식 규칙 전집
|
||
|
||
모든 규칙은 `worksheet.conditional_format(first_row, first_col, last_row, last_col, options)` 로 건다. `first_row` 등은 **0-index**지만 `criteria` 수식 안의 셀 참조는 **A1 표기(1-index)** 다. 아래 표의 "수식" 열은 데이터 첫 행이 워크시트 4행(0-index 3)임을 전제한다 — `$C4` 의 `4` 가 그것이다. **이 한 칸 어긋남이 "한 행씩 밀린 색칠"의 대부분 원인이다.**
|
||
|
||
| ID | 시트 | 대상 범위 | 유형 | 수식 / 조건 | 서식 결과 |
|
||
|---|---|---|---|---|---|
|
||
| `CF-01-01` | `01_오늘변경분` | `B4:S{last}` | `formula` | `=$C4="신규"` | bg `#E3F3EA`, font `#005A32` |
|
||
| `CF-01-02` | `01_오늘변경분` | `B4:S{last}` | `formula` | `=$C4="변경"` | bg `#FDF0DC`, font `#7A3E00` |
|
||
| `CF-01-03` | `01_오늘변경분` | `B4:S{last}` | `formula` | `=$C4="취하"` | bg `#FBE5DC`, font `#8A1D00`, **취소선** |
|
||
| `CF-01-04` | `01_오늘변경분` | `N4:N{last}` | `cell` | `> 0` | bg `#F7E9F0`, font `#7A2E55`, bold |
|
||
| `CF-01-05` | `01_오늘변경분` | `I4:I{last}` | `text` | `containing` `대한민국` | bg `#E3F3EA`, font `#005A32` |
|
||
| `CF-01-06` | `01_오늘변경분` | `E4:F{last}` | `formula` | `=$N4>0` | bold (성분명·업체명만) |
|
||
| `CF-02-01` | `02_전체현황` | `A4:A{last}` | `duplicate` | — | bg `#FFF2CC`, font `#7F6000` |
|
||
| `CF-02-02` | `02_전체현황` | `L4:L{last}` | `formula` | `=$L4=REPORT_DATE` | bg `#FDF0DC`, font `#7A3E00`, bold |
|
||
| `CF-02-03` | `02_전체현황` | `A4:Q{last}` | `formula` | `=$M4="취하"` | bg `#FBE5DC`, font `#8A1D00`, **취소선** |
|
||
| `CF-02-04` | `02_전체현황` | `F4:F{last}` | `text` | `containing` `대한민국` | bg `#E3F3EA`, font `#005A32` |
|
||
| `CF-02-05` | `02_전체현황` | `B4:C{last}` | `formula` | `=COUNTIF(WATCH_KEYS,"*"&B4&"*")>0` → **미사용**. 파이썬이 워치 매칭 여부를 계산해 히든 열에 쓰고 `=$R4="Y"` 로 판정 | bg `#F7E9F0`, font `#7A2E55` |
|
||
| `CF-02-06` | `02_전체현황` | `G4:G{last}` | `cell` | `>= 2` | bold |
|
||
| `CF-02-07` | `02_전체현황` | `I4:I{last}` | `data_bar` | `bar_color #0072B2`, `bar_solid True`, `bar_only False` | 수리일자 최신일수록 긴 막대 |
|
||
| `CF-02-08` | `02_전체현황` | `N4:N{last}` | `icon_set` | `3_arrows_gray`, `icons=[{'criteria':'>=','type':'number','value':1095},{'criteria':'>=','type':'number','value':365}]`, `icons_only False` | 3년↑ / 1년↑ / 그 외 방향 표시 |
|
||
| `CF-03-01` | `03_성분별` | `C4:C{last}` | `cell` | `> 0` | bg `#E3F3EA`, font `#005A32` |
|
||
| `CF-03-02` | `03_성분별` | `D4:D{last}` | `cell` | `> 0` | bg `#FDF0DC`, font `#7A3E00` |
|
||
| `CF-03-03` | `03_성분별` | `E4:E{last}` | `cell` | `> 0` | bg `#FBE5DC`, font `#8A1D00` |
|
||
| `CF-03-04` | `03_성분별` | `N4:N{last}` | `icon_set` | `3_arrows_gray`, `icons=[{'criteria':'>=','type':'number','value':1},{'criteria':'>=','type':'number','value':0}]` | ▲ / ▬ / ▼ |
|
||
| `CF-03-05` | `03_성분별` | `B4:B{last}` | `formula` | `=$Q4="Y"` (히든 워치플래그 열) | bg `#F7E9F0`, font `#7A2E55`, bold |
|
||
| `CF-03-06` | `03_성분별` | `G4:I{last}` | `data_bar` | `bar_color #0072B2`, `bar_solid True` | 3열 각각 별도 규칙(`multi_range` 사용 금지 — 열별 스케일이 달라야 함) |
|
||
| `CF-03-07` | `03_성분별` | `J4:J{last}` | `cell` | `>= 5` | bold |
|
||
| `CF-03-08` | `03_성분별` | `M4:M{last}` | `text` | `containing` `Y` | bg `#E3F3EA`, font `#005A32` |
|
||
| `CF-04-01` | `04_업체별` | `D4:D{last}` | `cell` | `> 0` | bg `#E3F3EA`, font `#005A32` |
|
||
| `CF-04-02` | `04_업체별` | `E4:E{last}` | `cell` | `> 0` | bg `#FDF0DC`, font `#7A3E00` |
|
||
| `CF-04-03` | `04_업체별` | `F4:F{last}` | `cell` | `> 0` | bg `#FBE5DC`, font `#8A1D00` |
|
||
| `CF-04-04` | `04_업체별` | `H4:I{last}` | `data_bar` | `bar_color #0072B2` | 열별 개별 규칙 |
|
||
| `CF-04-05` | `04_업체별` | `N4:N{last}` | `formula` | `=AND($N4<>"",REPORT_DATE-$N4>90)` | bg `#EFEFEF`, font `#5B6770` |
|
||
| `CF-04-06` | `04_업체별` | `O4:O{last}` | `text` | `containing` `★` | bg `#F7E9F0`, font `#7A2E55` |
|
||
| `CF-05-01` | `05_워치리스트` | `I4:I{last}` | `cell` | `> 0` | bg `#F7E9F0`, font `#7A2E55`, bold |
|
||
| `CF-05-02` | `05_워치리스트` | `M4:M{last}` | `formula` | `=$M4=REPORT_DATE` | bg `#F7E9F0`, font `#7A2E55` |
|
||
| `CF-05-03` | `05_워치리스트` | `C4:C{last}` | `blanks` | — | bg `#FBE5DC`, font `#8A1D00` |
|
||
| `CF-05-04` | `05_워치리스트` | `J4:K{last}` | `data_bar` | `bar_color #CC79A7` | 열별 개별 규칙 |
|
||
| `CF-05-05` | `05_워치리스트` (블록C) | `A{c}:G{cend}` | `formula` ×3 | `=$B{c}="신규"` / `="변경"` / `="취하"` | `CF-01-01~03` 과 동일 서식 |
|
||
| `CF-06-01` | `06_추이` | `A4:A{last}` | `formula` | `=$A4=REPORT_DATE` | bg `#E5F1F8`, font `#00456B`, bold |
|
||
| `CF-06-02` | `06_추이` | `B4:B{last}` / `C…` / `D…` | `data_bar` ×3 | `bar_color` 각각 `#009E73` / `#E69F00` / `#D55E00` | 열별 |
|
||
| `CF-06-03` | `06_추이` | `H4:H{last}` | `formula` | `=AND(ROW()>4,$H4<$H3*0.95)` | bg `#FBE5DC`, font `#8A1D00` |
|
||
| `CF-06-04` | `06_추이` | `I4:I{last}` | `text` ×3 | `containing` `SUCCESS` / `PARTIAL` / `FAILED` | `#E3F3EA` / `#FDF0DC` / `#FBE5DC` |
|
||
| `CF-99-01` | `99_메타` | `D27:D32` | `text` ×2 | `containing` `OK` / `불일치` | `#E3F3EA`+`#005A32` / `#FBE5DC`+`#8A1D00` |
|
||
| `CF-99-02` | `99_메타` | `B7` | `text` ×3 | `containing` `SUCCESS` / `PARTIAL` / `FAILED` | `#E3F3EA` / `#FDF0DC` / `#FBE5DC` |
|
||
| `CF-00-01` | `00_대시보드` | `F27:F36`, `L27:L36` | `data_bar` ×2 | `bar_color #0072B2`, `bar_solid True` | Top 표 누적 열 |
|
||
| `CF-00-02` | `00_대시보드` | `R27:R36` | `text` | `containing` `신규` | `#E3F3EA` + `#005A32` |
|
||
| `CF-00-03` | `00_대시보드` | `R27:R36` | `text` | `containing` `변경` | `#FDF0DC` + `#7A3E00` |
|
||
| `CF-00-04` | `00_대시보드` | `R27:R36` | `text` | `containing` `취하` | `#FBE5DC` + `#8A1D00` |
|
||
|
||
### 7.1 규칙 적용 순서와 `stop_if_true`
|
||
|
||
- 행 전체 규칙 3종(`CF-01-01~03`, `CF-02-03`)은 상태 값이 상호 배타적이므로 `stop_if_true` 를 **주지 않는다**(기본 False). 서로 겹치지 않는다.
|
||
- 열 단위 강조(`CF-01-04`, `CF-01-06`)는 행 규칙 **뒤에** 등록해야 위에 얹힌다. XlsxWriter 는 등록 순서를 그대로 규칙 우선순위로 쓰므로, `report/sheets/*` 안에서 **행 규칙 → 열 규칙 → 데이터바/아이콘셋** 순서로 호출한다.
|
||
- `data_bar` 와 `icon_set` 은 배경색을 덮지 않으므로 언제 등록해도 무방하지만, 관례를 위해 항상 마지막에 둔다.
|
||
|
||
### 7.2 아이콘셋 금지 목록
|
||
|
||
`3_traffic_lights`, `3_traffic_lights_rimmed`, `4_traffic_lights` 는 **사용 금지**. red/green 조합에 형태까지 동일한 원이라 색각 이상에서 정보가 완전히 소실된다. 허용은 `3_arrows_gray`(무채색, 방향만) 하나뿐이며, `3_symbols_circled` 는 예비다.
|
||
|
||
`icons` 파라미터 규칙: `criteria` 는 `>=` 또는 `<` 만 가능(기본 `>=`), `type` 은 `number`/`percentile`/`percent`/`formula`(기본 `percent`), **값은 높은 것부터 내림차순**으로 나열한다.
|
||
|
||
---
|
||
|
||
## 8. 생성 코드
|
||
|
||
### 8.0 모듈 매핑 — `src/report/workbook.py` 는 없다
|
||
|
||
요구 항목에 적힌 `src/report/workbook.py` 는 아키텍처 SSOT §2 의 확정 트리에 존재하지 않는다. SSOT 가 이긴다. 아래가 확정 매핑이다.
|
||
|
||
| 요구가 말한 것 | 실제 파일 | 책임 |
|
||
|---|---|---|
|
||
| `workbook.py` 의 워크북 조립 | `src/dmf_crawler/report/build.py` | 워크북 생성·시트 순서·정의된 이름·저장 호출 |
|
||
| `workbook.py` 의 스타일 헬퍼 | `src/dmf_crawler/report/theme.py` | 팔레트 상수·`Format` 캐시 |
|
||
| `workbook.py` 의 한글 열너비 계산 | `src/dmf_crawler/report/widgets.py` | `display_width`·`compute_col_widths`·KPI 타일·역링크 |
|
||
| `workbook.py` 의 원자적 저장 | `src/dmf_crawler/report/atomic.py` | 임시파일 → 검증 → `os.replace` → 폴백 |
|
||
| `workbook.py` 의 시트 빌더 | `src/dmf_crawler/report/sheets/s00~s99` | 시트 1개당 파일 1개 |
|
||
| (신규) DB 조회 | `src/dmf_crawler/report/data.py` | `ReportData` 조립. 시트별 SQL 상수 |
|
||
|
||
공개 진입점은 아키텍처 §3.13 대로 `report/__init__.py` 의 `build_report(conn, run_id, cfg) -> ReportOutcome` 하나다.
|
||
|
||
### 8.1 `src/dmf_crawler/report/theme.py`
|
||
|
||
```python
|
||
"""리포트 디자인 토큰과 Format 캐시.
|
||
|
||
XlsxWriter 의 Format 객체는 워크북에 종속되고, 같은 속성이면 재사용해야
|
||
파일 크기와 생성 속도가 유지된다. FormatCache 가 그 재사용을 강제한다.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
import xlsxwriter
|
||
|
||
# ─────────────────────────────────────────────────────────── 팔레트 (§6.1)
|
||
PALETTE: dict[str, str] = {
|
||
"ACCENT": "#0072B2",
|
||
"NEW": "#009E73",
|
||
"CHANGED": "#E69F00",
|
||
"WITHDRAWN": "#D55E00",
|
||
"WATCH": "#CC79A7",
|
||
"SKY": "#56B4E9",
|
||
"INK": "#1F2933",
|
||
"MUTED": "#5B6770",
|
||
"NEUTRAL": "#888888",
|
||
"RULE": "#D0D7DE",
|
||
"GRID": "#EFEFEF",
|
||
"TILE_BG": "#F7F7F7",
|
||
"PAPER": "#FFFFFF",
|
||
}
|
||
|
||
# ─────────────────────────────────────────────── 상태 4중 코딩 매핑 (§6.2)
|
||
@dataclass(frozen=True, slots=True)
|
||
class StatusStyle:
|
||
label: str
|
||
symbol: str
|
||
bg: str
|
||
fg: str
|
||
accent: str
|
||
strikeout: bool = False
|
||
sort_key: int = 9
|
||
|
||
|
||
STATUS: dict[str, StatusStyle] = {
|
||
"취하": StatusStyle("취하", "✕", "#FBE5DC", "#8A1D00", "#D55E00", True, 1),
|
||
"신규": StatusStyle("신규", "+", "#E3F3EA", "#005A32", "#009E73", False, 2),
|
||
"변경": StatusStyle("변경", "◆", "#FDF0DC", "#7A3E00", "#E69F00", False, 3),
|
||
"워치": StatusStyle("★", "★", "#F7E9F0", "#7A2E55", "#CC79A7", False, 4),
|
||
"정보": StatusStyle("OK", "✓", "#E5F1F8", "#00456B", "#0072B2", False, 5),
|
||
"없음": StatusStyle("-", "·", "#FFFFFF", "#5B6770", "#888888", False, 8),
|
||
"오류": StatusStyle("?", "⚠", "#EFEFEF", "#3A3A3A", "#888888", False, 9),
|
||
}
|
||
|
||
EVENT_TO_STATUS = {"NEW": "신규", "CHANGED": "변경", "WITHDRAWN": "취하"}
|
||
|
||
# ─────────────────────────────────────────────────────── 표시 형식 (§6.7)
|
||
NUMFMT: dict[str, str] = {
|
||
"INT": "#,##0",
|
||
"INT_DASH": "#,##0;;-",
|
||
"INT_UNIT": '#,##0"건"',
|
||
"DELTA": "▲ #,##0;▼ -#,##0;– 0",
|
||
"DELTA_PCT": "▲ 0.0%;▼ -0.0%;– 0.0%",
|
||
"DATE": "yyyy-mm-dd",
|
||
"DATETIME": "yyyy-mm-dd hh:mm:ss",
|
||
"TEXT": "@",
|
||
"RATIO": "0.0%",
|
||
"SEC": '0.0"초"',
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────── 탭 색 · 시트명 (§2.1)
|
||
SHEET_NAMES = {
|
||
"dashboard": "00_대시보드",
|
||
"changes": "01_오늘변경분",
|
||
"ledger": "02_전체현황",
|
||
"ingredient": "03_성분별",
|
||
"company": "04_업체별",
|
||
"watchlist": "05_워치리스트",
|
||
"trend": "06_추이",
|
||
"meta": "99_메타",
|
||
}
|
||
|
||
TAB_COLORS = {
|
||
"00_대시보드": "#0072B2",
|
||
"01_오늘변경분": "#D55E00",
|
||
"02_전체현황": "#5B6770",
|
||
"03_성분별": "#009E73",
|
||
"04_업체별": "#009E73",
|
||
"05_워치리스트": "#CC79A7",
|
||
"06_추이": "#0072B2",
|
||
"99_메타": "#888888",
|
||
}
|
||
|
||
TABLE_STYLE = "Table Style Light 11"
|
||
|
||
|
||
def qsheet(name: str) -> str:
|
||
"""시트명을 수식·링크에 쓸 수 있게 작은따옴표로 감싼다.
|
||
|
||
이 워크북의 시트명은 전부 숫자로 시작하므로 인용이 필수다.
|
||
시트명 안의 작은따옴표는 Excel 규칙대로 두 번 반복해 이스케이프한다.
|
||
"""
|
||
return "'" + name.replace("'", "''") + "'"
|
||
|
||
|
||
class FormatCache:
|
||
"""같은 속성 조합에 대해 Format 객체를 단 한 번만 만든다."""
|
||
|
||
def __init__(self, wb: xlsxwriter.Workbook, font_name: str = "맑은 고딕") -> None:
|
||
self._wb = wb
|
||
self._font = font_name
|
||
self._cache: dict[tuple[tuple[str, Any], ...], Any] = {}
|
||
|
||
def get(self, **props: Any):
|
||
props.setdefault("font_name", self._font)
|
||
props.setdefault("font_size", 10)
|
||
props.setdefault("font_color", PALETTE["INK"])
|
||
key = tuple(sorted(props.items(), key=lambda kv: kv[0]))
|
||
fmt = self._cache.get(key)
|
||
if fmt is None:
|
||
fmt = self._wb.add_format(props)
|
||
self._cache[key] = fmt
|
||
return fmt
|
||
|
||
# ── 자주 쓰는 조합 (이름으로 접근) ────────────────────────────────
|
||
def title(self, size: int = 18):
|
||
return self.get(font_size=size, bold=True, valign="vcenter")
|
||
|
||
def subtitle(self):
|
||
return self.get(font_size=11, font_color=PALETTE["MUTED"], valign="vcenter")
|
||
|
||
def header(self, wrap: bool = True):
|
||
return self.get(bold=True, font_color="#FFFFFF", bg_color=PALETTE["INK"],
|
||
align="center", valign="vcenter", text_wrap=wrap,
|
||
bottom=2, bottom_color=PALETTE["INK"])
|
||
|
||
def cell_text(self, wrap: bool = False, size: int = 10, color: str | None = None):
|
||
return self.get(font_size=size, font_color=color or PALETTE["INK"],
|
||
align="left", valign="vcenter" if not wrap else "top",
|
||
text_wrap=wrap, num_format=NUMFMT["TEXT"],
|
||
bottom=7, bottom_color=PALETTE["RULE"])
|
||
|
||
def cell_num(self, fmt_key: str = "INT_DASH"):
|
||
return self.get(align="center", valign="vcenter",
|
||
num_format=NUMFMT[fmt_key],
|
||
bottom=7, bottom_color=PALETTE["RULE"])
|
||
|
||
def cell_date(self):
|
||
return self.get(align="center", valign="vcenter",
|
||
num_format=NUMFMT["DATE"],
|
||
bottom=7, bottom_color=PALETTE["RULE"])
|
||
|
||
def link_internal(self, bold: bool = False):
|
||
return self.get(font_color=PALETTE["ACCENT"], bold=bold, underline=0,
|
||
align="center", valign="vcenter",
|
||
bottom=7, bottom_color=PALETTE["RULE"])
|
||
|
||
def link_external(self):
|
||
return self.get(font_color=PALETTE["ACCENT"], underline=1,
|
||
align="center", valign="vcenter",
|
||
bottom=7, bottom_color=PALETTE["RULE"])
|
||
|
||
def nav_button(self):
|
||
return self.get(bold=True, font_color=PALETTE["ACCENT"],
|
||
bg_color=PALETTE["TILE_BG"], align="center",
|
||
valign="vcenter", border=1, border_color=PALETTE["RULE"])
|
||
|
||
def footnote(self):
|
||
return self.get(font_size=9, font_color=PALETTE["MUTED"], valign="vcenter")
|
||
|
||
def status_cell(self, status: str, *, align: str = "center"):
|
||
s = STATUS[status]
|
||
return self.get(bg_color=s.bg, font_color=s.fg, bold=True,
|
||
align=align, valign="vcenter",
|
||
font_strikeout=s.strikeout,
|
||
bottom=7, bottom_color=PALETTE["RULE"])
|
||
|
||
def cf(self, status: str):
|
||
"""조건부서식 전용 — 배경·폰트·취소선만 지정(테두리는 건드리지 않는다)."""
|
||
s = STATUS[status]
|
||
props = {"bg_color": s.bg, "font_color": s.fg}
|
||
if s.strikeout:
|
||
props["font_strikeout"] = True
|
||
return self._wb.add_format(props) # CF 서식은 캐시하지 않는다(부분 서식이라 재사용 위험)
|
||
```
|
||
|
||
> **`cf()` 를 캐시하지 않는 이유**: 조건부서식용 Format 은 "지정한 속성만 덮어쓰는" 부분 서식이다. 일반 셀 서식 캐시와 섞이면 폰트 크기·테두리까지 딸려 들어가 예상 밖의 결과를 만든다. 규칙마다 새로 만드는 비용은 규칙 수(40개 미만)를 고려하면 무시할 만하다.
|
||
|
||
### 8.2 `src/dmf_crawler/report/widgets.py`
|
||
|
||
```python
|
||
"""리포트 공용 위젯: 한글 열너비, 시트 머리글, KPI 타일, Top 표."""
|
||
from __future__ import annotations
|
||
|
||
import unicodedata
|
||
from typing import Any, Iterable, Sequence
|
||
|
||
from .theme import NUMFMT, PALETTE, STATUS, FormatCache, qsheet
|
||
|
||
# ───────────────────────────────────────── 한글 폭 계산 (§6.4)
|
||
# east_asian_width 의 W/F 를 2.0 으로 잡으면 맑은 고딕에서 과대 추정된다.
|
||
# 실무 경험치 1.8 을 쓴다. A(Ambiguous)는 1.2.
|
||
_EAW_WIDTH: dict[str, float] = {
|
||
"F": 1.8, # Fullwidth
|
||
"H": 1.0, # Halfwidth
|
||
"W": 1.8, # Wide (한글·한자·가나)
|
||
"Na": 1.0, # Narrow
|
||
"A": 1.2, # Ambiguous
|
||
"N": 1.0, # Neutral
|
||
}
|
||
|
||
|
||
def display_width(value: Any) -> float:
|
||
"""맑은 고딕 10pt 기준 셀 표시 폭(문자 단위)을 근사한다.
|
||
|
||
줄바꿈이 든 문자열은 가장 긴 줄을 기준으로 한다.
|
||
"""
|
||
if value is None:
|
||
return 0.0
|
||
text = str(value)
|
||
if not text:
|
||
return 0.0
|
||
best = 0.0
|
||
for line in text.split("\n"):
|
||
w = 0.0
|
||
for ch in line:
|
||
w += _EAW_WIDTH.get(unicodedata.east_asian_width(ch), 1.0)
|
||
if w > best:
|
||
best = w
|
||
return best
|
||
|
||
|
||
def compute_col_widths(
|
||
header: Sequence[Any],
|
||
rows: Iterable[Sequence[Any]],
|
||
*,
|
||
min_w: float = 8.0,
|
||
max_w: float = 48.0,
|
||
margin: float = 2.0,
|
||
sample: int = 2000,
|
||
overrides: dict[int, float] | None = None,
|
||
) -> list[float]:
|
||
"""헤더 + 데이터 표본으로 열별 폭 리스트를 만든다.
|
||
|
||
overrides 로 특정 열의 폭을 강제 고정한다(스파크라인 열 등).
|
||
"""
|
||
widths = [display_width(h) for h in header]
|
||
for i, row in enumerate(rows):
|
||
if i >= sample:
|
||
break
|
||
for c, v in enumerate(row):
|
||
if c >= len(widths):
|
||
widths.append(0.0)
|
||
w = display_width(v)
|
||
if w > widths[c]:
|
||
widths[c] = w
|
||
result = [max(min_w, min(max_w, w + margin)) for w in widths]
|
||
for c, w in (overrides or {}).items():
|
||
if 0 <= c < len(result):
|
||
result[c] = w
|
||
return result
|
||
|
||
|
||
def apply_col_widths(ws, widths: Sequence[float],
|
||
formats: Sequence[Any] | None = None,
|
||
options: dict[int, dict] | None = None) -> None:
|
||
"""열 너비 + 열 기본 서식 + 열 옵션(hidden/level)을 적용한다."""
|
||
opts = options or {}
|
||
for i, w in enumerate(widths):
|
||
fmt = formats[i] if formats else None
|
||
ws.set_column(i, i, w, fmt, opts.get(i, {}))
|
||
|
||
|
||
# ───────────────────────────────────────── 시트 머리글 (§2.3)
|
||
def write_sheet_header(ws, fc: FormatCache, *, title: str, dashboard: str,
|
||
report_date: str, generated_at: str,
|
||
last_col: int) -> None:
|
||
"""1행에 역링크 + 제목 + 생성 정보를 쓰고, 2행을 여백으로 비운다."""
|
||
ws.set_row(0, 24)
|
||
ws.set_row(1, 6)
|
||
ws.write_url(0, 0, f"internal:{qsheet(dashboard)}!A1",
|
||
fc.get(font_color=PALETTE["ACCENT"], valign="vcenter"),
|
||
string="← 대시보드", tip="대시보드로 돌아가기")
|
||
ws.write(0, 1, title, fc.get(font_size=14, bold=True, valign="vcenter"))
|
||
ws.write(0, max(3, last_col - 3),
|
||
f"기준일 {report_date} · 생성 {generated_at}",
|
||
fc.get(font_size=9, font_color=PALETTE["MUTED"],
|
||
align="right", valign="vcenter"))
|
||
|
||
|
||
def write_table_header(ws, fc: FormatCache, row: int, header: Sequence[str],
|
||
comments: dict[int, str] | None = None,
|
||
height: float = 32.0) -> None:
|
||
"""헤더 행을 쓰고 정의 툴팁(셀 주석)을 단다."""
|
||
ws.set_row(row, height)
|
||
hfmt = fc.header()
|
||
for c, name in enumerate(header):
|
||
ws.write(row, c, name, hfmt)
|
||
for c, text in (comments or {}).items():
|
||
ws.write_comment(row, c, text,
|
||
{"width": 220, "height": 90, "font_name": "맑은 고딕",
|
||
"font_size": 9, "x_scale": 1, "y_scale": 1})
|
||
|
||
|
||
def write_empty_notice(ws, fc: FormatCache, row: int, last_col: int,
|
||
message: str = "(해당 없음)") -> None:
|
||
ws.merge_range(row, 0, row, last_col, message,
|
||
fc.get(font_color=PALETTE["MUTED"], align="center",
|
||
valign="vcenter", italic=True))
|
||
|
||
|
||
# ───────────────────────────────────────── KPI 타일 (§4.4)
|
||
KPI_TILES: tuple[tuple[str, int, int, str, str], ...] = (
|
||
# (라벨, 시작 열(0-index), 끝 열, 액센트, 스파크라인 타입)
|
||
("오늘 신규", 1, 2, "#009E73", "column"), # B:C
|
||
("오늘 변경", 4, 5, "#E69F00", "column"), # E:F
|
||
("오늘 취하", 7, 8, "#D55E00", "column"), # H:I
|
||
("오늘 총 변경분", 10, 11, "#0072B2", "column"), # K:L
|
||
("워치리스트 히트",13, 14, "#CC79A7", "win_loss"), # N:O
|
||
("전체 누적 등록", 16, 17, "#1F2933", "line"), # Q:R
|
||
)
|
||
|
||
|
||
def tile_formats(fc: FormatCache, accent: str) -> tuple[Any, Any, Any, Any]:
|
||
"""KPI 타일 4행(라벨/값/델타/스파크라인)용 서식 세트."""
|
||
common = {
|
||
"bg_color": PALETTE["TILE_BG"],
|
||
"align": "center", "valign": "vcenter",
|
||
"left": 1, "left_color": PALETTE["RULE"],
|
||
"right": 1, "right_color": PALETTE["RULE"],
|
||
}
|
||
label = fc.get(**common, font_size=10, bold=True,
|
||
font_color=PALETTE["MUTED"], top=5, top_color=accent)
|
||
value = fc.get(**common, font_size=28, bold=True,
|
||
font_color=accent, num_format=NUMFMT["INT"])
|
||
delta = fc.get(**common, font_size=10, bold=True,
|
||
font_color=PALETTE["MUTED"], num_format=NUMFMT["DELTA"])
|
||
spark = fc.get(**common, bottom=1, bottom_color=PALETTE["RULE"])
|
||
return label, value, delta, spark
|
||
|
||
|
||
def write_kpi_tiles(ws, fc: FormatCache, *, trend_sheet: str,
|
||
trend_last_row: int, trend_first_row: int,
|
||
values: Sequence[int], deltas: Sequence[int],
|
||
value_formulas: Sequence[str | None],
|
||
delta_formulas: Sequence[str | None]) -> None:
|
||
"""5~8행에 KPI 타일 6장을 그린다.
|
||
|
||
trend_last_row / trend_first_row 는 워크시트 1-index 행 번호다.
|
||
value_formulas / delta_formulas 는 None 이면 값만 직접 쓴다.
|
||
"""
|
||
ws.set_row(4, 18)
|
||
ws.set_row(5, 34)
|
||
ws.set_row(6, 16)
|
||
ws.set_row(7, 14)
|
||
# 스파크라인 원본 열: B(1) 신규, C(2) 변경, D(3) 취하, E(4) 합계, F(5) 워치, G(6) 누적
|
||
src_cols = ("B", "C", "D", "E", "F", "G")
|
||
q = qsheet(trend_sheet)
|
||
for i, (label, c1, c2, accent, stype) in enumerate(KPI_TILES):
|
||
f_label, f_value, f_delta, f_spark = tile_formats(fc, accent)
|
||
ws.merge_range(4, c1, 4, c2, label, f_label)
|
||
|
||
if value_formulas[i]:
|
||
ws.merge_range(5, c1, 5, c2, "", f_value)
|
||
ws.write_formula(5, c1, value_formulas[i], f_value, values[i])
|
||
else:
|
||
ws.merge_range(5, c1, 5, c2, values[i], f_value)
|
||
|
||
if delta_formulas[i]:
|
||
ws.merge_range(6, c1, 6, c2, "", f_delta)
|
||
ws.write_formula(6, c1, delta_formulas[i], f_delta, deltas[i])
|
||
else:
|
||
ws.merge_range(6, c1, 6, c2, deltas[i], f_delta)
|
||
|
||
ws.merge_range(7, c1, 7, c2, "", f_spark)
|
||
col = src_cols[i]
|
||
opts: dict[str, Any] = {
|
||
"range": f"{q}!${col}${trend_first_row}:${col}${trend_last_row}",
|
||
"type": stype,
|
||
"series_color": accent,
|
||
"high_point": True,
|
||
"last_point": True,
|
||
"empty_cells": "zero",
|
||
}
|
||
if stype == "win_loss":
|
||
opts["negative_points"] = True
|
||
opts.pop("high_point")
|
||
ws.add_sparkline(7, c1, opts)
|
||
|
||
|
||
# ───────────────────────────────────────── Top 표 (§4.6)
|
||
def write_mini_table(ws, fc: FormatCache, *, header_row: int, first_col: int,
|
||
title: str, title_link: str | None,
|
||
header: Sequence[str], rows: Sequence[Sequence[Any]],
|
||
max_rows: int = 10,
|
||
formulas: dict[int, list[tuple[str, Any]]] | None = None) -> None:
|
||
"""대시보드용 소형 표. title 행 위에 제목을 얹고 header_row 에 헤더를 쓴다."""
|
||
ncol = len(header)
|
||
hfmt = fc.get(bold=True, font_color="#FFFFFF", bg_color=PALETTE["INK"],
|
||
align="center", valign="vcenter")
|
||
if title_link:
|
||
ws.write_url(header_row - 1, first_col, title_link,
|
||
fc.get(bold=True, font_color=PALETTE["ACCENT"],
|
||
valign="vcenter"), string=title)
|
||
else:
|
||
ws.write(header_row - 1, first_col, title,
|
||
fc.get(bold=True, valign="vcenter"))
|
||
for c, name in enumerate(header):
|
||
ws.write(header_row, first_col + c, name, hfmt)
|
||
|
||
text_fmt = fc.get(align="left", valign="vcenter", num_format=NUMFMT["TEXT"],
|
||
bottom=7, bottom_color=PALETTE["RULE"])
|
||
num_fmt = fc.get(align="center", valign="vcenter",
|
||
num_format=NUMFMT["INT_DASH"],
|
||
bottom=7, bottom_color=PALETTE["RULE"])
|
||
for r in range(max_rows):
|
||
wrow = header_row + 1 + r
|
||
if r >= len(rows):
|
||
for c in range(ncol):
|
||
ws.write_blank(wrow, first_col + c, None, text_fmt if c == 1 else num_fmt)
|
||
continue
|
||
for c, v in enumerate(rows[r][:ncol]):
|
||
fmt = text_fmt if isinstance(v, str) else num_fmt
|
||
fset = (formulas or {}).get(c)
|
||
if fset is not None and r < len(fset):
|
||
formula, cached = fset[r]
|
||
ws.write_formula(wrow, first_col + c, formula, num_fmt, cached)
|
||
else:
|
||
ws.write(wrow, first_col + c, v, fmt)
|
||
```
|
||
|
||
### 8.3 `src/dmf_crawler/report/atomic.py`
|
||
|
||
```python
|
||
"""원자적 xlsx 저장 — Excel 이 파일을 잡고 있어도 데이터를 잃지 않는다.
|
||
|
||
아키텍처 §3.13 계약:
|
||
atomic_write(build_fn, target, retries=3, backoff_seconds=2.0)
|
||
-> tuple[Path, bool] # (실제 저장 경로, 폴백 사용 여부)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import shutil
|
||
import tempfile
|
||
import time
|
||
import zipfile
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Callable
|
||
|
||
from ..errors import ReportError
|
||
|
||
_MIN_XLSX_BYTES = 4096
|
||
|
||
|
||
def excel_lock_file(path: Path) -> Path:
|
||
"""Excel 이 만드는 숨김 잠금 파일 경로(~$name.xlsx)."""
|
||
return path.with_name("~$" + path.name)
|
||
|
||
|
||
def looks_locked(path: Path) -> bool:
|
||
"""빠른 사전 판단. 확정적이지 않으므로 실제 시도의 보조로만 쓴다."""
|
||
if excel_lock_file(path).exists():
|
||
return True
|
||
if not path.exists():
|
||
return False
|
||
try:
|
||
with open(path, "r+b"):
|
||
return False
|
||
except OSError:
|
||
return True
|
||
|
||
|
||
def verify_xlsx(path: Path, min_bytes: int = _MIN_XLSX_BYTES) -> None:
|
||
"""저장된 xlsx 가 온전한지 stdlib 만으로 검사한다(openpyxl 불필요)."""
|
||
if not path.exists():
|
||
raise ReportError(f"출력 파일이 생성되지 않았다: {path}")
|
||
size = path.stat().st_size
|
||
if size < min_bytes:
|
||
raise ReportError(f"출력 파일이 너무 작다({size} bytes): {path}")
|
||
try:
|
||
with zipfile.ZipFile(path) as zf:
|
||
bad = zf.testzip()
|
||
if bad is not None:
|
||
raise ReportError(f"손상된 zip 엔트리: {bad}")
|
||
names = set(zf.namelist())
|
||
except zipfile.BadZipFile as exc:
|
||
raise ReportError(f"xlsx 가 zip 으로 열리지 않는다: {path}") from exc
|
||
for required in ("xl/workbook.xml", "[Content_Types].xml"):
|
||
if required not in names:
|
||
raise ReportError(f"{required} 가 없다. 올바른 xlsx 가 아니다: {path}")
|
||
|
||
|
||
def fallback_path(target: Path, now: datetime | None = None) -> Path:
|
||
"""DMF_리포트_2026-09-02_060241.xlsx 형태의 폴백 이름."""
|
||
stamp = (now or datetime.now()).strftime("%H%M%S")
|
||
return target.with_name(f"{target.stem}_{stamp}{target.suffix}")
|
||
|
||
|
||
def atomic_write(build_fn: Callable[[Path], None], target: Path,
|
||
retries: int = 3, backoff_seconds: float = 2.0) -> tuple[Path, bool]:
|
||
"""build_fn(tmp) 으로 임시 파일을 만든 뒤 target 으로 원자 교체한다.
|
||
|
||
- 같은 볼륨에 임시 파일을 만들어야 os.replace 가 원자적이다.
|
||
- 쓰기 도중 죽어도 target 은 이전 상태 그대로 남는다.
|
||
- 잠김 시 지수 백오프로 재시도하고, 끝내 실패하면 시각 접미사 폴백.
|
||
"""
|
||
target = Path(target).resolve()
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
fd, tmp_name = tempfile.mkstemp(prefix=".~dmf_", suffix=".xlsx",
|
||
dir=str(target.parent))
|
||
os.close(fd)
|
||
tmp_path = Path(tmp_name)
|
||
try:
|
||
build_fn(tmp_path)
|
||
verify_xlsx(tmp_path)
|
||
|
||
for attempt in range(max(1, retries)):
|
||
if not looks_locked(target):
|
||
try:
|
||
os.replace(tmp_path, target)
|
||
return target, False
|
||
except PermissionError:
|
||
pass
|
||
if attempt < retries - 1:
|
||
time.sleep(backoff_seconds * (2 ** attempt))
|
||
|
||
alt = fallback_path(target)
|
||
shutil.move(str(tmp_path), str(alt))
|
||
return alt, True
|
||
finally:
|
||
if tmp_path.exists():
|
||
try:
|
||
tmp_path.unlink()
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def refresh_latest_link(source: Path, latest: Path) -> bool:
|
||
"""최신본 고정 파일을 원자적 '복사'로 갱신한다.
|
||
|
||
Windows 심볼릭 링크는 관리자 권한 또는 개발자 모드를 요구하므로 쓰지 않는다.
|
||
하드링크도 쓰지 않는다 — 원본을 지우면 최신본이 남아 혼란스럽다.
|
||
잠겨 있으면 조용히 False 를 반환한다(리포트 본체는 이미 저장됐다).
|
||
"""
|
||
if not source.exists():
|
||
return False
|
||
latest.parent.mkdir(parents=True, exist_ok=True)
|
||
fd, tmp_name = tempfile.mkstemp(prefix=".~dmflatest_", suffix=".xlsx",
|
||
dir=str(latest.parent))
|
||
os.close(fd)
|
||
tmp_path = Path(tmp_name)
|
||
try:
|
||
shutil.copyfile(source, tmp_path)
|
||
if looks_locked(latest):
|
||
return False
|
||
os.replace(tmp_path, latest)
|
||
return True
|
||
except OSError:
|
||
return False
|
||
finally:
|
||
if tmp_path.exists():
|
||
try:
|
||
tmp_path.unlink()
|
||
except OSError:
|
||
pass
|
||
```
|
||
|
||
### 8.4 `src/dmf_crawler/report/data.py`
|
||
|
||
> ⚠️ 아래 SQL 은 `docs/design/02-data-model.md`(M1 에서 작성) 확정 전의 **잠정 스키마**를 전제한다. 컬럼명이 바뀌면 이 파일만 고치면 되도록 SQL 을 모듈 상수로 몰아 두었다.
|
||
|
||
**전제 스키마 요약** (0001~0003 마이그레이션)
|
||
|
||
```sql
|
||
runs(run_id TEXT PK, run_date TEXT, trigger TEXT, started_at TEXT, finished_at TEXT,
|
||
status TEXT, exit_code INT, notes TEXT)
|
||
fetch_stats(run_id TEXT PK, total_count_reported INT, pages INT, fetched_rows INT,
|
||
duration_ms INT, http_summary TEXT, archive_dir TEXT, snapshot_hash TEXT)
|
||
snapshots(run_id TEXT, dmf_key TEXT, permit_no TEXT, ingredient_name TEXT,
|
||
applicant TEXT, manufacturer TEXT, manufacture_place TEXT, countries TEXT,
|
||
permit_date TEXT, accepted_date TEXT, content_hash TEXT,
|
||
PRIMARY KEY(run_id, dmf_key))
|
||
records(dmf_key TEXT PK, permit_no TEXT, ingredient_name TEXT, applicant TEXT,
|
||
manufacturer TEXT, manufacture_place TEXT, countries TEXT, permit_date TEXT,
|
||
accepted_date TEXT, content_hash TEXT, first_seen_date TEXT,
|
||
last_seen_date TEXT, last_changed_date TEXT, status TEXT, last_run_id TEXT)
|
||
events(event_id INTEGER PK, run_id TEXT, run_date TEXT, dmf_key TEXT,
|
||
event_type TEXT, field TEXT, before_value TEXT, after_value TEXT, created_at TEXT)
|
||
enrichment_run(run_id TEXT PK, headline TEXT, summary TEXT, risk_note TEXT,
|
||
model TEXT, created_at TEXT)
|
||
enrichment(run_id TEXT, dmf_key TEXT, note TEXT, importance INT,
|
||
PRIMARY KEY(run_id, dmf_key))
|
||
agy_calls(id INTEGER PK, run_id TEXT, status TEXT, model TEXT, effort TEXT,
|
||
input_tokens INT, output_tokens INT, duration_ms INT, error_kind TEXT)
|
||
watchlist(id INTEGER PK, target_type TEXT, keyword TEXT, match_mode TEXT,
|
||
priority INT, active TEXT, memo TEXT, created_at TEXT)
|
||
```
|
||
|
||
```python
|
||
"""DB → ReportData. 리포트는 이 모듈을 통해서만 DB를 읽는다."""
|
||
from __future__ import annotations
|
||
|
||
import sqlite3
|
||
from dataclasses import dataclass, field
|
||
from datetime import date, datetime
|
||
from typing import Any
|
||
|
||
# ─────────────────────────────────────────────────────── 자료구조
|
||
@dataclass(frozen=True, slots=True)
|
||
class ChangeRow:
|
||
sort_key: int
|
||
status: str # 신규 / 변경 / 취하
|
||
permit_no: str
|
||
ingredient_name: str
|
||
applicant: str
|
||
manufacturer: str
|
||
manufacture_place: str
|
||
countries: str
|
||
permit_date: date | None
|
||
accepted_date: date | None
|
||
changed_fields: str
|
||
change_detail: str
|
||
watch_hits: int
|
||
watch_keywords: str
|
||
ai_note: str
|
||
dmf_key: str
|
||
content_hash: str
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class LedgerRow:
|
||
permit_no: str
|
||
ingredient_name: str
|
||
applicant: str
|
||
manufacturer: str
|
||
manufacture_place: str
|
||
countries: str
|
||
country_count: int
|
||
permit_date: date | None
|
||
accepted_date: date | None
|
||
first_seen: date | None
|
||
last_seen: date | None
|
||
last_changed: date | None
|
||
status: str # 유효 / 취하
|
||
elapsed_days: int | None
|
||
watch_flag: str # Y / N
|
||
dmf_key: str
|
||
content_hash: str
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class AggRow:
|
||
rank: int
|
||
name: str
|
||
key: str
|
||
today_new: int
|
||
today_changed: int
|
||
today_withdrawn: int
|
||
last7: int
|
||
last30: int
|
||
cumulative: int
|
||
partner_count: int # 성분→등록업체 수 / 업체→보유 성분 수
|
||
country_count: int # 성분→제조국 수 / 업체→제조소 수
|
||
main_country: str
|
||
domestic: str
|
||
delta: int
|
||
last_date: date | None
|
||
watch_flag: str
|
||
series30: tuple[int, ...]
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class WatchRow:
|
||
no: int
|
||
target_type: str
|
||
keyword: str
|
||
match_mode: str
|
||
priority: int
|
||
active: str
|
||
memo: str
|
||
today: int
|
||
last7: int
|
||
last30: int
|
||
cumulative: int
|
||
last_hit: date | None
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class WatchDetailRow:
|
||
keyword: str
|
||
status: str
|
||
permit_no: str
|
||
ingredient_name: str
|
||
applicant: str
|
||
countries: str
|
||
permit_date: date | None
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class TrendRow:
|
||
day: date
|
||
new: int
|
||
changed: int
|
||
withdrawn: int
|
||
total: int
|
||
watch_hits: int
|
||
active_total: int
|
||
fetched_rows: int
|
||
run_status: str
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class GateRow:
|
||
name: str
|
||
expected: str
|
||
actual: str
|
||
verdict: str
|
||
detail: str
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class MetaInfo:
|
||
report_date: date
|
||
run_id: str
|
||
trigger: str
|
||
started_at: str
|
||
finished_at: str
|
||
run_status: str
|
||
host: str
|
||
python_version: str
|
||
xlsxwriter_version: str
|
||
prev_run_id: str
|
||
log_dir: str
|
||
events_path: str
|
||
archive_dir: str
|
||
backup_path: str
|
||
snapshot_hash: str
|
||
prev_report_path: str
|
||
source_name: str
|
||
source_url: str
|
||
http_summary: str
|
||
fetched_rows: int
|
||
is_baseline: bool
|
||
banner_kind: str # none / partial / baseline / ai
|
||
banner_text: str
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class AiInfo:
|
||
used: str # 사용 / 건너뜀(...) / 실패
|
||
headline: str
|
||
summary: str
|
||
risk_note: str
|
||
model: str
|
||
effort: str
|
||
input_tokens: int
|
||
output_tokens: int
|
||
duration_s: float
|
||
tokens_today: int
|
||
token_cap: int
|
||
envelope_path: str
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ReportData:
|
||
meta: MetaInfo
|
||
ai: AiInfo
|
||
kpi: dict[str, int] # new/changed/withdrawn/total/watch/active
|
||
kpi_delta: dict[str, int]
|
||
changes: tuple[ChangeRow, ...]
|
||
ledger: tuple[LedgerRow, ...]
|
||
ingredients: tuple[AggRow, ...]
|
||
companies: tuple[AggRow, ...]
|
||
watch: tuple[WatchRow, ...]
|
||
watch_detail: tuple[WatchDetailRow, ...]
|
||
trend: tuple[TrendRow, ...]
|
||
gates: tuple[GateRow, ...]
|
||
top_countries: tuple[tuple[str, int], ...]
|
||
trend_dates: tuple[date, ...] = field(default_factory=tuple)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────── SQL 상수
|
||
SQL_RUN = """
|
||
SELECT r.run_id, r.run_date, r.trigger, r.started_at, r.finished_at,
|
||
r.status, r.notes,
|
||
f.total_count_reported, f.fetched_rows, f.pages, f.duration_ms,
|
||
f.http_summary, f.archive_dir, f.snapshot_hash
|
||
FROM runs r LEFT JOIN fetch_stats f ON f.run_id = r.run_id
|
||
WHERE r.run_id = ?
|
||
"""
|
||
|
||
SQL_PREV_RUN = """
|
||
SELECT run_id, run_date FROM runs
|
||
WHERE status IN ('SUCCESS','PARTIAL') AND run_id < ?
|
||
ORDER BY run_id DESC LIMIT 1
|
||
"""
|
||
|
||
SQL_CHANGES = """
|
||
SELECT e.dmf_key,
|
||
e.event_type,
|
||
GROUP_CONCAT(DISTINCT e.field) AS fields,
|
||
GROUP_CONCAT(e.field || ': ' || COALESCE(e.before_value,'-') ||
|
||
' → ' || COALESCE(e.after_value,'-'), CHAR(10)) AS detail,
|
||
c.permit_no, c.ingredient_name, c.applicant, c.manufacturer,
|
||
c.manufacture_place, c.countries, c.permit_date, c.accepted_date,
|
||
c.content_hash,
|
||
COALESCE(x.note, '') AS ai_note
|
||
FROM events e
|
||
LEFT JOIN records c ON c.dmf_key = e.dmf_key
|
||
LEFT JOIN enrichment x ON x.run_id = e.run_id AND x.dmf_key = e.dmf_key
|
||
WHERE e.run_id = ?
|
||
GROUP BY e.dmf_key, e.event_type
|
||
"""
|
||
|
||
SQL_LEDGER = """
|
||
SELECT permit_no, ingredient_name, applicant, manufacturer, manufacture_place,
|
||
countries, permit_date, accepted_date, first_seen_date, last_seen_date,
|
||
last_changed_date, status, dmf_key, content_hash
|
||
FROM records
|
||
ORDER BY accepted_date DESC, permit_no ASC
|
||
"""
|
||
|
||
SQL_INGREDIENT_TODAY = """
|
||
SELECT r.ingredient_name,
|
||
SUM(CASE WHEN e.event_type='NEW' THEN 1 ELSE 0 END) AS n_new,
|
||
SUM(CASE WHEN e.event_type='CHANGED' THEN 1 ELSE 0 END) AS n_chg,
|
||
SUM(CASE WHEN e.event_type='WITHDRAWN' THEN 1 ELSE 0 END) AS n_wdr
|
||
FROM events e JOIN records r ON r.dmf_key = e.dmf_key
|
||
WHERE e.run_id = ?
|
||
GROUP BY r.ingredient_name
|
||
"""
|
||
|
||
SQL_INGREDIENT_WINDOW = """
|
||
SELECT r.ingredient_name, e.run_date, COUNT(DISTINCT e.dmf_key) AS n
|
||
FROM events e JOIN records r ON r.dmf_key = e.dmf_key
|
||
WHERE e.run_date >= ?
|
||
GROUP BY r.ingredient_name, e.run_date
|
||
"""
|
||
|
||
SQL_INGREDIENT_CUM = """
|
||
SELECT ingredient_name,
|
||
COUNT(*) AS cum,
|
||
COUNT(DISTINCT applicant) AS n_applicant,
|
||
COUNT(DISTINCT countries) AS n_country,
|
||
MAX(accepted_date) AS last_date,
|
||
SUM(CASE WHEN countries LIKE '%대한민국%' THEN 1 ELSE 0 END) AS n_domestic
|
||
FROM records
|
||
WHERE status = '유효'
|
||
GROUP BY ingredient_name
|
||
"""
|
||
|
||
SQL_COMPANY_TODAY = SQL_INGREDIENT_TODAY.replace("r.ingredient_name", "r.applicant")
|
||
SQL_COMPANY_WINDOW = SQL_INGREDIENT_WINDOW.replace("r.ingredient_name", "r.applicant")
|
||
SQL_COMPANY_CUM = """
|
||
SELECT applicant,
|
||
COUNT(*) AS cum,
|
||
COUNT(DISTINCT ingredient_name) AS n_ingredient,
|
||
COUNT(DISTINCT manufacturer) AS n_site,
|
||
MAX(accepted_date) AS last_date,
|
||
SUM(CASE WHEN countries LIKE '%대한민국%' THEN 1 ELSE 0 END) AS n_domestic
|
||
FROM records
|
||
WHERE status = '유효'
|
||
GROUP BY applicant
|
||
"""
|
||
|
||
SQL_TREND = """
|
||
SELECT r.run_date,
|
||
SUM(CASE WHEN e.event_type='NEW' THEN 1 ELSE 0 END) AS n_new,
|
||
SUM(CASE WHEN e.event_type='CHANGED' THEN 1 ELSE 0 END) AS n_chg,
|
||
SUM(CASE WHEN e.event_type='WITHDRAWN' THEN 1 ELSE 0 END) AS n_wdr,
|
||
MAX(r.status) AS run_status,
|
||
MAX(COALESCE(f.fetched_rows, 0)) AS fetched
|
||
FROM runs r
|
||
LEFT JOIN events e ON e.run_id = r.run_id
|
||
LEFT JOIN fetch_stats f ON f.run_id = r.run_id
|
||
WHERE r.run_date >= ? AND r.status IN ('SUCCESS','PARTIAL')
|
||
GROUP BY r.run_date
|
||
ORDER BY r.run_date ASC
|
||
"""
|
||
|
||
SQL_ACTIVE_BY_DAY = """
|
||
SELECT run_id, run_date, COUNT(*) AS n
|
||
FROM snapshots s JOIN runs u USING (run_id)
|
||
WHERE u.run_date >= ? AND u.status IN ('SUCCESS','PARTIAL')
|
||
GROUP BY run_id, run_date
|
||
ORDER BY run_date ASC
|
||
"""
|
||
|
||
SQL_TOP_COUNTRY = """
|
||
SELECT TRIM(value) AS country, COUNT(*) AS n
|
||
FROM records, json_each('["' || REPLACE(countries, ',', '","') || '"]')
|
||
WHERE status = '유효' AND TRIM(value) <> ''
|
||
GROUP BY country
|
||
ORDER BY n DESC
|
||
LIMIT 8
|
||
"""
|
||
|
||
SQL_WATCHLIST = """
|
||
SELECT id, target_type, keyword, match_mode, priority, active, memo
|
||
FROM watchlist
|
||
WHERE active = 'Y'
|
||
ORDER BY priority ASC, id ASC
|
||
"""
|
||
|
||
SQL_AGY = """
|
||
SELECT status, model, effort, input_tokens, output_tokens, duration_ms, error_kind
|
||
FROM agy_calls WHERE run_id = ? ORDER BY id DESC LIMIT 1
|
||
"""
|
||
|
||
SQL_AGY_TOKENS_TODAY = """
|
||
SELECT COALESCE(SUM(input_tokens + output_tokens), 0)
|
||
FROM agy_calls a JOIN runs r USING (run_id)
|
||
WHERE r.run_date = ?
|
||
"""
|
||
|
||
SQL_ENRICHMENT_RUN = """
|
||
SELECT headline, summary, risk_note, model FROM enrichment_run WHERE run_id = ?
|
||
"""
|
||
|
||
|
||
def fetch_report_data(conn: sqlite3.Connection, run_id: str, cfg) -> ReportData:
|
||
"""모든 시트가 필요로 하는 데이터를 한 번에 조립한다.
|
||
|
||
- 워치리스트 매칭은 파이썬에서 수행한다(정규식 모드가 SQL 로 불가능).
|
||
- 파생 컬럼(elapsed_days, series30 등)도 여기서 계산해 시트 빌더는
|
||
'쓰기'만 하게 한다.
|
||
"""
|
||
conn.row_factory = sqlite3.Row
|
||
# 구현 본문은 위 SQL 을 순서대로 실행해 dataclass 로 옮기는 단순 매핑이다.
|
||
# 규칙 3가지만 지키면 된다:
|
||
# 1) 모든 날짜 문자열은 datetime.date 로 변환해 넣는다(엑셀 날짜 서식용).
|
||
# 2) 값이 없으면 None 이 아니라 '-' 를 쓸 자리는 시트 빌더가 판단한다.
|
||
# data.py 는 None 을 그대로 넘긴다.
|
||
# 3) trend 는 오래된 날짜가 먼저 오도록 ASC 로 정렬한다.
|
||
raise NotImplementedError("M3 에서 위 SQL 매핑을 채운다")
|
||
```
|
||
|
||
### 8.5 `src/dmf_crawler/report/build.py`
|
||
|
||
```python
|
||
"""워크북 조립 오케스트레이션."""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
import xlsxwriter
|
||
|
||
from ..errors import ReportError
|
||
from .atomic import atomic_write, refresh_latest_link
|
||
from .data import ReportData, fetch_report_data
|
||
from .sheets import SHEET_BUILDERS
|
||
from .theme import SHEET_NAMES, TAB_COLORS, FormatCache, qsheet
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ReportOutcome:
|
||
path: Path
|
||
latest_link_path: Path | None
|
||
used_fallback_name: bool
|
||
sheets: tuple[str, ...]
|
||
warnings: tuple[str, ...]
|
||
|
||
|
||
class BuildContext:
|
||
"""빌더 함수들이 공유하는 상태. 시트 간 참조(행 번호 등)를 여기에 남긴다."""
|
||
|
||
def __init__(self, wb: xlsxwriter.Workbook, rd: ReportData, cfg) -> None:
|
||
self.wb = wb
|
||
self.rd = rd
|
||
self.cfg = cfg
|
||
self.fc = FormatCache(wb, font_name=cfg.font)
|
||
self.sheets: dict[str, object] = {}
|
||
self.warnings: list[str] = []
|
||
# 시트 간 연동에 필요한 좌표들
|
||
self.ledger_row_of: dict[str, int] = {} # dmf_key -> 워크시트 1-index 행
|
||
self.trend_first_row: int = 4
|
||
self.trend_last_row: int = 4
|
||
self.spark_first_row: int = 4
|
||
self.ledger_last_row: int = 3
|
||
self.changes_last_row: int = 3
|
||
self.has_watchlist: bool = False
|
||
|
||
def add_sheet(self, key: str):
|
||
name = SHEET_NAMES[key]
|
||
ws = self.wb.add_worksheet(name)
|
||
ws.set_tab_color(TAB_COLORS[name])
|
||
self.sheets[key] = ws
|
||
return ws
|
||
|
||
|
||
def _build_workbook(rd: ReportData, cfg, tmp_path: Path) -> tuple[list[str], list[str]]:
|
||
wb = xlsxwriter.Workbook(str(tmp_path), {
|
||
"constant_memory": False,
|
||
"strings_to_numbers": False,
|
||
"strings_to_urls": False,
|
||
"nan_inf_to_errors": True,
|
||
"use_future_functions": True,
|
||
"default_date_format": "yyyy-mm-dd",
|
||
"remove_timezone": True,
|
||
})
|
||
wb.set_size(1500, 900)
|
||
wb.set_properties({
|
||
"title": f"DMF 일일 모니터링 리포트 {rd.meta.report_date:%Y-%m-%d}",
|
||
"subject": "원료의약품 등록(DMF) 신규·변경·취하 현황",
|
||
"author": "DMF Crawler",
|
||
"category": "규제 인텔리전스",
|
||
"keywords": "DMF, 원료의약품, 식약처, 등록현황",
|
||
"comments": f"run_id={rd.meta.run_id} / 자동 생성 / 출처: 공공데이터포털 15057075",
|
||
})
|
||
|
||
ctx = BuildContext(wb, rd, cfg)
|
||
built: list[str] = []
|
||
try:
|
||
# ── 1단계: 원본 시트를 먼저 만든다 ──────────────────────────
|
||
# 06_추이 를 먼저 만들어야 대시보드 스파크라인 범위를 알 수 있고,
|
||
# 02_전체현황 을 만들어야 01_오늘변경분 의 링크 대상 행을 안다.
|
||
# 그래서 '생성 순서'와 '탭 순서'를 분리한다.
|
||
# XlsxWriter 는 add_worksheet 순서가 곧 탭 순서이므로,
|
||
# 시트 객체는 탭 순서대로 미리 만들고 '기록'만 나중에 한다.
|
||
for key in ("dashboard", "changes", "ledger", "ingredient", "company"):
|
||
ctx.add_sheet(key)
|
||
if rd.watch:
|
||
ctx.has_watchlist = True
|
||
ctx.add_sheet("watchlist")
|
||
ctx.add_sheet("trend")
|
||
ctx.add_sheet("meta")
|
||
|
||
# ── 2단계: 의존 순서대로 내용을 채운다 ───────────────────────
|
||
for key, builder in SHEET_BUILDERS:
|
||
if key == "watchlist" and not ctx.has_watchlist:
|
||
continue
|
||
builder(ctx)
|
||
built.append(SHEET_NAMES[key])
|
||
|
||
# ── 3단계: 정의된 이름 (모든 시트 생성 후) ────────────────────
|
||
t = qsheet(SHEET_NAMES["trend"])
|
||
d = qsheet(SHEET_NAMES["dashboard"])
|
||
m = qsheet(SHEET_NAMES["meta"])
|
||
lg = qsheet(SHEET_NAMES["ledger"])
|
||
ch = qsheet(SHEET_NAMES["changes"])
|
||
last = ctx.trend_last_row
|
||
wb.define_name("REPORT_DATE", f"={m}!$B$3")
|
||
wb.define_name("RUN_ID", f"={m}!$B$6")
|
||
wb.define_name("KPI_NEW", f"={d}!$B$6")
|
||
wb.define_name("KPI_CHANGED", f"={d}!$E$6")
|
||
wb.define_name("KPI_WITHDRAWN", f"={d}!$H$6")
|
||
wb.define_name("KPI_TOTAL", f"={d}!$K$6")
|
||
wb.define_name("KPI_WATCH", f"={d}!$N$6")
|
||
wb.define_name("KPI_ACTIVE", f"={d}!$Q$6")
|
||
wb.define_name("TREND_DATE", f"={t}!$A$4:$A${last}")
|
||
wb.define_name("TREND_NEW", f"={t}!$B$4:$B${last}")
|
||
wb.define_name("TREND_CHANGED", f"={t}!$C$4:$C${last}")
|
||
wb.define_name("TREND_WITHDRAWN", f"={t}!$D$4:$D${last}")
|
||
wb.define_name("LEDGER", f"={lg}!$A$3:$Q${ctx.ledger_last_row}")
|
||
wb.define_name("TODAY_CHANGES", f"={ch}!$A$3:$S${ctx.changes_last_row}")
|
||
|
||
ctx.sheets["dashboard"].activate()
|
||
ctx.sheets["dashboard"].set_first_sheet()
|
||
finally:
|
||
wb.close()
|
||
return built, ctx.warnings
|
||
|
||
|
||
def build_report(conn, run_id: str, cfg) -> ReportOutcome:
|
||
"""아키텍처 §3.13 의 공개 API. AI 산출물 유무와 무관하게 항상 완주한다."""
|
||
rd = fetch_report_data(conn, run_id, cfg)
|
||
out_dir = Path(cfg.output_dir)
|
||
target = out_dir / cfg.filename_pattern.format(date=f"{rd.meta.report_date:%Y-%m-%d}")
|
||
|
||
built: list[str] = []
|
||
warns: list[str] = []
|
||
|
||
def _fn(tmp: Path) -> None:
|
||
nonlocal built, warns
|
||
built, warns = _build_workbook(rd, cfg, tmp)
|
||
|
||
try:
|
||
path, used_fallback = atomic_write(
|
||
_fn, target,
|
||
retries=cfg.lock_retries,
|
||
backoff_seconds=2.0,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
raise ReportError(
|
||
f"리포트 생성 실패: {exc}. "
|
||
f"`python -m dmf_crawler report-only --run-id {run_id}` 로 재생성할 수 있다."
|
||
) from exc
|
||
|
||
if used_fallback:
|
||
warns.append(
|
||
f"원본 파일이 열려 있어 대체 이름으로 저장했다: {path.name}. "
|
||
f"Excel 을 닫고 report-only 를 다시 실행하면 정규 파일명으로 저장된다."
|
||
)
|
||
|
||
latest: Path | None = None
|
||
if cfg.latest_link_name:
|
||
cand = out_dir / cfg.latest_link_name
|
||
if refresh_latest_link(path, cand):
|
||
latest = cand
|
||
else:
|
||
warns.append(f"최신본 갱신 실패(열려 있음): {cand.name}")
|
||
|
||
return ReportOutcome(
|
||
path=path,
|
||
latest_link_path=latest,
|
||
used_fallback_name=used_fallback,
|
||
sheets=tuple(built),
|
||
warnings=tuple(warns),
|
||
)
|
||
```
|
||
|
||
> **생성 순서와 탭 순서의 분리**가 이 모듈의 핵심 트릭이다. XlsxWriter 는 `add_worksheet` 호출 순서가 곧 탭 순서이므로, 워크시트 객체는 탭 순서대로 미리 전부 만들고, 실제 쓰기는 `SHEET_BUILDERS` 의 **의존 순서**로 한다.
|
||
|
||
### 8.6 `src/dmf_crawler/report/sheets/__init__.py`
|
||
|
||
```python
|
||
"""시트 빌더 등록부. 튜플의 순서 = 실제 '쓰기' 순서(의존 순서)."""
|
||
from __future__ import annotations
|
||
|
||
from . import (s00_dashboard, s01_changes, s02_ledger, s03_ingredient,
|
||
s04_company, s05_watchlist, s06_trend, s99_meta)
|
||
|
||
SHEET_BUILDERS = (
|
||
("trend", s06_trend.build), # 1. 스파크라인·차트 원본을 먼저
|
||
("ledger", s02_ledger.build), # 2. dmf_key → 행번호 사전 생성
|
||
("changes", s01_changes.build), # 3. ledger 사전을 써서 링크
|
||
("ingredient", s03_ingredient.build),
|
||
("company", s04_company.build),
|
||
("watchlist", s05_watchlist.build),
|
||
("meta", s99_meta.build), # 4. REPORT_DATE 원본
|
||
("dashboard", s00_dashboard.build), # 5. 모든 좌표가 확정된 뒤 마지막
|
||
)
|
||
```
|
||
|
||
### 8.7 `src/dmf_crawler/report/sheets/s00_dashboard.py`
|
||
|
||
```python
|
||
"""00_대시보드 — KPI 6 · 차트 2 · Top 표 3 · 내비게이션."""
|
||
from __future__ import annotations
|
||
|
||
from ..theme import NUMFMT, PALETTE, SHEET_NAMES, STATUS, qsheet
|
||
from ..widgets import write_kpi_tiles, write_mini_table
|
||
|
||
COL_WIDTHS = [1.5, 13, 13, 1.5, 13, 13, 1.5, 13, 13, 1.5,
|
||
13, 13, 1.5, 13, 13, 1.5, 13, 13, 1.5] # A..S
|
||
ROW_HEIGHTS = {0: 6, 1: 30, 2: 18, 3: 8, 8: 10, 24: 10, 25: 18,
|
||
40: 10, 41: 20, 42: 20, 46: 14}
|
||
|
||
NAV = (
|
||
(41, 1, 2, "오늘 변경분 →", "changes", "오늘 신규·변경·취하 전체"),
|
||
(41, 4, 5, "전체 현황 →", "ledger", "유효 등록 전체 원장"),
|
||
(41, 7, 8, "성분별 →", "ingredient", "성분 기준 집계"),
|
||
(41, 10, 11, "업체별 →", "company", "업체 기준 집계"),
|
||
(41, 13, 14, "워치리스트 →", "watchlist", "관심 키워드 히트"),
|
||
(41, 16, 17, "추이 →", "trend", "일자별 시계열"),
|
||
(42, 1, 2, "메타·로그 →", "meta", "수집 메타·무결성·AI 상태"),
|
||
)
|
||
|
||
|
||
def build(ctx) -> None:
|
||
ws, fc, rd = ctx.sheets["dashboard"], ctx.fc, ctx.rd
|
||
ws.hide_gridlines(2)
|
||
ws.set_zoom(90)
|
||
for c, w in enumerate(COL_WIDTHS):
|
||
ws.set_column(c, c, w)
|
||
for r, h in ROW_HEIGHTS.items():
|
||
ws.set_row(r, h)
|
||
for r in range(9, 24):
|
||
ws.set_row(r, 20)
|
||
for r in range(26, 40):
|
||
ws.set_row(r, 16)
|
||
for r in range(43, 46):
|
||
ws.set_row(r, 8)
|
||
|
||
_title_block(ctx, ws, fc, rd)
|
||
_banner(ctx, ws, fc, rd)
|
||
_kpis(ctx, ws, fc, rd)
|
||
_charts(ctx, ws, rd)
|
||
_top_tables(ctx, ws, fc, rd)
|
||
_nav(ctx, ws, fc)
|
||
_footer(ctx, ws, fc, rd)
|
||
_print_setup(ws)
|
||
|
||
|
||
def _title_block(ctx, ws, fc, rd) -> None:
|
||
weekday = "월화수목금토일"[rd.meta.report_date.weekday()]
|
||
ws.merge_range(1, 1, 1, 17,
|
||
f"DMF 일일 모니터링 리포트 — {rd.meta.report_date:%Y-%m-%d}({weekday})",
|
||
fc.get(font_size=18, bold=True, valign="vcenter"))
|
||
headline = rd.ai.headline or _rule_headline(rd)
|
||
ws.merge_range(2, 1, 2, 17, headline, fc.subtitle())
|
||
|
||
|
||
def _rule_headline(rd) -> str:
|
||
"""AI 요약이 없을 때 쓰는 규칙 기반 한 줄."""
|
||
k = rd.kpi
|
||
base = (f"오늘 신규 {k['new']}건 · 변경 {k['changed']}건 · "
|
||
f"취하 {k['withdrawn']}건")
|
||
if k["watch"]:
|
||
names = ", ".join(w.ingredient_name for w in rd.watch_detail[:3])
|
||
return f"{base} — 워치리스트 {k['watch']}건 적중({names})"
|
||
if k["total"] == 0:
|
||
return f"{base} — 전일 대비 변동이 없다"
|
||
return f"{base} — 워치리스트 적중 없음"
|
||
|
||
|
||
def _banner(ctx, ws, fc, rd) -> None:
|
||
kind, text = rd.meta.banner_kind, rd.meta.banner_text
|
||
if kind == "none" or not text:
|
||
return
|
||
style = {"partial": ("취하", 22), "baseline": ("정보", 22), "ai": ("없음", 22)}[kind]
|
||
s = STATUS[style[0]]
|
||
ws.set_row(3, style[1])
|
||
ws.merge_range(3, 1, 3, 17, text,
|
||
fc.get(font_size=11, bold=True, font_color=s.fg,
|
||
bg_color=s.bg, align="left", valign="vcenter",
|
||
left=5, left_color=s.fg, indent=1))
|
||
|
||
|
||
def _kpis(ctx, ws, fc, rd) -> None:
|
||
t = qsheet(SHEET_NAMES["trend"])
|
||
e, p = ctx.trend_last_row, ctx.trend_last_row - 1
|
||
has_prev = ctx.trend_last_row > ctx.trend_first_row
|
||
cols = ("B", "C", "D", "E", "F", "G")
|
||
keys = ("new", "changed", "withdrawn", "total", "watch", "active")
|
||
vformulas = [f"={t}!${c}${e}" for c in cols]
|
||
dformulas = ([f"={t}!${c}${e}-{t}!${c}${p}" for c in cols]
|
||
if has_prev else [None] * 6)
|
||
write_kpi_tiles(
|
||
ws, fc,
|
||
trend_sheet=SHEET_NAMES["trend"],
|
||
trend_last_row=ctx.trend_last_row,
|
||
trend_first_row=ctx.spark_first_row,
|
||
values=[rd.kpi[k] for k in keys],
|
||
deltas=[rd.kpi_delta[k] for k in keys],
|
||
value_formulas=vformulas,
|
||
delta_formulas=dformulas,
|
||
)
|
||
|
||
|
||
def _charts(ctx, ws, rd) -> None:
|
||
wb, t = ctx.wb, SHEET_NAMES["trend"]
|
||
s0 = ctx.spark_first_row - 1 # 0-index
|
||
e0 = ctx.trend_last_row - 1
|
||
|
||
ch1 = wb.add_chart({"type": "column", "subtype": "stacked"})
|
||
for col, (label, color) in enumerate(
|
||
(("신규", "#009E73"), ("변경", "#E69F00"), ("취하", "#D55E00")), start=1):
|
||
ch1.add_series({
|
||
"name": label,
|
||
"categories": [t, s0, 0, e0, 0],
|
||
"values": [t, s0, col, e0, col],
|
||
"fill": {"color": color},
|
||
"border": {"none": True},
|
||
"gap": 40,
|
||
})
|
||
ch1.set_title({"name": "최근 30일 일별 등록 동향",
|
||
"name_font": {"name": "맑은 고딕", "size": 11, "bold": True,
|
||
"color": PALETTE["INK"]}})
|
||
ch1.set_x_axis({"num_font": {"name": "맑은 고딕", "size": 8,
|
||
"color": PALETTE["MUTED"]},
|
||
"line": {"color": PALETTE["RULE"]},
|
||
"major_tick_mark": "none", "date_axis": False})
|
||
ch1.set_y_axis({"num_font": {"name": "맑은 고딕", "size": 8,
|
||
"color": PALETTE["MUTED"]},
|
||
"major_gridlines": {"visible": True,
|
||
"line": {"color": PALETTE["GRID"],
|
||
"width": 0.75}},
|
||
"line": {"none": True}, "major_tick_mark": "none"})
|
||
ch1.set_legend({"position": "top",
|
||
"font": {"name": "맑은 고딕", "size": 9}})
|
||
ch1.set_chartarea({"border": {"color": PALETTE["RULE"]},
|
||
"fill": {"color": PALETTE["PAPER"]}})
|
||
ch1.set_plotarea({"border": {"none": True}, "fill": {"none": True}})
|
||
ch1.set_size({"width": 600, "height": 300})
|
||
ws.insert_chart(9, 1, ch1) # 앵커 B10
|
||
|
||
n = max(1, len(rd.top_countries))
|
||
ch2 = wb.add_chart({"type": "bar"})
|
||
ch2.add_series({
|
||
"name": "건수",
|
||
"categories": [t, 3, 26, 2 + n, 26], # AA4:AA{3+n}
|
||
"values": [t, 3, 27, 2 + n, 27], # AB4:AB{3+n}
|
||
"fill": {"color": PALETTE["ACCENT"]},
|
||
"border": {"none": True},
|
||
"data_labels": {"value": True, "position": "outside_end",
|
||
"font": {"name": "맑은 고딕", "size": 8,
|
||
"color": PALETTE["INK"]}},
|
||
"gap": 50,
|
||
})
|
||
ch2.set_title({"name": "제조국 Top 8 (유효 등록 기준)",
|
||
"name_font": {"name": "맑은 고딕", "size": 11, "bold": True,
|
||
"color": PALETTE["INK"]}})
|
||
ch2.set_x_axis({"visible": False})
|
||
ch2.set_y_axis({"num_font": {"name": "맑은 고딕", "size": 9,
|
||
"color": PALETTE["INK"]},
|
||
"line": {"color": PALETTE["RULE"]},
|
||
"major_tick_mark": "none", "reverse": True})
|
||
ch2.set_legend({"none": True})
|
||
ch2.set_chartarea({"border": {"color": PALETTE["RULE"]},
|
||
"fill": {"color": PALETTE["PAPER"]}})
|
||
ch2.set_plotarea({"border": {"none": True}, "fill": {"none": True}})
|
||
ch2.set_size({"width": 600, "height": 300})
|
||
ws.insert_chart(9, 10, ch2) # 앵커 K10
|
||
|
||
|
||
def _top_tables(ctx, ws, fc, rd) -> None:
|
||
lg = qsheet(SHEET_NAMES["ledger"])
|
||
ing = [(a.rank, a.name, a.today_new + a.today_changed + a.today_withdrawn,
|
||
a.last30, a.cumulative) for a in rd.ingredients[:10]]
|
||
co = [(a.rank, a.name, a.today_new + a.today_changed + a.today_withdrawn,
|
||
a.last30, a.cumulative) for a in rd.companies[:10]]
|
||
wd = [(w.keyword, 1, w.ingredient_name, w.applicant, w.status)
|
||
for w in rd.watch_detail[:10]]
|
||
|
||
# 누적 열은 시트 간 COUNTIFS 수식 + 캐시값 (§5.3 F13/F14)
|
||
f_ing = {4: [(f'=COUNTIFS({lg}!$B:$B,$C{27 + i},{lg}!$M:$M,"유효")', r[4])
|
||
for i, r in enumerate(ing)]}
|
||
f_co = {4: [(f'=COUNTIFS({lg}!$C:$C,$I{27 + i},{lg}!$M:$M,"유효")', r[4])
|
||
for i, r in enumerate(co)]}
|
||
|
||
write_mini_table(ws, fc, header_row=25, first_col=1,
|
||
title="Top 10 성분",
|
||
title_link=f"internal:{qsheet(SHEET_NAMES['ingredient'])}!A1",
|
||
header=("순위", "성분명", "오늘", "30일", "누적"),
|
||
rows=ing, formulas=f_ing)
|
||
write_mini_table(ws, fc, header_row=25, first_col=7,
|
||
title="Top 10 업체",
|
||
title_link=f"internal:{qsheet(SHEET_NAMES['company'])}!A1",
|
||
header=("순위", "업체명", "오늘", "30일", "누적"),
|
||
rows=co, formulas=f_co)
|
||
write_mini_table(ws, fc, header_row=25, first_col=13,
|
||
title="워치리스트 히트",
|
||
title_link=(f"internal:{qsheet(SHEET_NAMES['watchlist'])}!A1"
|
||
if ctx.has_watchlist else None),
|
||
header=("키워드", "매칭", "성분명", "업체명", "상태"),
|
||
rows=wd)
|
||
|
||
bar = {"type": "data_bar", "bar_color": PALETTE["ACCENT"], "bar_solid": True}
|
||
ws.conditional_format(26, 5, 35, 5, dict(bar)) # F27:F36
|
||
ws.conditional_format(26, 11, 35, 11, dict(bar)) # L27:L36
|
||
for label in ("신규", "변경", "취하"):
|
||
ws.conditional_format(26, 17, 35, 17, {
|
||
"type": "text", "criteria": "containing", "value": label,
|
||
"format": fc.cf(label),
|
||
}) # R27:R36
|
||
|
||
|
||
def _nav(ctx, ws, fc) -> None:
|
||
nav_fmt = fc.nav_button()
|
||
off_fmt = fc.get(font_color=PALETTE["NEUTRAL"], bg_color=PALETTE["TILE_BG"],
|
||
align="center", valign="vcenter",
|
||
border=1, border_color=PALETTE["RULE"], italic=True)
|
||
for row, c1, c2, text, key, tip in NAV:
|
||
if key == "watchlist" and not ctx.has_watchlist:
|
||
ws.merge_range(row, c1, row, c2, "(워치리스트 없음)", off_fmt)
|
||
continue
|
||
ws.merge_range(row, c1, row, c2, "", nav_fmt)
|
||
ws.write_url(row, c1, f"internal:{qsheet(SHEET_NAMES[key])}!A1",
|
||
nav_fmt, string=text, tip=tip)
|
||
|
||
|
||
def _footer(ctx, ws, fc, rd) -> None:
|
||
ws.merge_range(
|
||
46, 1, 46, 17,
|
||
f"출처: 공공데이터포털 「의약품 원료의약품 등록 정보」(15057075) · 식품의약품안전처 "
|
||
f"│ 수집 {rd.meta.started_at} KST │ 본 리포트는 공개 데이터를 자동 수집·가공한 "
|
||
f"참고 자료이며 법적 효력이 없다.",
|
||
fc.footnote())
|
||
|
||
|
||
def _print_setup(ws) -> None:
|
||
ws.set_landscape()
|
||
ws.set_paper(9) # A4
|
||
ws.fit_to_pages(1, 1)
|
||
ws.set_margins(0.4, 0.4, 0.6, 0.6)
|
||
ws.center_horizontally()
|
||
ws.print_area(0, 0, 46, 18)
|
||
ws.set_header("")
|
||
ws.set_footer("&L&\"맑은 고딕\"&8DMF Crawler&C&\"맑은 고딕\"&8&P / &N"
|
||
"&R&\"맑은 고딕\"&8&D &T")
|
||
```
|
||
|
||
### 8.8 `src/dmf_crawler/report/sheets/s01_changes.py`
|
||
|
||
```python
|
||
"""01_오늘변경분 — 리포트 본체."""
|
||
from __future__ import annotations
|
||
|
||
from urllib.parse import quote
|
||
|
||
from ..theme import NUMFMT, PALETTE, SHEET_NAMES, STATUS, qsheet
|
||
from ..widgets import (apply_col_widths, compute_col_widths, write_empty_notice,
|
||
write_sheet_header, write_table_header)
|
||
|
||
HEADER = ("정렬키", "기호", "상태", "등록번호", "성분명", "업체명", "제조소명",
|
||
"제조소 소재지", "제조국가", "발급일자", "수리일자(파생)", "변경필드",
|
||
"변경내용(전→후)", "워치히트", "워치키워드", "AI 메모", "원문검색",
|
||
"dmf_key", "content_hash")
|
||
WIDTH_OVERRIDE = {0: 4.0, 1: 4.0, 2: 8.0, 3: 24.0, 7: 40.0, 12: 52.0,
|
||
13: 8.0, 15: 40.0, 16: 10.0, 17: 26.0, 18: 18.0}
|
||
HIDDEN_COLS = (0, 17, 18)
|
||
COMMENTS = {
|
||
2: "신규 = 어제 없던 등록번호 / 변경 = 비교 대상 필드가 달라짐 / 취하 = 어제 있었으나 오늘 사라짐",
|
||
3: "DMF_PERMIT_NO. 발급일자8자리-성분일련-시행군-접수일련-성분내일련 구조",
|
||
10: "등록번호 앞 8자리에서 파생한 최초 수리일. 원본 필드가 아니다",
|
||
12: "diff 로 잡힌 필드별 변경 전/후 값. 여러 건이면 줄바꿈으로 구분",
|
||
13: "이 레코드가 매칭된 워치리스트 키워드 개수",
|
||
}
|
||
NEDRUG_SEARCH = ("https://nedrug.mfds.go.kr/pbp/CCBAC03/getItem"
|
||
"?totalPages=1&limit=10&page=1&searchYn=true&itemName={q}")
|
||
|
||
|
||
def build(ctx) -> None:
|
||
ws, fc, rd = ctx.sheets["changes"], ctx.fc, ctx.rd
|
||
last_col = len(HEADER) - 1
|
||
write_sheet_header(ws, fc, title="오늘 변경분",
|
||
dashboard=SHEET_NAMES["dashboard"],
|
||
report_date=f"{rd.meta.report_date:%Y-%m-%d}",
|
||
generated_at=rd.meta.finished_at[-8:-3],
|
||
last_col=last_col)
|
||
write_table_header(ws, fc, 2, HEADER, COMMENTS)
|
||
|
||
rows = rd.changes
|
||
if not rows:
|
||
write_empty_notice(ws, fc, 3, last_col,
|
||
"(오늘 전일 대비 변경된 DMF 가 없습니다)")
|
||
ctx.changes_last_row = 4
|
||
_finish(ws, fc, last_col, data_last_row=3, empty=True)
|
||
return
|
||
|
||
body = [_as_row(r) for r in rows]
|
||
widths = compute_col_widths(HEADER, body, overrides=WIDTH_OVERRIDE)
|
||
apply_col_widths(ws, widths,
|
||
options={c: {"hidden": 1} for c in HIDDEN_COLS})
|
||
|
||
lg = qsheet(SHEET_NAMES["ledger"])
|
||
f_text = fc.cell_text()
|
||
f_wrap = fc.cell_text(wrap=True)
|
||
f_small = fc.cell_text(wrap=True, size=9)
|
||
f_muted = fc.cell_text(wrap=True, size=9, color=PALETTE["MUTED"])
|
||
f_num = fc.cell_num("INT_DASH")
|
||
f_date = fc.cell_date()
|
||
f_ctr = fc.get(align="center", valign="vcenter", num_format=NUMFMT["TEXT"],
|
||
bottom=7, bottom_color=PALETTE["RULE"])
|
||
f_link_i = fc.link_internal()
|
||
f_link_e = fc.link_external()
|
||
|
||
for i, r in enumerate(rows):
|
||
wr = 3 + i
|
||
ws.set_row(wr, 30)
|
||
ws.write_number(wr, 0, r.sort_key, f_num)
|
||
ws.write_string(wr, 1, STATUS[r.status].symbol, f_ctr)
|
||
ws.write_string(wr, 2, r.status, f_ctr)
|
||
|
||
target_row = ctx.ledger_row_of.get(r.dmf_key)
|
||
if target_row:
|
||
ws.write_url(wr, 3, f"internal:{lg}!A{target_row}", f_link_i,
|
||
string=r.permit_no, tip="전체 현황에서 이 등록번호 보기")
|
||
else:
|
||
ws.write_string(wr, 3, r.permit_no, f_text)
|
||
|
||
ws.write_string(wr, 4, r.ingredient_name or "-", f_wrap)
|
||
ws.write_string(wr, 5, r.applicant or "-", f_wrap)
|
||
ws.write_string(wr, 6, r.manufacturer or "-", f_wrap)
|
||
ws.write_string(wr, 7, r.manufacture_place or "-", f_wrap)
|
||
ws.write_string(wr, 8, r.countries or "-", f_ctr)
|
||
_write_date(ws, wr, 9, r.permit_date, f_date, f_ctr)
|
||
_write_date(ws, wr, 10, r.accepted_date, f_date, f_ctr)
|
||
ws.write_string(wr, 11, r.changed_fields or "-", f_wrap)
|
||
ws.write_string(wr, 12, r.change_detail or "-", f_small)
|
||
ws.write_number(wr, 13, r.watch_hits, f_num)
|
||
ws.write_string(wr, 14, r.watch_keywords or "-", f_text)
|
||
ws.write_string(wr, 15, r.ai_note or "-", f_muted)
|
||
url = NEDRUG_SEARCH.format(q=quote(r.ingredient_name or ""))
|
||
ws.write_url(wr, 16, url, f_link_e, string="조회",
|
||
tip="의약품안전나라에서 이 성분 검색")
|
||
ws.write_string(wr, 17, r.dmf_key, f_text)
|
||
ws.write_string(wr, 18, r.content_hash, f_text)
|
||
|
||
data_last = 3 + len(rows) - 1
|
||
ctx.changes_last_row = data_last + 1
|
||
_conditional(ws, fc, data_last)
|
||
_finish(ws, fc, last_col, data_last_row=data_last, empty=False)
|
||
|
||
|
||
def _as_row(r):
|
||
return (r.sort_key, "", r.status, r.permit_no, r.ingredient_name, r.applicant,
|
||
r.manufacturer, r.manufacture_place, r.countries, r.permit_date,
|
||
r.accepted_date, r.changed_fields, r.change_detail, r.watch_hits,
|
||
r.watch_keywords, r.ai_note, "조회", r.dmf_key, r.content_hash)
|
||
|
||
|
||
def _write_date(ws, row, col, value, f_date, f_dash) -> None:
|
||
if value is None:
|
||
ws.write_string(row, col, "-", f_dash)
|
||
else:
|
||
ws.write_datetime(row, col, value, f_date)
|
||
|
||
|
||
def _conditional(ws, fc, last: int) -> None:
|
||
# 순서가 곧 우선순위다: 행 규칙 → 열 규칙 → 데이터바/아이콘
|
||
for label in ("신규", "변경", "취하"): # CF-01-01~03
|
||
ws.conditional_format(3, 1, last, 18, {
|
||
"type": "formula", "criteria": f'=$C4="{label}"',
|
||
"format": fc.cf(label),
|
||
})
|
||
ws.conditional_format(3, 13, last, 13, { # CF-01-04
|
||
"type": "cell", "criteria": ">", "value": 0,
|
||
"format": fc.cf("워치"),
|
||
})
|
||
ws.conditional_format(3, 8, last, 8, { # CF-01-05
|
||
"type": "text", "criteria": "containing", "value": "대한민국",
|
||
"format": fc.cf("신규"),
|
||
})
|
||
ws.conditional_format(3, 4, last, 5, { # CF-01-06
|
||
"type": "formula", "criteria": "=$N4>0",
|
||
"format": fc._wb.add_format({"bold": True}),
|
||
})
|
||
|
||
|
||
def _finish(ws, fc, last_col: int, *, data_last_row: int, empty: bool) -> None:
|
||
ws.freeze_panes(3, 5)
|
||
if not empty:
|
||
ws.autofilter(2, 0, data_last_row, last_col)
|
||
ws.set_landscape()
|
||
ws.set_paper(9)
|
||
ws.fit_to_pages(1, 0)
|
||
ws.set_margins(0.4, 0.4, 0.6, 0.6)
|
||
ws.repeat_rows(0, 2)
|
||
ws.print_area(0, 0, data_last_row, last_col)
|
||
ws.set_footer("&L&\"맑은 고딕\"&8DMF 오늘 변경분&C&\"맑은 고딕\"&8&P / &N"
|
||
"&R&\"맑은 고딕\"&8&D")
|
||
```
|
||
|
||
### 8.9 `src/dmf_crawler/report/sheets/s02_ledger.py`
|
||
|
||
```python
|
||
"""02_전체현황 — 누적 원장. dmf_key → 행번호 사전을 ctx 에 남긴다."""
|
||
from __future__ import annotations
|
||
|
||
from urllib.parse import quote
|
||
|
||
from ..theme import NUMFMT, PALETTE, SHEET_NAMES, TABLE_STYLE
|
||
from ..widgets import (apply_col_widths, compute_col_widths, write_sheet_header,
|
||
write_table_header)
|
||
|
||
HEADER = ("등록번호", "성분명", "업체명", "제조소명", "제조소 소재지", "제조국가",
|
||
"제조국 수", "발급일자", "수리일자", "최초관측일", "최종관측일",
|
||
"최종변경일", "상태", "경과일", "원문검색", "dmf_key", "content_hash")
|
||
WIDTH_OVERRIDE = {0: 24.0, 4: 44.0, 6: 8.0, 12: 8.0, 13: 8.0, 14: 10.0,
|
||
15: 26.0, 16: 18.0}
|
||
HIDDEN_COLS = (15, 16)
|
||
NEDRUG_SEARCH = ("https://nedrug.mfds.go.kr/pbp/CCBAC03/getItem"
|
||
"?totalPages=1&limit=10&page=1&searchYn=true&itemName={q}")
|
||
|
||
|
||
def build(ctx) -> None:
|
||
ws, fc, rd = ctx.sheets["ledger"], ctx.fc, ctx.rd
|
||
last_col = len(HEADER) - 1
|
||
write_sheet_header(ws, fc, title="전체 현황 (누적 원장)",
|
||
dashboard=SHEET_NAMES["dashboard"],
|
||
report_date=f"{rd.meta.report_date:%Y-%m-%d}",
|
||
generated_at=rd.meta.finished_at[-8:-3],
|
||
last_col=last_col)
|
||
write_table_header(ws, fc, 2, HEADER, {
|
||
4: "MNFCTR_PLACE 원문. 원본 데이터에 공백 오류가 섞여 있을 수 있다",
|
||
13: f"기준일({rd.meta.report_date:%Y-%m-%d}) − 수리일자. 3년 이상이면 아이콘이 바뀐다",
|
||
})
|
||
|
||
rows = rd.ledger
|
||
body = [_as_row(r) for r in rows]
|
||
widths = compute_col_widths(HEADER, body, overrides=WIDTH_OVERRIDE)
|
||
apply_col_widths(ws, widths, options={
|
||
**{c: {"hidden": 1, "level": 1} for c in HIDDEN_COLS},
|
||
})
|
||
|
||
f_text = fc.cell_text()
|
||
f_ctr = fc.get(align="center", valign="vcenter", num_format=NUMFMT["TEXT"],
|
||
bottom=7, bottom_color=PALETTE["RULE"])
|
||
f_num = fc.cell_num("INT")
|
||
f_date = fc.cell_date()
|
||
f_link_e = fc.link_external()
|
||
|
||
for i, r in enumerate(rows):
|
||
wr = 3 + i
|
||
ws.set_row(wr, 15)
|
||
ctx.ledger_row_of[r.dmf_key] = wr + 1 # 1-index 행 번호
|
||
ws.write_string(wr, 0, r.permit_no, f_text)
|
||
ws.write_string(wr, 1, r.ingredient_name or "-", f_text)
|
||
ws.write_string(wr, 2, r.applicant or "-", f_text)
|
||
ws.write_string(wr, 3, r.manufacturer or "-", f_text)
|
||
ws.write_string(wr, 4, r.manufacture_place or "-", f_text)
|
||
ws.write_string(wr, 5, r.countries or "-", f_ctr)
|
||
ws.write_number(wr, 6, r.country_count, f_num)
|
||
_wd(ws, wr, 7, r.permit_date, f_date, f_ctr)
|
||
_wd(ws, wr, 8, r.accepted_date, f_date, f_ctr)
|
||
_wd(ws, wr, 9, r.first_seen, f_date, f_ctr)
|
||
_wd(ws, wr, 10, r.last_seen, f_date, f_ctr)
|
||
_wd(ws, wr, 11, r.last_changed, f_date, f_ctr)
|
||
ws.write_string(wr, 12, r.status, f_ctr)
|
||
if r.elapsed_days is None:
|
||
ws.write_string(wr, 13, "-", f_ctr)
|
||
else:
|
||
ws.write_formula(wr, 13, f'=IF($I{wr + 1}="","-",INT(REPORT_DATE-$I{wr + 1}))',
|
||
fc.cell_num("INT_DASH"), r.elapsed_days)
|
||
ws.write_url(wr, 14, NEDRUG_SEARCH.format(q=quote(r.ingredient_name or "")),
|
||
f_link_e, string="조회")
|
||
ws.write_string(wr, 15, r.dmf_key, f_text)
|
||
ws.write_string(wr, 16, r.watch_flag, f_text) # 히든: CF-02-05 판정용
|
||
|
||
last = 3 + len(rows) - 1
|
||
ctx.ledger_last_row = last + 1
|
||
|
||
ws.add_table(2, 0, last, last_col, {
|
||
"name": "T_LEDGER", "style": TABLE_STYLE,
|
||
"banded_rows": True, "autofilter": True, "header_row": True,
|
||
"columns": [{"header": h} for h in HEADER],
|
||
})
|
||
_conditional(ws, fc, last)
|
||
ws.freeze_panes(3, 3)
|
||
ws.set_landscape()
|
||
ws.set_paper(9)
|
||
ws.fit_to_pages(1, 0)
|
||
ws.set_margins(0.4, 0.4, 0.6, 0.6)
|
||
ws.repeat_rows(0, 2)
|
||
ws.print_area(0, 0, last, 14) # 히든 열은 인쇄 영역에서 제외
|
||
ws.set_footer("&L&\"맑은 고딕\"&8DMF 전체 현황&C&\"맑은 고딕\"&8&P / &N"
|
||
"&R&\"맑은 고딕\"&8&D")
|
||
|
||
|
||
def _as_row(r):
|
||
return (r.permit_no, r.ingredient_name, r.applicant, r.manufacturer,
|
||
r.manufacture_place, r.countries, r.country_count, r.permit_date,
|
||
r.accepted_date, r.first_seen, r.last_seen, r.last_changed,
|
||
r.status, r.elapsed_days, "조회", r.dmf_key, r.watch_flag)
|
||
|
||
|
||
def _wd(ws, row, col, value, f_date, f_dash) -> None:
|
||
if value is None:
|
||
ws.write_string(row, col, "-", f_dash)
|
||
else:
|
||
ws.write_datetime(row, col, value, f_date)
|
||
|
||
|
||
def _conditional(ws, fc, last: int) -> None:
|
||
ws.conditional_format(3, 0, last, 16, { # CF-02-03
|
||
"type": "formula", "criteria": '=$M4="취하"',
|
||
"format": fc.cf("취하"),
|
||
})
|
||
ws.conditional_format(3, 0, last, 0, { # CF-02-01
|
||
"type": "duplicate",
|
||
"format": fc._wb.add_format({"bg_color": "#FFF2CC",
|
||
"font_color": "#7F6000"}),
|
||
})
|
||
ws.conditional_format(3, 11, last, 11, { # CF-02-02
|
||
"type": "formula", "criteria": "=$L4=REPORT_DATE",
|
||
"format": fc.cf("변경"),
|
||
})
|
||
ws.conditional_format(3, 5, last, 5, { # CF-02-04
|
||
"type": "text", "criteria": "containing", "value": "대한민국",
|
||
"format": fc.cf("신규"),
|
||
})
|
||
ws.conditional_format(3, 1, last, 2, { # CF-02-05
|
||
"type": "formula", "criteria": '=$Q4="Y"',
|
||
"format": fc.cf("워치"),
|
||
})
|
||
ws.conditional_format(3, 6, last, 6, { # CF-02-06
|
||
"type": "cell", "criteria": ">=", "value": 2,
|
||
"format": fc._wb.add_format({"bold": True}),
|
||
})
|
||
ws.conditional_format(3, 8, last, 8, { # CF-02-07
|
||
"type": "data_bar", "bar_color": PALETTE["ACCENT"], "bar_solid": True,
|
||
})
|
||
ws.conditional_format(3, 13, last, 13, { # CF-02-08
|
||
"type": "icon_set", "icon_style": "3_arrows_gray",
|
||
"icons_only": False, "reverse_icons": True,
|
||
"icons": [{"criteria": ">=", "type": "number", "value": 1095},
|
||
{"criteria": ">=", "type": "number", "value": 365}],
|
||
})
|
||
```
|
||
|
||
### 8.10 `src/dmf_crawler/report/sheets/s03_ingredient.py` · `s04_company.py`
|
||
|
||
두 시트는 컬럼 구성만 다르고 로직이 같다. 공용 빌더를 두고 스펙 테이블로 분기한다.
|
||
|
||
```python
|
||
# s03_ingredient.py
|
||
"""03_성분별 — 성분 기준 집계."""
|
||
from __future__ import annotations
|
||
|
||
from ._agg import AggSpec, build_agg
|
||
from ..theme import SHEET_NAMES
|
||
|
||
SPEC = AggSpec(
|
||
sheet_key="ingredient",
|
||
title="성분별 집계",
|
||
name_header="성분명",
|
||
name_width=34.0,
|
||
partner_header="등록업체 수",
|
||
country_header="제조국 수",
|
||
domestic_header="국산 보유",
|
||
domestic_values=("Y", "N"),
|
||
has_company_key=False,
|
||
spark_block_col=29, # AD (0-index)
|
||
table_name="T_INGREDIENT",
|
||
)
|
||
|
||
|
||
def build(ctx) -> None:
|
||
build_agg(ctx, SPEC)
|
||
```
|
||
|
||
```python
|
||
# s04_company.py
|
||
"""04_업체별 — 업체 기준 집계."""
|
||
from __future__ import annotations
|
||
|
||
from ._agg import AggSpec, build_agg
|
||
|
||
SPEC = AggSpec(
|
||
sheet_key="company",
|
||
title="업체별 집계",
|
||
name_header="업체명",
|
||
name_width=30.0,
|
||
partner_header="보유 성분 수",
|
||
country_header="제조소 수",
|
||
domestic_header="국산여부",
|
||
domestic_values=("국산", "해외"),
|
||
has_company_key=True, # C열에 히든 업체키를 넣는다
|
||
spark_block_col=61, # BJ (0-index)
|
||
table_name="T_COMPANY",
|
||
)
|
||
|
||
|
||
def build(ctx) -> None:
|
||
build_agg(ctx, SPEC)
|
||
```
|
||
|
||
```python
|
||
# _agg.py — 03/04 공용 빌더
|
||
"""성분별·업체별 집계 시트 공용 구현."""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
|
||
from ..theme import NUMFMT, PALETTE, SHEET_NAMES, TABLE_STYLE, qsheet
|
||
from ..widgets import (apply_col_widths, compute_col_widths, write_empty_notice,
|
||
write_sheet_header, write_table_header)
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class AggSpec:
|
||
sheet_key: str
|
||
title: str
|
||
name_header: str
|
||
name_width: float
|
||
partner_header: str
|
||
country_header: str
|
||
domestic_header: str
|
||
domestic_values: tuple[str, str]
|
||
has_company_key: bool
|
||
spark_block_col: int
|
||
table_name: str
|
||
|
||
|
||
def _header(spec: AggSpec) -> tuple[str, ...]:
|
||
base = ["순위", spec.name_header]
|
||
if spec.has_company_key:
|
||
base.append("업체키")
|
||
base += ["오늘 신규", "오늘 변경", "오늘 취하", "오늘 합계"]
|
||
if not spec.has_company_key:
|
||
base.append("최근 7일")
|
||
base += ["최근 30일", "누적 유효등록", spec.partner_header, spec.country_header,
|
||
"주요 제조국", spec.domestic_header]
|
||
if spec.has_company_key:
|
||
base += ["최근 등록일", "워치리스트"]
|
||
else:
|
||
base.append("전일 대비")
|
||
base += ["30일 추이", "상세"]
|
||
return tuple(base)
|
||
|
||
|
||
def build_agg(ctx, spec: AggSpec) -> None:
|
||
ws, fc, rd = ctx.sheets[spec.sheet_key], ctx.fc, ctx.rd
|
||
rows = rd.ingredients if spec.sheet_key == "ingredient" else rd.companies
|
||
header = _header(spec)
|
||
last_col = len(header) - 1
|
||
idx = {name: i for i, name in enumerate(header)}
|
||
|
||
write_sheet_header(ws, fc, title=spec.title,
|
||
dashboard=SHEET_NAMES["dashboard"],
|
||
report_date=f"{rd.meta.report_date:%Y-%m-%d}",
|
||
generated_at=rd.meta.finished_at[-8:-3],
|
||
last_col=last_col)
|
||
write_table_header(ws, fc, 2, header, {
|
||
idx["누적 유효등록"]: "취하되지 않은 유효 등록 건수",
|
||
idx["주요 제조국"]: "이 항목에서 건수가 가장 많은 제조국",
|
||
})
|
||
|
||
if not rows:
|
||
write_empty_notice(ws, fc, 3, last_col, "(집계할 데이터가 없습니다)")
|
||
ws.freeze_panes(3, 2)
|
||
return
|
||
|
||
body = [_as_row(r, spec, idx) for r in rows]
|
||
overrides = {idx["순위"]: 6.0, idx[spec.name_header]: spec.name_width,
|
||
idx["30일 추이"]: 14.0, idx["상세"]: 8.0}
|
||
if spec.has_company_key:
|
||
overrides[idx["업체키"]] = 26.0
|
||
widths = compute_col_widths(header, body, overrides=overrides)
|
||
opts = {idx["업체키"]: {"hidden": 1}} if spec.has_company_key else {}
|
||
opts[last_col + 1] = {"hidden": 1} # 워치플래그 히든 열 (CF-03-05)
|
||
apply_col_widths(ws, widths, options=opts)
|
||
|
||
f_text = fc.cell_text()
|
||
f_ctr = fc.get(align="center", valign="vcenter", num_format=NUMFMT["TEXT"],
|
||
bottom=7, bottom_color=PALETTE["RULE"])
|
||
f_num = fc.cell_num("INT_DASH")
|
||
f_cum = fc.cell_num("INT")
|
||
f_date = fc.cell_date()
|
||
f_delta = fc.cell_num("DELTA")
|
||
f_link = fc.link_internal()
|
||
lg = qsheet(SHEET_NAMES["ledger"])
|
||
trend = qsheet(SHEET_NAMES["trend"])
|
||
|
||
for i, r in enumerate(rows):
|
||
wr = 3 + i
|
||
ws.set_row(wr, 18)
|
||
ws.write_number(wr, idx["순위"], r.rank, f_num)
|
||
ws.write_string(wr, idx[spec.name_header], r.name or "-", f_text)
|
||
if spec.has_company_key:
|
||
ws.write_string(wr, idx["업체키"], r.key, f_text)
|
||
ws.write_number(wr, idx["오늘 신규"], r.today_new, f_num)
|
||
ws.write_number(wr, idx["오늘 변경"], r.today_changed, f_num)
|
||
ws.write_number(wr, idx["오늘 취하"], r.today_withdrawn, f_num)
|
||
c1 = _colletter(idx["오늘 신규"])
|
||
c3 = _colletter(idx["오늘 취하"])
|
||
ws.write_formula(wr, idx["오늘 합계"], f"=SUM(${c1}{wr + 1}:${c3}{wr + 1})",
|
||
f_num, r.today_new + r.today_changed + r.today_withdrawn)
|
||
if "최근 7일" in idx:
|
||
ws.write_number(wr, idx["최근 7일"], r.last7, f_num)
|
||
ws.write_number(wr, idx["최근 30일"], r.last30, f_num)
|
||
ws.write_number(wr, idx["누적 유효등록"], r.cumulative, f_cum)
|
||
ws.write_number(wr, idx[spec.partner_header], r.partner_count, f_cum)
|
||
ws.write_number(wr, idx[spec.country_header], r.country_count, f_cum)
|
||
ws.write_string(wr, idx["주요 제조국"], r.main_country or "-", f_ctr)
|
||
ws.write_string(wr, idx[spec.domestic_header], r.domestic, f_ctr)
|
||
if "최근 등록일" in idx:
|
||
if r.last_date is None:
|
||
ws.write_string(wr, idx["최근 등록일"], "-", f_ctr)
|
||
else:
|
||
ws.write_datetime(wr, idx["최근 등록일"], r.last_date, f_date)
|
||
if "워치리스트" in idx:
|
||
ws.write_string(wr, idx["워치리스트"],
|
||
"★" if r.watch_flag == "Y" else "-", f_ctr)
|
||
if "전일 대비" in idx:
|
||
ws.write_number(wr, idx["전일 대비"], r.delta, f_delta)
|
||
ws.write_blank(wr, idx["30일 추이"], None, f_ctr)
|
||
ws.add_sparkline(wr, idx["30일 추이"], {
|
||
"range": (f"{trend}!${_colletter(spec.spark_block_col + 1)}${wr + 1}"
|
||
f":${_colletter(spec.spark_block_col + 30)}${wr + 1}"),
|
||
"type": "column", "series_color": PALETTE["ACCENT"],
|
||
"high_point": True, "empty_cells": "zero",
|
||
})
|
||
ws.write_url(wr, idx["상세"], f"internal:{lg}!A3", f_link, string="보기",
|
||
tip="전체 현황에서 필터로 확인")
|
||
ws.write_string(wr, last_col + 1, r.watch_flag, f_text) # 히든 판정 열
|
||
|
||
last = 3 + len(rows) - 1
|
||
total_row = last + 1
|
||
ws.add_table(2, 0, total_row, last_col, {
|
||
"name": spec.table_name, "style": TABLE_STYLE,
|
||
"banded_rows": True, "autofilter": True, "total_row": True,
|
||
"columns": _table_columns(header, idx, rows),
|
||
})
|
||
_conditional(ws, fc, spec, idx, last, last_col)
|
||
ws.freeze_panes(3, 2)
|
||
ws.set_landscape()
|
||
ws.set_paper(9)
|
||
ws.fit_to_pages(1, 0)
|
||
ws.set_margins(0.4, 0.4, 0.6, 0.6)
|
||
ws.repeat_rows(0, 2)
|
||
ws.print_area(0, 0, total_row, last_col)
|
||
|
||
|
||
def _table_columns(header, idx, rows):
|
||
"""add_table 컬럼 정의. 합계행은 total_value 로 캐시값을 함께 준다."""
|
||
sum_cols = {"오늘 신규", "오늘 변경", "오늘 취하", "오늘 합계",
|
||
"최근 7일", "최근 30일", "누적 유효등록"}
|
||
attr = {"오늘 신규": "today_new", "오늘 변경": "today_changed",
|
||
"오늘 취하": "today_withdrawn", "최근 7일": "last7",
|
||
"최근 30일": "last30", "누적 유효등록": "cumulative"}
|
||
cols = []
|
||
for name in header:
|
||
col: dict = {"header": name}
|
||
if name == "순위":
|
||
col["total_string"] = "합계"
|
||
elif name in sum_cols:
|
||
col["total_function"] = "sum"
|
||
if name == "오늘 합계":
|
||
col["total_value"] = sum(r.today_new + r.today_changed
|
||
+ r.today_withdrawn for r in rows)
|
||
else:
|
||
col["total_value"] = sum(getattr(r, attr[name]) for r in rows)
|
||
cols.append(col)
|
||
return cols
|
||
|
||
|
||
def _conditional(ws, fc, spec, idx, last, last_col) -> None:
|
||
for name, status in (("오늘 신규", "신규"), ("오늘 변경", "변경"),
|
||
("오늘 취하", "취하")):
|
||
c = idx[name]
|
||
ws.conditional_format(3, c, last, c, {
|
||
"type": "cell", "criteria": ">", "value": 0,
|
||
"format": fc.cf(status),
|
||
})
|
||
c = idx[spec.name_header]
|
||
ws.conditional_format(3, c, last, c, {
|
||
"type": "formula",
|
||
"criteria": f"=${_colletter(last_col + 1)}4=\"Y\"",
|
||
"format": fc.cf("워치"),
|
||
})
|
||
for name in ("최근 7일", "최근 30일", "누적 유효등록"):
|
||
if name not in idx:
|
||
continue
|
||
c = idx[name]
|
||
ws.conditional_format(3, c, last, c, {
|
||
"type": "data_bar", "bar_color": PALETTE["ACCENT"], "bar_solid": True,
|
||
})
|
||
c = idx[spec.partner_header]
|
||
ws.conditional_format(3, c, last, c, {
|
||
"type": "cell", "criteria": ">=", "value": 5,
|
||
"format": fc._wb.add_format({"bold": True}),
|
||
})
|
||
c = idx[spec.domestic_header]
|
||
ws.conditional_format(3, c, last, c, {
|
||
"type": "text", "criteria": "containing",
|
||
"value": spec.domestic_values[0], "format": fc.cf("신규"),
|
||
})
|
||
if "전일 대비" in idx:
|
||
c = idx["전일 대비"]
|
||
ws.conditional_format(3, c, last, c, {
|
||
"type": "icon_set", "icon_style": "3_arrows_gray", "icons_only": False,
|
||
"icons": [{"criteria": ">=", "type": "number", "value": 1},
|
||
{"criteria": ">=", "type": "number", "value": 0}],
|
||
})
|
||
if "최근 등록일" in idx:
|
||
c = idx["최근 등록일"]
|
||
ws.conditional_format(3, c, last, c, {
|
||
"type": "formula",
|
||
"criteria": f'=AND(${_colletter(c)}4<>"",REPORT_DATE-${_colletter(c)}4>90)',
|
||
"format": fc.cf("없음"),
|
||
})
|
||
if "워치리스트" in idx:
|
||
c = idx["워치리스트"]
|
||
ws.conditional_format(3, c, last, c, {
|
||
"type": "text", "criteria": "containing", "value": "★",
|
||
"format": fc.cf("워치"),
|
||
})
|
||
|
||
|
||
def _colletter(col0: int) -> str:
|
||
"""0-index 열 번호 → 엑셀 열 문자."""
|
||
s = ""
|
||
n = col0 + 1
|
||
while n:
|
||
n, rem = divmod(n - 1, 26)
|
||
s = chr(65 + rem) + s
|
||
return s
|
||
|
||
|
||
def _as_row(r, spec, idx):
|
||
row = [None] * len(idx)
|
||
row[idx["순위"]] = r.rank
|
||
row[idx[spec.name_header]] = r.name
|
||
if spec.has_company_key:
|
||
row[idx["업체키"]] = r.key
|
||
row[idx["오늘 신규"]] = r.today_new
|
||
row[idx["오늘 변경"]] = r.today_changed
|
||
row[idx["오늘 취하"]] = r.today_withdrawn
|
||
row[idx["오늘 합계"]] = r.today_new + r.today_changed + r.today_withdrawn
|
||
if "최근 7일" in idx:
|
||
row[idx["최근 7일"]] = r.last7
|
||
row[idx["최근 30일"]] = r.last30
|
||
row[idx["누적 유효등록"]] = r.cumulative
|
||
row[idx[spec.partner_header]] = r.partner_count
|
||
row[idx[spec.country_header]] = r.country_count
|
||
row[idx["주요 제조국"]] = r.main_country
|
||
row[idx[spec.domestic_header]] = r.domestic
|
||
if "최근 등록일" in idx:
|
||
row[idx["최근 등록일"]] = r.last_date
|
||
if "워치리스트" in idx:
|
||
row[idx["워치리스트"]] = "★" if r.watch_flag == "Y" else "-"
|
||
if "전일 대비" in idx:
|
||
row[idx["전일 대비"]] = r.delta
|
||
row[idx["30일 추이"]] = ""
|
||
row[idx["상세"]] = "보기"
|
||
return row
|
||
```
|
||
|
||
### 8.11 `src/dmf_crawler/report/sheets/s05_watchlist.py`
|
||
|
||
```python
|
||
"""05_워치리스트 — 읽기 전용 미러. active 항목이 0건이면 호출되지 않는다."""
|
||
from __future__ import annotations
|
||
|
||
from ..theme import NUMFMT, PALETTE, SHEET_NAMES, STATUS, qsheet
|
||
from ..widgets import apply_col_widths, write_sheet_header, write_table_header
|
||
|
||
HEADER_A = ("번호", "대상유형", "키워드", "매칭방식", "우선순위", "활성", "메모")
|
||
HEADER_B = ("오늘 매칭", "최근 7일", "최근 30일", "누적 매칭", "마지막 매칭일", "상세")
|
||
HEADER_C = ("키워드", "상태", "등록번호", "성분명", "업체명", "제조국가", "발급일자")
|
||
WIDTHS = [6, 12, 30, 12, 8, 6, 26, 1.5, 9, 9, 9, 9, 12, 8] # A..N
|
||
VALID = {
|
||
1: ["성분명", "업체명", "제조소명", "제조국가", "등록번호"],
|
||
3: ["부분일치", "정확일치", "정규식"],
|
||
4: [1, 2, 3],
|
||
5: ["Y", "N"],
|
||
}
|
||
|
||
|
||
def build(ctx) -> None:
|
||
ws, fc, rd = ctx.sheets["watchlist"], ctx.fc, ctx.rd
|
||
write_sheet_header(ws, fc, title="워치리스트 (읽기 전용)",
|
||
dashboard=SHEET_NAMES["dashboard"],
|
||
report_date=f"{rd.meta.report_date:%Y-%m-%d}",
|
||
generated_at=rd.meta.finished_at[-8:-3], last_col=13)
|
||
apply_col_widths(ws, WIDTHS)
|
||
write_table_header(ws, fc, 2, HEADER_A + ("",) + HEADER_B, {
|
||
2: "이 값이 성분명·업체명 등에 포함되면 히트로 센다",
|
||
3: "정규식 모드는 파이썬 re 문법을 따른다",
|
||
})
|
||
|
||
f_text = fc.cell_text()
|
||
f_ctr = fc.get(align="center", valign="vcenter", num_format=NUMFMT["TEXT"],
|
||
bottom=7, bottom_color=PALETTE["RULE"])
|
||
f_num = fc.cell_num("INT_DASH")
|
||
f_cum = fc.cell_num("INT")
|
||
f_date = fc.cell_date()
|
||
f_link = fc.link_internal()
|
||
ch = qsheet(SHEET_NAMES["changes"])
|
||
|
||
for i, w in enumerate(rd.watch):
|
||
wr = 3 + i
|
||
ws.set_row(wr, 16)
|
||
ws.write_number(wr, 0, w.no, f_num)
|
||
ws.write_string(wr, 1, w.target_type, f_ctr)
|
||
ws.write_string(wr, 2, w.keyword, f_text)
|
||
ws.write_string(wr, 3, w.match_mode, f_ctr)
|
||
ws.write_number(wr, 4, w.priority, f_num)
|
||
ws.write_string(wr, 5, w.active, f_ctr)
|
||
ws.write_string(wr, 6, w.memo or "-", f_text)
|
||
ws.write_formula(
|
||
wr, 8,
|
||
f'=IF($C{wr + 1}="",0,COUNTIF({ch}!$O$4:$O$100000,"*"&$C{wr + 1}&"*"))',
|
||
f_num, w.today)
|
||
ws.write_number(wr, 9, w.last7, f_num)
|
||
ws.write_number(wr, 10, w.last30, f_num)
|
||
ws.write_number(wr, 11, w.cumulative, f_cum)
|
||
if w.last_hit is None:
|
||
ws.write_string(wr, 12, "-", f_ctr)
|
||
else:
|
||
ws.write_datetime(wr, 12, w.last_hit, f_date)
|
||
ws.write_url(wr, 13, f"internal:{ch}!A3", f_link, string="보기")
|
||
|
||
last = 3 + len(rd.watch) - 1
|
||
for col, values in VALID.items():
|
||
ws.data_validation(3, col, last, col,
|
||
{"validate": "list", "source": values})
|
||
_conditional_ab(ws, fc, last)
|
||
|
||
# ── 블록 C: 매칭 상세표 ──────────────────────────────────────
|
||
cstart = last + 3
|
||
ws.write(cstart - 1, 0, "오늘 매칭 상세",
|
||
fc.get(font_size=12, bold=True, valign="vcenter"))
|
||
write_table_header(ws, fc, cstart, HEADER_C, height=24)
|
||
for i, d in enumerate(rd.watch_detail):
|
||
wr = cstart + 1 + i
|
||
ws.set_row(wr, 16)
|
||
ws.write_string(wr, 0, d.keyword, f_text)
|
||
ws.write_string(wr, 1, d.status, f_ctr)
|
||
ws.write_string(wr, 2, d.permit_no, f_text)
|
||
ws.write_string(wr, 3, d.ingredient_name or "-", f_text)
|
||
ws.write_string(wr, 4, d.applicant or "-", f_text)
|
||
ws.write_string(wr, 5, d.countries or "-", f_ctr)
|
||
if d.permit_date is None:
|
||
ws.write_string(wr, 6, "-", f_ctr)
|
||
else:
|
||
ws.write_datetime(wr, 6, d.permit_date, f_date)
|
||
cend = cstart + max(1, len(rd.watch_detail))
|
||
for label in ("신규", "변경", "취하"):
|
||
ws.conditional_format(cstart + 1, 0, cend, 6, {
|
||
"type": "formula",
|
||
"criteria": f'=$B{cstart + 2}="{label}"',
|
||
"format": fc.cf(label),
|
||
})
|
||
|
||
ws.freeze_panes(3, 3)
|
||
ws.protect("", {"objects": True, "scenarios": True,
|
||
"select_locked_cells": True, "select_unlocked_cells": True,
|
||
"sort": True, "autofilter": True})
|
||
ws.set_landscape()
|
||
ws.set_paper(9)
|
||
ws.fit_to_pages(1, 0)
|
||
ws.set_margins(0.4, 0.4, 0.6, 0.6)
|
||
ws.repeat_rows(0, 2)
|
||
ws.print_area(0, 0, cend, 13)
|
||
|
||
|
||
def _conditional_ab(ws, fc, last: int) -> None:
|
||
ws.conditional_format(3, 8, last, 8, { # CF-05-01
|
||
"type": "cell", "criteria": ">", "value": 0, "format": fc.cf("워치"),
|
||
})
|
||
ws.conditional_format(3, 12, last, 12, { # CF-05-02
|
||
"type": "formula", "criteria": "=$M4=REPORT_DATE", "format": fc.cf("워치"),
|
||
})
|
||
ws.conditional_format(3, 2, last, 2, { # CF-05-03
|
||
"type": "blanks", "format": fc.cf("취하"),
|
||
})
|
||
for c in (9, 10): # CF-05-04
|
||
ws.conditional_format(3, c, last, c, {
|
||
"type": "data_bar", "bar_color": PALETTE["WATCH"], "bar_solid": True,
|
||
})
|
||
```
|
||
|
||
### 8.12 `src/dmf_crawler/report/sheets/s06_trend.py`
|
||
|
||
```python
|
||
"""06_추이 — 시각화 원본. 가장 먼저 만들어진다."""
|
||
from __future__ import annotations
|
||
|
||
from ..theme import NUMFMT, PALETTE, SHEET_NAMES, TABLE_STYLE
|
||
from ..widgets import apply_col_widths, write_sheet_header, write_table_header
|
||
|
||
HEADER = ("일자", "신규", "변경", "취하", "합계", "워치 히트",
|
||
"누적 유효등록", "수집 건수", "실행 상태")
|
||
WIDTHS = [12, 9, 9, 9, 9, 9, 12, 10, 10]
|
||
BAR_COLORS = {1: "#009E73", 2: "#E69F00", 3: "#D55E00"}
|
||
|
||
|
||
def build(ctx) -> None:
|
||
ws, fc, rd = ctx.sheets["trend"], ctx.fc, ctx.rd
|
||
write_sheet_header(ws, fc, title="일자별 추이",
|
||
dashboard=SHEET_NAMES["dashboard"],
|
||
report_date=f"{rd.meta.report_date:%Y-%m-%d}",
|
||
generated_at=rd.meta.finished_at[-8:-3], last_col=8)
|
||
apply_col_widths(ws, WIDTHS)
|
||
write_table_header(ws, fc, 2, HEADER, {
|
||
6: "그날 스냅샷의 유효 등록 총건수",
|
||
7: "그날 API 에서 실제로 수집한 행 수. 급감하면 붉게 표시된다",
|
||
})
|
||
|
||
f_num = fc.cell_num("INT_DASH")
|
||
f_cum = fc.cell_num("INT")
|
||
f_date = fc.cell_date()
|
||
f_ctr = fc.get(align="center", valign="vcenter", num_format=NUMFMT["TEXT"],
|
||
bottom=7, bottom_color=PALETTE["RULE"])
|
||
|
||
for i, t in enumerate(rd.trend):
|
||
wr = 3 + i
|
||
ws.set_row(wr, 16)
|
||
ws.write_datetime(wr, 0, t.day, f_date)
|
||
ws.write_number(wr, 1, t.new, f_num)
|
||
ws.write_number(wr, 2, t.changed, f_num)
|
||
ws.write_number(wr, 3, t.withdrawn, f_num)
|
||
ws.write_formula(wr, 4, f"=SUM($B{wr + 1}:$D{wr + 1})", f_num, t.total)
|
||
ws.write_number(wr, 5, t.watch_hits, f_num)
|
||
ws.write_number(wr, 6, t.active_total, f_cum)
|
||
ws.write_number(wr, 7, t.fetched_rows, f_cum)
|
||
ws.write_string(wr, 8, t.run_status, f_ctr)
|
||
|
||
last = 3 + len(rd.trend) - 1
|
||
ctx.trend_first_row = 4
|
||
ctx.trend_last_row = last + 1
|
||
ctx.spark_first_row = max(4, ctx.trend_last_row - 29)
|
||
|
||
ws.add_table(2, 0, last, 8, {
|
||
"name": "T_TREND", "style": TABLE_STYLE,
|
||
"banded_rows": True, "autofilter": True,
|
||
"columns": [{"header": h} for h in HEADER],
|
||
})
|
||
_conditional(ws, fc, last)
|
||
_hidden_blocks(ctx, ws, fc, rd)
|
||
|
||
ws.freeze_panes(3, 1)
|
||
ws.set_landscape()
|
||
ws.set_paper(9)
|
||
ws.fit_to_pages(1, 0)
|
||
ws.set_margins(0.4, 0.4, 0.6, 0.6)
|
||
ws.repeat_rows(0, 2)
|
||
ws.print_area(0, 0, last, 8)
|
||
|
||
|
||
def _conditional(ws, fc, last: int) -> None:
|
||
ws.conditional_format(3, 0, last, 0, { # CF-06-01
|
||
"type": "formula", "criteria": "=$A4=REPORT_DATE", "format": fc.cf("정보"),
|
||
})
|
||
for c, color in BAR_COLORS.items(): # CF-06-02
|
||
ws.conditional_format(3, c, last, c, {
|
||
"type": "data_bar", "bar_color": color, "bar_solid": True,
|
||
})
|
||
ws.conditional_format(3, 5, last, 5, {
|
||
"type": "cell", "criteria": ">", "value": 0, "format": fc.cf("워치"),
|
||
})
|
||
ws.conditional_format(4, 7, last, 7, { # CF-06-03
|
||
"type": "formula", "criteria": "=AND($H4<>\"\",$H4<$H3*0.95)",
|
||
"format": fc.cf("취하"),
|
||
})
|
||
for value, status in (("SUCCESS", "신규"), ("PARTIAL", "변경"),
|
||
("FAILED", "취하")): # CF-06-04
|
||
ws.conditional_format(3, 8, last, 8, {
|
||
"type": "text", "criteria": "containing", "value": value,
|
||
"format": fc.cf(status),
|
||
})
|
||
|
||
|
||
def _hidden_blocks(ctx, ws, fc, rd) -> None:
|
||
"""차트2·스파크라인 원본. 열 폭 0 + hidden."""
|
||
plain = fc.get()
|
||
num = fc.get(num_format=NUMFMT["INT"])
|
||
# AA(26)/AB(27): 제조국 Top 8 — 차트2 원본
|
||
ws.set_column(26, 27, 12, None, {"hidden": 1})
|
||
ws.write(2, 26, "제조국", plain)
|
||
ws.write(2, 27, "건수", plain)
|
||
for i, (country, n) in enumerate(rd.top_countries):
|
||
ws.write_string(3 + i, 26, country, plain)
|
||
ws.write_number(3 + i, 27, n, num)
|
||
|
||
# AD(29)~BH(59): 성분별 30일 시계열
|
||
ws.set_column(29, 59, 6, None, {"hidden": 1})
|
||
ws.write(2, 29, "성분명", plain)
|
||
for i, d in enumerate(rd.trend_dates[-30:]):
|
||
ws.write_datetime(2, 30 + i, d, fc.cell_date())
|
||
for r, agg in enumerate(rd.ingredients):
|
||
ws.write_string(3 + r, 29, agg.name, plain)
|
||
for c, v in enumerate(agg.series30[:30]):
|
||
ws.write_number(3 + r, 30 + c, v, num)
|
||
|
||
# BJ(61)~CN(91): 업체별 30일 시계열
|
||
ws.set_column(61, 91, 6, None, {"hidden": 1})
|
||
ws.write(2, 61, "업체명", plain)
|
||
for i, d in enumerate(rd.trend_dates[-30:]):
|
||
ws.write_datetime(2, 62 + i, d, fc.cell_date())
|
||
for r, agg in enumerate(rd.companies):
|
||
ws.write_string(3 + r, 61, agg.name, plain)
|
||
for c, v in enumerate(agg.series30[:30]):
|
||
ws.write_number(3 + r, 62 + c, v, num)
|
||
```
|
||
|
||
### 8.13 `src/dmf_crawler/report/sheets/s99_meta.py`
|
||
|
||
```python
|
||
"""99_메타 — 수집 메타·소스·무결성 게이트·AI 상태·코드북·면책."""
|
||
from __future__ import annotations
|
||
|
||
from ..theme import NUMFMT, PALETTE, SHEET_NAMES
|
||
from ..widgets import apply_col_widths, write_sheet_header
|
||
|
||
WIDTHS = [28, 46, 16, 16, 30]
|
||
CODEBOOK = (
|
||
("상태", "신규", "어제 스냅샷에 없던 등록번호가 오늘 나타남"),
|
||
("상태", "변경", "양쪽에 있으나 비교 대상 6필드 중 하나 이상이 달라짐"),
|
||
("상태", "취하", "어제 있었으나 오늘 스냅샷에서 사라짐"),
|
||
("등록번호", "20121228-168-I-169-04", "발급일자8-성분일련-시행군-접수일련-성분내일련"),
|
||
("실행 상태", "SUCCESS", "전 단계 성공"),
|
||
("실행 상태", "PARTIAL", "무결성 게이트 차단 또는 선택 단계 실패. 수치는 직전 성공 기준"),
|
||
("실행 상태", "FAILED", "필수 단계 실패. 리포트가 갱신되지 않았을 수 있다"),
|
||
)
|
||
|
||
|
||
def build(ctx) -> None:
|
||
ws, fc, rd = ctx.sheets["meta"], ctx.fc, ctx.rd
|
||
m, ai = rd.meta, rd.ai
|
||
write_sheet_header(ws, fc, title="메타·로그",
|
||
dashboard=SHEET_NAMES["dashboard"],
|
||
report_date=f"{m.report_date:%Y-%m-%d}",
|
||
generated_at=m.finished_at[-8:-3], last_col=4)
|
||
apply_col_widths(ws, WIDTHS)
|
||
|
||
k = fc.get(bold=True, font_color=PALETTE["MUTED"], valign="vcenter")
|
||
v = fc.get(valign="vcenter", num_format=NUMFMT["TEXT"])
|
||
vd = fc.get(valign="vcenter", num_format=NUMFMT["DATE"])
|
||
vn = fc.get(valign="vcenter", num_format=NUMFMT["INT"])
|
||
link = fc.link_external()
|
||
hdr = fc.header(wrap=False)
|
||
|
||
# ── 블록 1: 실행 메타 (A3:B18) ──────────────────────────────
|
||
ws.write_string(2, 0, "리포트 기준일", k)
|
||
ws.write_datetime(2, 1, m.report_date, vd) # ← REPORT_DATE 원본
|
||
pairs = (
|
||
("수집 시작 시각", m.started_at), ("수집 종료 시각", m.finished_at),
|
||
("실행 ID", m.run_id), ("실행 상태", m.run_status),
|
||
("실행 트리거", m.trigger), ("실행 호스트", m.host),
|
||
("파이썬 버전", m.python_version), ("XlsxWriter 버전", m.xlsxwriter_version),
|
||
("직전 성공 실행", m.prev_run_id or "-"),
|
||
)
|
||
for i, (key, val) in enumerate(pairs):
|
||
ws.write_string(3 + i, 0, key, k)
|
||
ws.write_string(3 + i, 1, str(val), v)
|
||
files = (("실행 로그 경로", m.log_dir), ("이벤트 로그 경로", m.events_path),
|
||
("원문 아카이브", m.archive_dir), ("DB 백업", m.backup_path),
|
||
("스냅샷 해시", m.snapshot_hash), ("직전 리포트", m.prev_report_path))
|
||
for i, (key, val) in enumerate(files):
|
||
r = 12 + i
|
||
ws.write_string(r, 0, key, k)
|
||
if val and key != "스냅샷 해시":
|
||
ws.write_url(r, 1, f"external:{val}", link, string=val)
|
||
else:
|
||
ws.write_string(r, 1, val or "-", v)
|
||
|
||
# ── 블록 2: 소스 (A21:E22) ───────────────────────────────────
|
||
ws.write_row(20, 0, ("소스명", "URL", "HTTP", "수집행수", "비고"), hdr)
|
||
ws.write_string(21, 0, m.source_name, v)
|
||
ws.write_url(21, 1, m.source_url, link, string=m.source_url)
|
||
ws.write_string(21, 2, m.http_summary, v)
|
||
ws.write_number(21, 3, m.fetched_rows, vn)
|
||
ws.write_string(21, 4, "공공데이터포털 15057075", v)
|
||
|
||
# ── 블록 3: 무결성 게이트 (A26:E32) ──────────────────────────
|
||
ws.write_row(25, 0, ("게이트", "기대", "실제", "판정", "상세"), hdr)
|
||
for i, g in enumerate(rd.gates):
|
||
r = 26 + i
|
||
ws.write_string(r, 0, g.name, k)
|
||
ws.write_string(r, 1, g.expected, v)
|
||
ws.write_string(r, 2, g.actual, v)
|
||
ws.write_formula(r, 3, f"=IF($C{r + 1}=$B{r + 1},\"OK\",\"불일치\")",
|
||
v, g.verdict)
|
||
ws.write_string(r, 4, g.detail, v)
|
||
tail = 26 + len(rd.gates)
|
||
ws.write_string(tail, 0, "종합", k)
|
||
ws.write_formula(tail, 3,
|
||
f'=IF(COUNTIF($D$27:$D${tail},"불일치")=0,"OK","불일치")',
|
||
v, "OK" if all(g.verdict == "OK" for g in rd.gates) else "불일치")
|
||
for value, status in (("OK", "신규"), ("불일치", "취하")): # CF-99-01
|
||
ws.conditional_format(26, 3, tail, 3, {
|
||
"type": "text", "criteria": "containing", "value": value,
|
||
"format": fc.cf(status),
|
||
})
|
||
|
||
# ── 블록 4: AI 계층 상태 ─────────────────────────────────────
|
||
base = tail + 3
|
||
ws.write_string(base - 1, 0, "AI 계층 (agy)",
|
||
fc.get(font_size=12, bold=True, valign="vcenter"))
|
||
ai_pairs = (
|
||
("agy 사용 여부", ai.used), ("모델", ai.model or "-"),
|
||
("노력 수준", ai.effort or "-"),
|
||
("입력 토큰", f"{ai.input_tokens:,}"),
|
||
("출력 토큰", f"{ai.output_tokens:,}"),
|
||
("소요 시간(초)", f"{ai.duration_s:.1f}"),
|
||
("오늘 누적 토큰", f"{ai.tokens_today:,} / {ai.token_cap:,}"),
|
||
("헤드라인", ai.headline or "(AI 요약 없음)"),
|
||
("요약", ai.summary or "(AI 요약 없음)"),
|
||
("리스크 메모", ai.risk_note or "-"),
|
||
)
|
||
for i, (key, val) in enumerate(ai_pairs):
|
||
ws.write_string(base + i, 0, key, k)
|
||
ws.write_string(base + i, 1, str(val), v)
|
||
envr = base + len(ai_pairs)
|
||
ws.write_string(envr, 0, "봉투 원문 경로", k)
|
||
if ai.envelope_path:
|
||
ws.write_url(envr, 1, f"external:{ai.envelope_path}", link,
|
||
string=ai.envelope_path)
|
||
else:
|
||
ws.write_string(envr, 1, "-", v)
|
||
ws.conditional_format(base, 1, base, 1, { # CF-99-02
|
||
"type": "text", "criteria": "containing", "value": "실패",
|
||
"format": fc.cf("취하"),
|
||
})
|
||
|
||
# ── 블록 5: 코드북 ───────────────────────────────────────────
|
||
cb = envr + 3
|
||
ws.write_row(cb, 0, ("구분", "코드", "설명"), hdr)
|
||
for i, (g, code, desc) in enumerate(CODEBOOK):
|
||
ws.write_string(cb + 1 + i, 0, g, v)
|
||
ws.write_string(cb + 1 + i, 1, code, v)
|
||
ws.write_string(cb + 1 + i, 2, desc, v)
|
||
|
||
# ── 블록 6: 출처·면책 ────────────────────────────────────────
|
||
foot = cb + len(CODEBOOK) + 3
|
||
ws.merge_range(
|
||
foot, 0, foot, 4,
|
||
"출처: 공공데이터포털 「의약품 원료의약품 등록 정보」(서비스 15057075), "
|
||
"식품의약품안전처. 이용약관에 따라 출처를 표시하고 정중한 호출 간격을 지킨다.",
|
||
fc.footnote())
|
||
ws.merge_range(
|
||
foot + 1, 0, foot + 1, 4,
|
||
"면책: 본 리포트는 공개 데이터를 자동 수집·가공한 참고 자료이며, "
|
||
"원본과 차이가 있을 수 있고 법적 효력이 없다. "
|
||
"규제 판단은 반드시 원문 공고를 확인할 것.",
|
||
fc.footnote())
|
||
|
||
ws.freeze_panes(3, 0)
|
||
ws.set_portrait()
|
||
ws.set_paper(9)
|
||
ws.fit_to_pages(1, 0)
|
||
ws.set_margins(0.5, 0.5, 0.6, 0.6)
|
||
ws.print_area(0, 0, foot + 1, 4)
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 인쇄·공유 설정
|
||
|
||
### 9.1 시트별 인쇄 설정표
|
||
|
||
| 시트 | 방향 | 용지 | 맞춤 | 제목 반복 | 인쇄 영역 | 눈금선 인쇄 | 가운데 |
|
||
|---|---|---|---|---|---|---|---|
|
||
| `00_대시보드` | 가로 | A4(`set_paper(9)`) | `fit_to_pages(1, 1)` | 없음 | `A1:S47` | 숨김(`hide_gridlines(2)`) | 가로 가운데 |
|
||
| `01_오늘변경분` | 가로 | A4 | `fit_to_pages(1, 0)` | `repeat_rows(0, 2)` | `A1:S{last}` | 기본 | 아니오 |
|
||
| `02_전체현황` | 가로 | A4 | `fit_to_pages(1, 0)` | `repeat_rows(0, 2)` | `A1:O{last}` (히든 열 제외) | 기본 | 아니오 |
|
||
| `03_성분별` | 가로 | A4 | `fit_to_pages(1, 0)` | `repeat_rows(0, 2)` | `A1:P{total_row}` | 기본 | 아니오 |
|
||
| `04_업체별` | 가로 | A4 | `fit_to_pages(1, 0)` | `repeat_rows(0, 2)` | `A1:Q{total_row}` | 기본 | 아니오 |
|
||
| `05_워치리스트` | 가로 | A4 | `fit_to_pages(1, 0)` | `repeat_rows(0, 2)` | `A1:N{cend}` | 기본 | 아니오 |
|
||
| `06_추이` | 가로 | A4 | `fit_to_pages(1, 0)` | `repeat_rows(0, 2)` | `A1:I{last}` | 기본 | 아니오 |
|
||
| `99_메타` | **세로** | A4 | `fit_to_pages(1, 0)` | 없음 | `A1:E{foot+1}` | 기본 | 아니오 |
|
||
|
||
- `fit_to_pages(1, 0)` = **너비 1페이지, 높이 무제한**. 원장이 수백 페이지가 되어도 열이 잘리지 않는다.
|
||
- `99_메타` 만 세로다. 5열짜리 목록이라 가로로 하면 여백만 커진다.
|
||
- **인쇄 영역에서 히든 열은 제외**한다. 숨겨진 열은 인쇄되지 않지만, 인쇄 영역 끝을 히든 열에 두면 마지막 페이지에 빈 페이지가 생기는 경우가 있다.
|
||
|
||
### 9.2 여백·머리글·바닥글
|
||
|
||
```python
|
||
ws.set_margins(left=0.4, right=0.4, top=0.6, bottom=0.6) # 인치
|
||
ws.set_header("") # 머리글 없음
|
||
ws.set_footer('&L&"맑은 고딕"&8{시트 이름}'
|
||
'&C&"맑은 고딕"&8&P / &N'
|
||
'&R&"맑은 고딕"&8&D')
|
||
```
|
||
|
||
| 코드 | 의미 |
|
||
|---|---|
|
||
| `&L` / `&C` / `&R` | 좌 / 가운데 / 우 구역 |
|
||
| `&"맑은 고딕"` | 폰트 지정 |
|
||
| `&8` | 8pt |
|
||
| `&P` / `&N` | 현재 페이지 / 전체 페이지 |
|
||
| `&D` / `&T` | 인쇄 날짜 / 시각 |
|
||
|
||
머리글을 비우는 이유: 1행에 이미 제목과 기준일이 있고, `repeat_rows(0, 2)` 로 매 페이지 반복되므로 머리글이 중복된다.
|
||
|
||
### 9.3 화면 공유용 설정
|
||
|
||
| 항목 | 값 | 이유 |
|
||
|---|---|---|
|
||
| 최초 열림 창 크기 | `wb.set_size(1500, 900)` | 노트북 화면에서 대시보드가 잘리지 않게 |
|
||
| 최초 활성 시트 | `00_대시보드` | 파일을 여는 사람이 먼저 볼 것 |
|
||
| 최초 활성 셀 | 각 시트 `A1` (기본) | 스크롤 위치 초기화 |
|
||
| 확대 | 대시보드 90%, 나머지 100% | |
|
||
| 문서 속성 | `set_properties(...)` 로 제목·주제·작성자·키워드 채움 | 파일 탐색기 미리보기와 검색에 잡힘 |
|
||
| 시트 보호 | `05_워치리스트` 만 | 읽기 전용 미러임을 물리적으로 표시 |
|
||
| 통합문서 보호 | **걸지 않는다** | 사용자가 시트를 복사·추가해 자기 분석을 할 수 있어야 한다 |
|
||
| 비밀번호 | **없음** | 자동 배치가 만드는 파일이라 비밀번호 관리 주체가 없다 |
|
||
|
||
### 9.4 PDF 변환
|
||
|
||
내장 변환은 제공하지 않는다. LibreOffice headless 는 아키텍처 부록에서 기각됐고, Excel COM 자동화는 Excel 설치와 대화형 세션을 요구해 배치(S4U)에서 동작하지 않는다. 사용자가 Excel 에서 `파일 → 내보내기 → PDF` 를 누르면 위 인쇄 설정이 그대로 적용된다. **인쇄 설정을 정확히 잡아 두는 것이 곧 PDF 품질 보증이다.**
|
||
|
||
---
|
||
|
||
## 10. 파일명·보관 규약
|
||
|
||
### 10.1 파일명
|
||
|
||
| 종류 | 패턴 | 예 | 설정키 |
|
||
|---|---|---|---|
|
||
| 일자별 정본 | `DMF_리포트_{date}.xlsx` (`date` = `YYYY-MM-DD`) | `DMF_리포트_2026-09-02.xlsx` | `report.filename_pattern` |
|
||
| 최신본 고정 링크 | `DMF_리포트_최신.xlsx` | 좌동 | `report.latest_link_name` (빈 문자열이면 생성 안 함) |
|
||
| 잠김 폴백 | `DMF_리포트_{date}_{HHMMSS}.xlsx` | `DMF_리포트_2026-09-02_060241.xlsx` | 자동 |
|
||
| 생성 중 임시 | `.~dmf_XXXXXX.xlsx` (`tempfile.mkstemp`) | — | 자동, 반드시 같은 디렉터리 |
|
||
| 최신본 임시 | `.~dmflatest_XXXXXX.xlsx` | — | 자동 |
|
||
|
||
**날짜는 `report_date` 이지 실행 시각이 아니다.** 06:00 실행이 자정을 넘겨 07:00 에 끝나도 파일명은 그날 날짜다. `general.timezone`(`Asia/Seoul`) 기준으로 판정한다.
|
||
|
||
**파일명에 한글을 쓰는 이유**: 사용자가 탐색기에서 바로 찾는 것이 이 프로젝트의 배포 방식이다. Windows 로 배포처가 한정되므로 인코딩 문제가 없다. 다만 이메일 첨부나 웹 업로드 시 깨질 수 있음을 README 에 적는다.
|
||
|
||
### 10.2 "최신본" 을 심볼릭 링크로 만들지 않는 이유
|
||
|
||
| 방식 | 문제 |
|
||
|---|---|
|
||
| 심볼릭 링크(`os.symlink`) | Windows 에서 관리자 권한 또는 개발자 모드 필요. 배치는 일반 사용자 계정(S4U)으로 돈다 |
|
||
| 하드링크(`os.link`) | 원본을 보존기간 정리로 지우면 최신본만 남아, 어느 날짜인지 알 수 없게 된다 |
|
||
| 바로가기(`.lnk`) | xlsx 가 아니라 파일 형식이 달라진다. 더블클릭 동작이 환경마다 다르다 |
|
||
| **원자적 복사(채택)** | 디스크를 2배 쓰지만 파일 하나가 수 MB 수준이라 무시할 수 있다. 완전히 독립적이고 안전하다 |
|
||
|
||
`refresh_latest_link()` 는 최신본이 열려 있으면 **조용히 실패하고 False 를 반환**한다. 리포트 본체는 이미 저장됐으므로 실패시켜서는 안 된다. 경고만 `ReportOutcome.warnings` 에 남긴다.
|
||
|
||
### 10.3 보관 폴더 구조
|
||
|
||
```
|
||
reports\
|
||
├── DMF_리포트_최신.xlsx ← 항상 최신 성공본의 복사
|
||
├── DMF_리포트_2026-09-02.xlsx
|
||
├── DMF_리포트_2026-09-01.xlsx
|
||
├── DMF_리포트_2026-08-31.xlsx
|
||
└── … ← report.retain_days(기본 365) 초과분 삭제
|
||
```
|
||
|
||
**하위 폴더를 만들지 않는다.** 아키텍처 §2 의 확정 트리가 `reports\` 를 평면으로 정의했고, 1년치 365개는 탐색기가 문제없이 다룬다. 날짜가 파일명에 있어 이름순 정렬이 곧 시간순이다.
|
||
|
||
### 10.4 보존 정리 규칙
|
||
|
||
`finalize` 스테이지에서 로그 정리와 함께 수행한다.
|
||
|
||
| 대상 | 보존 | 설정키 |
|
||
|---|---|---|
|
||
| `reports\DMF_리포트_YYYY-MM-DD.xlsx` | `report.retain_days` = 365일 | `report.retain_days` |
|
||
| `reports\DMF_리포트_최신.xlsx` | 삭제하지 않음 | — |
|
||
| 폴백 파일(`_HHMMSS.xlsx`) | **7일**. 정규 파일이 같은 날짜로 존재하면 즉시 삭제 후보 | 하드코딩 |
|
||
| 잔여 임시 파일(`.~dmf_*.xlsx`) | 24시간 초과분 무조건 삭제 | 하드코딩 |
|
||
| `data\raw\YYYY-MM-DD\` | `source.archive_retain_days` = 180일 | 별도 |
|
||
| `logs\run_*\` | `logging.retain_days` = 90일 | 별도 |
|
||
|
||
```python
|
||
def prune_reports(out_dir: Path, retain_days: int, today: date) -> list[Path]:
|
||
"""보존 기간 초과 리포트와 잔여 임시 파일을 지운다. 지운 경로 목록 반환."""
|
||
removed: list[Path] = []
|
||
cutoff = today - timedelta(days=retain_days)
|
||
for p in out_dir.glob("DMF_리포트_*.xlsx"):
|
||
if p.name == "DMF_리포트_최신.xlsx":
|
||
continue
|
||
m = re.match(r"DMF_리포트_(\d{4}-\d{2}-\d{2})", p.stem)
|
||
if not m:
|
||
continue
|
||
d = date.fromisoformat(m.group(1))
|
||
# 폴백 파일: 정규 파일이 있으면 7일, 없으면 정규와 동일 취급
|
||
is_fallback = re.search(r"_\d{6}$", p.stem) is not None
|
||
limit = today - timedelta(days=7) if is_fallback else cutoff
|
||
if d < limit:
|
||
p.unlink(missing_ok=True)
|
||
removed.append(p)
|
||
now = time.time()
|
||
for p in out_dir.glob(".~dmf*.xlsx"):
|
||
if now - p.stat().st_mtime > 86400:
|
||
p.unlink(missing_ok=True)
|
||
removed.append(p)
|
||
return removed
|
||
```
|
||
|
||
### 10.5 백업과의 관계
|
||
|
||
리포트는 **재생성 가능한 파생물**이다. SQLite 정본(`data\dmf.sqlite3`)과 원문 아카이브(`data\raw\`)가 살아 있으면 `python -m dmf_crawler report-only --run-id <id>` 로 언제든 다시 만들 수 있다. 따라서 `backup\` 에는 리포트를 넣지 않는다. 백업 대상은 DB 하나다.
|
||
|
||
---
|
||
|
||
## 부록. 미해결 / 실측 필요
|
||
|
||
### A. 실측이 필요한 것
|
||
|
||
- [ ] nedrug 성분명 검색 URL(`CCBAC03/getItem?...&itemName=`)이 실제로 동작하는지. 실패 시 목록 URL 폴백으로 전환.
|
||
- [ ] XlsxWriter `define_name()` 이 한글 정의 이름을 통과시키는지. 통과하면 `REPORT_DATE` 옆에 `기준일` 같은 한글 별칭을 추가할 수 있다(현재는 ASCII 만 사용).
|
||
- [ ] `add_table` 의 `total_value` 옵션이 설치 버전에서 실제로 캐시값을 기록하는지. 기록하지 않으면 합계행을 표 밖의 일반 행으로 옮긴다.
|
||
- [ ] `write_url` 로 만든 `external:` 로컬 경로 링크가 한글 경로(`DMF_리포트_최신.xlsx`)에서 정상 동작하는지. Excel 보안 설정에 따라 차단될 수 있다.
|
||
- [ ] 조건부서식에서 정의된 이름(`REPORT_DATE`)을 참조하는 수식(`CF-02-02`, `CF-05-02`, `CF-06-01`)이 Excel 에서 정상 평가되는지. 실패하면 기준일 문자열을 리터럴로 굽는다.
|
||
- [ ] 스파크라인 30 포인트가 `column` 타입에서 96px 폭(2열 병합)에 시각적으로 읽히는지. 안 읽히면 14 포인트로 줄인다.
|
||
- [ ] `records` 테이블 행수가 수만 건일 때 `02_전체현황` 생성 시간과 파일 크기. 30초를 넘으면 Excel 표를 포기하고 `autofilter` 로 대체(`constant_memory` 를 켤 수 있게).
|
||
- [ ] 맑은 고딕 10pt 기준 열 폭 계수 1.8 의 실제 적합도. 첫 산출물을 열어 눈으로 확인하고 1.7~1.9 사이에서 보정.
|
||
- [ ] `SQL_TOP_COUNTRY` 의 `json_each` 트릭이 설치된 SQLite 버전에서 동작하는지(JSON1 확장 필요). 안 되면 파이썬에서 콤마 분해로 집계.
|
||
- [ ] `06_추이` 히든 블록의 열 인덱스(AD=29, BJ=61)가 성분·업체 30일 시계열 폭과 충돌하지 않는지. Top N 이 커지면 블록 시작 열을 재배치.
|
||
|
||
### B. 상위 문서 확정 후 갱신할 것
|
||
|
||
- [ ] `docs/design/02-data-model.md` 확정 시 §8.4 의 잠정 스키마·SQL 을 실제 DDL 에 맞춰 교체.
|
||
- [ ] `records.status` 의 값 도메인(`유효` / `취하`)이 데이터 모델에서 확정되면 `CF-02-03`·`SQL_*_CUM` 의 리터럴을 맞춘다.
|
||
- [ ] `watchlist` 테이블의 편집 UI 를 `docs/design/04-onboarding-wizard.md` 에서 확정. 현재는 "설정 GUI 에서 편집" 이라고만 정해 두었고 화면이 없다.
|
||
- [ ] `enrichment` 스키마(`headline`/`summary`/`risk_note`/`note`/`importance`)가 `prompts/daily_briefing.schema.json` 과 일치하는지 M3 에서 교차 확인.
|
||
|
||
### C. 의도적으로 채택하지 않은 것 (기록)
|
||
|
||
- [ ] **`스냅샷·로그` 시트** — 리서치 07 제안. SQLite `events` 가 정본이라 xlsx 중복. `99_메타` 의 경로 포인터로 대체.
|
||
- [ ] **상태 `연차보고` / `사전등록`** — 원천 API 에 등록구분 필드가 없다. nedrug 화면 크롤링을 추가하면 그때 재검토.
|
||
- [ ] **피벗테이블** — 파이썬에서 생성 불가. `03`/`04` 의 정적 집계표 + 차트로 대체(리서치 07 §10 의 방식 A).
|
||
- [ ] **워치리스트 사용자 편집 왕복** — openpyxl 의존을 만들어야 해서 기각. SSOT 를 SQLite 로 옮겨 해결.
|
||
- [ ] **도형(shape) 기반 KPI 카드** — XlsxWriter 로 도형 그룹을 만들 수 없다. 셀 병합 + 테두리 + 배경으로 동일 효과.
|
||
- [ ] **신호등 아이콘셋** — CVD 위험(red/green + 동일 형태). `3_arrows_gray` 만 사용.
|
||
- [ ] **`constant_memory` 모드** — Excel 표와 배타적. 현재 규모에서 불필요.
|
||
- [ ] **PDF 자동 변환** — LibreOffice·Excel COM 둘 다 기각. 인쇄 설정으로 대체.
|
||
- [ ] **Okabe-Ito Yellow(`#F0E442`)** — 흰 배경 대비 1.32. 팔레트에는 있으나 사용 금지.
|