@simplysm/sd-claude 14.0.78 → 14.0.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/claude/output-styles/sd-tone.md +26 -2
  2. package/claude/references/sd-simplysm14/manuals/logging.md +1 -1
  3. package/claude/rules/sd-base-rules.md +109 -87
  4. package/claude/skills/sd-dev/SKILL.md +1 -1
  5. package/claude/skills/sd-impl/SKILL.md +15 -14
  6. package/claude/skills/sd-impl/references/spec-cross-check.md +2 -2
  7. package/claude/skills/sd-spec/SKILL.md +746 -192
  8. package/claude/skills/sd-spec/references/example-spec.md +107 -35
  9. package/claude/skills/sd-unpack/SKILL.md +39 -14
  10. package/claude/skills/sd-unpack/scripts/handlers/__pycache__/_common.cpython-314.pyc +0 -0
  11. package/claude/skills/sd-unpack/scripts/handlers/__pycache__/eml_handler.cpython-314.pyc +0 -0
  12. package/claude/skills/sd-unpack/scripts/handlers/__pycache__/office_com.cpython-314.pyc +0 -0
  13. package/claude/skills/sd-unpack/scripts/handlers/__pycache__/pdf_handler.cpython-314.pyc +0 -0
  14. package/claude/skills/sd-unpack/scripts/handlers/_common.py +59 -0
  15. package/claude/skills/sd-unpack/scripts/handlers/eml_handler.py +7 -0
  16. package/claude/skills/sd-unpack/scripts/handlers/msg_handler.py +11 -0
  17. package/claude/skills/sd-unpack/scripts/handlers/office_com.py +288 -79
  18. package/claude/skills/sd-unpack/scripts/handlers/office_worker.py +3 -2
  19. package/claude/skills/sd-unpack/scripts/handlers/pdf_handler.py +78 -10
  20. package/package.json +1 -1
  21. package/claude/skills/sd-spec/references/spec-authoring.md +0 -298
  22. package/claude/skills/sd-spec/references/spec-md-template.md +0 -29
  23. package/claude/skills/sd-wip/SKILL.md +0 -38
  24. package/claude/skills/sd-wip/evals/fixtures/empty/.gitkeep +0 -0
  25. package/claude/skills/sd-wip/evals/fixtures/with-artifact/projects/acct/_wip.md +0 -3
  26. package/claude/skills/sd-wip/evals/fixtures/with-artifact/projects/acct/spec.md +0 -15
  27. package/claude/skills/sd-wip/evals/fixtures/with-existing-wip/.wips/260101120000_acct.md +0 -6
  28. package/claude/skills/sd-wip/evals/fixtures/with-existing-wip-for-compact/.wips/260101120000_acct.md +0 -14
  29. package/claude/skills/sd-wip/evals/golden.jsonl +0 -4
  30. package/claude/skills/sd-wip/references/compact.md +0 -79
@@ -86,6 +86,12 @@ flowchart LR
86
86
 
87
87
  관련 섹션: [화면.재고 확인], [자동 처리.재고 스냅샷]
88
88
 
89
+ ### 3.2 마스터 데이터 변경 이력 [확정: 2026-04-01]
90
+
91
+ 모든 마스터 데이터의 변경 (등록·편집·활성/비활성 전환) 이력을 자동 기록해야 함. 변경 추적·감사 목적.
92
+
93
+ 관련 섹션: [횡단 처리.마스터 데이터 변경 이력]
94
+
89
95
  ## 4. 화면
90
96
 
91
97
  | § | 분류 | 화면 | 유형 | 장치 |
@@ -415,21 +421,67 @@ flowchart LR
415
421
 
416
422
  관련 섹션: [기타.과거 재고 조회], [화면.재고 확인]
417
423
 
