데이터 모델 · 식별자 계약 (DM-*)
이 문서는 LogiNippon의 D1 스키마를 규범 요구사항으로 동결한다. techspec 02-data-model/model.md의 설계와 as-built 마이그레이션(0001_init.sql·0002_auth.sql·0003_geofence_membership.sql)을 대조해 11개 핵심 테이블, 타임스탬프·식별자·dedup 계약, 이벤트 분류·정밀도 매트릭스, shipment 상태머신, 테넌트 격리·용량 한계, 그리고 표준 식별자 부재를 메우는 送り状番号 정규화 레이어를 고정한다. 모든 수치는 마스터 §4 정규화 수치표에서 글자 그대로 인용했으며, 진입 단계의 초기 확정 목표(설계)로서 운영 베이스라인으로 분기별 조정한다.
Shipment → Stop → Event 3계층이다(ADR-0005). 일본 국내 트럭은 복합운송 구간이 1~2개라 5계층(Order→Shipment→Leg→Stop→Event) 대신 3계층으로 시작한다 추정. Order는 shipper_ref로 흡수, Leg(다중 캐리어 구간)는 Phase 2. 이벤트는 불변(immutable)이며 다중 소스에서 같은 사실이 중복 도착한다고 가정한다(Queues at-least-once 정합).
1. 스키마 개요 (DM-SCHEMA-001)
요구. 시스템은 아래 11개 D1 테이블(tenant, carrier, vehicle, driver, geofence, geofence_cell, shipment, stop, event, event_code_map, regulatory_report)을 반드시(MUST) 마이그레이션 0001_init.sql이 정의한 컬럼·CHECK 제약·UNIQUE·인덱스 그대로 유지해야 한다. 적용된 마이그레이션 파일은 불변이며, 변경은 새 번호 마이그레이션으로만 전진(forward-only) 한다.
수용기준
- Given 신규 D1 인스턴스, When
0001→0002→0003을 순서대로 적용, Then 11 코어 테이블 + auth 2테이블(auth_credential·api_key) +geofence_membership이 생성되고 모든 CHECK enum이 본 문서 §5·§6 카탈로그와 일치한다. - Given 스키마, When 컬럼/제약을 본 문서와 대조, Then 차이가 0이다(이 페이지가 단일 진실원).
1.1 테이블 개요 · 마이그레이션 매핑
| 테이블 | 역할 | PK | 주요 컬럼 / 관계 | 마이그레이션 | 상태 |
|---|---|---|---|---|---|
tenant | 멀티테넌시 격리 루트(화주/운송사/3PL) | tenant_id | type IN ('SHIPPER','CARRIER','3PL') | 0001 | DONE |
carrier | 운송사 · 多重下請け 계층 | carrier_id | tier IN ('PRIME','SUB1','SUB2','SUB3') (元請/下請/孫請), parent_carrier_id 자기참조 → tenant | 0001 | DONE |
vehicle | 차량(番号板) | vehicle_id | plate(개인정보), class IN ('LIGHT','MEDIUM','HEAVY') → carrier | 0001 | DONE |
driver | 운전자(個人情報保護法 마스킹) | driver_id | display_name, consent_at(동의 시각) → carrier | 0001 | DONE |
geofence | 거점 폴리곤 + 히스테리시스/荷待ち 임계 | geofence_id | type IN ('WAREHOUSE','CUSTOMER','PORT','BORDER'), geometry_geojson, h3_resolution, entry_buffer_m/exit_buffer_m, dwell_threshold_min | 0001 | DONE |
geofence_cell | 폴리곤 H3 덮개 셀(진실 사본·재생성·분석) | (h3_cell, geofence_id) | kind IN ('interior','boundary'), resolution → geofence | 0001 | DONE |
shipment | 화물 1단위(Order 흡수) | shipment_id | tenant_id, shipper_ref(送り状番号), carrier/vehicle/driver_id, current_status, predicted_delivery_eta, eta_is_estimate | 0001 | DONE |
stop | 정류점 · 荷待ち 측정 단위 | stop_id | sequence, type IN ('PICKUP','DROPOFF','INTERMEDIATE'), actual_arrival_at/actual_departure_at/dwell_minutes, h3_r10, UNIQUE(shipment_id, sequence) | 0001 | DONE |
event | 불변 이벤트(중복 수신 가정) | event_id | canonical_code, raw_code, source_type, occurred_at/received_at, h3_r10, exception_flag, dedup_key UNIQUE | 0001 | DONE |
event_code_map | raw→canonical 정규화 매핑 | (source_type, raw_code) | canonical_code, confidence IN ('HIGH','MEDIUM','LOW') | 0001 | DONE |
regulatory_report | 규제 산출물 메타(파일은 R2) | report_id | kind IN ('JITSUUNSO_KANRIBO','NIMACHI_RECORD','KOSOKU_TIME'), r2_key → tenant | 0001 | DONE |
| 아래는 as-built 보조 테이블(코어 11 외) | |||||
auth_credential | 사람 주체 로그인(PBKDF2) | subject_id | role, pw_salt/hash/iterations, scopes → tenant/carrier | 0002 | DONE |
api_key | 머신 클라이언트 키(해시 저장) | key_id | key_hash, revoked, scopes → tenant | 0002 | DONE |
geofence_membership | 지오펜스 OUTSIDE↔INSIDE 상태(히스테리시스/dwell) | (shipment_id, geofence_id) | state, entered_at, last_seen_at(out-of-order 가드), last_h3_cell | 0003 | DONE |
| 웹훅 구독 | endpoint_secret·HMAC·전달상태 | (미정) | 구독 테이블 부재 → handleNotifyBatch가 ack-only stub | 0004 신설 권고 | ABSENT |
endpoint_secret·HMAC 서명·전달상태 테이블이 부재하여 handleNotifyBatch는 ack-only stub 상태다(as-built). 전달 계약(IR-WH-001)·재시도 래더(FR-DLV-WH-001) 구현 전, 0004_webhook_subscriptions.sql을 전진 마이그레이션으로 신설해야 한다.1.2 핵심 DDL 발췌 (as-built, 0001)
CREATE TABLE shipment (
shipment_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenant(tenant_id),
shipper_ref TEXT, -- 送り状番号 또는 화주 PO (표준 식별자 부재 → 정규화 대상)
carrier_id TEXT REFERENCES carrier(carrier_id),
vehicle_id TEXT REFERENCES vehicle(vehicle_id),
driver_id TEXT REFERENCES driver(driver_id),
mode TEXT NOT NULL DEFAULT 'TRUCK',
current_status TEXT NOT NULL DEFAULT 'CREATED', -- canonical taxonomy
planned_pickup_at TEXT,
planned_delivery_at TEXT,
predicted_delivery_eta TEXT,
eta_is_estimate INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE INDEX idx_shipment_tenant_status ON shipment(tenant_id, current_status);
CREATE TABLE event (
event_id TEXT PRIMARY KEY,
shipment_id TEXT NOT NULL REFERENCES shipment(shipment_id),
stop_id TEXT REFERENCES stop(stop_id),
canonical_code TEXT NOT NULL,
raw_code TEXT, -- 원본 코드 (감사 보존) 예: X12:214:AF
source_type TEXT NOT NULL CHECK (source_type IN
('APP_GPS','GEOFENCE','TELEMATICS','CARRIER_API','EDI_X12','EDIFACT','MANUAL')),
occurred_at TEXT NOT NULL, -- 발생 시각(ISO8601+TZ)
received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
lat REAL, lon REAL, h3_r10 TEXT, description TEXT,
exception_flag INTEGER NOT NULL DEFAULT 0,
dedup_key TEXT NOT NULL,
UNIQUE (dedup_key)
);
2. 타임스탬프 계약 (DM-TS-001)
요구. 모든 시각 필드(occurred_at, actual_arrival_at, planned_* 등)는 ISO 8601 +09:00 오프셋을 명시해 저장(MUST)한다. 시스템은 오프셋이 없거나 잘못된 형식의 타임스탬프를 반드시 거부해야 한다. 荷待ち 법정 계산(dwell_minutes = actual_departure_at − actual_arrival_at)과 구속시간 산정이 이 정합성에 의존한다.
0001_init.sql의 모든 created_at/received_at DEFAULT는 strftime('%Y-%m-%dT%H:%M:%SZ','now') — 즉 UTC 'Z' 리터럴을 쓴다. 이는 +09:00 저장 규칙과 모순이다. +09:00 규칙으로 통일하고, 서버 기록 시각도 JST 오프셋으로 정규화한다(DEFAULT 리터럴은 0004+ 마이그레이션 또는 애플리케이션 계층 정규화로 교정). as-built geofence-engine.ts의 dedupKey()는 혼재 오프셋(Z, +09:00)을 Date.parse로 UTC 정규화 후 분 절단해 이 모순을 흡수한다(§4 참조).수용기준
- Given 인제스트/이벤트 페이로드, When 시각이
2026-06-13T09:00:00+09:00형식, Then 그대로 저장된다. - Given 오프셋 없는
...T09:00:00또는 모호한 로컬 시각, When 인제스트, Then 422로 거부된다. - Given 같은 순간을
Z와+09:00로 표현한 두 핑, When dedup, Then 동일 키로 1건만 저장(UTC 정규화 후 분 절단).
3. 식별자 포맷 계약 (DM-ID-001)
요구. 내부 식별은 시스템이 발급한 불변 ID(shipment_id, stop_id, event_id, geofence_id, carrier_id 등)로만 한다(MUST). 외부 식별자(shipper_ref = 送り状番号 / 화주 PO)는 별개 필드로 보관하며 PK로 쓰지 않는다(SHOULD). event_id는 UUID, 모든 ID는 라우팅/API에서 안정적이어야 한다.
| ID | 종류 | 발급 | 가변성 | 예 |
|---|---|---|---|---|
shipment_id | 내부 PK | 우리 | 불변 | SHP-2025-00456 |
event_id | 내부 PK (UUID) | 우리 | 불변 | UUID v4 |
shipper_ref | 외부 참조 | 화주/운송사 | 가변(정규화 대상) | 送り状番号 A-2025-0001, PO PO-98765 |
raw_code | 원본 이벤트 코드(감사) | 소스 | 불변 보존 | X12:214:AF |
수용기준
- Given 동일 화물이 두 번 인입(상이한
shipper_ref), When 복합 키로 매핑, Then 단일shipment_id로 수렴(§11 정규화 레이어). - Given 외부 시스템이
shipper_ref변경, When 갱신, Thenshipment_id는 불변 유지.
4. 중복 제거 키 계약 (DM-DEDUP-001)
요구. 이벤트 멱등 삽입을 위해 event.dedup_key는 반드시 다음 정규 공식으로 합성하고 D1 UNIQUE로 강제한다:
dedup_key = sha256( source_type | shipment_id | canonical_code | floor(occurred_at→분) [| stop_id] )
occurred_at은 분 단위 내림(floor) 후 사용한다(혼재 오프셋은 UTC 정규화 후 절단 — DM-TS-001 정합). 삽입은 INSERT ... ON CONFLICT(dedup_key) DO NOTHING으로 처리해 Queues at-least-once 재전달과 지오펜스 OUTSIDE→INSIDE + last_seen_at 단조 가드를 안전하게 만든다.
geofence-engine.ts의 dedupKey()는 sha256 없이 평문 합성 GEOFENCE:{shipment_id}:{canonical}:{minute(UTC)}을 쓴다(소스 prefix 고정 GEOFENCE, stop_id 미포함). 정규 공식(sha256 · source_type 가변 · 선택적 stop_id)과 정렬은 후속 과제다. UNIQUE 제약·UTC 분 절단·멱등 의미는 이미 충족(Research/data-model 정합).수용기준
- Given 동일
(source_type, shipment_id, canonical_code, 분)의 핑 2건, When 두 번 삽입, Then 1건만 저장(ON CONFLICT DO NOTHING). - Given 같은 순간의
Z·+09:00표현, When dedup, Then 동일 키. - Given 다른 분의 동일 이벤트, When 삽입, Then 별개 행(정상).
5. 소스 정밀도 매트릭스 (DM-EVT-PREC-001)
요구. 동일 shipment에 여러 소스가 같은 canonical 이벤트를 보내 충돌할 때, 시스템은 반드시 아래 정밀도 순서로 "정답"을 고른다. 동률(같은 정밀도)은 received_at 최소값(먼저 도착)을 채택한다.
| 순위 | source_type | 정밀도 | 비고 |
|---|---|---|---|
| 1 | EDI_X12 | HIGH | 캐리어 EDI 214 등 |
| 2 | CARRIER_API | HIGH | 운송사 직접 API |
| 3 | EDIFACT | HIGH | 국제 EDI |
| 4 | GEOFENCE | MED | 지오펜스 ENTER/EXIT 파생 |
| 5 | TELEMATICS | MED | 차량 텔레매틱스 |
| 6 | APP_GPS | MED | 드라이버 앱 GPS |
| 7 | MANUAL | 역할 의존 | 오퍼레이터/화주 수동 입력 |
event_code_map.confidence(HIGH/MEDIUM/LOW)는 매핑 신뢰도, 위 매트릭스는 소스 간 충돌 해소로 역할이 다르다. 일반적으로 캐리어 EDI > GPS 지오펜스 퇴출 추정.
수용기준
- Given
EDI_X12:DELIVERED와GEOFENCE:DELIVERED동시 수신, When 충돌 해소, Then EDI_X12 채택. - Given 동률 두 소스, When 해소, Then
received_at최소가 우선.
6. Canonical 이벤트 enum + 지오펜스 파생 (DM-EVT-ENUM-001)
요구. 모든 소스의 raw 코드는 반드시 아래 canonical 11종 집합으로 매핑된다(+ 예외 코드 TRACKING_LOST). geofence-type → canonical 파생표를 동결하며, PORT/BORDER 파생은 PARTIAL(Phase 2, 현재 미파생)이다.
canonical_code | 의미 | 주 파생 소스 |
|---|---|---|
CREATED | 화물 등록 | MANUAL/API |
DISPATCHED | 배차·경로 배정 | CARRIER_API / X12 AG |
ARRIVED_AT_PICKUP | 픽업지 도착 | GEOFENCE(WAREHOUSE ENTERED) |
PICKUP_COMPLETED | 픽업 완료 | GEOFENCE(WAREHOUSE EXITED) / X12 AF / 集荷完了 |
IN_TRANSIT | 운송 중 | APP_GPS / X12 X1 |
ARRIVED_AT_DELIVERY | 배달지 도착 | GEOFENCE(CUSTOMER ENTERED) |
DELIVERED | 배달 완료 | GEOFENCE(CUSTOMER EXITED, dwell>0) / X12 D1 / 配達完了 |
OUT_FOR_DELIVERY | 배달 중 | X12 OA |
DELIVERY_ATTEMPTED | 배달 시도 | X12 AE |
EXCEPTION | 예외 | X12 X6 / 룰 엔진 |
CANCELLED | 취소 | X12 CA |
TRACKING_LOST (예외코드) | 추적 손실(45min 무핑) | 룰 엔진(Cron sweep) — FR-ENG-EXC-003 |
geofence-type → canonical 파생표(동결)
geofence.type | ENTERED → | EXITED → | 상태 |
|---|---|---|---|
WAREHOUSE | ARRIVED_AT_PICKUP | PICKUP_COMPLETED | DONE |
CUSTOMER | ARRIVED_AT_DELIVERY | DELIVERED | DONE |
PORT | (미파생) | (미파생) | PARTIAL · Phase 2 |
BORDER | (미파생) | (미파생) | PARTIAL · Phase 2 |
geofence.ts의 deriveCanonical()는 WAREHOUSE·CUSTOMER만 매핑하고 PORT/BORDER는 null 반환(// TODO: PORT/BORDER are Phase 2). 즉 PORT/BORDER 지오펜스 적중은 현재 canonical 이벤트를 만들지 않는다.수용기준
- Given WAREHOUSE 지오펜스 ENTER, When 파생, Then
ARRIVED_AT_PICKUP. - Given CUSTOMER 지오펜스 EXIT(dwell>0), When 파생, Then
DELIVERED+stop.actual_departure_at채움. - Given PORT/BORDER 적중, When 파생, Then 이벤트 미생성(PARTIAL, Phase 2 트래킹).
7. Shipment 상태머신 (DM-STATE-001)
요구. shipment.current_status는 canonical 코드만 가지며(MUST), 정상 경로를 따라 단조 전진한다. 지오펜스 ENTER/EXIT가 current_status를 advance하고 stop.actual_arrival_at/actual_departure_at/dwell_minutes를 채운다.
CREATED → DISPATCHED → ARRIVED_AT_PICKUP → PICKUP_COMPLETED
→ IN_TRANSIT → ARRIVED_AT_DELIVERY → DELIVERED (정상 종결)
분기: (임의 상태) → EXCEPTION (룰 엔진/X12 X6; exception_flag=1)
(종결 전) → CANCELLED (X12 CA)
배달 변형: OUT_FOR_DELIVERY → DELIVERY_ATTEMPTED → DELIVERED
예외코드: TRACKING_LOST (45min 무핑, 상태머신 외 예외 표식)
arrival/departure 기록 규칙.
- arrival: 지오펜스 OUTSIDE→INSIDE(ENTER) 확정 시
stop.actual_arrival_at를 first-write-wins(COALESCE)로 기록 — 荷待ち 도착 시계가 뒤로 밀리지 않게 함. - departure: INSIDE에서 핑 셀이
gridDisk(cover, +1)를 벗어나면(EXIT)actual_departure_at기록 +dwell_minutes = departure − arrival계산. - 순서 가드:
geofence_membership.last_seen_at(단조)로 늦게 도착한 pre-exit 핑이 arrival/departure를 뒤집지 못하게 한다(out-of-order drop). - dwell >
geofence.dwell_threshold_min(기본 120, 법정 2시간)이면 EXIT 이벤트에 荷待ち超過 플래그(FR-ENG-EXC-002).
수용기준
- Given WAREHOUSE ENTER 후 EXIT, When 처리, Then
ARRIVED_AT_PICKUP→PICKUP_COMPLETED,arrival/departure/dwell_minutes채워짐. - Given 같은 stop의 두 번째 ENTER 핑, When 처리, Then
actual_arrival_at은 첫 값 유지(COALESCE first-write-wins). - Given 늦게 도착한 pre-exit 핑(
captured_at ≤ last_seen_at), When 처리, Then drop되어 departure가 arrival을 역전하지 않음.
8. KV 역인덱스 값 계약 (DM-KV-001)
요구. 핑당 O(1) 지오펜스 멤버십 판정을 위해 KV에 역인덱스 cell → geofence_id[]를 둔다(권장). 키는 H3 셀 ID(latLngToCell 결과, 16진), 값은 그 셀을 덮는 지오펜스 ID 배열이다. D1 geofence_cell이 진실, KV는 판정 가속 사본이다.
- 값 형식:
geofence_id문자열 배열(빈 셀은 키 부재 = 매칭 없음). - 재기록 시점: 지오펜스 생성·수정·삭제 시에만(읽기 多·쓰기 少). 등록/수정 시
polygonToCells로geofence_cell·KV 역인덱스를 함께 재생성한다. - 판정:
interior셀 적중 = 확정,boundary셀 적중 = 그 핑만 point-in-polygon 1회 확인(하이브리드 PIP).
수용기준
- Given 지오펜스 폴리곤 수정, When 저장, Then
geofence_cell재생성 + KV 역인덱스 동일 트랜잭션적 재기록. - Given 핑 셀 ID, When KV 조회, Then 1회 lookup으로 덮는
geofence_id[]반환(폴리곤 PIP 미수행).
9. 테넌트 격리 규칙 (DM-ISO-001)
요구. 모든 shipment/stop/event/geofence(및 regulatory_report) 접근은 반드시 인증 컨텍스트의 tenant_id로 스코프된다. tenant_id는 토큰/API 키 클레임에서만 바인딩하고 클라이언트 입력에서 절대 받지 않는다. 타테넌트 행은 0행 → 404(존재 자체를 숨김; 403 아님).
- DRIVER 역할은 추가로
driver_id스코프(자기 案件만). - 모든 DB 접근을 단일 repo 계층으로 라우팅해
tenant_id미바인딩 쿼리를 구조적으로 불가능하게 한다.
수용기준
- Given 테넌트 A 토큰, When 테넌트 B의
shipment_idGET, Then 404(0행). - Given DRIVER 토큰, When 타 드라이버 案件 GET, Then 404.
- Given 임의 쿼리 경로, When 코드 검사, Then
tenant_id바인딩이 auth 컨텍스트에서만 옴.
10. 용량 한계 가드 (DM-CAP-001)
요구. 단일 화물의 무한 폭증으로부터 D1·DO를 보호하기 위해 shipment당 stop·event 상한과 비정상 증가 알람을 둔다(권장). 일본 국내 트럭의 3계층 모델상 stop은 소수(1~2 구간)이므로 비정상 폭증은 데이터 오류·악용 신호다.
- 인제스트 배치는 서버 하드캡 500 samples/req(초과 413); 클라 목표 배치 100(FR-ACQ-API-001) — 이벤트 유입의 1차 가드.
- shipment당 stop/event 상한은 초기 확정 목표(설계)로 두고 운영 베이스라인으로 조정(OD-006 용량 베이스라인 post-launch).
- 비정상 증가(예: 한 shipment에 분당 이벤트 급증)는 비용/한도 근접 대시보드 알람(NFR-COST-001)과 연계.
수용기준
- Given 501 samples 단일 인제스트, When POST, Then 413.
- Given 한 shipment의 이벤트 비정상 폭증, When 모니터, Then 알람 발화(미매핑 급증 >50/시간 알람과 동형).
11. 送り状番号 정규화 레이어 (DM-NORM-001)
요구. 외부에서 들어오는 화물을 우리 shipment_id(불변)로 수렴시키는 정규화 레이어를 둔다(SHOULD).
- 복합 키 매핑:
(화주 tenant + shipper_ref)또는(화주 PO + 운송사 + 날짜)복합 키로 내부shipment에 연결. - 운송사별 파서: 번호 형식 비표준 → 운송사별 파싱·정규화 규칙(설정 관리). 초기엔 대형 운송사(ヤマト·佐川·NX·セイノー) 위주 선구축 — 개발보다 협상·조사 공수가 큰 작업.
- GS1 흡수 여지: GS1 Japan의 SSCC(출하포장 시리얼)·GLN(장소 식별) 보급 시
shipment.shipper_ref/geofence에 GLN을 표준 식별자로 승격할 수 있게 여지를 남긴다 추정(OD-008).
운송사 A 送り状番号: A-2025-0001 ┐
화주 PO: PO-98765 ├─→ [정규화 레이어 · 운송사별 파서]
운송사 B 전표: B/240515/77 ┘ │ 복합 키 매핑
▼
내부 shipment_id: SHP-2025-00456 (불변)
수용기준
- Given 운송사 A 형식
shipper_ref, When 파서 적용, Then 정규 복합 키로 단일shipment_id매핑. - Given 동일 화물 중복 인입(상이 형식), When 매핑, Then 단일
shipment_id로 수렴(신규 행 생성 안 함). - Given GLN 포함 화주 데이터, When 인입, Then
shipper_ref에 GLN 보존(흡수 여지).
근거·상호참조
- techspec: 02-data-model/model.md(3계층 모델·D1 DDL·이벤트 분류·送り状番号 정규화·H3 인덱싱)
- techspec: 04-tracking-engine/engine.md(지오펜스 ENTER/EXIT·히스테리시스·dwell)
- techspec: 07-security/security-privacy.md(테넌트 격리·마스킹)
- ADR: ADR-0005(3계층) · ADR-0008(D1) · ADR-0009(H3)
- as-built 마이그레이션:
server/migrations/0001_init.sql·0002_auth.sql·0003_geofence_membership.sql(server) - as-built 코드:
server/src/lib/geofence-engine.ts·geofence.ts·db.ts·h3.ts - Research: Research/data-model · tracking-engine · landscape-map
- 상호참조: FR-ACQ-API-001 · FR-GEO-CRUD-001 · FR-ENG-EXC-002 · FR-ENG-EXC-003 · IR-WH-001 · NFR-COST-001 · SR-TENANT-001 · KPI-NORM-001 · OD-006 · OD-008