개발(IT)/C#(VisualStudio)

의약품 반품 보상 프로그램 만들기 — 단계별 강의 (3편 · 완성)

isony 2026. 7. 8. 06:33
반응형

의약품 반품 보상 프로그램 만들기 — 단계별 강의 (3편 · 완성)

1편에서 "두뇌"(파싱·대조·보상), 2편에서 "눈"(바코드 디코드)을 만들었다. 3편은 **사람이 쓰는 화면(WinForms)**을 붙여 완성한다.

완성 후 기능: 이미지 여러 장 불러오기 → 자동 판독 → 미리보기 + 파란 박스/번호 라벨확대/축소·이동영역 드래그 재판독 → 흐린 코드 가독문자 OCR 보완그리드 편집 + 보상 계산 + 총액배포(설치 파일).

이번 편도 설명을 자세히 넣었다. WinForms가 처음이어도 따라올 수 있게 개념부터 짚는다.

 

들어가기 전에 — WinForms 핵심 개념 5가지

3편 내내 쓰는 개념이라 먼저 한 번에 정리한다.

  1. Form(폼) = 창 하나. MainForm : Form이 우리 메인 창이다.
  2. Control(컨트롤) = 창 안의 부품. 버튼(Button), 표(DataGridView), 그림판(Panel) 등.
  3. Dock(도킹) = 부품을 창의 어느 변에 "붙일지". Dock = DockStyle.Top이면 위쪽에 가로로 꽉 차게, Fill이면 남은 공간을 다 채움. 좌표를 일일이 안 줘도 창 크기에 맞춰 배치된다.
  4. 이벤트(Event) = "버튼이 눌리면 이 함수를 실행". button.Click += OnClick;처럼 함수를 **연결(구독)**한다.
  5. 그리기(OnPaint) = 부품이 화면을 다시 그릴 때 호출되는 함수. 우리가 직접 이미지·박스를 그릴 때 여기에 코드를 넣는다.

💡 우리는 화면을 코드로 만든다(디자이너 끌어다 놓기 대신). 코드로 만들면 무엇이 왜 배치되는지 명확히 보이고, 복습·수정이 쉽다. 디자이너(.Designer.cs)는 쓰지 않는다.

전체 화면 구성(완성형):

┌───────────────────────────────────────────────┐
│ [파일 불러오기][보상 계산]  상태문구            │  ← top (Dock=Top)
├──────────────────────────┬────────────────────┤
│                          │                    │
│      이미지 미리보기       │     결과 그리드     │  ← split(좌:캔버스 / 우:표)
│   (파란 박스 + 라벨)       │  (편집·결과)        │
│                          │                    │
│ [◀][1/5][▶][−][+][맞춤]   │                    │  ← navBar (캔버스 아래)
│ [영역판독][OCR]  힌트      │                    │
├──────────────────────────┴────────────────────┤
│ 총 보상 금액: 0 원                              │  ← bottom (Dock=Bottom)
└───────────────────────────────────────────────┘

 

Lesson 8 — WinForms 골격: 파일 열기 + 그리드 + 보상

🎯 목표

콘솔을 WinForms 앱으로 바꾸고, 이미지를 여러 장 골라 → 자동 판독 → 표에 채우고 → 보상 계산까지 하는 최소 동작 화면을 만든다. (미리보기·줌·OCR은 다음 Lesson에서 추가)

📖 개념: 프로젝트 종류 바꾸기

지금까지는 콘솔(OutputType=Exe, net8.0)이었다. WinForms로 바꾸려면 .csproj를 수정한다.

  • OutputType → WinExe(창 앱, 콘솔 검은 창 안 뜸)
  • TargetFramework → net8.0-windows(윈도우 전용 기능 사용)
  • UseWindowsForms → true

💻 코드 (8-1) .csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0-windows</TargetFramework>
    <UseWindowsForms>true</UseWindowsForms>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>PharmaReturn</RootNamespace>
    <Platforms>x64</Platforms>
    <PlatformTarget>x64</PlatformTarget>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="ZXingCpp" Version="0.5.1" />
    <PackageReference Include="ZXing.Net" Version="0.16.10" />
    <PackageReference Include="SixLabors.ImageSharp" Version="2.1.9" />
  </ItemGroup>

  <ItemGroup>
    <None Include="data\**\*" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>
</Project>

Tesseract(OCR) 패키지는 Lesson 11에서 추가한다.

💻 코드 (8-2) Program.cs — 진입점

using System;
using System.Windows.Forms;

namespace PharmaReturn;

internal static class Program
{
    [STAThread]                                   // ① 윈도우 UI는 STA 스레드에서 시작
    private static void Main()
    {
        Application.EnableVisualStyles();         // ② 요즘 윈도우 테마 적용
        Application.SetCompatibleTextRenderingDefault(false);
        Application.Run(new MainForm());          // ③ 메인 창을 띄우고 대기
    }
}

💡 [STAThread]는 WinForms 필수 표시(클립보드·파일 대화상자 등이 단일 스레드 아파트 모델을 요구). 빼면 일부 대화상자가 예외를 던진다.

💻 코드 (8-3) MainForm.cs — 최소 버전

먼저 미리보기 없이, 파일 열기 + 표 + 보상만 되는 최소 화면을 만든다. (다음 Lesson에서 이 파일을 키운다.)

using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Windows.Forms;

namespace PharmaReturn;

public sealed class MainForm : Form
{
    private readonly Button _btnLoad = new() { Text = "파일 불러오기 (여러 개)", AutoSize = true, Margin = new Padding(4) };
    private readonly Button _btnCalc = new() { Text = "보상 계산", AutoSize = true, Margin = new Padding(4) };
    private readonly Label _lblStatus = new() { AutoSize = true, Margin = new Padding(12, 10, 4, 4), Text = "이미지를 불러오세요." };
    private readonly Label _lblTotal = new() { AutoSize = true, Text = "총 보상 금액: 0 원" };

    private readonly DataGridView _dgv = new()
    {
        Dock = DockStyle.Fill, AllowUserToAddRows = false,
        AutoGenerateColumns = false, RowHeadersVisible = false,
    };

    private readonly string _csvPath = Path.Combine(AppContext.BaseDirectory, "data", "deliveries.csv");

    public MainForm()
    {
        Text = "약국 반품 보상";
        Width = 900; Height = 600; StartPosition = FormStartPosition.CenterScreen;
        _lblTotal.Font = new Font(_lblTotal.Font, FontStyle.Bold);

        BuildGrid();

        var top = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 48, Padding = new Padding(8, 6, 8, 6) };
        top.Controls.AddRange(new Control[] { _btnLoad, _btnCalc, _lblStatus });

        var bottom = new Panel { Dock = DockStyle.Bottom, Height = 40, Padding = new Padding(12, 8, 8, 8) };
        _lblTotal.Dock = DockStyle.Left; bottom.Controls.Add(_lblTotal);

        Controls.Add(_dgv);     // 가운데(Fill)
        Controls.Add(top);      // 위
        Controls.Add(bottom);   // 아래

