개발(IT)/C#(VisualStudio)

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

isony 2026. 6. 29. 08:43
반응형

의약품 반품 보상 프로그램 만들기 — 단계별 강의 (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 데스크톱 개발" 워크로드를 고르면 함께 깔린다.
  • 콘솔 앱: 화면(창) 없이 텍스트만 입출력하는 가장 단순한 프로그램. 로직을 배우기에 최적.

💻 따라 하기

  1. Visual Studio 2022 설치 시 워크로드 ".NET 데스크톱 개발" 체크.
  2. VS2022 실행 → 새 프로젝트 만들기콘솔 앱(C#) 선택.
  3. 프로젝트 이름: PharmaReturn, 프레임워크: .NET 8.0.
  4. 만들어진 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가 디코드되면 보통 두 형태 중 하나로 나온다.

  1. 원시(raw): 01 + GTIN(14자리) + 17 + 날짜(6자리) + 10 + 로트(가변) … 가변 길이 항목 뒤에는 GS(ASCII 29) 라는 보이지 않는 구분자가 붙는다.
  2. 괄호(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를 한 흐름으로 묶어 실행한다.

📖 개념

보상 규칙(샘플):

  1. GTIN(+로트) 매칭 안 되면 → 거부(납품내역 없음).
  2. 만기 사유인데 유통기한이 아직 한참 남았으면 → 거부(이상 없음).
  3. 잔여 반품 가능 수량 = 납품 − 기반품. 0 이하면 → 거부.
  4. 승인 수량 = 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)·결과 그리드·보상 계산까지 완성하고 배포한다.

 

 

반응형