초기 정리: PIT3000 분석 자료 업로드
- 좌표/SQL/API 분석 문서화 - 재현 스크립트와 예시 응답 추가 - 개인정보/원본 실행파일 제외
This commit is contained in:
169
scripts/parse_delphi_report_bin.py
Normal file
169
scripts/parse_delphi_report_bin.py
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import json, math, struct, sys
|
||||
from pathlib import Path
|
||||
|
||||
CLASSES=[
|
||||
b'TfrxMemoView', b'TfrxShapeView', b'TfrxLineView', b'TfrxPictureView',
|
||||
b'TfrxReport', b'TfrxReportPage', b'TfrxMasterData', b'TfrxDetailData',
|
||||
b'TfrxHeader', b'TfrxFooter', b'TfrxPageHeader', b'TfrxPageFooter',
|
||||
b'TfrxDBDataset', b'TfrxUserDataSet', b'TfrxDataPage', b'TfrxDialogPage',
|
||||
# QuickReport / QRDesign objects used by older PIT A4 forms
|
||||
b'TQuickRep', b'TQRBand', b'TQRChildBand', b'TQRLabel', b'TQRDBText', b'TQRShape',
|
||||
b'TQRRichText', b'TQRExpr', b'TQRImage', b'TQRDBImage', b'TQRCompositeReport',
|
||||
]
|
||||
# Keep essentially all useful report-layout/data-binding properties. Large binary payloads
|
||||
# are still summarized by read_value() as {'binary_len': N}.
|
||||
PROP_KEEP={
|
||||
'Left','Top','Width','Height','Font.Height','Font.Name','Font.Style','Font.Charset','Font.Color',
|
||||
'HAlign','VAlign','Memo.UTF8W','Memo.Text','Text','Caption','Frame.Typ','Frame.Width','Frame.Color',
|
||||
'Color','DataSet','DataSetName','DataField','DataSource','FieldName','Master','Detail','Filter',
|
||||
'Format','FormatStr','DisplayFormat','ExpressionDelimiters','Name','Tag','Visible','Printable',
|
||||
'StretchMode','WordWrap','AutoWidth','CharSpacing','LineSpacing','GapX','GapY','Rotation','Align',
|
||||
'Band','ParentFont','Transparent','BrushStyle','Frame.Style','Frame.ShadowWidth','Frame.ShadowColor',
|
||||
'Restrictions','Page','PaperWidth','PaperHeight','PaperSize','Orientation','Margins.Left','Margins.Top',
|
||||
'Margins.Right','Margins.Bottom','Columns','ColumnWidth','ColumnPositions','RowCount','Version',
|
||||
}
|
||||
|
||||
# Delphi DFM binary value kinds (subset)
|
||||
VK={0:'Null',1:'List',2:'Int8',3:'Int16',4:'Int32',5:'Extended',6:'String',7:'Ident',8:'False',9:'True',10:'Binary',11:'Set',12:'LString',13:'Nil',14:'Collection',15:'Single',16:'Currency',17:'Date',18:'WString',19:'Int64',20:'UTF8String'}
|
||||
|
||||
def ext80(b: bytes) -> float:
|
||||
if len(b)!=10: return math.nan
|
||||
lo=int.from_bytes(b[:8],'little')
|
||||
se=int.from_bytes(b[8:10],'little')
|
||||
sign=-1 if (se & 0x8000) else 1
|
||||
exp=se & 0x7fff
|
||||
if exp==0 and lo==0: return 0.0
|
||||
if exp==0x7fff: return math.inf*sign
|
||||
# 80-bit extended has explicit integer bit, value = lo/2^63 * 2^(exp-bias)
|
||||
return sign * (lo/(1<<63)) * (2**(exp-16383))
|
||||
|
||||
def read_lp(data,i):
|
||||
if i>=len(data): raise EOFError
|
||||
n=data[i]; i+=1
|
||||
if i+n>len(data): raise EOFError
|
||||
try: s=data[i:i+n].decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
try: s=data[i:i+n].decode('cp949')
|
||||
except Exception: s=data[i:i+n].decode('latin1','replace')
|
||||
return s,i+n
|
||||
|
||||
def read_value(data,i):
|
||||
if i>=len(data): raise EOFError
|
||||
tag=data[i]; i+=1
|
||||
if tag==0: return None,i
|
||||
if tag==1:
|
||||
vals=[]
|
||||
# Delphi DFM vaList: nested values terminated by vaNull (0).
|
||||
while i < len(data):
|
||||
if data[i] == 0:
|
||||
i += 1
|
||||
break
|
||||
v,i = read_value(data,i)
|
||||
vals.append(v)
|
||||
return vals,i
|
||||
if tag==2:
|
||||
v=struct.unpack_from('<b',data,i)[0]; return v,i+1
|
||||
if tag==3:
|
||||
v=struct.unpack_from('<h',data,i)[0]; return v,i+2
|
||||
if tag==4:
|
||||
v=struct.unpack_from('<i',data,i)[0]; return v,i+4
|
||||
if tag==5:
|
||||
return ext80(data[i:i+10]), i+10
|
||||
if tag==6 or tag==7:
|
||||
return read_lp(data,i)
|
||||
if tag==8: return False,i
|
||||
if tag==9: return True,i
|
||||
if tag==10:
|
||||
# binary blob: int32 length + bytes
|
||||
if i+4>len(data): raise EOFError
|
||||
n=struct.unpack_from('<I',data,i)[0]; i+=4
|
||||
return {'binary_len':n}, min(len(data),i+n)
|
||||
if tag==11:
|
||||
vals=[]
|
||||
while i < len(data) and data[i]!=0:
|
||||
s,i=read_lp(data,i); vals.append(s)
|
||||
if i<len(data) and data[i]==0: i+=1
|
||||
return vals,i
|
||||
if tag==12:
|
||||
if i+4>len(data): raise EOFError
|
||||
n=struct.unpack_from('<I',data,i)[0]; i+=4
|
||||
raw=data[i:i+n]; i+=n
|
||||
return raw.decode('cp949','replace'),i
|
||||
if tag==15:
|
||||
return struct.unpack_from('<f',data,i)[0],i+4
|
||||
if tag==18:
|
||||
if i+4>len(data): raise EOFError
|
||||
n=struct.unpack_from('<I',data,i)[0]; i+=4
|
||||
raw=data[i:i+n*2]; i+=n*2
|
||||
return raw.decode('utf-16le','replace'),i
|
||||
if tag==19:
|
||||
return struct.unpack_from('<q',data,i)[0],i+8
|
||||
if tag==20:
|
||||
if i+4>len(data): raise EOFError
|
||||
n=struct.unpack_from('<I',data,i)[0]; i+=4
|
||||
raw=data[i:i+n]; i+=n
|
||||
return raw.decode('utf-8','replace'),i
|
||||
# unknown; fail to let caller resync
|
||||
raise ValueError(f'unknown tag {tag} at {i-1:x}')
|
||||
|
||||
def find_objects(data):
|
||||
starts=[]
|
||||
for cls in CLASSES:
|
||||
pos=0
|
||||
while True:
|
||||
j=data.find(cls,pos)
|
||||
if j<0: break
|
||||
if j>0 and data[j-1]==len(cls):
|
||||
starts.append(j-1)
|
||||
pos=j+1
|
||||
return sorted(set(starts))
|
||||
|
||||
def parse_object_at(data,start,next_start=None):
|
||||
i=start
|
||||
cls,i=read_lp(data,i)
|
||||
name,i=read_lp(data,i)
|
||||
props={}
|
||||
limit=next_start if next_start else min(len(data), start+5000)
|
||||
# Parse until next object start; binary blobs can skip far, so limit may be before i after Picture.Data.
|
||||
while i < len(data) and i < limit:
|
||||
if data[i]==0:
|
||||
i+=1; continue
|
||||
# Avoid eating next object header as property if at a known start.
|
||||
if i in OBJECT_STARTS and i!=start:
|
||||
break
|
||||
try:
|
||||
prop,i2=read_lp(data,i)
|
||||
except Exception:
|
||||
break
|
||||
if not prop or len(prop)>80 or any(ord(ch)<32 for ch in prop):
|
||||
break
|
||||
try:
|
||||
val,i3=read_value(data,i2)
|
||||
except Exception:
|
||||
# Some extracted chunks are not aligned; advance one byte and try to keep scanning.
|
||||
i=i+1
|
||||
continue
|
||||
if prop in PROP_KEEP or prop in {'Tag','Name'}:
|
||||
props[prop]=val
|
||||
i=i3
|
||||
return {'class':cls,'name':name,'offset':start,'props':props}
|
||||
|
||||
def main(path):
|
||||
global OBJECT_STARTS
|
||||
data=Path(path).read_bytes()
|
||||
OBJECT_STARTS=find_objects(data)
|
||||
objs=[]
|
||||
for idx,s in enumerate(OBJECT_STARTS):
|
||||
ns=OBJECT_STARTS[idx+1] if idx+1<len(OBJECT_STARTS) else None
|
||||
try:
|
||||
o=parse_object_at(data,s,ns)
|
||||
if o['class'] in [c.decode() for c in CLASSES]:
|
||||
objs.append(o)
|
||||
except Exception:
|
||||
pass
|
||||
print(json.dumps({'file':Path(path).name,'objects':objs}, ensure_ascii=False, indent=2))
|
||||
|
||||
if __name__=='__main__':
|
||||
main(sys.argv[1])
|
||||
216
scripts/render_a4_quickreport_receipt.py
Normal file
216
scripts/render_a4_quickreport_receipt.py
Normal 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()
|
||||
128
scripts/render_a4_receipt_pm_main.py
Normal file
128
scripts/render_a4_receipt_pm_main.py
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import csv, re, 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
|
||||
import json
|
||||
|
||||
ROOT=Path('/root/work/pharmit-ghidra')
|
||||
JSONP=ROOT/'out/a4_receipt_template_candidates/quickreport_deep/PM_MAIN.EXE.a4receipt.05.02812c03.현금영수증 문의.bin.qr.objects.json'
|
||||
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'
|
||||
|
||||
# Actual SQL fields seen in a4_receipt_three_raw.txt / decomp SQL
|
||||
FIELD_MAP={
|
||||
'Q_paname':'PANAME','Q_InDate':'_DATE_FMT','Q_Days':'_DAYS','Q_PreSerial':'PRESERIAL',
|
||||
'Q_Price_A':'TOT_C','Q_Price_p':'BON_C','Q_Price_c':'CHUNG_C','Q_s_FastMon':'BI_PRICE','Q_Price_n':'_PAY_TOTAL',
|
||||
'Q_CardPrice':'CARD_C','Q_CashPrint':'PAPER_C','Q_Chah':'CHASH_C','Q_PersonNum':'PANUM',
|
||||
'Q_Year':'_YEAR','Q_Month':'_MONTH','Q_day':'_DAY',
|
||||
'Q_name':'_PHARM_NAME','Q_comcode':'_BIZNO','Q_add':'_PHARM_ADDR','Q_comname':'_PHARMACIST',
|
||||
'qrlApproval_Num':'_CASH_APPROVAL','Q_Bag':'_BAG',
|
||||
}
|
||||
MONEY={'TOT_C','BON_C','CHUNG_C','BI_PRICE','_PAY_TOTAL','CARD_C','PAPER_C','CHASH_C'}
|
||||
|
||||
def money(v):
|
||||
try: return f"{int(float(str(v).replace(',','') or '0')):,}"
|
||||
except Exception: return str(v or '')
|
||||
|
||||
def read_sql_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()) <= {'-'}]
|
||||
rows=[]
|
||||
for r in csv.DictReader(data, delimiter='|'):
|
||||
if r.get('CUSCODE') and not r['CUSCODE'].startswith('-'):
|
||||
rows.append({k:(v or '').strip() for k,v in r.items()})
|
||||
return rows
|
||||
|
||||
def enrich(r):
|
||||
r=dict(r)
|
||||
s=re.sub(r'\D','',r.get('INDATE',''))
|
||||
if len(s)>=8:
|
||||
r['_YEAR'],r['_MONTH'],r['_DAY']=s[:4],s[4:6],s[6:8]
|
||||
r['_DATE_FMT']=f'{s[:4]}-{s[4:6]}-{s[6:8]}'
|
||||
else:
|
||||
r['_YEAR']=r['_MONTH']=r['_DAY']=''; r['_DATE_FMT']=r.get('INDATE','')
|
||||
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']=''; r['_BAG']=''
|
||||
if '<EFBFBD>' in r.get('PANAME','') or not r.get('PANAME'): r['PANAME']='홍길동'
|
||||
panum=re.sub(r'\D','',r.get('PANUM',''))
|
||||
r['PANUM']=(panum[:6]+'-'+panum[6:7]+'******') if len(panum)>=7 else '050930-3******'
|
||||
r['_PHARM_NAME']='샘플약국'; r['_BIZNO']='123-45-67890'; r['_PHARM_ADDR']='서울특별시 샘플구 약국로 1'; r['_PHARMACIST']='김약사'; r['_CASH_APPROVAL']=''
|
||||
return r
|
||||
|
||||
def load_objects():
|
||||
j=json.loads(JSONP.read_text(encoding='utf-8'))
|
||||
q=[o for o in j['objects'] if o.get('class','').startswith('TQR')]
|
||||
# First PM_MAIN 약제비 계산서·영수증 form: q indices 19..103 from object inspection.
|
||||
objs=q[19:104]
|
||||
return objs
|
||||
|
||||
def prop(o,k,default=''):
|
||||
return o.get('props',{}).get(k,default)
|
||||
|
||||
def value(r,o):
|
||||
name=o.get('name',''); cap=str(prop(o,'Caption','') or '')
|
||||
fld=FIELD_MAP.get(name)
|
||||
if fld:
|
||||
v=r.get(fld,'')
|
||||
return money(v) if fld in MONEY else str(v)
|
||||
# hide design placeholders if equal to object name
|
||||
if name.startswith('Q_') and cap==name: return ''
|
||||
return cap
|
||||
|
||||
def draw_shape(c,o,H):
|
||||
try:
|
||||
x=float(prop(o,'Left',0))*SCALE; top=float(prop(o,'Top',0))*SCALE; w=float(prop(o,'Width',0))*SCALE; h=float(prop(o,'Height',0))*SCALE
|
||||
except Exception: return
|
||||
if w<=0 or h<=0: return
|
||||
y=H-top-h
|
||||
c.setStrokeColor(colors.HexColor('#333333')); c.setLineWidth(0.45)
|
||||
if w<=1.6 or h<=1.6: c.line(x,y,x+w,y+h)
|
||||
else: c.rect(x,y,w,h,stroke=1,fill=0)
|
||||
|
||||
def draw_text(c,r,o,H):
|
||||
txt=value(r,o)
|
||||
if not txt: return
|
||||
try:
|
||||
x=float(prop(o,'Left',0))*SCALE; top=float(prop(o,'Top',0))*SCALE; w=float(prop(o,'Width',0))*SCALE; h=float(prop(o,'Height',0))*SCALE
|
||||
except Exception: return
|
||||
y=H-top-h
|
||||
fh=max(5.5,min(abs(float(prop(o,'Font.Height',-13)))*0.72,16))
|
||||
c.setFont(FONT,fh); c.setFillColor(colors.black)
|
||||
fld=FIELD_MAP.get(o.get('name',''))
|
||||
if fld in MONEY:
|
||||
c.drawRightString(x+w-2,y+max(1,(h-fh)/2)+1,txt)
|
||||
else:
|
||||
c.drawString(x+1,y+max(1,(h-fh)/2)+1,txt)
|
||||
|
||||
def render(row, idx, objs):
|
||||
r=enrich(row)
|
||||
out=OUT/f'a4_pm_main_receipt_sql_{idx}_{r["PRESERIAL"]}.pdf'
|
||||
c=canvas.Canvas(str(out),pagesize=A4); W,H=A4
|
||||
for o in objs:
|
||||
if o.get('class')=='TQRShape': draw_shape(c,o,H)
|
||||
for o in objs:
|
||||
if o.get('class') in {'TQRLabel','TQRDBText'}: draw_text(c,r,o,H)
|
||||
c.setFont(FONT,6); c.setFillColor(colors.HexColor('#777777'))
|
||||
c.drawString(4,4,f'PM_MAIN QuickReport receipt mapped / CUSCODE={r.get("CUSCODE")} PRESERIAL={r.get("PRESERIAL")}')
|
||||
c.save(); return out
|
||||
|
||||
def main():
|
||||
rows=read_sql_rows(); objs=load_objects(); outs=[]
|
||||
for i,r in enumerate(rows[:3],1): outs.append(render(r,i,objs))
|
||||
z=ROOT/'out/a4_pm_main_receipt_sql_mapped_20260628.zip'
|
||||
with zipfile.ZipFile(z,'w',zipfile.ZIP_DEFLATED) as zz:
|
||||
for p in outs+[Path(__file__),JSONP,RAW]: zz.write(p,p.relative_to(ROOT))
|
||||
print('\n'.join(map(str,outs))); print(z); print(z.stat().st_size)
|
||||
if __name__=='__main__': main()
|
||||
110
scripts/summarize_quickreport_receipts.py
Normal file
110
scripts/summarize_quickreport_receipts.py
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import csv, json, re, subprocess
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path('/root/work/pharmit-ghidra')
|
||||
CAND=ROOT/'out/a4_receipt_template_candidates'
|
||||
OUT=CAND/'quickreport_receipt_deep'
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
PARSER=ROOT/'scripts/parse_fastreport_bin.py'
|
||||
QR_CLASSES={'TQuickRep','TQRBand','TQRChildBand','TQRLabel','TQRDBText','TQRShape','TQRImage','TQRDBImage','TQRRichText','TQRExpr','TQRCompositeReport'}
|
||||
KEYWORDS=['약제비 계산서','약제비','영수증','진료비','납입확인서','납입 확인서','소득공제','요양기관 종류','환 자','환자성명','약 제 비 총 액','총 수 납 금 액']
|
||||
|
||||
def text(o):
|
||||
pr=o.get('props',{})
|
||||
for k in ['Caption','Text','DataField','FieldName','Memo.UTF8W']:
|
||||
v=pr.get(k)
|
||||
if isinstance(v,str) and v.strip(): return v.strip()
|
||||
if isinstance(v,list):
|
||||
s='\n'.join(str(x) for x in v if str(x).strip()).strip()
|
||||
if s: return s
|
||||
return ''
|
||||
|
||||
def parse_file(p:Path):
|
||||
jpath=OUT/(p.name+'.objects.json')
|
||||
if not jpath.exists():
|
||||
subprocess.run(['python3',str(PARSER),str(p)], stdout=jpath.open('w'), stderr=subprocess.DEVNULL, check=False)
|
||||
try: return json.loads(jpath.read_text(encoding='utf-8'))
|
||||
except Exception: return {'objects':[]}
|
||||
|
||||
def bounds(objs):
|
||||
xs=[]; ys=[]
|
||||
for o in objs:
|
||||
pr=o.get('props',{})
|
||||
try:
|
||||
if 'Left' in pr and 'Width' in pr:
|
||||
xs += [float(pr['Left']), float(pr['Left'])+float(pr['Width'])]
|
||||
if 'Top' in pr and 'Height' in pr:
|
||||
ys += [float(pr['Top']), float(pr['Top'])+float(pr['Height'])]
|
||||
except Exception: pass
|
||||
return ([round(min(xs),3),round(max(xs),3)] if xs else None, [round(min(ys),3),round(max(ys),3)] if ys else None)
|
||||
|
||||
def group_reports(objs):
|
||||
q=[o for o in objs if o.get('class') in QR_CLASSES]
|
||||
starts=[i for i,o in enumerate(q) if o.get('class') in {'TQuickRep','TQRCompositeReport'}]
|
||||
if not starts:
|
||||
return [('qr_blob', q)] if q else []
|
||||
groups=[]
|
||||
for n,si in enumerate(starts):
|
||||
ei=starts[n+1] if n+1<len(starts) else len(q)
|
||||
name=q[si].get('name') or f'report_{n}'
|
||||
groups.append((name,q[si:ei]))
|
||||
return groups
|
||||
|
||||
def score_group(g):
|
||||
txt=' '.join(text(o) for o in g)
|
||||
return sum(10 for k in KEYWORDS if k in txt) + sum(1 for o in g if o.get('class')=='TQRLabel')
|
||||
|
||||
def main():
|
||||
all_rows=[]; summaries=[]
|
||||
for p in sorted(CAND.glob('*.bin')):
|
||||
if not any(s in p.name for s in ['진료비','소득공제','요양기관','현금영수증']):
|
||||
continue
|
||||
j=parse_file(p); objs=j.get('objects',[])
|
||||
groups=group_reports(objs)
|
||||
for gi,(gname,gobjs) in enumerate(groups):
|
||||
captions=[text(o) for o in gobjs if text(o)]
|
||||
joined=' | '.join(captions)
|
||||
if not any(k in joined for k in KEYWORDS):
|
||||
continue
|
||||
bx,by=bounds(gobjs)
|
||||
classes=Counter(o.get('class') for o in gobjs)
|
||||
summ={'source_bin':p.name,'group_index':gi,'group_name':gname,'objects':len(gobjs),'classes':dict(classes),'bounds_x':bx,'bounds_y':by,'score':score_group(gobjs),'key_captions':[c for c in captions if any(k in c for k in KEYWORDS)][:80]}
|
||||
summaries.append(summ)
|
||||
for idx,o in enumerate(gobjs):
|
||||
pr=o.get('props',{})
|
||||
if o.get('class') not in QR_CLASSES: continue
|
||||
all_rows.append({
|
||||
'source_bin':p.name,'group_index':gi,'group_name':gname,'idx':idx,'class':o.get('class'),'name':o.get('name'),'offset':o.get('offset'),
|
||||
'left':pr.get('Left',''),'top':pr.get('Top',''),'width':pr.get('Width',''),'height':pr.get('Height',''),
|
||||
'caption':pr.get('Caption',''),'text':text(o),'datafield':pr.get('DataField') or pr.get('FieldName') or '', 'dataset':pr.get('DataSet') or pr.get('DataSetName') or '',
|
||||
'font_height':pr.get('Font.Height',''),'alignment':pr.get('Alignment') or pr.get('HAlign') or '',
|
||||
'props':json.dumps(pr,ensure_ascii=False,sort_keys=True)
|
||||
})
|
||||
summaries=sorted(summaries,key=lambda x:(-x['score'],x['source_bin'],x['group_index']))
|
||||
(OUT/'quickreport_receipt_summary.json').write_text(json.dumps(summaries,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
with (OUT/'quickreport_receipt_objects_all.csv').open('w',newline='',encoding='utf-8-sig') as f:
|
||||
fields=list(all_rows[0].keys()) if all_rows else ['source_bin']
|
||||
w=csv.DictWriter(f,fieldnames=fields); w.writeheader(); w.writerows(all_rows)
|
||||
md=['# QuickReport receipt templates deep summary','']
|
||||
for s in summaries:
|
||||
md += [f"## score={s['score']} {s['source_bin']} group={s['group_index']} `{s['group_name']}`",
|
||||
f"- objects: {s['objects']}",f"- bounds_x: {s['bounds_x']}",f"- bounds_y: {s['bounds_y']}",f"- classes: `{s['classes']}`",'- key captions:']
|
||||
md += [f" - {c}" for c in s['key_captions'][:40]]
|
||||
md += ['']
|
||||
(OUT/'quickreport_receipt_summary.md').write_text('\n'.join(md),encoding='utf-8')
|
||||
# Write top group's canonical CSV for immediate renderer work.
|
||||
if summaries:
|
||||
top=summaries[0]
|
||||
top_rows=[r for r in all_rows if r['source_bin']==top['source_bin'] and int(r['group_index'])==int(top['group_index'])]
|
||||
with (OUT/'quickreport_receipt_canonical_top.csv').open('w',newline='',encoding='utf-8-sig') as f:
|
||||
w=csv.DictWriter(f,fieldnames=list(top_rows[0].keys())); w.writeheader(); w.writerows(top_rows)
|
||||
(OUT/'quickreport_receipt_canonical_top.json').write_text(json.dumps(top,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
print('TOP',top)
|
||||
print(OUT/'quickreport_receipt_summary.md')
|
||||
print(OUT/'quickreport_receipt_objects_all.csv')
|
||||
print(OUT/'quickreport_receipt_canonical_top.csv')
|
||||
|
||||
if __name__=='__main__': main()
|
||||
Reference in New Issue
Block a user