418
- ## 6. 공통 정의
424
+ ## 6. 횡단 처리
425
+
426
+ ### 6.1 마스터 데이터 변경 이력 [확정: 2026-04-01]
427
+
428
+ ```mermaid
429
+ flowchart LR
430
+ S([마스터 엔티티 insert/update/delete]) --> T1[변경 전·후 값 캡처]
431
+ T1 --> T2[DataLog 테이블에 한 행 적재]
432
+ T2 --> E1([완료])
433
+ ```
434
+
435
+ 목적:
436
+
437
+ - 마스터 변경 추적·감사 (누가 언제 무엇을 어떻게 바꿨는지)
438
+
439
+ 트리거 (부수효과 발동 조건):
440
+
441
+ - 마스터 엔티티의 insert / update / delete 트랜잭션 commit 시
442
+ - 적용 범위: [모델.품목], [모델.Location]
443
+ - 적용 제외: 트랜잭션 모델 ([모델.재고], [모델.재고 스냅샷])
444
+
445
+ 처리:
446
+
447
+ - 변경 전 값과 변경 후 값 캡처 (insert 시 변경 전 = null, delete 시 변경 후 = null)
448
+ - [모델.DataLog] 에 한 행 적재 (테이블·키·전·후·작업자·시각)
449
+
450
+ 예외 처리:
451
+
452
+ - DataLog 적재 실패 시:
453
+ - 위험: 변경 추적 누락
454
+ - 대처: 본 트랜잭션은 정상 commit 유지, DataLog 적재 실패만 별도 큐로 적재 후 관리자 알림
455
+ - 재시도 한계: 큐에서 5회 지수 백오프 후 운영팀 알림
419
456
 
420
- ### 6.1 용어 사전 [확정: 2026-04-01]
457
+ 모델 매핑:
458
+
459
+ | 필드 ([모델.DataLog]) | 용도 |
460
+ | --------------------- | ------------------------------- |
461
+ | [모델.DataLog.테이블명] | 변경 대상 모델명 (예: `품목`) |
462
+ | [모델.DataLog.키] | 변경 대상 ID |
463
+ | [모델.DataLog.변경 전] | 컬럼별 변경 전 값 (JSON) |
464
+ | [모델.DataLog.변경 후] | 컬럼별 변경 후 값 (JSON) |
465
+ | [모델.DataLog.작업자] | 세션 사용자 |
466
+ | [모델.DataLog.시각] | 변경 commit 시각 |
467
+
468
+ 관련 섹션: [기타.마스터 데이터 변경 이력], [모델.품목], [모델.Location], [모델.DataLog]
469
+
470
+ ## 7. 공통 정의
471
+
472
+ ### 7.1 용어 사전 [확정: 2026-04-01]
421
473
 
422
474
  - 박스: 외부에서 이미 바코드(품목+수량+박스번호)가 부착된 입고 단위
423
475
  - Location: 창고 내 적치 위치 ([공통 정의.Location 라벨])
424
476
  - ERP 통보: 입출고 발생 시 WMS → ERP 단방향 알림
425
477
  - 활성 여부: 마스터 데이터의 소프트 삭제 플래그 (true = 정상, false = 삭제 처리됨). UI 의 "삭제/복구" 액션과 매핑. 마스터에 완전 삭제(DB 레코드 제거) 없음.
426
478
 
427
- ### 6.2 박스 바코드 형식 [OPEN: 2026-04-01]
479
+ ### 7.2 박스 바코드 형식 [OPEN: 2026-04-01]
428
480
 
429
481
  - 자료 위치: 첨부B.pdf p.5 "박스 코드 체계" — 외부 송장 시스템 규격 명세
430
- - 자료 연결 메모: 박스 코드는 외부 규격이지만 [모델.박스] 식별 키 구조와 직결 — §7 박스 확정과 함께 검토
482
+ - 자료 연결 메모: 박스 코드는 외부 규격이지만 [모델.박스] 식별 키 구조와 직결 — §8 박스 확정과 함께 검토
431
483
 
432
- ### 6.3 Location 라벨 [확정: 2026-04-01]
484
+ ### 7.3 Location 라벨 [확정: 2026-04-01]
433
485
 