        _btnLoad.Click += OnLoad;
        _btnCalc.Click += OnCalc;
        _dgv.DataError += (_, e) => e.ThrowException = false; // 콤보 값 오류 무시
    }

    private void BuildGrid()
    {
        void Col(string name, string header, int w, bool ro = true)
            => _dgv.Columns.Add(new DataGridViewTextBoxColumn { Name = name, HeaderText = header, Width = w, ReadOnly = ro });

        Col("file", "파일", 110); Col("gtin", "GTIN", 130); Col("lot", "로트", 80);
        Col("expiry", "유통기한", 90); Col("serial", "일련번호", 70);
        var reason = new DataGridViewComboBoxColumn { Name = "reason", HeaderText = "사유", Width = 64 };
        reason.Items.AddRange("만기", "손상", "기타");
        _dgv.Columns.Add(reason);
        Col("reqty", "요청수량", 64, ro: false);
        Col("approved", "승인", 52); Col("credit", "보상금액", 90); Col("status", "상태", 220);
    }

    private void OnLoad(object? sender, EventArgs e)
    {
        using var ofd = new OpenFileDialog
        {
            Multiselect = true,                                   // ★ 여러 개 선택
            Filter = "이미지 (*.png;*.jpg;*.jpeg;*.bmp)|*.png;*.jpg;*.jpeg;*.bmp|모든 파일|*.*",
        };
        if (ofd.ShowDialog(this) != DialogResult.OK) return;

        var repo = DeliveryRepository.LoadCsv(_csvPath);
        _dgv.Rows.Clear();

        foreach (var path in ofd.FileNames)
        {
            var codes = BarcodeServiceCpp.DecodeDetailed(path);   // 2편 디코더
            if (codes.Count == 0)
            {
                _dgv.Rows.Add(Path.GetFileName(path), "", "", "", "", "만기", "", "", "", "디코드 실패");
                continue;
            }
            foreach (var c in codes)
            {
                var f = Gs1Parser.Parse(c.Text);                  // 1편 파서
                int prefill = f.Gtin is null ? 0
                    : repo.FindByGtinLot(f.Gtin, f.Lot).Sum(m => m.DeliveredQty);
                _dgv.Rows.Add(Path.GetFileName(path), f.Gtin ?? "", f.Lot ?? "",
                    f.Expiry?.ToString("yyyy-MM-dd") ?? "", f.Serial ?? "",
                    "만기", prefill > 0 ? prefill.ToString() : "", "", "", "불러옴");
            }
        }
        _lblStatus.Text = $"{_dgv.Rows.Count}개 코드 인식. 사유·요청수량 확인 후 [보상 계산].";
    }

    private void OnCalc(object? sender, EventArgs e)
    {
        var svc = new CompensationService(DeliveryRepository.LoadCsv(_csvPath));
        decimal total = 0;
        foreach (DataGridViewRow row in _dgv.Rows)
        {
            string gtin = Cell(row, "gtin");
            if (string.IsNullOrEmpty(gtin)) continue;
            string lot = Cell(row, "lot");
            DateOnly? exp = DateOnly.TryParse(Cell(row, "expiry"), out var d) ? d : null;
            ReturnReason reason = Cell(row, "reason") switch { "손상" => ReturnReason.Damage, "기타" => ReturnReason.Other, _ => ReturnReason.Expiry };
            int qty = int.TryParse(Cell(row, "reqty"), out var q) ? q : 0;

            var r = svc.Evaluate(new ReturnRequest(gtin, string.IsNullOrEmpty(lot) ? null : lot, exp, qty, reason));
            total += r.CreditAmount;
            row.Cells["approved"].Value = r.ApprovedQty.ToString();
            row.Cells["credit"].Value = r.CreditAmount.ToString("N0");
            row.Cells["status"].Value = $"[{r.Status}] {r.Message}";
        }
        _lblTotal.Text = $"총 보상 금액: {total:N0} 원";
    }

    private static string Cell(DataGridViewRow row, string col) => row.Cells[col].Value?.ToString() ?? "";
}

🔍 핵심:

  • 레이아웃: top(Dock=Top), bottom(Dock=Bottom), _dgv(Fill)을 Controls.Add 한다. 추가 순서가 중요하다 — Fill을 먼저 넣고 Top/Bottom을 나중에 넣어야 가운데가 위/아래에 안 가린다.
  • OpenFileDialog { Multiselect = true }: 파일 여러 개 선택. ofd.FileNames로 경로 배열을 받는다.
  • 그리드: AutoGenerateColumns=false로 우리가 컬럼을 직접 정의(BuildGrid). "사유"는 콤보박스 컬럼, "요청수량"만 편집 가능.
  • 흐름: 각 파일 → DecodeDetailed(2편) → Gs1Parser.Parse(1편) → 행 추가. [보상 계산]은 행을 돌며 CompensationService.Evaluate(1편).

💡 deliveries.csv 준비: 1편의 data/deliveries.csv를 이 프로젝트에도 두고, "출력 디렉터리로 복사 = 새 버전이면 복사"로 설정(또는 위 csproj의 <None Include="data\**\*" ...>).

✅ 직접 해보기

F5로 실행 → [파일 불러오기]로 바코드 사진(2편에서 만든 test.png나 실제 사진) 선택 → 표에 GTIN/유통기한/로트가 채워지고 → [보상 계산] 누르면 승인·보상·총액이 나온다. 여기까지가 "쓸 수 있는 앱"의 첫 모습이다.

 

Lesson 9 — 이미지 미리보기 + 파란 박스 오버레이

🎯 목표

왼쪽에 현재 이미지를 보여주고, 판독한 코드 위치를 파란 사각형 + 번호·문자 라벨로 겹쳐 그린다. 파일 간 [이전]/[다음] 이동도 넣는다.

📖 개념 1: 커스텀 그림판 만들기 (Panel 상속)

이미지와 박스를 그리려면 직접 그리는 컨트롤이 필요하다. Panel을 상속해 OnPaint(그리기)와 마우스 이벤트를 우리가 처리하는 ImageCanvas를 만든다.

  • DoubleBuffered = true: 깜빡임 방지(뒤에서 다 그린 뒤 한 번에 표시).
  • OnPaint에서 Graphics로 이미지·도형·글자를 그린다.

📖 개념 2: 두 좌표계와 변환 (아주 중요)

  • 원본 이미지 좌표: 디코더가 준 꼭짓점(2편). 사진 픽셀 기준.
  • 화면(컨트롤) 좌표: 캔버스에 실제로 그려지는 위치. 이미지를 비율 맞춰 줄여 그리므로 다르다.

이미지가 그려진 사각형을 _dispRect라 하면, 원본의 점 (ix, iy)는 화면에서:

화면X = _dispRect.Left + ix × (_dispRect.Width  / 이미지폭)
화면Y = _dispRect.Top  + iy × (_dispRect.Height / 이미지높이)

반대로 화면→원본은 나누면 된다(영역 선택에서 쓴다). 이 변환 덕분에 확대/축소해도 박스가 코드에 정확히 붙는다.

💻 코드 (9-1) ImageCanvas.cs (완성형)

아래는 미리보기·오버레이는 물론 확대/축소·이동·영역 선택까지 포함한 완성본이다. 이번 Lesson은 그리기·오버레이 부분(OnPaint, ComputeDispRect)에 집중하고, 확대/이동(줌·팬)·영역선택은 Lesson 10에서 설명한다. (한 파일이라 미리 통째로 둔다.)

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;

namespace PharmaReturn;

/// <summary>
/// 이미지를 비율 유지로 표시하고, 판독 위치를 파란 사각형 + 작은 라벨(번호·판독문자)로 겹쳐 그린다.
/// 확대/축소(줌)와 패닝을 지원하며, 마우스 드래그로 영역을 선택해 원본 픽셀 좌표로 돌려준다.
///  - 마우스 휠: 커서 기준 확대/축소
///  - 좌버튼 드래그: 영역 선택
///  - 가운데버튼 또는 Space+좌드래그: 화면 이동(패닝)
/// </summary>
public sealed class ImageCanvas : Panel
{
    private Image? _image;
    private List<DecodedCode> _codes = new();

