초기 정리: PIT3000 분석 자료 업로드

- 좌표/SQL/API 분석 문서화
- 재현 스크립트와 예시 응답 추가
- 개인정보/원본 실행파일 제외
This commit is contained in:
2026-06-29 00:27:06 +00:00
commit bde81cdc7e
18 changed files with 3393 additions and 0 deletions

View File

@@ -0,0 +1,216 @@
#!/usr/bin/env python3
from __future__ import annotations
import csv, re, json, zipfile
from pathlib import Path
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
ROOT=Path('/root/work/pharmit-ghidra')
LAYOUT=ROOT/'out/a4_receipt_template_candidates/quickreport_receipt_deep/quickreport_receipt_canonical_top.csv'
RAW=ROOT/'out/a4_receipt_three_raw.txt'
OUT=ROOT/'out/a4_receipt_quickreport_render_samples'
OUT.mkdir(parents=True, exist_ok=True)
SCALE=72/96
FONT='HYGothic-Medium'
try:
pdfmetrics.registerFont(UnicodeCIDFont(FONT))
except Exception:
FONT='Helvetica'
# QuickReport labels found in PIT template -> actual SQL field names from A4 amount query.
FIELD_MAP={
# common receipt form
'Q_paname':'PANAME', 'Q_name2':'PANAME', 'Q_num':'PANUM',
'Q_InDate':'INDATE', 'Q_Year':'_YEAR', 'Q_year':'_YEAR', 'Q_year1':'_YEAR',
'Q_Month':'_MONTH', 'Q_mon':'_MONTH', 'Q_mon1':'_MONTH',
'Q_day':'_DAY', 'Q_day1':'_DAY', 'Q_Days':'_DAYS',
'Q_PreSerial':'PRESERIAL', 'QR_PreSerial':'PRESERIAL',
'Q_Price_A':'TOT_C', 'QR_Price_a':'TOT_C',
'Q_Price_p':'BON_C', 'QR_PriceP':'BON_C', 'QR_Price_psum':'BI_CNT',
'Q_Price_c':'CHUNG_C', 'QR_PriceC':'CHUNG_C', 'QR_Pricec1':'CHUNG_C',
'Q_s_FastMon':'BI_PRICE', 'QR_PriceN':'BI_PRICE',
'Q_Price_n':'_PAY_TOTAL',
'Q_Card':'CARD_C', 'Q_Cash':'CHASH_C', 'Q_PCash':'PAPER_C',
'Q_TotGum':'_PAY_TOTAL',
# company/pharmacy placeholders; real pharmacy master query is separate, seed for visual proof
'Q_iscode':'_PHARM_ISCODE', 'Q_isname':'_PHARM_NAME', 'Q_istel':'_PHARM_TEL',
'Q_tel':'_PHARM_TEL', 'Q_add':'_PHARM_ADDR', 'Q_post':'_PHARM_POST',
'Q_name':'_PHARM_NAME', 'Q_comcode':'_BIZNO', 'Q_comname':'_PHARMACIST', 'Q_name1':'_PHARMACIST',
'Q_COMMIT_CODE':'_COMMIT_CODE', 'Q_COMMIT_NAME':'_COMMIT_NAME', 'Q_Unit_Gubun':'_UNIT_GUBUN',
'qrlApproval_Num':'_CASH_APPROVAL',
}
MONEY_FIELDS={'TOT_C','CHUNG_C','BON_C','CARD_C','CHASH_C','PAPER_C','BI_PRICE','BI_CNT','_PAY_TOTAL'}
# Keep only the actual receipt/certificate templates in the canonical blob, excluding prescription-review form tail.
KEEP_KEYWORDS=['약제비','영수증','진료비','납입 확인서','소득공제','환자성명','조제일자','총 수 납','요양기관','사업자','상 호','대 표 자','전 화 번 호','현금','카드']
KEEP_NAMES=set(FIELD_MAP)|{'Q_tag1','Q_tag2'}
def load_layout(form='certificate'):
with LAYOUT.open(encoding='utf-8-sig') as f:
rows=list(csv.DictReader(f))
# The selected binary is a concatenated QR blob. Band indices reveal independent forms:
# 0..77 = 진료비(약제비) 납입 확인서 summary/table
# 78..169 = 약제비 계산서·영수증
# 171..394/396..626 = 청구/기관확인서 variants
ranges={
'certificate': (0,77),
'receipt': (78,169),
'claim1': (171,394),
'claim2': (396,626),
}
lo,hi=ranges.get(form,ranges['certificate'])
out=[]
for r in rows:
idx=int(r['idx'])
if not (lo <= idx <= hi):
continue
cls=r.get('class','')
cap=(r.get('caption') or r.get('text') or '')
name=r.get('name','')
if cls in {'TQRShape','TQRBand'}:
out.append(r)
elif cls in {'TQRLabel','TQRDBText'}:
# In an isolated form keep most labels; mapped dynamic placeholders are replaced.
if cap or name in KEEP_NAMES or name.startswith(('QR_Price','QR_PreSerial')):
out.append(r)
return out
def parse_rows():
lines=RAW.read_text(encoding='utf-8', errors='replace').splitlines()
data=[ln for ln in lines if '|' in ln and not set(ln.replace('|','').strip()) <= {'-'}]
reader=csv.DictReader(data, delimiter='|')
rows=[]
for r in reader:
if r.get('CUSCODE') and not r['CUSCODE'].startswith('-'):
rows.append({k:(v or '').strip() for k,v in r.items()})
return rows
def money(v):
try: return f"{int(float(str(v).replace(',','') or '0')):,}"
except Exception: return str(v or '')
def ymd_parts(s):
s=re.sub(r'\D','',s or '')
if len(s)>=8: return s[:4],s[4:6],s[6:8],f'{s[:4]}-{s[4:6]}-{s[6:8]}'
return '','','',s
def enrich(r):
r=dict(r)
y,m,d,fmt=ymd_parts(r.get('INDATE'))
r['_YEAR']=y; r['_MONTH']=m; r['_DAY']=d; r['_DATE_FMT']=fmt
# Actual SQL sample had payment method columns zero in these rows; PIT sometimes updates CD_SELL_MASTE later.
pay=sum(int(float(r.get(k) or 0)) for k in ['CARD_C','CHASH_C','PAPER_C'])
if pay==0:
pay=int(float(r.get('BON_C') or 0))
r['CHASH_C']=str(pay)
r['_PAY_TOTAL']=str(pay)
r['_DAYS']=''
# raw names are codepage-garbled in saved text; mask/use generic visual proof, keep ids real.
r['PANAME']='홍길동' if '<EFBFBD>' in r.get('PANAME','') else r.get('PANAME') or '홍길동'
panum=r.get('PANUM') or ''
r['PANUM']=(panum[:6]+'-'+panum[6:7]+'******') if len(re.sub(r'\D','',panum))>=7 else '******-*******'
r['_PHARM_ISCODE']='12345678'
r['_PHARM_NAME']='샘플약국'
r['_PHARM_TEL']='02-1234-5678'
r['_PHARM_ADDR']='서울특별시 샘플구 약국로 1'
r['_PHARM_POST']='01234'
r['_BIZNO']='123-45-67890'
r['_PHARMACIST']='김약사'
r['_COMMIT_CODE']=''
r['_COMMIT_NAME']=''
r['_UNIT_GUBUN']='약국'
r['_CASH_APPROVAL']=''
return r
def obj_value(r, o):
name=o['name']; cap=o.get('caption') or o.get('text') or ''
fld=FIELD_MAP.get(name)
if fld:
v=r.get(fld,'')
return money(v) if fld in MONEY_FIELDS else str(v)
# TQRDBText from actual sqlMain in certificate table
df=o.get('datafield') or ''
if df and df in r:
return money(r[df]) if df in MONEY_FIELDS else r[df]
# drop design placeholders that are dynamic but not mapped
if name.startswith(('Q_','QR_Price','QR_PreSerial','qrlApproval')) and cap==name:
return ''
return cap
def draw_text(c, r, row, page_h):
try:
x=float(row['left'] or 0)*SCALE; top=float(row['top'] or 0)*SCALE
w=float(row['width'] or 0)*SCALE; h=float(row['height'] or 0)*SCALE
except Exception: return
txt=obj_value(r,row)
if not txt: return
y=page_h-top-h
fh=abs(float(row.get('font_height') or -10))*0.72
fh=max(5.5,min(fh,17))
c.setFont(FONT,fh)
c.setFillColor(colors.black)
# right-align amount/name-ish labels by PIT naming convention
if row['name'] in FIELD_MAP and FIELD_MAP[row['name']] in MONEY_FIELDS:
c.drawRightString(x+w-1,y+max(1,(h-fh)/2)+1,txt)
else:
# simplistic wrap for long notes
if len(txt)>70 and w>100:
words=list(txt)
line=''; yy=y+h-fh
maxchars=max(10,int(w/(fh*0.55)))
for ch in words:
line+=ch
if len(line)>=maxchars or ch=='\n':
c.drawString(x+1,yy,line.strip()); yy-=fh*1.15; line=''
if yy<y: break
if line and yy>=y: c.drawString(x+1,yy,line)
else:
c.drawString(x+1,y+max(1,(h-fh)/2)+1,txt)
def draw_shape(c,row,page_h):
try:
x=float(row['left'] or 0)*SCALE; top=float(row['top'] or 0)*SCALE
w=float(row['width'] or 0)*SCALE; h=float(row['height'] or 0)*SCALE
except Exception: return
if w<=0 or h<=0: return
y=page_h-top-h
c.setStrokeColor(colors.HexColor('#444444'))
c.setLineWidth(0.35)
if w<=2*SCALE or h<=2*SCALE:
c.line(x,y,x+w,y+h)
else:
c.rect(x,y,w,h,stroke=1,fill=0)
def render_one(data_row, idx, layout, form):
r=enrich(data_row)
out=OUT/f'a4_quickreport_{form}_{idx}_{r["PRESERIAL"]}.pdf'
c=canvas.Canvas(str(out), pagesize=A4)
W,H=A4
# QuickReport object order: shapes first, text on top.
for o in layout:
if o['class']=='TQRShape': draw_shape(c,o,H)
for o in layout:
if o['class'] in {'TQRLabel','TQRDBText'}: draw_text(c,r,o,H)
c.setFont(FONT,6); c.setFillColor(colors.HexColor('#777777'))
c.drawString(4,4,f'PIT QuickReport mapped render / CUSCODE={r.get("CUSCODE")} PRESERIAL={r.get("PRESERIAL")} / source SQL fields: TOT_C CHUNG_C BON_C CARD_C CHASH_C PAPER_C BI_PRICE')
c.save()
return out
def main():
rows=parse_rows()
outs=[]
for form in ['certificate','receipt']:
layout=load_layout(form)
for i,r in enumerate(rows[:3],1):
outs.append(render_one(r,i,layout,form))
z=ROOT/'out/a4_quickreport_receipt_sql_mapped_20260628.zip'
with zipfile.ZipFile(z,'w',zipfile.ZIP_DEFLATED) as zz:
for p in outs+[Path(__file__),LAYOUT,RAW]:
zz.write(p,p.relative_to(ROOT))
print('\n'.join(str(p) for p in outs))
print(z)
print(z.stat().st_size)
if __name__=='__main__': main()