434
486
  ```
435
487
  ┌────────────────────────────────┐
@@ -446,7 +498,7 @@ flowchart LR
446
498
  - 라벨 크기: 100 × 50 mm
447
499
  - 라벨 재질: 유포지, 흰색 무지
448
500
 
449
- ### 6.4 화주 품목 자료 [확정: 2026-04-01]
501
+ ### 7.4 화주 품목 자료 [확정: 2026-04-01]
450
502
 
451
503
  화주(품목을 본 WMS 에 보관 위탁한 거래처)가 자기 ERP 에서 수시로 다운로드해 화주별 별도 파일로 전달한다. 신규 화주 등록 또는 화주 측 품목 마스터 변경 시점에 본 WMS 에 1회 일괄 업로드한다. 식별 키는 `화주코드 + SEQ` 2컬럼 합성.
452
504
 
@@ -457,18 +509,18 @@ flowchart LR
457
509
  | 품목명 | O | 화주 측 명칭 |
458
510
  | 단위 | X | `EA` / `BOX` 등 |
459
511
 
460
- ## 7. 도메인 모델
512
+ ## 8. 도메인 모델
461
513
 
462
- ### 7.1 품목 [확정: 2026-04-01]
514
+ ### 8.1 품목 [확정: 2026-04-01]
463
515
 
464
516
  필드:
465
517
 
466
- | 필드 | 타입 | 필수 | 비고 |
467
- | --------- | ------- | ---- | ---------------- |
468
- | ID | 숫자 | O | 자동 부여 |
518
+ | 필드 | 타입 | 필수 | 비고 |
519
+ | --------- | ------- | ---- | -------------------- |
520
+ | ID | 숫자 | O | 자동 부여 |
469
521
  | 코드 | 문자 | O | 자유 형식, 수정 가능 |
470
- | 명칭 | 문자 | O | |
471
- | 활성 여부 | boolean | O | |
522
+ | 명칭 | 문자 | O | |
523
+ | 활성 여부 | boolean | O | |
472
524
 
473
525
  키/제약:
474
526
 
@@ -476,7 +528,7 @@ flowchart LR
476
528
  - 비즈니스 키: 코드 (사용자 부여, 수정 가능)
477
529
  - 유일성: 활성 품목 내 코드, 명칭 각각 유일
478
530
 
479
- ### 7.2 박스 [확정: 2026-04-01]
531
+ ### 8.2 박스 [확정: 2026-04-01]
480
532
 
481
533
  필드:
482
534
 
@@ -494,7 +546,7 @@ flowchart LR
494
546
  - 박스 1개 = 단일 품목
495
547
  - 유일성: 박스번호 유일
496
548
 
497
- ### 7.3 Location [확정: 2026-04-01]
549
+ ### 8.3 Location [확정: 2026-04-01]
498
550
 
499
551
  필드:
500
552
 
@@ -510,7 +562,7 @@ flowchart LR
510
562
  - 비즈니스 키: 코드 (사용자 부여, 수정 가능)
511
563
  - 유일성: 활성 Location 내 코드 유일
512
564
 
513
- ### 7.4 재고 [확정: 2026-04-01]
565
+ ### 8.4 재고 [확정: 2026-04-01]
514
566
 
515
567
  필드:
516
568
 
@@ -526,25 +578,44 @@ flowchart LR
526
578
  - 박스 1개는 동시에 1개 Location 에만 존재 (= 박스-재고 1:1)
527
579
  - Location 1개에 박스 다중 가능
528
580
 
529
- ### 7.5 재고 스냅샷 [확정: 2026-04-01]
581
+ ### 8.5 재고 스냅샷 [확정: 2026-04-01]
530
582
 
531
583
  필드:
532
584
 
533
- | 필드 | 타입 | 필수 | 비고 |
534
- | -------- | --------------- | ---- | --------- |
535
- | ID | 숫자 | O | 자동 부여 |
585
+ | 필드 | 타입 | 필수 | 비고 |
586
+ | -------- | --------------- | ---- | ----------- |
587
+ | ID | 숫자 | O | 자동 부여 |
536
588
  | 날짜 | 날짜 | O | 스냅샷 일자 |
537
- | 박스 | 참조 - 박스 | O | |
538
- | Location | 참조 - Location | O | |
589
+ | 박스 | 참조 - 박스 | O | |
590
+ | Location | 참조 - Location | O | |
539
591
 
540
592
  키/제약:
541
593
 
542
594
  - 식별 키: ID (자동 부여)
543
595
  - 비즈니스 키: (날짜, 박스) 조합 유일
544
596
 
545
- ## 8. 외부 인터페이스
597
+ ### 8.6 DataLog [확정: 2026-04-01]
546
598
 
547
- ### 8.1 ERP 입고 통보 [확정: 2026-04-01]
599
+ 필드:
600
+
601
+ | 필드 | 타입 | 필수 | 비고 |
602
+ | -------- | ---- | ---- | ------------------------------------- |
603
+ | ID | 숫자 | O | 자동 부여 |
604
+ | 테이블명 | 문자 | O | 변경 대상 모델명 (예: `품목`) |
605
+ | 키 | 숫자 | O | 변경 대상 ID |
606
+ | 변경 전 | JSON | X | 컬럼별 이전 값. insert 시 null |
607
+ | 변경 후 | JSON | X | 컬럼별 변경 후 값. delete 시 null |
608
+ | 작업자 | 문자 | O | 세션 사용자 |
609
+ | 시각 | 일시 | O | 변경 commit 시각 |
610
+
611
+ 키/제약:
612
+
613
+ - 식별 키: ID (자동 부여)
614
+ - 인덱스: (테이블명, 키, 시각) — 특정 엔티티 변경 이력 조회용
615
+
616
+ ## 9. 외부 인터페이스
617
+
618
+ ### 9.1 ERP 입고 통보 [확정: 2026-04-01]
548
619
 
549
620
  - 상대 시스템: ERP
550
621
  - 방향: WMS → ERP
@@ -569,21 +640,22 @@ flowchart LR
569
640
 
570
641
  관련 섹션: [화면.입고 스캔]
571
642
 
572
- ## 9. 본문 외 결정사항
643
+ ## 10. 본문 외 결정사항
573
644
 
574
- ### 9.1 분석 제외 항목 [확정: 2026-04-01]
575
-
576
- - 직원 관리 사원번호 컬럼 추가
645
+ - 2026-04-01 [제외]: 직원 관리 사원번호 컬럼 추가
646
+ - 근거: 본 spec 범위 밖 — 인사 마스터 영역
577
647
  - 후속 처리: sd-dev
578
648
  - 자료 위치: 회의록.md L42
579
- - 재고 확인 화면 정렬 버그
649
+
650
+ - 2026-04-01 [제외]: 재고 확인 화면 정렬 버그
651
+ - 근거: 본 spec 범위 밖 — 운영 잔손
580
652
  - 후속 처리: 운영 잔손
581
653
  - 자료 위치: 이슈 #128
582
654
 
583
- ## 10. 변경 이력
655
+ - 2026-04-01: 출고 분석은 입고 끝나고 다음 단계
656
+ - 근거: 사용자 결정 — 입고 검증 후 출고 진행이 안전
657
+ - 자료 위치: 회의록.md L40-50
658
+ - 영향: §2.2 출고 OPEN 상태 유지
584
659
 
585
- - 2026-04-01: 운영 잔손 분리 직원 관리 사원번호 컬럼 추가, 재고 확인 화면 정렬 버그. 분석 제외 항목으로 기록.
586
- - 2026-04-01: 입고 분석 완료. 출고·박스 바코드 형식 보류.
587
- - 2026-04-01: 과거 재고 조회 요구 식별 → 재고 스냅샷 모델·자동 처리 추가, 재고 확인 화면에 기준일 필터 반영.
588
- - 2026-04-01: ERP 입고 통보 식별 → 입고 스캔 등록 후속 동작에서 호출.
589
- - 2026-04-05: 품목 관리·품목 등록·편집 sd-impl 완료 (구현 마커 부착).
660
+ - 2026-04-05: 품목 마스터 sd-impl 완료 (구현 마커 부착)
661
+ - 근거: 시연 검증 통과 (§4.1·§4.2)
@@ -49,10 +49,10 @@ meeting_eml/
49
49
  embedded_xlsx/
50
50
  README.md
51
51
  _source.xlsx
52
- workbook.meta.json ← defined names (있을 때)
52
+ workbook.meta.json ← defined names·sheet_code_map (있을 때)
53
53
  sheets/
54
- 01_Sheet1.png
55
- 01_Sheet1.jsonl
54
+ 01_Sheet1.png ← 시각 (서식·바탕색·border 모두)
55
+ 01_Sheet1.jsonl ← 분석 데이터 (값·number_format·수식·merges 등)
56
56
  ```