    private float _fitScale = 1f;          // 화면에 맞춘 기본 배율
    private float _zoom = 1f;              // 사용자 추가 배율(1=맞춤)
    private PointF _offset = PointF.Empty; // 패닝 이동
    private RectangleF _dispRect;          // 이미지가 그려진 영역(컨트롤 좌표)

    private bool _selecting;
    private Point _selStart;
    private Rectangle _selControl;

    private bool _panning;
    private Point _panStart;
    private PointF _panOffsetStart;
    private bool _spaceDown;

    public ImageCanvas()
    {
        DoubleBuffered = true;
        BackColor = SystemColors.ControlDark;
        Cursor = Cursors.Cross;
        TabStop = true; // Space 키 입력 받기
    }

    public void SetContent(Image? image, List<DecodedCode> codes)
    {
        _image?.Dispose();
        _image = image;
        _codes = codes ?? new();
        _selControl = Rectangle.Empty;
        ResetView();
    }

    public void SetCodes(List<DecodedCode> codes) { _codes = codes ?? new(); Invalidate(); }
    public bool HasImage => _image is not null;

    public void ResetView() { _zoom = 1f; _offset = PointF.Empty; Invalidate(); }
    public void ZoomBy(float factor) => ZoomAt(factor, new Point(ClientSize.Width / 2, ClientSize.Height / 2));

    public void ZoomAt(float factor, Point anchor)
    {
        if (_image is null) return;
        float newZoom = Math.Clamp(_zoom * factor, 1f, 12f);
        if (Math.Abs(newZoom - _zoom) < 0.0001f) return;
        ComputeDispRect();
        float ix = (anchor.X - _dispRect.Left) / _dispRect.Width;
        float iy = (anchor.Y - _dispRect.Top) / _dispRect.Height;
        _zoom = newZoom;
        ComputeDispRect(applyOffset: false);
        float targetLeft = anchor.X - ix * _dispRect.Width;
        float targetTop = anchor.Y - iy * _dispRect.Height;
        _offset = new PointF(targetLeft - _dispRect.Left, targetTop - _dispRect.Top);
        ClampOffset();
        Invalidate();
    }

    /// <summary>선택 영역을 원본 이미지 픽셀 좌표로 반환. 없으면 Empty.</summary>
    public Rectangle GetSelectionImageRect()
    {
        if (_image is null || _selControl.Width < 3 || _selControl.Height < 3 || _dispRect.Width <= 0)
            return Rectangle.Empty;
        float sx = _image.Width / _dispRect.Width;
        float sy = _image.Height / _dispRect.Height;
        int ix = (int)Math.Round((_selControl.Left - _dispRect.Left) * sx);
        int iy = (int)Math.Round((_selControl.Top - _dispRect.Top) * sy);
        int iw = (int)Math.Round(_selControl.Width * sx);
        int ih = (int)Math.Round(_selControl.Height * sy);
        return new Rectangle(ix, iy, iw, ih);
    }

    // ── 입력(줌/팬/선택) ───────────────────────────────────
    protected override void OnMouseWheel(MouseEventArgs e)
    {
        base.OnMouseWheel(e);
        if (_image is null) return;
        Focus();
        ZoomAt(e.Delta > 0 ? 1.2f : 1f / 1.2f, e.Location);
    }

    protected override void OnMouseDown(MouseEventArgs e)
    {
        base.OnMouseDown(e);
        if (_image is null) return;
        Focus();
        bool pan = e.Button == MouseButtons.Middle || (e.Button == MouseButtons.Left && _spaceDown);
        if (pan) { _panning = true; _panStart = e.Location; _panOffsetStart = _offset; Cursor = Cursors.SizeAll; }
        else if (e.Button == MouseButtons.Left)
        {
            _selecting = true; _selStart = e.Location;
            _selControl = new Rectangle(e.Location, Size.Empty); Invalidate();
        }
    }

    protected override void OnMouseMove(MouseEventArgs e)
    {
        base.OnMouseMove(e);
        if (_panning)
        {
            _offset = new PointF(_panOffsetStart.X + (e.X - _panStart.X), _panOffsetStart.Y + (e.Y - _panStart.Y));
            ClampOffset(); Invalidate();
        }
        else if (_selecting)
        {
            int x = Math.Min(_selStart.X, e.X), y = Math.Min(_selStart.Y, e.Y);
            _selControl = new Rectangle(x, y, Math.Abs(e.X - _selStart.X), Math.Abs(e.Y - _selStart.Y));
            Invalidate();
        }
    }

    protected override void OnMouseUp(MouseEventArgs e)
    {
        base.OnMouseUp(e);
        _selecting = false; _panning = false;
        Cursor = _spaceDown ? Cursors.SizeAll : Cursors.Cross;
    }

    protected override bool IsInputKey(Keys keyData) => keyData == Keys.Space || base.IsInputKey(keyData);
    protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (e.KeyCode == Keys.Space) { _spaceDown = true; Cursor = Cursors.SizeAll; } }
    protected override void OnKeyUp(KeyEventArgs e) { base.OnKeyUp(e); if (e.KeyCode == Keys.Space) { _spaceDown = false; Cursor = Cursors.Cross; } }

    // ── 그리기 ─────────────────────────────────────────────
    protected override void OnPaint(PaintEventArgs e)
    {
        base.OnPaint(e);
        var g = e.Graphics;

        if (_image is null)
        {
            TextRenderer.DrawText(g, "파일을 불러오면 이미지가 여기에 표시됩니다.",
                Font, ClientRectangle, Color.White,
                TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
            return;
        }

        g.InterpolationMode = InterpolationMode.HighQualityBicubic;
        ComputeDispRect();
        g.DrawImage(_image, _dispRect.X, _dispRect.Y, _dispRect.Width, _dispRect.Height);

        float sx = _dispRect.Width / _image.Width;     // 원본→화면 배율
        float sy = _dispRect.Height / _image.Height;

        using var pen = new Pen(Color.DodgerBlue, 2f);
        using var labelBg = new SolidBrush(Color.FromArgb(210, 20, 90, 200));
        using var labelFg = new SolidBrush(Color.White);
        using var labelFont = new Font(Font.FontFamily, 8f, FontStyle.Bold);

        for (int i = 0; i < _codes.Count; i++)
        {
            var c = _codes[i];
            if (c.Corners is null || c.Corners.Length < 3) continue;

            var pts = new PointF[c.Corners.Length];
            for (int k = 0; k < pts.Length; k++)        // 원본 꼭짓점 → 화면 좌표로 변환
                pts[k] = new PointF(_dispRect.Left + c.Corners[k].X * sx, _dispRect.Top + c.Corners[k].Y * sy);
            g.DrawPolygon(pen, pts);                    // 파란 사각형

            string label = $"{i + 1}. {Short(c.Text)}"; // 번호 + 판독문자 앞부분
            SizeF szf = g.MeasureString(label, labelFont);
            float lx = pts[0].X, ly = pts[0].Y - szf.Height - 2;
            if (ly < _dispRect.Top) ly = pts[0].Y + 2;
            g.FillRectangle(labelBg, lx, ly, szf.Width + 6, szf.Height + 2);
            g.DrawString(label, labelFont, labelFg, lx + 3, ly + 1);
        }

        if (_selControl.Width > 0 && _selControl.Height > 0) // 영역 선택 점선
        {
            using var selPen = new Pen(Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dash };
            g.DrawRectangle(selPen, _selControl);
        }

        if (_zoom > 1.001f)
            TextRenderer.DrawText(g, $"{_zoom * 100:0}%", Font, new Point(6, 6), Color.White);
    }

    // ── 보기 계산 ──────────────────────────────────────────
    private void ComputeDispRect(bool applyOffset = true)
    {
        if (_image is null) { _dispRect = RectangleF.Empty; return; }
        _fitScale = Math.Min((float)ClientSize.Width / _image.Width, (float)ClientSize.Height / _image.Height);
        float scale = _fitScale * _zoom;
        float w = _image.Width * scale, h = _image.Height * scale;
        float baseLeft = (ClientSize.Width - w) / 2f, baseTop = (ClientSize.Height - h) / 2f;
        _dispRect = applyOffset
            ? new RectangleF(baseLeft + _offset.X, baseTop + _offset.Y, w, h)
            : new RectangleF(baseLeft, baseTop, w, h);
    }

    private void ClampOffset()
    {
        ComputeDispRect(applyOffset: false);
        float maxX = Math.Max(0, (_dispRect.Width - ClientSize.Width) / 2f);
        float maxY = Math.Max(0, (_dispRect.Height - ClientSize.Height) / 2f);
        _offset = new PointF(Math.Clamp(_offset.X, -maxX, maxX), Math.Clamp(_offset.Y, -maxY, maxY));
    }

    private static string Short(string s) => s.Length <= 16 ? s : s[..16] + "…";
}

