openpyxl로 CSV 점포 수량 합계 만들기: 3행 입력과 저장값 검산

CSV에 같은 점포가 여러 번 나오면 행 개수와 합계 수량을 혼동하기 쉽습니다. 이 예제는 가점 두 행을 합쳐 5개, 나점 한 행을 5개로 저장합니다. 날짜 열이나 월별 필터는 없으므로 월간 실적이라고 부르지 않습니다. 입력 파일 하나가 집계 범위이며, 점포 이름의 앞뒤 공백만 제거합니다. 실제 매장 자료가 아닌 아래 세 행을 사용합니다.

준비: CSV 세 행과 새 출력 경로

Python과 openpyxl이 설치된 환경에서 ZIP을 빈 실습 폴더에 풉니다. rows.csv와 csv_to_excel.py를 같은 폴더에 두고 그 폴더에서 실행합니다. 검산 환경은 ZIP의 verification.json에 남겼습니다. 이 예제는 수식을 Excel에 넣는 방식이 아니라 Python이 계산한 정수 결과를 셀에 기록하는 방식입니다.

점포,수량
가점,3
나점,5
가점,2

정수 수량은 0부터 999999까지 허용합니다. 1.5, 음수, 빈 수량, 추가 열은 거부하며 아무 값이나 0으로 바꾸지 않습니다. 점포 이름은 대소문자나 내부 공백을 합치지 않습니다. 예를 들어 가 점과 가점은 서로 다른 점포입니다. 헤더만 있는 파일도 빈 결과로 저장하지 않고 중단합니다.

전체 코드: 검증 후 한 번만 저장

openpyxl의 Workbook.save()는 기존 파일을 경고 없이 덮어쓸 수 있습니다. 그래서 출력은 xb 모드로 먼저 확보합니다. 이미 같은 경로의 파일이나 심볼릭 링크가 있으면 덮어쓰지 않습니다. =1+1처럼 보이는 점포명도 텍스트 자료형으로 넣어 수식으로 바뀌지 않게 합니다. csv 모듈의 reader와 writer는 행을 읽고 쓰는 객체입니다. CSV의 따옴표 처리와 행 분리는 reader에 맡기고 열 개수는 별도로 검사합니다.

import argparse
import csv
import json
import re
from collections import Counter
from pathlib import Path
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, PatternFill

LIMIT = 1_000_000

def collect(source):
    with Path(source).open('rb') as handle:
        raw = handle.read(LIMIT + 1)
    if len(raw) > LIMIT:
        raise ValueError('Practice CSV exceeds 1 MB.')
    import io
    reader = csv.reader(io.StringIO(raw.decode('utf-8-sig'), newline=''), strict=True)
    if next(reader, None) != ['점포', '수량']:
        raise ValueError('Expected header: 점포,수량')
    totals = Counter()
    count = 0
    for line, row in enumerate(reader, 2):
        if len(row) != 2 or not row[0].strip():
            raise ValueError(f'Line {line}: expected store and quantity.')
        store, quantity = row[0].strip(), row[1]
        if not re.fullmatch(r'[0-9]{1,6}', quantity):
            raise ValueError(f'Line {line}: quantity must be 0..999999 integer text.')
        # These labels will be spreadsheet text, never interpreted as formulas.
        totals[store] += int(quantity)
        count += 1
        if count > 10000:
            raise ValueError('Practice row limit exceeded.')
    if not count:
        raise ValueError('Header-only CSV is not a result.')
    return count, totals

def convert(source, target):
    source, target = Path(source), Path(target)
    if target.suffix.lower() != '.xlsx' or source.resolve() == target.resolve():
        raise ValueError('Use a separate NEW .xlsx filename.')
    count, totals = collect(source)
    wb = Workbook()
    ws = wb.active
    ws.title = '점포합계'
    ws.append(['점포', '합계수량'])
    for store, total in sorted(totals.items()):
        ws.append([store, total])
        ws.cell(ws.max_row, 1).data_type = 's'
        ws.cell(ws.max_row, 2).number_format = '#,##0'
    for cell in ws[1]:
        cell.font = Font(name='Arial', bold=True, color='FFFFFF')
        cell.fill = PatternFill('solid', fgColor='17365D')
    ws.column_dimensions['A'].width = 24
    ws.column_dimensions['B'].width = 18
    ws.freeze_panes = 'A2'
    ws.auto_filter.ref = ws.dimensions
    # Exclusive creation protects an existing result, including a symlink.
    with target.open('xb') as handle:
        wb.save(handle)
    expected = [('점포', '합계수량')] + sorted(totals.items())
    check = load_workbook(target, read_only=True, data_only=True)
    try:
        if list(check['점포합계'].values) != expected:
            raise RuntimeError('Saved cell values differ; preserve output for inspection.')
    finally:
        check.close()
    result = {'input_rows': count, 'stores': len(totals),
              'total_quantity': sum(totals.values()), 'readback_equal': True}
    return result

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--input', default='rows.csv')
    parser.add_argument('--out', default='store_totals.xlsx')
    args = parser.parse_args()
    print(json.dumps(convert(args.input, args.out), ensure_ascii=False))