57
57
 
58
58
  형식별 산출물 매트릭스:
@@ -61,7 +61,7 @@ meeting_eml/
61
61
  |---|---|---|---|
62
62
  | pptx/ppt | `slides/<idx>_<title>.png` | `slides/<idx>_<title>.jsonl` (슬라이드별 노드) | `charts/*.data.json`, `images/`, `attachments/`, `macros/` |
63
63
  | docx/doc | `pages/<NNN>.png` (시각 검증용) | `content.jsonl` (단일 시퀀스), `pages.meta.json` (PNG↔노드 매핑) | `images/`, `attachments/`, `macros/` |
64
- | xlsx/xlsb/xls | `sheets/<idx>_<name>.png` | `sheets/<idx>_<name>.jsonl` (값·수식·시트 메타 통합), `workbook.meta.json` | `charts/*.data.json`, `images/<sheet>_<cell>`, `attachments/`, `macros/` |
64
+ | xlsx/xlsb/xls | `sheets/<idx>_<name>.png` (일반 시트만) | `sheets/<idx>_<name>.jsonl` (값·수식·시트 메타), `workbook.meta.json` | `charts/sheet<idx>_chart*.data.json` (일반 시트 안 차트 + Chartsheet 의 차트), `images/<sheet>_<cell>`, `attachments/`, `macros/` |
65
65
  | pdf | `pages/<NNN>.png` | `pages/<NNN>.jsonl` (블록 bbox + 표 셀 단위) | `images/p<NNN>_b<bid>.<ext>`, `attachments/` (PDF 임베드) |