🔍 이번 Lesson에서 볼 부분:

  • OnPaint: 배경에 이미지를 _dispRect만큼 그리고, 각 코드의 원본 꼭짓점 → 화면 좌표(sx,sy 곱)로 변환해 DrawPolygon(파란 박스) + 라벨을 그린다.
  • ComputeDispRect: 이미지를 화면에 비율 유지로 가운데 배치하는 사각형 계산(_fitScale). 줌/패닝은 다음 Lesson.
  • SetContent: 새 이미지·코드를 넣고 다시 그린다(Invalidate()는 "다시 그려라" 요청).

💻 코드 (9-2) MainForm에 미리보기·이동 추가

Lesson 8 MainForm을 다음과 같이 키운다(달라지는 부분 위주).

(a) 파일 1개의 상태를 묶는 작은 클래스 + 목록: MainForm 안 맨 위에 추가

private sealed class LoadedFile
{
    public required string Path { get; init; }
    public List<DecodedCode> Codes { get; init; } = new();
    public bool Failed { get; set; }
}
private readonly List<LoadedFile> _files = new();
private int _current = -1;

(b) 캔버스·이동 버튼 필드 추가

private readonly ImageCanvas _canvas = new() { Dock = DockStyle.Fill };
private readonly Button _btnPrev = new() { Text = "◀ 이전", AutoSize = true, Margin = new Padding(4) };
private readonly Button _btnNext = new() { Text = "다음 ▶", AutoSize = true, Margin = new Padding(4) };
private readonly Label _lblCounter = new() { AutoSize = true, Margin = new Padding(12, 10, 8, 4), Text = "0 / 0" };

(c) 레이아웃을 좌(캔버스)·우(그리드) 분할로: 생성자의 Controls.Add(_dgv) 부분을 아래로 교체

var navBar = new FlowLayoutPanel { Dock = DockStyle.Bottom, Height = 40, Padding = new Padding(6, 4, 6, 4) };
navBar.Controls.AddRange(new Control[] { _btnPrev, _lblCounter, _btnNext });
var left = new Panel { Dock = DockStyle.Fill };
left.Controls.Add(_canvas);     // 캔버스(Fill)
left.Controls.Add(navBar);      // 그 아래 이동바

var split = new SplitContainer { Dock = DockStyle.Fill, SplitterDistance = 640 };
split.Panel1.Controls.Add(left);   // 왼쪽: 미리보기
split.Panel2.Controls.Add(_dgv);   // 오른쪽: 그리드
Controls.Add(split);               // _dgv 대신 split을 Fill로

_btnPrev.Click += (_, _) => Move(-1);
_btnNext.Click += (_, _) => Move(+1);

(d) 불러올 때 파일별로 보관하고 현재 이미지 표시: OnLoad에서 행을 추가하면서 _files에도 담고, 끝에 ShowCurrent() 호출. 그리고 아래 메서드 추가

private void Move(int delta)
{
    if (_files.Count == 0) return;
    _current = Math.Clamp(_current + delta, 0, _files.Count - 1);
    ShowCurrent();
}

private void ShowCurrent()
{
    if (_current < 0 || _current >= _files.Count)
    { _canvas.SetContent(null, new List<DecodedCode>()); _lblCounter.Text = "0 / 0"; return; }

    var file = _files[_current];
    Image? img = null;
    try { img = LoadImageUnlocked(file.Path); } catch { }
    _canvas.SetContent(img, file.Codes);            // 이미지 + 그 파일의 코드(박스)
    _lblCounter.Text = $"{_current + 1} / {_files.Count}  ·  {Path.GetFileName(file.Path)}";
}

// 파일을 잠그지 않고 이미지 로드(원본 파일이 잠기면 곤란)
private static Image LoadImageUnlocked(string path)
{
    using var ms = new MemoryStream(File.ReadAllBytes(path));
    using var tmp = Image.FromStream(ms);
    return new Bitmap(tmp);
}

💡 왜 LoadImageUnlocked? Image.FromFile(path)는 그 파일을 계속 잠근다(다른 데서 못 건드림). 바이트로 읽어 메모리에서 만들면 잠금이 안 걸린다. 사소하지만 실무에서 자주 무는 함정이다.

Lesson 8의 OnLoad는 _files에 담도록 살짝 바꾸고(파일마다 LoadedFile 만들어 Codes에 추가), 디코드 결과 0개면 Failed=true로 표시한다. 완성형 전체 코드는 Lesson 12에 있다.

✅ 직접 해보기

실행 → 사진 여러 장 불러오기 → 왼쪽에 첫 이미지가 뜨고 코드 위에 파란 박스 + "1. ..." 라벨이 보이며, [이전]/[다음]으로 사진을 넘길 수 있으면 성공.

 

Lesson 10 — 확대/축소 + 이동(줌·패닝)

🎯 목표

미리보기를 휠/버튼으로 확대·축소하고, 확대 상태에서 화면을 끌어 이동한다.

📖 개념: "맞춤 배율 × 사용자 배율" + 오프셋

  • _fitScale: 이미지를 창에 꽉 맞추는 기본 배율(Lesson 9).
  • _zoom: 사용자가 추가로 거는 배율(1=맞춤, 2=2배…). 실제 그리는 배율 = _fitScale × _zoom.
  • _offset: 확대 후 화면을 끌어 옮긴 양(패닝). ClampOffset으로 이미지가 화면 밖으로 너무 빠지지 않게 제한.
  • 커서 기준 확대(ZoomAt): 휠을 굴린 커서 위치의 이미지 점이 그대로 있도록 오프셋을 보정한다 → "보던 지점이 유지되는" 자연스러운 줌.

이 메서드들은 이미 9-1 ImageCanvas.cs에 다 들어 있다(ZoomBy, ZoomAt, ResetView, OnMouseWheel, 패닝용 OnMouseDown/Move/Up, OnKeyDown/Up). 이번엔 버튼만 연결하면 된다.

💡 선택과 이동의 충돌 방지: 좌버튼 드래그는 "영역 선택"으로 이미 쓰고 있다. 그래서 **이동은 Space+좌드래그(또는 가운데 버튼)**로 분리했다(OnMouseDown의 pan 판정). 한 버튼에 두 동작을 욱여넣지 않는 게 사용성에 좋다.

💻 코드: MainForm에 줌 버튼 추가

(a) 버튼 필드

private readonly Button _btnZoomIn  = new() { Text = "+",  AutoSize = true, Margin = new Padding(4), Width = 36 };
private readonly Button _btnZoomOut = new() { Text = "-",  AutoSize = true, Margin = new Padding(4), Width = 36 };
private readonly Button _btnZoomFit = new() { Text = "맞춤", AutoSize = true, Margin = new Padding(4) };

