의약품 반품 보상 프로그램 만들기 — 단계별 강의 (3편 · 완성)
1편에서 "두뇌"(파싱·대조·보상), 2편에서 "눈"(바코드 디코드)을 만들었다. 3편은 **사람이 쓰는 화면(WinForms)**을 붙여 완성한다.
완성 후 기능: 이미지 여러 장 불러오기 → 자동 판독 → 미리보기 + 파란 박스/번호 라벨 → 확대/축소·이동 → 영역 드래그 재판독 → 흐린 코드 가독문자 OCR 보완 → 그리드 편집 + 보상 계산 + 총액 → 배포(설치 파일).
이번 편도 설명을 자세히 넣었다. WinForms가 처음이어도 따라올 수 있게 개념부터 짚는다.
들어가기 전에 — WinForms 핵심 개념 5가지
3편 내내 쓰는 개념이라 먼저 한 번에 정리한다.
- Form(폼) = 창 하나. MainForm : Form이 우리 메인 창이다.
- Control(컨트롤) = 창 안의 부품. 버튼(Button), 표(DataGridView), 그림판(Panel) 등.
- Dock(도킹) = 부품을 창의 어느 변에 "붙일지". Dock = DockStyle.Top이면 위쪽에 가로로 꽉 차게, Fill이면 남은 공간을 다 채움. 좌표를 일일이 안 줘도 창 크기에 맞춰 배치된다.
- 이벤트(Event) = "버튼이 눌리면 이 함수를 실행". button.Click += OnClick;처럼 함수를 **연결(구독)**한다.
- 그리기(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 보완 · 보상 · 배포
핵심 설계 교훈:
- 로직과 UI를 분리하면, 두뇌를 콘솔로 먼저 검증하고 UI를 자유롭게 얹을 수 있다.
- 좌표 변환(원본 ↔ 화면, 타일 ↔ 원본)을 한 곳에서 일관되게 다루면 줌·박스·영역선택이 모두 들어맞는다.
- 현실의 한계(흐린 인쇄)는 코드가 아니라 운영(촬영 개선·OCR 보완·재라벨)으로 보완한다.
추가 사항
- DB 연동은 개인 취향에 맞게 교체가 필요
- 결과 저장 기록 이력이 없어 추가개발 필요
- 무료 SDK 인경우 곡선 바코드 보정능력이 떨어짐 (상용 SDK 연동필요)
'개발(IT) > C#(VisualStudio)' 카테고리의 다른 글
| 의약품 반품 보상 프로그램 만들기 — 단계별 강의 (2편) (0) | 2026.07.06 |
|---|---|
| 의약품 반품 보상 프로그램 만들기 — 단계별 강의 (1편) (0) | 2026.06.29 |
| [c#] MS-SQL 연동 Helper (0) | 2023.09.23 |
| [c#] 오라클(Oracle) 연동 Helper (0) | 2023.09.23 |
| 자동 업그레이드 다운로드 (Auto Checker) (0) | 2023.09.14 |