66
66
  | eml/msg | — | `body.md` (평문 본문), `headers.json`, `images.rels.json` | `body.html` (원본), `attachments/` (컨테이너면 재귀) |
67
67
 
@@ -78,11 +78,14 @@ meeting_eml/
78
78
 
79
79
  ## xlsx jsonl 규약
80
80
 
81
- 시트별 `.jsonl`. 좌표 명시로 위치 오차 차단.
81
+ 시트별 `.jsonl` 분석 핵심 (값·number_format·수식·merges·hyperlinks·comments). 시각 표시 (바탕색·border·폰트)·frozen·dims 는 미보존 (PNG 가 시각 보조, 필요 시 `_source.xlsx` 직접 추출).
82
82
 
83
- - 첫 줄: `{"_meta":{"dims":[행수,열수], "merges":["A1:C1",...], "frozen":"A4", "hyperlinks":{"D5":"http://..."}, "comments":{"E3":"메모"}, "number_formats":{"H4":"#,##0", "E1":"yyyy-mm-dd"}}}`
84
- - 비어있는 메타 키는 생략
85
- - `number_formats`: General(기본) 외 셀의 표시 형식 (통화·날짜·%)
83
+ - 첫 줄: `{"_meta":{"merges":["A1:C1",...], "number_formats":{"E1":"yyyy-mm-dd",...}, "hyperlinks":{"D5":"http://..."}, "comments":{"E3":"메모"}}}`
84
+ - `merges`: 머지된 영역 (셀 좌표 해석에 필수 — 머지 영역 안 빈 셀 오해 차단)
85
+ - `number_formats`: General(기본) 외 표시 형식 Date·통화·% 셀 값 의미 단서
86
+ - `hyperlinks`: 셀 URL (URL 자체가 셀 정보)
87
+ - `comments`: 셀 메모
88
+ - 비어있는 메타 키는 생략 (모두 비면 `{"_meta":{}}`)
86
89
  - 데이터 줄: `{"r":11, "A":"P001", "I":7800, "J":12.5, "_f":{"I":"=SUM(...)", "J":"=I11*1.5"}}`