(b) navBar에 끼우기 (Lesson 9의 navBar 줄을 교체)

navBar.Controls.AddRange(new Control[] { _btnPrev, _lblCounter, _btnNext, _btnZoomOut, _btnZoomIn, _btnZoomFit });

(c) 클릭 연결

_btnZoomIn.Click  += (_, _) => _canvas.ZoomBy(1.25f);
_btnZoomOut.Click += (_, _) => _canvas.ZoomBy(0.8f);
_btnZoomFit.Click += (_, _) => _canvas.ResetView();

✅ 직접 해보기

실행 → 이미지 위에서 을 굴리면 커서 기준으로 확대/축소, +/-/맞춤 버튼도 동작, Space+드래그로 이동되면 성공. 확대해도 파란 박스가 코드에 정확히 붙어 따라온다(좌표 변환 덕분).

 

Lesson 11 — 영역 판독 + 가독문자 OCR(Tesseract)

🎯 목표

① 마우스로 영역을 드래그해 그 부분만 다시 디코드(흐린/작은 코드 회수). ② 바코드가 안 읽히는 박스의 인쇄문자를 OCR로 읽어 목록에 보완.

📖 개념 1: 영역 판독

캔버스는 드래그한 사각형을 GetSelectionImageRect()로 원본 픽셀 좌표로 돌려준다(Lesson 9의 화면→원본 변환). 그 사각형을 2편의 BarcodeServiceCpp.DecodeRegion(path, rect)에 넘기면 그 부분만 잘라 확대 디코드한다.

📖 개념 2: OCR(광학 문자 인식)

바코드 인쇄가 흐려 디코드가 안 돼도, 사람이 읽는 글자(예: (01) ..., (21) ...)는 또렷한 경우가 많다. 그 글자를 Tesseract 엔진으로 읽어 GTIN·유통기한·로트·일련번호를 뽑는다. 정확도를 위해 영역만 잘라 확대·이진화한 뒤 인식한다.

  • Tesseract는 언어별 학습데이터 파일이 필요하다: tessdata/eng.traineddata.
  • OCR은 사진 품질에 민감해 1~2글자 오인식이 날 수 있다 → 보조/교차검증 용도, 결과는 사람이 확인.

💻 코드 (11-1) 패키지·데이터

.csproj에 Tesseract 추가 + tessdata 복사:

<ItemGroup>
  <PackageReference Include="Tesseract" Version="5.2.0" />
</ItemGroup>
<ItemGroup>
  <None Include="tessdata\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

프로젝트에 tessdata 폴더를 만들고 eng.traineddata 를 넣는다. (구글 "tessdata_fast eng.traineddata" 검색 → tessdata_fast 저장소의 eng.traineddata 다운로드. 약 4MB.)

💻 코드 (11-2) OcrService.cs

using System;
using System.IO;
using System.Text.RegularExpressions;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Tesseract;
using DrawRect = System.Drawing.Rectangle;
using SsImage = SixLabors.ImageSharp.Image;

namespace PharmaReturn;

public sealed record OcrFields(string? Sn, string? Gtin, string? Lot, string? Exp, string RawText);

/// <summary>
/// 사람이 읽는 문자를 Tesseract로 읽는다. 두 인쇄 형식 모두 인식.
///  - 괄호형: (01) 08806443096813  (17) 280315  (10) 6248607  (21) 6P0155272
///  - 라벨형: GTIN ...  EXP ...  LOT ...  SN ...
/// 필요: 실행 폴더의 tessdata/eng.traineddata
/// </summary>
public static class OcrService
{
    private static readonly string TessDataDir = Path.Combine(AppContext.BaseDirectory, "tessdata");

    private static readonly Regex PGtin = new(@"\(01\)\s*([0-9]{12,14})");
    private static readonly Regex PExp  = new(@"\(17\)\s*([0-9]{6})");
    private static readonly Regex PLot  = new(@"\(10\)\s*([0-9A-Za-z]{2,})");
    private static readonly Regex PSn   = new(@"\(21\)\s*([0-9A-Za-z]{4,})");
    private static readonly Regex LGtin = new(@"GTIN[\s:]+([A-Z0-9]+)", RegexOptions.IgnoreCase);
    private static readonly Regex LExp  = new(@"EXP[\s:]+([A-Z0-9 ./-]+)", RegexOptions.IgnoreCase);
    private static readonly Regex LLot  = new(@"LOT[\s:]+([A-Z0-9]+)", RegexOptions.IgnoreCase);
    private static readonly Regex LSn   = new(@"SN[\s:]+([A-Z0-9]+)", RegexOptions.IgnoreCase);

    public static OcrFields ReadFields(string imagePath, DrawRect region = default)
    {
        string text = RunOcr(imagePath, region);
        string? gtin = First(PGtin, text) ?? First(LGtin, text);
        string? lot  = First(PLot, text)  ?? First(LLot, text);
        string? sn   = First(PSn, text)   ?? First(LSn, text);
        string? exp  = FormatExp(First(PExp, text)) ?? First(LExp, text)?.Trim();
        return new OcrFields(sn, gtin, lot, exp, text);
    }

    private static string? First(Regex r, string text)
    { var m = r.Match(text); return m.Success ? m.Groups[1].Value.Trim() : null; }

    private static string? FormatExp(string? yymmdd)
    { var d = Gs1Fields.ParseGs1Date(yymmdd); return d?.ToString("yyyy-MM-dd"); }

    private static string RunOcr(string imagePath, DrawRect region)
    {
        string tmp = Path.Combine(Path.GetTempPath(), $"ocr_{Guid.NewGuid():N}.png");
        try
        {
            using (var image = SsImage.Load<Rgb24>(imagePath))
            {
                if (!region.IsEmpty)
                {
                    var r = DrawRect.Intersect(region, new DrawRect(0, 0, image.Width, image.Height));
                    if (r.Width > 1 && r.Height > 1)
                        image.Mutate(x => x.Crop(new Rectangle(r.X, r.Y, r.Width, r.Height)));
                }
                int maxSide = Math.Max(image.Width, image.Height);
                int up = Math.Clamp(1200 / Math.Max(1, Math.Min(image.Width, image.Height)), 1, 3);
                if (maxSide * up > 3200) up = Math.Max(1, 3200 / Math.Max(1, maxSide));
                image.Mutate(x => x.Resize(image.Width * up, image.Height * up).Grayscale().AdaptiveThreshold());
                image.SaveAsPng(tmp);
            }
            using var engine = new TesseractEngine(TessDataDir, "eng", EngineMode.Default);
            engine.SetVariable("tessedit_char_whitelist", "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789()[] :./-");
            using var pix = Pix.LoadFromFile(tmp);
            using var page = engine.Process(pix, PageSegMode.SparseText);
            return page.GetText() ?? "";
        }
        finally { try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } }
    }
}

🔍 핵심:

  • 정규식 두 벌(P*=괄호형, L*=영문 라벨형)로 어느 인쇄 형식이든 GTIN/유통기한/로트/일련번호를 뽑는다.
  • RunOcr: 영역만 잘라 → 확대 → 흑백 → 적응형 이진화(조명 편차 보정) → 임시 PNG → Tesseract Process.
  • tessedit_char_whitelist: 인식 후보 글자를 제한해 오인식을 줄인다.

💻 코드 (11-3) MainForm에 [영역 판독]·[가독문자 OCR] 버튼

(a) 버튼 필드 + navBar 추가 + 클릭 연결

