의약품 반품 보상 프로그램 만들기 — 단계별 강의 (1편)
목표: 약국에서 만기·손상 의약품을 반품하면, 케이스의 GS1 DataMatrix 바코드를 읽어 → 납품내역과 대조 → 보상 수량/금액을 계산해 주는 프로그램을 처음부터 직접 만든다.
이 1편에서는 외부 라이브러리 없이 동작하는 "두뇌"(파싱·대조·보상)를 콘솔 프로젝트로 만들고, 실제로 실행해 결과를 확인한다. 바코드 카메라·이미지·화면(UI)은 2~3편에서 붙인다.
0. 이 강의 사용법
- 각 Lesson은 🎯 목표 → 📖 개념 → 💻 코드 → 🔍 코드 설명 → ✅ 직접 해보기 순서다.
- 코드는 그대로 따라 쳐도 되고, 복사해도 된다. 단, 한 번은 직접 쳐 보는 것을 권한다(손이 기억한다).
- 막히면 그 Lesson의 "직접 해보기"까지 통과했는지 먼저 확인하자.
전체 로드맵
편 Lesson 내용 프로젝트 형태
| 1편(지금) | 0~4 | 환경 구축, 도메인 모델, GS1 파서, 납품내역 저장소, 보상 계산 | 콘솔 |
| 2편 | 5~7 | 바코드 디코드(ZXingCpp), 이미지 처리(ImageSharp), 다중·타일 판독 | 콘솔 |
| 3편 | 8~12 | WinForms 전환, 이미지 뷰어+확대/축소, 영역 판독, OCR(Tesseract), 배포 | WinForms |
핵심 원칙 하나: "두뇌(로직)"와 "얼굴(UI)"을 분리한다. 그래서 1편의 로직 클래스들은 화면을 전혀 모른다. 덕분에 콘솔로 먼저 검증하고, 나중에 WinForms든 웹이든 그대로 얹을 수 있다.
Lesson 0 — 개발 환경 구축
🎯 목표
Visual Studio 2022에서 C# 콘솔 프로젝트를 만들고 실행한다.
📖 개념
- .NET 8 SDK: C# 코드를 빌드·실행하는 도구. VS2022 설치 시 ".NET 데스크톱 개발" 워크로드를 고르면 함께 깔린다.
- 콘솔 앱: 화면(창) 없이 텍스트만 입출력하는 가장 단순한 프로그램. 로직을 배우기에 최적.
💻 따라 하기
- Visual Studio 2022 설치 시 워크로드 ".NET 데스크톱 개발" 체크.
- VS2022 실행 → 새 프로젝트 만들기 → 콘솔 앱(C#) 선택.
- 프로젝트 이름: PharmaReturn, 프레임워크: .NET 8.0.
- 만들어진 Program.cs에 이미 Console.WriteLine("Hello, World!");가 있다. F5(디버그 실행) 또는 Ctrl+F5(그냥 실행).
✅ 직접 해보기
검은 콘솔 창에 Hello, World!가 보이면 성공. 안 보이면 .NET 8 SDK 설치를 다시 확인하자.
명령줄을 선호하면: 폴더에서 dotnet new console -n PharmaReturn → cd PharmaReturn → dotnet run.
Lesson 1 — 도메인 모델 만들기 (Models.cs)
🎯 목표
프로그램이 다룰 "데이터의 모양"을 먼저 정의한다. 납품내역, 반품요청, 보상결과 같은 것들.
📖 개념
- 모델(Model): 현실의 개념을 코드로 표현한 데이터 묶음. 예) "납품 한 줄"은 납품번호·약국·GTIN·로트·유통기한·수량·단가를 가진다.
- record: 값을 담는 불변(immutable) 객체를 짧게 정의하는 C# 문법. 한 번 만들면 안 바뀌는 "기록"에 적합.
- enum: 정해진 몇 가지 값 중 하나(예: 반품 사유 = 만기 / 손상 / 기타).
- DateOnly: 시각 없이 날짜만 다루는 타입(유통기한·납품일에 적합).
- ?(nullable): 값이 "없을 수도 있음"을 표시(예: 로트번호를 모를 수도 있다 → string?).
용어 정리(의약품 GS1):
- GTIN: 국제거래단품식별코드. 제품 종류를 식별(AI 01).
- 로트(LOT)/배치: 같은 제조 묶음 번호(AI 10).
- 유통기한(EXP): AI 17, YYMMDD 6자리.
- 일련번호(Serial): 낱개 식별(AI 21).
💻 코드
프로젝트에 새 클래스 파일 Models.cs를 추가하고 아래로 교체한다.
using System.Linq;
namespace PharmaReturn; // ← 프로젝트 이름과 맞춘다(콘솔 새 프로젝트라면 PharmaReturn)
/// <summary>
/// GS1 파싱 결과. 모든 AI는 Ai 딕셔너리에 들어가고,
/// 자주 쓰는 항목(GTIN/유통기한/로트/일련번호)은 강타입 프로퍼티로 노출한다.
/// </summary>
public sealed class Gs1Fields
{
public Dictionary<string, string> Ai { get; } = new();
public string? Gtin => Ai.GetValueOrDefault("01"); // (01) GTIN-14
public string? Lot => Ai.GetValueOrDefault("10"); // (10) 로트/배치번호
public string? Serial => Ai.GetValueOrDefault("21"); // (21) 일련번호
public DateOnly? Expiry => ParseGs1Date(Ai.GetValueOrDefault("17")); // (17) 유통기한
public DateOnly? ProductionDate => ParseGs1Date(Ai.GetValueOrDefault("11")); // (11) 제조일
/// <summary>GS1 날짜(YYMMDD)를 DateOnly로 변환. DD가 00이면 해당 월 말일로 처리.</summary>
public static DateOnly? ParseGs1Date(string? yymmdd)
{
if (string.IsNullOrEmpty(yymmdd) || yymmdd.Length != 6) return null;
if (!int.TryParse(yymmdd[..2], out var yy)) return null;
if (!int.TryParse(yymmdd.Substring(2, 2), out var mm)) return null;
if (!int.TryParse(yymmdd.Substring(4, 2), out var dd)) return null;
if (mm is < 1 or > 12) return null;
// GS1 규칙(간이): 00~50 -> 20xx, 51~99 -> 19xx
int year = yy <= 50 ? 2000 + yy : 1900 + yy;
if (dd == 0) dd = DateTime.DaysInMonth(year, mm); // 일자 미지정 = 월 말일
if (dd < 1 || dd > DateTime.DaysInMonth(year, mm)) return null;
return new DateOnly(year, mm, dd);
}
public override string ToString() =>
Ai.Count == 0 ? "(빈 코드)" : string.Join(" ", Ai.Select(k => $"({k.Key}){k.Value}"));
}
/// <summary>납품(출고) 내역 한 줄.</summary>
public sealed record DeliveryRecord(
string DeliveryId,
string PharmacyId,
DateOnly DeliveryDate,
string Gtin,
string ProductName,
string LotNo,
DateOnly ExpiryDate,
int DeliveredQty,
decimal UnitPrice);
public enum ReturnReason { Expiry, Damage, Other }
/// <summary>약국이 올린 반품 요청 1건.</summary>
public sealed record ReturnRequest(
string Gtin,
string? LotNo,
DateOnly? ExpiryDate,
int RequestedQty,
ReturnReason Reason);
public enum CompensationStatus { Approved, PartiallyApproved, Rejected }
/// <summary>보상 계산 결과.</summary>
public sealed record CompensationResult(
ReturnRequest Request,
CompensationStatus Status,
int ApprovedQty,
decimal CreditAmount,
string Message,
DeliveryRecord? MatchedDelivery);
🔍 코드 설명
- Gs1Fields: 바코드에서 읽은 AI들을 Ai["01"]처럼 딕셔너리에 담고, 자주 쓰는 것만 Gtin, Expiry 등으로 편하게 꺼내쓰게 했다.
- ParseGs1Date: "280315" → 2028-03-15. 방어적으로 숫자가 아니면 null을 돌려준다(바코드가 깨졌을 수 있으니). DD==00(일자 미지정)은 GS1 규칙대로 "그 달 말일"로 본다.
- DeliveryRecord/ReturnRequest/CompensationResult: 한 줄짜리 record로 데이터 묶음을 정의. MatchedDelivery가 ?인 이유는 매칭이 안 될 수도 있어서.
- decimal UnitPrice: 돈은 double이 아니라 decimal(반올림 오차 없는 타입)로.
✅ 직접 해보기
빌드(Ctrl+Shift+B)가 에러 없이 통과하면 OK. 아직 실행 결과는 없다(데이터 정의만 했으니).
Lesson 2 — GS1 바코드 파서 (Gs1Parser.cs)
🎯 목표
바코드에서 읽은 한 줄 문자열을 GTIN·유통기한·로트로 분해한다.
📖 개념
GS1 DataMatrix가 디코드되면 보통 두 형태 중 하나로 나온다.
- 원시(raw): 01 + GTIN(14자리) + 17 + 날짜(6자리) + 10 + 로트(가변) … 가변 길이 항목 뒤에는 GS(ASCII 29) 라는 보이지 않는 구분자가 붙는다.
- 괄호(HRI): (01)08800051300003(17)220129(10)VL200101 — 실제 제품을 zxing-cpp로 읽으면 이렇게 많이 나온다.
파서는 둘 다 처리해야 한다. 핵심 규칙:
- 고정 길이 AI(01=14, 17=6 등)는 정해진 길이만큼 잘라낸다.
- 가변 길이 AI(10, 21 등)는 다음 GS(또는 끝)까지 읽는다.
- AI 길이는 2~4자리가 섞여 있어, 4→3→2자리 순으로 "아는 AI"인지 맞춰본다.
💻 코드
Gs1Parser.cs 추가:
using System.Text.RegularExpressions;
namespace PharmaReturn;
/// <summary>
/// GS1 Application Identifier(AI) 파서. 두 가지 입력 형식을 모두 처리한다.
/// 1) 원시(raw) 형식: 선택적 심볼로지 식별자(]d2) + AI/값 연접, 가변 AI는 GS(ASCII 29)로 구분
/// 2) 괄호(HRI) 형식: (01)08800051300003(17)220129(10)VL200101
/// </summary>
public static class Gs1Parser
{
/// <summary>그룹 구분자(Group Separator, ASCII 29).</summary>
public const char GS = '\u001D';
private static readonly Dictionary<string, int> FixedValueLen = new()
{
["00"] = 18, ["01"] = 14, ["02"] = 14,
["11"] = 6, ["12"] = 6, ["13"] = 6, ["15"] = 6, ["16"] = 6, ["17"] = 6,
["20"] = 2,
};
private static readonly HashSet<string> KnownVariable = new()
{
"10", "21", "22", "30", "37",
"90", "91", "92", "93", "94", "95", "96", "97", "98", "99",
"240", "241", "242", "250", "251", "253", "254", "255",
};
private static readonly HashSet<string> AllKnown =
FixedValueLen.Keys.Concat(KnownVariable).ToHashSet();
private static readonly Regex HriAi = new(@"\((\d{2,4})\)([^(]*)", RegexOptions.Compiled);
public static Gs1Fields Parse(string? raw)
{
var result = new Gs1Fields();
if (string.IsNullOrEmpty(raw)) return result;
// 괄호(HRI) 형식이면 그쪽으로 처리
if (raw.Contains('(') && HriAi.IsMatch(raw))
return ParseParenthesized(raw, result);
return ParseRaw(raw, result);
}
// (01)...(17)...(10)... 형태
private static Gs1Fields ParseParenthesized(string raw, Gs1Fields result)
{
foreach (Match m in HriAi.Matches(raw))
{
string ai = m.Groups[1].Value;
string val = m.Groups[2].Value.Trim();
result.Ai[ai] = val;
}
return result;
}
// 원시 연접 형식
private static Gs1Fields ParseRaw(string raw, Gs1Fields result)
{
string s = StripSymbologyId(raw);
int i = 0;
while (i < s.Length)
{
if (s[i] == GS) { i++; continue; }
string ai = MatchAi(s, i);
i += ai.Length;
if (i > s.Length) break;
if (FixedValueLen.TryGetValue(ai, out int len))
{
len = Math.Min(len, s.Length - i);
result.Ai[ai] = s.Substring(i, len);
i += len;
}
else
{
int gs = s.IndexOf(GS, i);
if (gs < 0) gs = s.Length;
result.Ai[ai] = s.Substring(i, gs - i);
i = gs;
}
}
return result;
}
private static string StripSymbologyId(string s)
=> s.Length >= 3 && s[0] == ']' ? s.Substring(3) : s;
private static string MatchAi(string s, int pos)
{
for (int n = 4; n >= 2; n--)
{
if (pos + n <= s.Length && AllKnown.Contains(s.Substring(pos, n)))
return s.Substring(pos, n);
}
return pos + 2 <= s.Length ? s.Substring(pos, 2) : s.Substring(pos);
}
}
🔍 코드 설명
- Parse가 입력을 보고 괄호형이면 ParseParenthesized, 아니면 ParseRaw로 보낸다.
- ParseParenthesized: 정규식 \((\d{2,4})\)([^(]*) 로 "(AI)값" 짝을 모두 뽑는다. 값은 다음 ( 전까지.
- ParseRaw: 맨 앞 심볼로지 식별자(]d2)를 떼고, AI를 4→3→2자리로 맞춰가며, 고정 길이면 길이만큼·가변이면 GS까지 잘라 담는다.
- static class인 이유: 상태(데이터)를 안 갖는 순수 기능 모음이라 인스턴스를 만들 필요가 없다.
✅ 직접 해보기
Program.cs를 잠깐 아래처럼 바꿔 실행해 보자.
using PharmaReturn;
var raw = "(01)08800051300003(17)220129(10)VL200101";
var f = Gs1Parser.Parse(raw);
Console.WriteLine($"GTIN={f.Gtin}"); // 08800051300003
Console.WriteLine($"유통기한={f.Expiry}"); // 2022-01-29
Console.WriteLine($"로트={f.Lot}"); // VL200101
세 줄이 위 주석값과 같이 나오면 파서 완성! (확인 후 Program.cs는 Lesson 4에서 다시 쓴다.)
Lesson 3 — 납품내역 저장소 (DeliveryRepository.cs)
🎯 목표
"무엇을 누구에게 얼마나 납품했는가"를 보관하고, GTIN·로트로 찾고, 이미 반품된 수량을 추적한다.
📖 개념
- 저장소(Repository): 데이터를 담고 꺼내는 창구. 지금은 메모리 + CSV 파일로 간단히. 나중에 DB로 바꿔도 이 창구만 교체하면 된다.
- CSV: 콤마로 구분된 표 데이터. 엑셀로 편집 가능해 테스트에 편하다.
- 기반품 추적: 같은 로트를 두 번 반품해 과다 보상받는 걸 막으려고, (GTIN|로트)별 누적 반품수량을 들고 있는다.
💻 코드
DeliveryRepository.cs 추가:
using System.Globalization;
namespace PharmaReturn;
/// <summary>
/// 납품내역 저장소(샘플: 인메모리 + CSV). 실제로는 DB(EF Core/Dapper)로 대체.
/// 이미 반품된 수량을 (GTIN|LOT) 단위로 누적 추적해 과다 보상을 막는다.
/// </summary>
public sealed class DeliveryRepository
{
private readonly List<DeliveryRecord> _deliveries = new();
private readonly Dictionary<string, int> _returnedQty = new();
public IReadOnlyList<DeliveryRecord> All => _deliveries;
public void Add(DeliveryRecord d) => _deliveries.Add(d);
public static DeliveryRepository LoadCsv(string path)
{
var repo = new DeliveryRepository();
var inv = CultureInfo.InvariantCulture;
foreach (var line in File.ReadLines(path).Skip(1)) // 헤더 스킵
{
if (string.IsNullOrWhiteSpace(line)) continue;
var c = line.Split(',');
repo.Add(new DeliveryRecord(
DeliveryId: c[0].Trim(),
PharmacyId: c[1].Trim(),
DeliveryDate: DateOnly.Parse(c[2].Trim(), inv),
Gtin: c[3].Trim(),
ProductName: c[4].Trim(),
LotNo: c[5].Trim(),
ExpiryDate: DateOnly.Parse(c[6].Trim(), inv),
DeliveredQty: int.Parse(c[7].Trim(), inv),
UnitPrice: decimal.Parse(c[8].Trim(), inv)));
}
return repo;
}
/// <summary>GTIN(필수)과 로트(선택)로 납품내역 매칭. 로트가 null이면 GTIN만으로 매칭.</summary>
public IEnumerable<DeliveryRecord> FindByGtinLot(string gtin, string? lot)
=> _deliveries.Where(d => d.Gtin == gtin && (lot == null || d.LotNo == lot));
public int GetReturnedQty(string gtin, string lot)
=> _returnedQty.GetValueOrDefault(Key(gtin, lot));
public void AddReturnedQty(string gtin, string lot, int qty)
=> _returnedQty[Key(gtin, lot)] = GetReturnedQty(gtin, lot) + qty;
private static string Key(string gtin, string lot) => $"{gtin}|{lot}";
}
이제 테스트용 데이터 파일을 만든다. 프로젝트에 폴더 data를 만들고 그 안에 deliveries.csv 를 추가:
DeliveryId,PharmacyId,DeliveryDate,Gtin,ProductName,LotNo,ExpiryDate,DeliveredQty,UnitPrice
D2025-001,PH-1001,2025-01-15,08801234567890,타이레놀정500mg,LOT2406A,2026-03-31,100,3200
D2025-002,PH-1001,2025-02-10,08801234567906,아목시실린캡슐250mg,LOT2501B,2027-08-31,60,1500
D2021-005,PH-1003,2021-12-01,08800051300003,테스트제품 VL,VL200101,2022-01-29,50,1200
그리고 이 파일이 실행 폴더로 복사되도록, 솔루션 탐색기에서 deliveries.csv 선택 → 속성 → 출력 디렉터리로 복사 = 새 버전이면 복사로 설정한다. (또는 .csproj에 아래 추가)
<ItemGroup>
<None Include="data\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
🔍 코드 설명
- LoadCsv: 첫 줄(헤더) 건너뛰고, 콤마로 쪼개 DeliveryRecord로 만든다. CultureInfo.InvariantCulture로 날짜·숫자 파싱을 지역설정에 흔들리지 않게 고정.
- FindByGtinLot: 로트를 주면 GTIN+로트, 안 주면 GTIN만으로 매칭(LINQ Where).
- _returnedQty: "GTIN|로트" → 누적 반품수량. GetReturnedQty/AddReturnedQty로 읽고 더한다.
✅ 직접 해보기
Program.cs에서 임시로:
using PharmaReturn;
var repo = DeliveryRepository.LoadCsv(Path.Combine(AppContext.BaseDirectory, "data", "deliveries.csv"));
Console.WriteLine($"납품 {repo.All.Count}건 로드"); // 납품 3건 로드
foreach (var d in repo.FindByGtinLot("08801234567890", null))
Console.WriteLine($"{d.ProductName} / {d.LotNo} / {d.DeliveredQty}개");
"납품 3건 로드"와 타이레놀 한 줄이 나오면 성공. (파일을 못 찾으면 "출력 디렉터리로 복사" 설정을 확인.)
Lesson 4 — 보상 계산 + 전체 통합 (CompensationService.cs)
🎯 목표
반품 요청을 납품내역과 대조해 승인/부분승인/거부와 보상 금액을 정한다. 그리고 Lesson 1~4를 한 흐름으로 묶어 실행한다.
📖 개념
보상 규칙(샘플):
- GTIN(+로트) 매칭 안 되면 → 거부(납품내역 없음).
- 만기 사유인데 유통기한이 아직 한참 남았으면 → 거부(이상 없음).
- 잔여 반품 가능 수량 = 납품 − 기반품. 0 이하면 → 거부.
- 승인 수량 = min(요청, 잔여). 요청보다 적게 승인되면 부분 승인. 보상 = 승인수량 × 단가.
💻 코드
CompensationService.cs 추가:
namespace PharmaReturn;
/// <summary>
/// 반품 요청을 납품내역과 대조해 보상(승인) 수량/금액을 계산한다.
/// </summary>
public sealed class CompensationService
{
private readonly DeliveryRepository _repo;
private readonly int _expiryWindowDays; // 만기 임박 허용일
private readonly DateOnly _today;
public CompensationService(DeliveryRepository repo, int expiryWindowDays = 90, DateOnly? today = null)
{
_repo = repo;
_expiryWindowDays = expiryWindowDays;
_today = today ?? DateOnly.FromDateTime(DateTime.Today);
}
public CompensationResult Evaluate(ReturnRequest req)
{
var matches = _repo.FindByGtinLot(req.Gtin, req.LotNo).ToList();
if (matches.Count == 0)
return Reject(req, "납품내역 없음(GTIN/로트 불일치) — 보상 불가");
var d = matches[0];
string lot = req.LotNo ?? d.LotNo;
// 만기 사유 검증
if (req.Reason == ReturnReason.Expiry)
{
DateOnly threshold = _today.AddDays(_expiryWindowDays);
if (d.ExpiryDate > threshold)
return Reject(req, $"유통기한 이상 없음({d.ExpiryDate:yyyy-MM-dd}) — 만기 반품 대상 아님");
}
int delivered = matches.Sum(m => m.DeliveredQty);
int alreadyReturned = _repo.GetReturnedQty(req.Gtin, lot);
int remaining = delivered - alreadyReturned;
if (remaining <= 0)
return Reject(req, $"반품 가능 수량 없음(납품 {delivered}, 기반품 {alreadyReturned})");
int approved = Math.Min(req.RequestedQty, remaining);
decimal credit = approved * d.UnitPrice;
_repo.AddReturnedQty(req.Gtin, lot, approved);
if (approved == req.RequestedQty)
return new CompensationResult(req, CompensationStatus.Approved, approved, credit,
$"승인: {approved}개 보상", d);
return new CompensationResult(req, CompensationStatus.PartiallyApproved, approved, credit,
$"부분 승인: 요청 {req.RequestedQty} 중 {approved}개 보상(잔여 한도 {remaining})", d);
}
private static CompensationResult Reject(ReturnRequest req, string msg)
=> new(req, CompensationStatus.Rejected, 0, 0m, msg, null);
}
🔍 코드 설명
- 생성자에서 today를 받게 한 건 테스트를 재현 가능하게 하려는 것(운영에선 비우면 오늘 날짜 사용).
- 규칙을 위에서 아래로 "걸리면 즉시 거부, 다 통과하면 승인" 구조로 짰다. 읽기 쉽고 디버깅 쉽다.
- _repo.AddReturnedQty(...)로 승인분을 바로 누적해, 같은 로트를 또 반품하면 잔여가 줄어든다.
💻 전체 통합 Program.cs
이제 1편의 결과물을 한 흐름으로 묶는다. Program.cs를 아래로 교체:
using PharmaReturn;
var evalDate = new DateOnly(2026, 6, 16); // 데모 재현용 기준일(운영은 생략 → 오늘)
var repo = DeliveryRepository.LoadCsv(Path.Combine(AppContext.BaseDirectory, "data", "deliveries.csv"));
var svc = new CompensationService(repo, expiryWindowDays: 90, today: evalDate);
// 바코드에서 읽었다고 가정한 코드들(괄호형/원시형 섞어서)
var scannedCodes = new[]
{
"(01)08801234567890(17)260331(10)LOT2406A(21)SN0001", // 타이레놀, 만기 경과
"(01)08800051300003(17)220129(10)VL200101", // VL, 만기 경과
"(01)08801234567906(17)270831(10)LOT2501B", // 아목시실린, 기한 멀음
};
// 각 코드의 (사유, 요청수량)은 검수 화면 입력값이라고 가정
var input = new (ReturnReason Reason, int Qty)[]
{
(ReturnReason.Expiry, 30),
(ReturnReason.Expiry, 10),
(ReturnReason.Expiry, 5),
};
Console.WriteLine($"{"GTIN",-16}{"로트",-10}{"유통기한",-12}{"요청",4}{"승인",5}{"보상금액",12} 결과");
Console.WriteLine(new string('-', 80));
decimal total = 0;
for (int i = 0; i < scannedCodes.Length; i++)
{
var f = Gs1Parser.Parse(scannedCodes[i]); // ① 파싱
var req = new ReturnRequest(f.Gtin!, f.Lot, f.Expiry, input[i].Qty, input[i].Reason);
var r = svc.Evaluate(req); // ② 대조 + 보상
total += r.CreditAmount;
Console.WriteLine($"{f.Gtin,-16}{f.Lot,-10}{f.Expiry,-12:yyyy-MM-dd}{req.RequestedQty,4}{r.ApprovedQty,5}{r.CreditAmount,12:N0} [{r.Status}] {r.Message}");
}
Console.WriteLine(new string('-', 80));
Console.WriteLine($"총 보상 금액: {total:N0} 원");
✅ 직접 해보기 (1편 최종 결과)
실행하면 대략 이런 표가 나온다(기준일 2026-06-16):
GTIN 로트 유통기한 요청 승인 보상금액 결과
--------------------------------------------------------------------------------
08801234567890 LOT2406A 2026-03-31 30 30 96,000 [Approved] 승인: 30개 보상
08800051300003 VL200101 2022-01-29 10 10 12,000 [Approved] 승인: 10개 보상
08801234567906 LOT2501B 2027-08-31 5 0 0 [Rejected] 유통기한 이상 없음(2027-08-31) ...
--------------------------------------------------------------------------------
총 보상 금액: 108,000 원
- 1번: 만기 경과 → 30개 전량 승인(30×3,200=96,000).
- 2번: 만기 경과 → 10개 승인(10×1,200=12,000).
- 3번: 유통기한이 2027년이라 만기 반품 대상 아님 → 거부.
이 표가 나오면 1편 완성이다. "바코드 한 줄 → 파싱 → 납품 대조 → 보상" 두뇌가 동작하는 것.
더 해보기: input의 사유를 Damage로 바꾸면(손상) 유통기한 검증을 건너뛴다. 요청 수량을 납품수량보다 크게 주면 "부분 승인"이 나온다. 같은 코드를 두 번 넣으면 두 번째는 잔여 한도가 줄어드는 것도 확인해 보자.
1편 마무리
지금까지 만든 것:
- Models.cs — 데이터의 모양
- Gs1Parser.cs — 바코드 문자열 → GTIN/유통기한/로트 (괄호형·원시형 모두)
- DeliveryRepository.cs — 납품내역 보관·매칭·기반품 추적
- CompensationService.cs — 보상 판정(승인/부분/거부)
- Program.cs — 위를 한 흐름으로 묶은 콘솔 데모
다음 편 예고
- 2편: 진짜 바코드 이미지를 읽는다. ZXingCpp(zxing-cpp)로 GS1 DataMatrix를 디코드하고, 위치 좌표를 얻고, 한 장에 여러 개·큰 사진의 작은 코드(타일 판독)를 처리한다.
- 3편: 콘솔을 WinForms로 바꿔, 파일 불러오기·이미지 미리보기·확대/축소·판독 위치 파란 박스·영역 판독·가독문자 OCR(Tesseract)·결과 그리드·보상 계산까지 완성하고 배포한다.
'개발(IT) > C#(VisualStudio)' 카테고리의 다른 글
| [c#] MS-SQL 연동 Helper (0) | 2023.09.23 |
|---|---|
| [c#] 오라클(Oracle) 연동 Helper (0) | 2023.09.23 |
| 자동 업그레이드 다운로드 (Auto Checker) (0) | 2023.09.14 |
| Visual Studio 2022 설치 순서 (1) | 2023.08.23 |