87
90
  - `r`: 1-based 행번호 (Excel 동일)
88
91
  - 열문자 키 (`A`·`B`·...·`AA`·...): 셀 값. 빈 셀은 키 생략
@@ -90,8 +93,22 @@ meeting_eml/
90
93
  - 빈 행도 `{"r":N}` 한 줄 유지 → Read offset = 행번호 (오프바이원 차단)
91
94
  - 값 타입: JSON 네이티브 (`int`·`float`·`bool`·`str`), datetime 은 ISO 8601 문자열
92
95
 
93
- 워크북 단위 메타 (시트 ) 는 `workbook.meta.json`:
96
+ ### Chartsheet (시트 자체가 차트)
97
+
98
+ xlsx 안 시트는 일반 Worksheet 외에 **Chartsheet** (셀 없이 차트 1개) 도 있을 수 있음.
99
+
100
+ - Chartsheet 는 `sheets/<idx>_<name>.jsonl` 미생성 (셀 없음)
101
+ - Chartsheet 의 차트 데이터: `charts/sheet<idx>_chart.data.json`
102
+ - README sheet_summaries 에 `(chart sheet — "...")` 명시
103
+ - 일반 시트·Chartsheet 통합 시트 순서 (idx) 대로 보존
104
+
105
+ ### 워크북 단위 `workbook.meta.json`
106
+
107
+ 시트 외 워크북 공통 정보 (있을 때만 생성):
108
+
94
109
  - `defined_names`: `{"이름":["'Sheet1'!$A$1:$C$10", ...]}` (다중 destination 시 list 다수 항목)
110
+ - `sheet_code_map`: `{"Sheet1":"BOA", ...}` (VBA codeName → raw 시트명. 매크로 모듈 파일명과 매칭용)
111
+ - `pivots`: pivot table 정의 list. 각 항목 `{name, source, location, rowFields, colFields, pageFields, dataFields}`. 결과 셀은 시트별 jsonl 에 일반 셀로 들어감
95
112
 
96
113
  ## pptx jsonl 규약
97
114
 