private readonly Button _btnRegion = new() { Text = "영역 판독", AutoSize = true, Margin = new Padding(4) };
private readonly Button _btnOcr    = new() { Text = "가독문자 OCR", AutoSize = true, Margin = new Padding(4) };
// navBar.Controls.AddRange(..., _btnRegion, _btnOcr);
// _btnRegion.Click += OnRegionDecode;
// _btnOcr.Click += OnOcr;

(b) 핸들러

private void OnRegionDecode(object? sender, EventArgs e)
{
    if (_current < 0) return;
    var file = _files[_current];
    var sel = _canvas.GetSelectionImageRect();
    if (sel.IsEmpty) { MessageBox.Show(this, "이미지에서 영역을 드래그한 뒤 [영역 판독]."); return; }

    var found = BarcodeServiceCpp.DecodeRegion(file.Path, sel);
    var repo = DeliveryRepository.LoadCsv(_csvPath);
    int added = 0;
    foreach (var c in found)
    {
        if (file.Codes.Any(x => x.Text == c.Text)) continue;   // 중복 제외
        file.Codes.Add(c);
        AddDecodedRow(Path.GetFileName(file.Path), c, repo);
        added++;
    }
    _canvas.SetCodes(file.Codes);
    _lblStatus.Text = added > 0 ? $"영역 판독: {added}개 추가" : "선택 영역에서 코드를 못 찾음";
}

private void OnOcr(object? sender, EventArgs e)
{
    if (_current < 0) return;
    var file = _files[_current];
    var sel = _canvas.GetSelectionImageRect();   // 비어 있으면 전체

    OcrFields ocr;
    try { ocr = OcrService.ReadFields(file.Path, sel); }
    catch (Exception ex)
    { MessageBox.Show(this, $"OCR 실패: {ex.Message}\n(tessdata/eng.traineddata 확인)"); return; }

    var add = MessageBox.Show(this,
        $"OCR 결과\n GTIN: {ocr.Gtin}\n LOT: {ocr.Lot}\n EXP: {ocr.Exp}\n SN: {ocr.Sn}\n\n목록에 추가할까요?",
        "가독문자 OCR", MessageBoxButtons.YesNo);
    if (add == DialogResult.Yes && !string.IsNullOrEmpty(ocr.Gtin))
    {
        DateOnly? exp = DateOnly.TryParse(ocr.Exp, out var d) ? d : null;
        var repo = DeliveryRepository.LoadCsv(_csvPath);
        int prefill = repo.FindByGtinLot(ocr.Gtin!, string.IsNullOrEmpty(ocr.Lot) ? null : ocr.Lot).Sum(m => m.DeliveredQty);
        AddRow(Path.GetFileName(file.Path), ocr.Gtin ?? "", ocr.Lot ?? "",
            exp?.ToString("yyyy-MM-dd") ?? "", ocr.Sn ?? "", "만기",
            prefill > 0 ? prefill.ToString() : "", "", "", "OCR 보완 — 확인 필요");
    }
}

(여기서 쓰는 AddDecodedRow, AddRow는 Lesson 12 전체 코드에 정의돼 있다.)

✅ 직접 해보기

  • 작은 코드 위를 드래그 → [영역 판독] → 새 박스/행이 생기면 성공.
  • 바코드가 흐린 박스의 글자 영역을 드래그 → [가독문자 OCR] → GTIN/LOT/EXP가 떠서 "예"로 목록에 추가되면 성공.

OCR 실패 메시지가 뜨면 tessdata/eng.traineddata가 실행 폴더(bin...\tessdata)에 복사됐는지 확인.

 

Lesson 12 — 완성형 MainForm + 보상 계산 + 배포

🎯 목표

지금까지의 조각을 합친 완성형 MainForm.cs 로 마무리하고, 남에게 줄 수 있는 설치/실행 파일로 배포한다.

💻 코드: 완성형 MainForm.cs (전체)

아래로 통째로 교체하면 된다. (Lesson 8~11에서 배운 것이 모두 들어 있다)

using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Windows.Forms;

namespace PharmaReturn;

public sealed class MainForm : Form
{
    private sealed class LoadedFile
    {
        public required string Path { get; init; }
        public List<DecodedCode> Codes { get; init; } = new();
        public bool Failed { get; set; }
    }

    private readonly List<LoadedFile> _files = new();
    private int _current = -1;

    private readonly Button _btnLoad = new() { Text = "파일 불러오기 (여러 개 선택 가능)", AutoSize = true, Margin = new Padding(4) };
    private readonly Button _btnCalc = new() { Text = "보상 계산", AutoSize = true, Margin = new Padding(4) };
    private readonly Label _lblStatus = new() { AutoSize = true, Margin = new Padding(12, 10, 4, 4), Text = "이미지를 불러오세요." };

    private readonly ImageCanvas _canvas = new() { Dock = DockStyle.Fill };
    private readonly Button _btnPrev = new() { Text = "◀ 이전", AutoSize = true, Margin = new Padding(4) };
    private readonly Button _btnNext = new() { Text = "다음 ▶", AutoSize = true, Margin = new Padding(4) };
    private readonly Button _btnRegion = new() { Text = "영역 판독", AutoSize = true, Margin = new Padding(4) };
    private readonly Button _btnOcr = new() { Text = "가독문자 OCR", AutoSize = true, Margin = new Padding(4) };
    private readonly Button _btnZoomIn = new() { Text = "+", AutoSize = true, Margin = new Padding(4), Width = 36 };
    private readonly Button _btnZoomOut = new() { Text = "-", AutoSize = true, Margin = new Padding(4), Width = 36 };
    private readonly Button _btnZoomFit = new() { Text = "맞춤", AutoSize = true, Margin = new Padding(4) };
    private readonly Label _lblCounter = new() { AutoSize = true, Margin = new Padding(12, 10, 8, 4), Text = "0 / 0" };
    private readonly Label _lblHint = new() { AutoSize = true, Margin = new Padding(12, 10, 4, 4), ForeColor = SystemColors.GrayText, Text = "휠=확대/축소, Space+드래그=이동, 드래그=영역 선택 후 [영역 판독]/[OCR]" };

    private readonly DataGridView _dgv = new()
    {
        Dock = DockStyle.Fill, AllowUserToAddRows = false,
        AutoGenerateColumns = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect,
        RowHeadersVisible = false,
    };
    private readonly Label _lblTotal = new() { AutoSize = true, Text = "총 보상 금액: 0 원" };

    private readonly string _csvPath = Path.Combine(AppContext.BaseDirectory, "data", "deliveries.csv");

    public MainForm()
    {
        Text = "약국 반품 보상 — 다중 바코드 판독";
        Width = 1180; Height = 700; StartPosition = FormStartPosition.CenterScreen;
        _lblTotal.Font = new Font(_lblTotal.Font, FontStyle.Bold);

        BuildGrid();

        var top = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 48, Padding = new Padding(8, 6, 8, 6) };
        top.Controls.AddRange(new Control[] { _btnLoad, _btnCalc, _lblStatus });

        var navBar = new FlowLayoutPanel { Dock = DockStyle.Bottom, Height = 40, Padding = new Padding(6, 4, 6, 4) };
        navBar.Controls.AddRange(new Control[] { _btnPrev, _lblCounter, _btnNext, _btnZoomOut, _btnZoomIn, _btnZoomFit, _btnRegion, _btnOcr, _lblHint });
        var left = new Panel { Dock = DockStyle.Fill };
        left.Controls.Add(_canvas);
        left.Controls.Add(navBar);

        var split = new SplitContainer { Dock = DockStyle.Fill, SplitterDistance = 640 };
        split.Panel1.Controls.Add(left);
        split.Panel2.Controls.Add(_dgv);

