공공기관2 작업 중. _temp 몽타주(재생성가능)는 제외. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
240 lines
11 KiB
Python
240 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""_작업현황.md → 작업현황.xlsx 대시보드.
|
|
컬럼: 광역 | 번호 | 기관 | 담당자 | 상태 | 검수 | 행수 | 비고
|
|
- 담당자: 충남/충북=프리랜서1, 전북=프리랜서2, 제주=(미지정) ← 드롭다운
|
|
- 상태: 작업상태(수집완료 등) ← 드롭다운+조건부서식(색)
|
|
- 검수: 완료/미검수 (md의 ☑️검수완료 → 완료) ← 드롭다운+조건부서식
|
|
- 행수: 데이터바
|
|
- 요약 시트: 광역별·담당자별 집계(수식 COUNTIFS/SUMIF)
|
|
md를 원장으로 두고 여기서 자동생성.
|
|
"""
|
|
import re
|
|
import sys
|
|
import openpyxl
|
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
|
from openpyxl.utils import get_column_letter
|
|
from openpyxl.formatting.rule import FormulaRule, DataBarRule
|
|
from openpyxl.worksheet.datavalidation import DataValidation
|
|
|
|
MD = '_작업현황.md'
|
|
OUT = sys.argv[1] if len(sys.argv) > 1 else '작업현황.xlsx'
|
|
REGIONS = ['충청남도', '충청북도', '전북특별자치도', '제주특별자치도']
|
|
ASSIGNEE = {'충청남도': '프리랜서1', '충청북도': '프리랜서1',
|
|
'전북특별자치도': '프리랜서2', '제주특별자치도': '프리랜서2'}
|
|
STATUS_OPTIONS = ['⬜ 미착수', '🔄 수집 중', '✅ 수집완료', '⚠️ 보류·이슈']
|
|
INSPECT_OPTIONS = ['완료', '미검수']
|
|
ASSIGNEE_OPTIONS = ['프리랜서1', '프리랜서2']
|
|
SUBMIT_OPTIONS = ['완료', '미완료']
|
|
def parse_submissions():
|
|
"""## 📤 제출 이력 표에서 {기관: 제출일} 추출 (제출 컬럼 원장).
|
|
제출분이 늘어도 md 제출 이력 표만 갱신하면 자동 반영됨(하드코딩 금지)."""
|
|
s = open(MD, encoding='utf-8').read()
|
|
i = s.find('## 📤 제출 이력')
|
|
if i < 0:
|
|
return {}
|
|
j = s.find('\n## ', i + 5)
|
|
sec = s[i:j if j > 0 else i + 3000]
|
|
out = {}
|
|
for ln in sec.splitlines():
|
|
m = re.match(r'\|\s*(2026-\d{2}-\d{2})\s*\|(.+?)\|', ln)
|
|
if not m:
|
|
continue
|
|
date, insts = m.group(1), m.group(2)
|
|
for nm in re.findall(r'([가-힣]+[시군])\(', insts):
|
|
out[nm] = date
|
|
return out
|
|
|
|
|
|
SUBMITTED = parse_submissions() # {기관: 제출일}
|
|
|
|
|
|
def parse():
|
|
lines = open(MD, encoding='utf-8').read().splitlines()
|
|
rows, cur = [], None
|
|
for ln in lines:
|
|
m = re.match(r'^##\s+(충청남도|충청북도|전북특별자치도|제주특별자치도)', ln)
|
|
if m:
|
|
cur = m.group(1); continue
|
|
if cur and ln.startswith('|'):
|
|
cells = [c.strip() for c in ln.strip().strip('|').split('|')]
|
|
if len(cells) < 5 or not cells[0].isdigit():
|
|
continue
|
|
gm = re.search(r'\[([^\]]+)\]', cells[1])
|
|
inst = gm.group(1) if gm else cells[1]
|
|
st = cells[2]
|
|
cntm = re.search(r'(\d[\d,]*)', cells[3])
|
|
cnt = int(cntm.group(1).replace(',', '')) if cntm else None
|
|
# md 상태 → (작업상태, 검수)
|
|
if '검수완료' in st:
|
|
work, insp = '✅ 수집완료', '완료'
|
|
elif '수집완료' in st:
|
|
work, insp = '✅ 수집완료', '미검수'
|
|
elif '보류' in st or '⚠' in st:
|
|
work, insp = '⚠️ 보류·이슈', '미검수'
|
|
elif '수집' in st and '중' in st:
|
|
work, insp = '🔄 수집 중', '미검수'
|
|
else:
|
|
work, insp = '⬜ 미착수', '미검수'
|
|
bigo = cells[4]
|
|
# 비고 앞 [검수 YYYY-MM-DD] 마커 → 검수완료일 컬럼으로 추출하고 비고에서 제거
|
|
dm = re.search(r'\[검수\s*(\d{4}-\d{2}-\d{2})\]', bigo)
|
|
idate = dm.group(1) if dm else ''
|
|
bigo = re.sub(r'\[검수\s*\d{4}-\d{2}-\d{2}\]\s*·?\s*', '', bigo).strip()
|
|
# 제출: 제출 이력 표(SUBMITTED 원장)에 있으면 제출완료(제출일=표의 날짜)
|
|
if inst in SUBMITTED:
|
|
submit, sdate = '완료', SUBMITTED[inst]
|
|
else:
|
|
submit, sdate = '미완료', ''
|
|
rows.append({'region': cur, 'num': int(cells[0]), 'inst': inst,
|
|
'assignee': ASSIGNEE.get(cur, ''), 'work': work,
|
|
'inspect': insp, 'idate': idate, 'count': cnt,
|
|
'submit': submit, 'sdate': sdate, 'bigo': bigo})
|
|
return rows
|
|
|
|
|
|
def main():
|
|
rows = parse()
|
|
wb = openpyxl.Workbook(); ws = wb.active; ws.title = '현황'
|
|
thin = Side(style='thin', color='BFBFBF')
|
|
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
|
hf = PatternFill('solid', fgColor='4472C4'); hfont = Font(bold=True, color='FFFFFF')
|
|
headers = ['광역', '번호', '기관', '담당자', '상태', '검수', '행수', '검수완료일', '제출완료', '제출일', '비고']
|
|
widths = [15, 6, 12, 11, 12, 9, 10, 13, 10, 12, 100]
|
|
for c, (h, w) in enumerate(zip(headers, widths), 1):
|
|
cell = ws.cell(1, c, h)
|
|
cell.fill = hf; cell.font = hfont; cell.border = border
|
|
cell.alignment = Alignment(horizontal='center', vertical='center')
|
|
ws.column_dimensions[get_column_letter(c)].width = w
|
|
ws.freeze_panes = 'A2'
|
|
r = 2
|
|
for row in rows:
|
|
ws.cell(r, 1, row['region']); ws.cell(r, 2, row['num']); ws.cell(r, 3, row['inst'])
|
|
ws.cell(r, 4, row['assignee']); ws.cell(r, 5, row['work']); ws.cell(r, 6, row['inspect'])
|
|
ws.cell(r, 7, row['count']); ws.cell(r, 8, row['idate'])
|
|
ws.cell(r, 9, row['submit']); ws.cell(r, 10, row['sdate']); ws.cell(r, 11, row['bigo'])
|
|
for c in range(1, 12):
|
|
cell = ws.cell(r, c); cell.border = border
|
|
cell.alignment = Alignment(horizontal='left' if c == 11 else 'center',
|
|
vertical='center', wrap_text=(c == 11))
|
|
ws.cell(r, 7).number_format = '#,##0'
|
|
ws.row_dimensions[r].height = 30
|
|
r += 1
|
|
last = r - 1
|
|
ws.auto_filter.ref = f'A1:K{last}'
|
|
|
|
# 드롭다운(셀렉트박스)
|
|
def add_dv(col, options, title):
|
|
dv = DataValidation(type='list', formula1='"' + ','.join(options) + '"',
|
|
allow_blank=True, showDropDown=False)
|
|
dv.promptTitle = title; dv.prompt = f'{title} 선택'
|
|
ws.add_data_validation(dv); dv.add(f'{col}2:{col}{last}')
|
|
add_dv('D', ASSIGNEE_OPTIONS, '담당자')
|
|
add_dv('E', STATUS_OPTIONS, '작업상태')
|
|
add_dv('F', INSPECT_OPTIONS, '검수')
|
|
add_dv('I', SUBMIT_OPTIONS, '제출완료')
|
|
|
|
# 조건부서식 (값 바뀌면 색 자동). priority 낮을수록 우선.
|
|
_pri = [0]
|
|
|
|
def cf(rng, kw, fill, font, formula=None):
|
|
_pri[0] += 1
|
|
rule = FormulaRule(
|
|
formula=[formula or f'ISNUMBER(SEARCH("{kw}",{rng.split(":")[0]}))'],
|
|
# ★ CF 채우기는 Excel이 bgColor로 렌더 → fg/bg 둘 다 지정해야 색이 보임
|
|
fill=PatternFill('solid', fgColor=fill, bgColor=fill),
|
|
font=(Font(bold=True, color=font) if font else None), stopIfTrue=False)
|
|
rule.priority = _pri[0]
|
|
ws.conditional_formatting.add(rng, rule)
|
|
|
|
# ★ 검수완료 & 제출미완료 → 행 전체 노랑 (초록보다 우선: 먼저 등록해 priority↑)
|
|
cf(f'A2:K{last}', None, 'FFEB9C', None, formula=f'AND($F2="완료",$I2="미완료")')
|
|
# ★ 검수=완료 → 행 전체 초록
|
|
cf(f'A2:K{last}', None, 'C6EFCE', None, formula=f'$F2="완료"')
|
|
# 제출완료: 완료=파랑 / 미완료=회색
|
|
cf(f'I2:I{last}', '완료', 'BDD7EE', '1F4E78', formula=f'$I2="완료"')
|
|
cf(f'I2:I{last}', '미완료', 'F2F2F2', '808080', formula=f'$I2="미완료"')
|
|
# 담당자
|
|
cf(f'D2:D{last}', '프리랜서1', 'DDEBF7', '1F4E78')
|
|
cf(f'D2:D{last}', '프리랜서2', 'FCE4D6', '974706')
|
|
# 상태
|
|
cf(f'E2:E{last}', '수집완료', 'E2EFDA', '375623')
|
|
cf(f'E2:E{last}', '보류', 'FFEB9C', '9C5700')
|
|
cf(f'E2:E{last}', '수집 중', 'FFF2CC', '7F6000')
|
|
cf(f'E2:E{last}', '미착수', 'F2F2F2', '808080')
|
|
# 검수 (미검수만 — 완료는 행 규칙이 처리)
|
|
cf(f'F2:F{last}', '미검수', 'F2F2F2', '808080')
|
|
# 행수 데이터바 (초록 행 위에도 막대 표시됨)
|
|
db = DataBarRule(start_type='min', end_type='max', color='8EAADB', showValue=True)
|
|
_pri[0] += 1; db.priority = _pri[0]
|
|
ws.conditional_formatting.add(f'G2:G{last}', db)
|
|
|
|
# ===== 요약 시트 =====
|
|
s = wb.create_sheet('요약')
|
|
A = f'현황!$A$2:$A${last}'; DD = f'현황!$D$2:$D${last}'
|
|
FF = f'현황!$F$2:$F${last}'; GG = f'현황!$G$2:$G${last}'
|
|
II = f'현황!$I$2:$I${last}' # 제출완료
|
|
|
|
def hdr(rowi, titles, fill='4472C4'):
|
|
for c, t in enumerate(titles, 1):
|
|
cell = s.cell(rowi, c, t)
|
|
cell.fill = PatternFill('solid', fgColor=fill)
|
|
cell.font = Font(bold=True, color='FFFFFF')
|
|
cell.border = border
|
|
cell.alignment = Alignment(horizontal='center', vertical='center')
|
|
for c, w in enumerate([16, 9, 11, 11, 11, 11], 1):
|
|
s.column_dimensions[get_column_letter(c)].width = w
|
|
|
|
# 광역별
|
|
s.cell(1, 1, '◆ 광역별').font = Font(bold=True, size=12)
|
|
hdr(2, ['광역', '기관수', '검수완료', '미검수', '제출완료', '총 행수'])
|
|
rr = 3
|
|
for region in REGIONS:
|
|
a = f'A{rr}'
|
|
s.cell(rr, 1, region)
|
|
s.cell(rr, 2, f'=COUNTIF({A},{a})')
|
|
s.cell(rr, 3, f'=COUNTIFS({A},{a},{FF},"완료")')
|
|
s.cell(rr, 4, f'=COUNTIFS({A},{a},{FF},"미검수")')
|
|
s.cell(rr, 5, f'=COUNTIFS({A},{a},{II},"완료")')
|
|
s.cell(rr, 6, f'=SUMIF({A},{a},{GG})')
|
|
for c in range(1, 7):
|
|
s.cell(rr, c).border = border
|
|
s.cell(rr, c).alignment = Alignment(horizontal='center' if c > 1 else 'left', vertical='center')
|
|
s.cell(rr, 6).number_format = '#,##0'
|
|
rr += 1
|
|
s.cell(rr, 1, '합계').font = Font(bold=True)
|
|
for c in range(2, 7):
|
|
col = get_column_letter(c)
|
|
cc = s.cell(rr, c, f'=SUM({col}3:{col}{rr-1})'); cc.font = Font(bold=True)
|
|
cc.border = border; cc.alignment = Alignment(horizontal='center', vertical='center')
|
|
cc.fill = PatternFill('solid', fgColor='FCE4D6')
|
|
if c == 6:
|
|
cc.number_format = '#,##0'
|
|
s.cell(rr, 1).fill = PatternFill('solid', fgColor='FCE4D6'); s.cell(rr, 1).border = border
|
|
|
|
# 담당자별
|
|
base = rr + 2
|
|
s.cell(base - 1, 1, '◆ 담당자별').font = Font(bold=True, size=12)
|
|
hdr(base, ['담당자', '기관수', '검수완료', '미검수', '제출완료', '총 행수'], fill='ED7D31')
|
|
rr2 = base + 1
|
|
for who in ['프리랜서1', '프리랜서2']:
|
|
a = f'A{rr2}'
|
|
s.cell(rr2, 1, who)
|
|
s.cell(rr2, 2, f'=COUNTIF({DD},{a})')
|
|
s.cell(rr2, 3, f'=COUNTIFS({DD},{a},{FF},"완료")')
|
|
s.cell(rr2, 4, f'=COUNTIFS({DD},{a},{FF},"미검수")')
|
|
s.cell(rr2, 5, f'=COUNTIFS({DD},{a},{II},"완료")')
|
|
s.cell(rr2, 6, f'=SUMIFS({GG},{DD},{a})')
|
|
for c in range(1, 7):
|
|
s.cell(rr2, c).border = border
|
|
s.cell(rr2, c).alignment = Alignment(horizontal='center' if c > 1 else 'left', vertical='center')
|
|
s.cell(rr2, 6).number_format = '#,##0'
|
|
rr2 += 1
|
|
s.freeze_panes = 'A3'
|
|
|
|
wb.save(OUT)
|
|
print(f'생성: {OUT} | 현황 {len(rows)}기관 (담당자/상태/검수 분리, 드롭다운·조건부서식·수식)')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|