@@ -146,15 +163,17 @@ paragraph 안 hyperlink 가 있으면 `hyperlinks`: `[{"text":"...", "url":"..."
146
163
 
147
164
  페이지별 `pages/<NNN>.jsonl`. PDF 페이지는 원본 단위.
148
165
 
149
- - 첫 줄: `{"_meta":{"page":N, "size":[w,h], "blocks":B, "tables":T, "table_cells":C}}`
166
+ - 첫 줄: `{"_meta":{"page":N, "size":[w,h], "blocks":B, "tables":T, "table_cells":C, "form_fields":F, "annotations":A}}`
150
167
  - 노드 줄:
151
168
  - `text_block`: `{"page":N, "block":B, "type":"text_block", "bbox":[x0,y0,x1,y1], "text":"..."}`
152
169
  - `image_block`: `{"page":N, "block":B, "type":"image_block", "bbox":[...], "ref":"images/p001_b03.png"}`
153
170
  - `table_cell`: `{"page":N, "type":"table_cell", "table_idx":T, "table_bbox":[...], "row":R, "col":C, "text":"..."}`
171
+ - `form_field`: `{"page":N, "type":"form_field", "name":"...", "field_type":"text", "value":"...", "bbox":[...]}` (PDF 양식 입력란)
172
+ - `annotation`: `{"page":N, "type":"annotation", "subtype":"Highlight", "bbox":[...], "content":"...", "author":"..."}` (주석·highlight·sticky note)
154
173
  - 모든 블록 보존 (표 영역과 겹쳐도 skip 안 함) — find_tables 정확도 100% 가정 시 정보 손실 위험 회피. text_block·image_block·table_cell 노드가 동일 영역에 중복 출력될 수 있음. Claude 가 양쪽 비교 판단
155
- - bbox 는 PDF 기준 좌표 (left-top, pt 단위, 소수점 2자리)
174
+ - bbox 는 PDF 기준 좌표 (left-top, pt 단위, raw float)
156
175
 
157
- heading 추출은 미적용 (PDF 는 style 정보 없음). 필요 본문 grep 으로 패턴 검출.
176
+ heading 추출은 미적용 (PDF 는 style 정보 없음). OCR 미적용 (스캔 PDF image_block 만 추출).
158
177
 
159
178
  ## 인라인 이미지 매핑 (eml/msg)
160
179
 
@@ -166,6 +185,12 @@ heading 추출은 미적용 (PDF 는 style 정보 없음). 필요 시 본문 gre
166
185
  - text/plain·HTML 둘 다 있을 때 → `body.md` 는 plain (placeholder 없음), `body.from_html.md` 가 변환본 (placeholder 포함)
167
186
  - 인라인 이미지 없으면 `images.rels.json` 미생성
168
187
 
188
+ ## TNEF (winmail.dat) 풀이
189
+
190
+ Outlook RTF 메일이 첨부를 `winmail.dat` 단일 binary (TNEF 형식) 로 패키징한 경우, `tnefparse` 로 내부 첨부 추출하여 `attachments/` 에 같이 풀어 둠. 원본 `winmail.dat` 도 유지 (원본 보존).
191
+
192
+ 내부 첨부도 컨테이너 (xlsx·pptx 등) 면 재귀 풀이 (다른 첨부와 동일).
193
+
169
194
  ## eml/msg 본문 규약
170
195
 
171
196
  본문 흐름 정확성(text/plain 우선) + 인라인 이미지 위치 단서(HTML→평문 변환본) 둘 다 보존:
@@ -183,8 +208,8 @@ heading 추출은 미적용 (PDF 는 style 정보 없음). 필요 시 본문 gre
183
208
  ## xlsb 클린업
184
209
 
185
210
  - legacy → xlsx 변환 시 `_converted.xlsx` 는 임시 폴더에서만 처리 (산출 폴더에 미잔존)
186
- - VBA 매크로 파일 줄에 시트 객체명↔raw 시트명 매핑 코멘트 추가
187
- - 예: `Sheet1.vba` `' (object: Sheet1, sheet: "BOA")`
211
+ - VBA 매크로는 원본 코드 그대로 `macros/<모듈명>.vba` 저장 (변형 X)
212
+ - VBA 시트 객체명↔raw 시트명 매핑은 `workbook.meta.json` `sheet_code_map` (예: `{"Sheet1":"BOA","Sheet3":"Mapping"}`)
188
213
 
189
214
  ## 산출물 사용
190
215
 
@@ -160,6 +160,65 @@ def temp_workdir():
160
160
  shutil.rmtree(d, ignore_errors=True)
161
161
 
162
162
 