        var bottom = new Panel { Dock = DockStyle.Bottom, Height = 40, Padding = new Padding(12, 8, 8, 8) };
        _lblTotal.Dock = DockStyle.Left;
        bottom.Controls.Add(_lblTotal);

        Controls.Add(split);
        Controls.Add(top);
        Controls.Add(bottom);

        _btnLoad.Click += OnLoad;
        _btnCalc.Click += OnCalc;
        _btnPrev.Click += (_, _) => Move(-1);
        _btnNext.Click += (_, _) => Move(+1);
        _btnRegion.Click += OnRegionDecode;
        _btnOcr.Click += OnOcr;
        _btnZoomIn.Click += (_, _) => _canvas.ZoomBy(1.25f);
        _btnZoomOut.Click += (_, _) => _canvas.ZoomBy(0.8f);
        _btnZoomFit.Click += (_, _) => _canvas.ResetView();
        _dgv.DataError += (_, e) => e.ThrowException = false;
    }

    private void BuildGrid()
    {
        void Col(string name, string header, int w, bool ro = true)
            => _dgv.Columns.Add(new DataGridViewTextBoxColumn { Name = name, HeaderText = header, Width = w, ReadOnly = ro });

        Col("file", "파일", 110); Col("gtin", "GTIN", 130); Col("lot", "로트", 80);
        Col("expiry", "유통기한", 90); Col("serial", "일련번호", 70);
        var reason = new DataGridViewComboBoxColumn { Name = "reason", HeaderText = "사유", Width = 64 };
        reason.Items.AddRange("만기", "손상", "기타");
        _dgv.Columns.Add(reason);
        Col("reqty", "요청수량", 64, ro: false);
        Col("approved", "승인", 52); Col("credit", "보상금액", 90); Col("status", "상태", 220);
    }

    private void OnLoad(object? sender, EventArgs e)
    {
        using var ofd = new OpenFileDialog
        {
            Multiselect = true, Title = "바코드 이미지 선택 (여러 개 가능)",
            Filter = "이미지 파일 (*.png;*.jpg;*.jpeg;*.bmp)|*.png;*.jpg;*.jpeg;*.bmp|모든 파일 (*.*)|*.*",
        };
        if (ofd.ShowDialog(this) != DialogResult.OK) return;

        var repo = SafeLoadRepo();
        _files.Clear(); _dgv.Rows.Clear();

        foreach (var path in ofd.FileNames)
        {
            var file = new LoadedFile { Path = path };
            try { file.Codes.AddRange(BarcodeServiceCpp.DecodeDetailed(path)); }
            catch (Exception ex)
            {
                file.Failed = true;
                AddRow(Path.GetFileName(path), "", "", "", "", "만기", "", "", "", $"오류: {ex.Message}");
                _files.Add(file); continue;
            }
            if (file.Codes.Count == 0)
            {
                file.Failed = true;
                AddRow(Path.GetFileName(path), "", "", "", "", "만기", "", "", "", "디코드 실패 — 영역 판독 시도 가능");
            }
            else foreach (var c in file.Codes) AddDecodedRow(Path.GetFileName(path), c, repo);
            _files.Add(file);
        }

        _current = _files.Count > 0 ? 0 : -1;
        ShowCurrent();
        int codes = _files.Sum(f => f.Codes.Count);
        _lblStatus.Text = $"파일 {_files.Count}개에서 코드 {codes}개 인식. 사유·요청수량 확인 후 [보상 계산].";
        _lblTotal.Text = "총 보상 금액: 0 원";
    }

    private void Move(int delta)
    {
        if (_files.Count == 0) return;
        _current = Math.Clamp(_current + delta, 0, _files.Count - 1);
        ShowCurrent();
    }

    private void ShowCurrent()
    {
        if (_current < 0 || _current >= _files.Count)
        { _canvas.SetContent(null, new List<DecodedCode>()); _lblCounter.Text = "0 / 0"; return; }

        var file = _files[_current];
        Image? img = null;
        try { img = LoadImageUnlocked(file.Path); } catch { }
        _canvas.SetContent(img, file.Codes);
        _lblCounter.Text = $"{_current + 1} / {_files.Count}  ·  {Path.GetFileName(file.Path)}"
                           + (file.Failed && file.Codes.Count == 0 ? "  (판독 실패)" : "");
    }

    private void OnRegionDecode(object? sender, EventArgs e)
    {
        if (_current < 0 || _current >= _files.Count) return;
        var file = _files[_current];
        var sel = _canvas.GetSelectionImageRect();
        if (sel.IsEmpty)
        { MessageBox.Show(this, "이미지 위에서 마우스로 영역을 드래그해 선택한 뒤 [영역 판독]을 누르세요.", "안내"); return; }

        List<DecodedCode> found;
        try { found = BarcodeServiceCpp.DecodeRegion(file.Path, sel); }
        catch (Exception ex) { MessageBox.Show(this, $"영역 판독 오류:\n{ex.Message}", "오류"); return; }

        var repo = SafeLoadRepo();
        int added = 0;
        foreach (var c in found)
        {
            if (file.Codes.Any(x => x.Text == c.Text)) continue;
            file.Codes.Add(c); AddDecodedRow(Path.GetFileName(file.Path), c, repo); added++;
        }
        if (added > 0) file.Failed = false;
        _canvas.SetCodes(file.Codes); ShowCurrent();
        _lblStatus.Text = added > 0 ? $"영역 판독: 새 코드 {added}개 추가됨." : "선택 영역에서 코드를 찾지 못했습니다.";
    }

    private void OnOcr(object? sender, EventArgs e)
    {
        if (_current < 0 || _current >= _files.Count) return;
        var file = _files[_current];
        var sel = _canvas.GetSelectionImageRect();
        if (sel.IsEmpty &&
            MessageBox.Show(this, "영역을 선택하지 않았습니다. 이미지 전체를 OCR할까요?\n(라벨 텍스트 영역만 드래그하면 정확도가 훨씬 높습니다.)",
                "가독문자 OCR", MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK)
            return;

        OcrFields ocr;
        try { ocr = OcrService.ReadFields(file.Path, sel); }
        catch (Exception ex)
        {
            MessageBox.Show(this, $"OCR 실행 실패:\n{ex.Message}\n\ntessdata/eng.traineddata 파일이 실행 폴더에 있는지 확인하세요.", "오류");
            return;
        }

        string cross = "(이 파일에 판독된 바코드 없음)";
        if (file.Codes.Count > 0)
        {
            var g = Gs1Parser.Parse(file.Codes[0].Text);
            cross = $"바코드: GTIN={g.Gtin} LOT={g.Lot} EXP={g.Expiry:yyyy-MM-dd}\n"
                  + $"교차검증: GTIN {(Same(ocr.Gtin, g.Gtin) ? "일치" : "불일치")}, LOT {(Same(ocr.Lot, g.Lot) ? "일치" : "불일치")}";
        }

        var add = MessageBox.Show(this,
            $"OCR 결과\n  SN   : {ocr.Sn}\n  GTIN : {ocr.Gtin}\n  LOT  : {ocr.Lot}\n  EXP  : {ocr.Exp}\n\n{cross}\n\n이 내용을 반품 목록에 추가할까요?",
            "가독문자 OCR", MessageBoxButtons.YesNo, MessageBoxIcon.Information);

        if (add == DialogResult.Yes && !string.IsNullOrEmpty(ocr.Gtin))
        {
            DateOnly? exp = DateOnly.TryParse(ocr.Exp, out var d) ? d : null;
            AddManualRow(Path.GetFileName(file.Path), ocr.Gtin, ocr.Lot, exp, ocr.Sn, "OCR 보완 — 값 확인 필요");
            _lblStatus.Text = "OCR 보완 행을 추가했습니다. GTIN·로트·수량을 확인 후 [보상 계산].";
        }
    }

    private void OnCalc(object? sender, EventArgs e)
    {
        var repo = SafeLoadRepo();
        if (repo is null) return;
        var svc = new CompensationService(repo); // 운영일(DateTime.Today)
        decimal total = 0;

        foreach (DataGridViewRow row in _dgv.Rows)
        {
            string gtin = Cell(row, "gtin");
            if (string.IsNullOrEmpty(gtin)) continue;
            string lot = Cell(row, "lot");
            DateOnly? expiry = DateOnly.TryParse(Cell(row, "expiry"), out var d) ? d : null;
            ReturnReason reason = Cell(row, "reason") switch { "손상" => ReturnReason.Damage, "기타" => ReturnReason.Other, _ => ReturnReason.Expiry };
            int qty = int.TryParse(Cell(row, "reqty"), out var q) ? q : 0;

            var r = svc.Evaluate(new ReturnRequest(gtin, string.IsNullOrEmpty(lot) ? null : lot, expiry, qty, reason));
            total += r.CreditAmount;
            SetResult(row, r.ApprovedQty.ToString(), r.CreditAmount.ToString("N0"), $"[{r.Status}] {r.Message}");
        }
        _lblTotal.Text = $"총 보상 금액: {total:N0} 원";
    }

    // ── 보조 ───────────────────────────────────────────────
    private void AddDecodedRow(string fileName, DecodedCode c, DeliveryRepository? repo)
    {
        var f = Gs1Parser.Parse(c.Text);
        int prefill = PrefillQty(repo, f);
        AddRow(fileName, f.Gtin ?? "", f.Lot ?? "", f.Expiry?.ToString("yyyy-MM-dd") ?? "", f.Serial ?? "",
            "만기", prefill > 0 ? prefill.ToString() : "", "", "", "불러옴");
    }

    private void AddManualRow(string fileName, string? gtin, string? lot, DateOnly? exp, string? serial, string status)
    {
        var repo = SafeLoadRepo();
        int prefill = (repo is not null && !string.IsNullOrEmpty(gtin))
            ? repo.FindByGtinLot(gtin!, string.IsNullOrEmpty(lot) ? null : lot).Sum(m => m.DeliveredQty) : 0;
        AddRow(fileName, gtin ?? "", lot ?? "", exp?.ToString("yyyy-MM-dd") ?? "", serial ?? "",
            "만기", prefill > 0 ? prefill.ToString() : "", "", "", status);
    }

    private static int PrefillQty(DeliveryRepository? repo, Gs1Fields f)
    {
        if (repo is null || f.Gtin is null) return 0;
        var matches = repo.FindByGtinLot(f.Gtin, f.Lot).ToList();
        return matches.Count == 0 ? 0 : matches.Sum(m => m.DeliveredQty);
    }

    private static bool Same(string? a, string? b)
        => !string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b)
           && string.Equals(a.Replace(" ", ""), b.Replace(" ", ""), StringComparison.OrdinalIgnoreCase);

    private DeliveryRepository? SafeLoadRepo()
    {
        try { return DeliveryRepository.LoadCsv(_csvPath); }
        catch (Exception ex)
        { MessageBox.Show(this, $"납품내역 로드 실패:\n{_csvPath}\n\n{ex.Message}", "오류"); return null; }
    }

    private static Image LoadImageUnlocked(string path)
    {
        using var ms = new MemoryStream(File.ReadAllBytes(path));
        using var tmp = Image.FromStream(ms);
        return new Bitmap(tmp);
    }

    private void AddRow(params string[] values) => _dgv.Rows.Add(values);
    private static string Cell(DataGridViewRow row, string col) => row.Cells[col].Value?.ToString() ?? "";
    private static void SetResult(DataGridViewRow row, string approved, string credit, string status)
    {
        row.Cells["approved"].Value = approved;
        row.Cells["credit"].Value = credit;
        row.Cells["status"].Value = status;
    }
}