if __name__ == '__main__':
    main()

실행과 셀 단위 예상 결과

python3 csv_to_excel.py
python3 -m unittest -v test_example.py

첫 명령은 store_totals.xlsx를 새로 만듭니다. 가점은 3+2=5이고 나점은 5이므로 합계는 10입니다. 시트 점포합계의 A1:B3은 다음과 같아야 합니다.

셀 행 점포 합계수량
1 점포 합계수량
2 가점 5
3 나점 5

표의 첫 행은 헤더이며 계산 대상이 아닙니다. 출력 JSON은 input_rows=3, stores=2, total_quantity=10, readback_equal=true를 담습니다. Python으로 실제 저장 후 다시 읽었고 테스트 5개가 통과했습니다. Microsoft Excel 앱을 직접 실행한 결과나 성능 측정은 아닙니다.

실패했을 때 보존할 것

가점,1.5로 바꾸면 저장 전에 ValueError가 발생해야 합니다. 숫자 표기를 수정할지 분수 수량을 허용할지는 업무 규칙을 먼저 정해야 하므로 이 예제는 자동 반올림하지 않습니다. 기존 출력이 있으면 --out second_totals.xlsx처럼 다른 이름을 사용합니다. 저장 도중 실패했다면 남은 파일을 보존해 원인을 확인하고 새 경로에서 다시 실행합니다.

적용 범위와 한계

CSV는 UTF-8 또는 UTF-8 BOM을 지원하며 1 MB와 10000행을 실습 한도로 둡니다. 이는 처리 성능 보장이 아닙니다. 여러 파일의 중복, 점포명 표준화, 월별 구분은 구현하지 않았습니다. 다운로드의 expected/store_totals.xlsx는 위 입력으로 만든 비교용 결과이며, 원본 입력을 바꾸면 같은 숫자가 나와야 한다는 뜻은 아닙니다.

실행 순서 정리

  1. 빈 실습 폴더에 rows.csv와 csv_to_excel.py를 저장하고 헤더와 세 행을 확인합니다.

  2. 그 폴더에서 python3 csv_to_excel.py를 실행해 새 store_totals.xlsx를 만듭니다.

  3. 점포합계 시트 A2:B3을 가점 5·나점 5와 비교하고 test_example.py의 실패 입력 검사를 실행합니다.

예제 흐름 그림

가점 3·나점 5·가점 2의 가상 CSV 세 행을 가점 5·나점 5로 합치는 설명도
설명용 도해: 가상 입력 3행은 점포 2개·수량 합계 10으로 저장합니다. 점포합계 시트 A2:B3의 기대값은 가점 5와 나점 5이며, 날짜나 월별 구분은 없습니다.
CSV 검사, 점포별 합산, 새 XLSX 저장, 재읽기 대조의 네 단계와 텍스트 점포명 보존
설명용 도해: 점포 이름은 앞뒤 공백만 제거하고 내부 공백은 유지합니다. =1+1 같은 이름도 텍스트로 기록하며, 기존 결과를 덮어쓰지 않고 저장 후 행·열·값을 다시 비교합니다.
허용 수량 범위와 잘못된 입력·기존 파일·부분 저장 실패를 구분하는 복구 기준
설명용 도해: 0~999999 정수 표기만 받고 소수·음수·빈 수량·추가 열과 헤더만 있는 입력은 중단합니다. 기존 파일과 심볼릭 링크는 보존하고, 저장 도중 실패하면 남은 파일을 보존해 새 경로에서 재실행합니다.

공식 자료

Tutorial — openpyxl 3.1.3 documentation

csv — CSV File Reading and Writing

출처 원문 수집일: S1 2026-09-24; S2 2026-09-24.

작성자 소개