163
+ def is_tnef(path: Path) -> bool:
164
+ """TNEF (winmail.dat) 형식인지 검사. filename 또는 magic bytes."""
165
+ if path.name.lower() in ("winmail.dat", "win.dat"):
166
+ return True
167
+ try:
168
+ with open(long_str(path), "rb") as f:
169
+ magic = f.read(4)
170
+ return magic == b"\x78\x9f\x3e\x22"
171
+ except Exception:
172
+ return False
173
+
174
+
175
+ def unpack_tnef(path: Path, attachments_dir: Path) -> list[Path]:
176
+ """TNEF (winmail.dat) 내부 첨부 추출. 추출된 path list 반환.
177
+
178
+ TNEF 아니거나 실패 시 빈 list. 원본 winmail.dat 은 유지 (원본 보존).
179
+ """
180
+ if not is_tnef(path):
181
+ return []
182
+ ensure_pip("tnefparse")
183
+ try:
184
+ from tnefparse import TNEF
185
+ with open(long_str(path), "rb") as f:
186
+ t = TNEF(f.read())
187
+ except Exception:
188
+ return []
189
+
190
+ saved: list[Path] = []
191
+ for att in getattr(t, "attachments", []):
192
+ try:
193
+ name = None
194
+ lf = getattr(att, "long_filename", None)
195
+ if callable(lf):
196
+ try:
197
+ name = lf()
198
+ except Exception:
199
+ name = None
200
+ if not name:
201
+ name = getattr(att, "name", None)
202
+ if isinstance(name, bytes):
203
+ try:
204
+ name = name.decode("utf-8")
205
+ except UnicodeDecodeError:
206
+ name = name.decode("cp949", errors="replace")
207
+ if not name:
208
+ name = "tnef_attachment.bin"
209
+ data = att.data
210
+ if not data:
211
+ continue
212
+ except Exception:
213
+ continue
214
+ if not isinstance(name, str):
215
+ name = "tnef_attachment.bin"
216
+ dst = unique_path(attachments_dir, name)
217
+ write_bytes(dst, data)
218
+ saved.append(dst)
219
+ return saved
220
+
221
+
163
222
  def save_source(input_path: Path, out_dir: Path) -> None:
164
223
  ext = input_path.suffix.lstrip(".")
165
224
  dst = out_dir / f"_source.{ext}"
@@ -136,6 +136,13 @@ def run(input_path: Path, out_dir: Path) -> None:
136
136
  json.dumps(rels, ensure_ascii=False, indent=2),
137
137
  )
138
138
 
139
+ # TNEF (winmail.dat) 풀이 — Outlook RTF 메일의 첨부 패키지 안 내부 첨부 추출
140
+ tnef_saved: list[Path] = []
141
+ for ap in saved_attachments:
142
+ extra = _common.unpack_tnef(ap, attachments_dir)
143
+ tnef_saved.extend(extra)
144
+ saved_attachments.extend(tnef_saved)
145
+
139
146
  attachment_links: list[str] = []
140
147
  for ap in saved_attachments:
141
148
  size = ap.stat().st_size
@@ -111,6 +111,17 @@ def run(input_path: Path, out_dir: Path) -> None:
111
111
  size = dst.stat().st_size
112
112
  if cid:
113
113
  cid_map[cid] = dst.name
114
+
115
+ # TNEF (winmail.dat) 풀이 — Outlook 첨부 패키지 안 내부 첨부 추출
116
+ for tnef_ap in _common.unpack_tnef(dst, attachments_dir):
117
+ t_size = tnef_ap.stat().st_size
118
+ t_recursed = maybe_recurse_attachment(tnef_ap, attachments_dir)
119
+ if t_recursed is not None:
120
+ os.unlink(_common.long_str(tnef_ap))
121
+ attachment_links.append(f"attachments/{t_recursed.name}/ ({_common.format_size(t_size)})")
122
+ else:
123
+ attachment_links.append(f"attachments/{tnef_ap.name} ({_common.format_size(t_size)})")
124
+
114
125
  recursed = maybe_recurse_attachment(dst, attachments_dir)
115
126
  if recursed is not None:
116
127
  os.unlink(_common.long_str(dst))