🔍 보상 계산(OnCalc) 한 번 더:

  • 그리드의 각 행에서 GTIN/로트/유통기한/사유/요청수량을 읽어 ReturnRequest를 만들고 Evaluate(1편). 승인·보상·상태를 그 행에 다시 적고 총액을 누적한다.
  • 매번 SafeLoadRepo()로 납품내역을 새로 읽어 계산하므로(기반품 누적이 초기화됨) 여러 번 눌러도 결과가 누적·왜곡되지 않는다(멱등).

📦 배포(다른 PC에서 실행하게 만들기)

개발 PC에선 F5면 되지만, 남에게 줄 땐 "실행 파일 묶음"을 만든다.

방법 A — 자체 포함 게시(권장, .NET 설치 불필요): 명령줄(프로젝트 폴더)에서

dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true
  • 결과: bin\Release\net8.0-windows\win-x64\publish\ 에 PharmaReturn.exe(+ 네이티브 dll, data, tessdata).
  • 받는 사람은 .NET 설치 없이 .exe 실행. 단, data\deliveries.csv와 tessdata\eng.traineddata가 exe 옆에 함께 있어야 한다(없으면 CSV/OCR 동작 실패).

방법 B — 프레임워크 종속(가벼움): 받는 PC에 .NET 8 데스크톱 런타임이 있을 때

dotnet publish -c Release

💡 ZXingCpp·Tesseract의 네이티브 dll은 64비트라, 게시도 win-x64로. (그래서 csproj에 x64를 고정했다.) 💡 첫 실행 시 SmartScreen 경고가 뜰 수 있다(서명 안 된 exe). 사내 배포면 코드 서명 인증서로 서명하면 깔끔하다.

✅ 최종 점검 체크리스트

  • [ ] 사진 여러 장 불러와 자동 판독 → 표에 채워진다
  • [ ] 미리보기에 파란 박스 + 번호·문자 라벨이 코드에 맞게 보인다
  • [ ] 휠/버튼 확대·축소, Space+드래그 이동
  • [ ] 작은 코드는 드래그 → [영역 판독]으로 회수
  • [ ] 흐린 코드는 글자 영역 드래그 → [가독문자 OCR] → 목록 보완
  • [ ] [보상 계산] → 승인/부분/거부 + 보상금액 + 총액
  • [ ] dotnet publish로 만든 exe가 다른 폴더(또는 PC)에서 data·tessdata와 함께 실행된다

 

강의 전체 마무리

세 편으로 만든 것:

  • 1편(두뇌): 모델 · GS1 파서 · 납품 저장소 · 보상 계산 (콘솔, 무의존)
  • 2편(눈): ZXingCpp + ImageSharp로 디코드 · 위치 · 다중 · 타일 · 영역
  • 3편(얼굴): WinForms — 불러오기 · 미리보기 · 줌/이동 · 박스 오버레이 · 영역 판독 · OCR 보완 · 보상 · 배포

핵심 설계 교훈:

  1. 로직과 UI를 분리하면, 두뇌를 콘솔로 먼저 검증하고 UI를 자유롭게 얹을 수 있다.
  2. 좌표 변환(원본 ↔ 화면, 타일 ↔ 원본)을 한 곳에서 일관되게 다루면 줌·박스·영역선택이 모두 들어맞는다.
  3. 현실의 한계(흐린 인쇄)는 코드가 아니라 운영(촬영 개선·OCR 보완·재라벨)으로 보완한다.

추가 사항

- DB 연동은  개인  취향에  맞게  교체가  필요

- 결과  저장  기록 이력이 없어  추가개발  필요

- 무료 SDK 인경우 곡선  바코드 보정능력이 떨어짐 (상용  SDK 연동필요)

반응형