C#でGS1形式QRコードを解析:AIを正規表現で分解しEntity Frameworkへマッピングする方法

GS1形式っぽいQRコード(例:(01)00358160740213(17)260515(10)32PF3(21)5HZ6R1PX8C)を読み取ると、AI(Application Identifier)と値が連結された文字列が返ってきます。順番が変わったり項目が増えたりしても壊れないように、C#でAI+値を抽出してDictionary化し、Entity FrameworkでSQLテーブルへ自然にマッピングする実装パターンをまとめます。

目次

QRコードリーダーが返す「GS1っぽい文字列」の正体

例にある (01) や (17) は、GS1でいうAI(Application Identifier:アプリケーション識別子)です。AIは「この後ろに続く値が何を意味するか」を示すプレフィックスで、代表例は次のとおりです。

AI意味(代表例)値の長さ例
01GTIN(商品識別)固定(14桁)00358160740213
17有効期限 / 使用期限(YYMMDD)固定(6桁)260515
10ロット / バッチ可変(運用で最大長を決める)32PF3
21シリアル可変(運用で最大長を決める)5HZ6R1PX8C

ここで重要なのは、現場で扱う「読み取り文字列」が次の性質を持つことです。

  • AIの並び順は固定ではない(現場・取引先・ラベル発行側で変わる)
  • AIは2桁だけとは限らず、3〜4桁のAIが出てくることがある
  • 可変長の値(例:10, 21)は「どこまでが値か」を区切る必要がある
  • 区切りとして、目に見えにくい制御文字(FNC1相当)が混ざることがある

そのため、Split('(') のような力技で始めると、AIが増えた瞬間に条件分岐が爆発し、保守がつらくなりがちです。ゴールは「AI → 値」の形で正規化して、後段(Entity Frameworkや業務ロジック)を単純にすることです。

結論:正規表現で「(AI) と 値」をペア抽出してDictionary化する

例のように (数字) でAIが明示されているなら、正規表現で「AI」と「次のAI直前までの値」を抜き出すのが、実装量が少なく拡張しやすいです。

基本パターン:次の「(」が来るまでを値にする

「値の中に ( は入らない」という前提であれば、次のパターンでも動きます。

var pattern = @"\((\d{2})\)([^\(]+)";

ただし実務ではAIが2桁に限らないこと、また可変長項目の直前に制御文字が入ることがあるため、次の“汎用形”をおすすめします。

おすすめ:2〜4桁AI+先読みで「次のAI直前 or 末尾まで」を値にする

より安定するのが、先読み(lookahead)を使った次のパターンです。

@"\((\d{2,4})\)(.*?)(?=\(\d{2,4}\)|$)"
  • \((\d{2,4})\):括弧内の2〜4桁数字をAIとして取得(グループ1)
  • (.*?):できるだけ短く値を取得(グループ2)
  • (?=\(\d{2,4}\)|$):次のAI開始((数字))の直前、または文字列末尾で止める

この形にしておくと、AIが増えても「抽出部分」は変えずに済みます。さらに、GS1 DataMatrixやGS1 QRコードなど、媒体が何であっても「AI+値の連なり」という構造は共通になりやすく、後段の設計が安定します。

実装例:抽出結果を「順序付きリスト」と「辞書」に落とす

まずは抽出したペアを「順番も保持できる」形で受け取り、必要に応じてDictionaryへ変換するのが安全です。理由は、同一AIが複数回出るパターン(運用ミス・複合ラベル・追加情報など)を後から追えるからです。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

public readonly record struct Gs1Element(string Ai, string Value);

public static class Gs1LikeParser
{
    // 先読みで「次の(AI)直前 or 末尾」までを値として取る
    private static readonly Regex AiRegex =
        new Regex(@"\((\d{2,4})\)(.*?)(?=\(\d{2,4}\)|$)",
            RegexOptions.Compiled | RegexOptions.Singleline);

    public static IReadOnlyList<Gs1Element> Parse(string input)
    {
        if (input is null) throw new ArgumentNullException(nameof(input));

        var list = new List<Gs1Element>();

        foreach (Match m in AiRegex.Matches(input))
        {
            var ai = m.Groups[1].Value;

            // スキャナ設定によっては、可変長の終端にFNC1相当(ASCII 29, 0x1D)が混ざることがある
            var value = m.Groups[2].Value.TrimEnd('\u001D');

            list.Add(new Gs1Element(ai, value));
        }

        return list;
    }

    // 同じAIが複数回出た場合の方針(ここでは「最後の値を採用」)
    public static Dictionary<string, string> ToDictionaryLastWins(IEnumerable<Gs1Element> elements)
        => elements
            .GroupBy(e => e.Ai)
            .ToDictionary(g => g.Key, g => g.Last().Value, StringComparer.Ordinal);
}

上記の ToDictionaryLastWins は「同じAIが複数回出たら最後の値を採用」する方針です。業務によっては次のように変えると良いです。

  • 最初の値を採用したい:g.First().Value
  • 重複は異常:重複検知して例外/エラー一覧に追加
  • 重複も意味がある:Lookup や Dictionary<string, List<string>> で保持

抽出結果のイメージ

例の入力を解析した結果は、概ね次のようになります。

順序AI値用途の例
10100358160740213商品(GTIN)
217260515期限(YYMMDD)
31032PF3ロット
4215HZ6R1PX8Cシリアル

“拡張しやすさ”は「AI→列の対応表(マッピング)」で決まる

AI抽出ができたら次にやることは、AIごとの意味をEntityの列へ割り当てることです。ここでおすすめなのが「AI→代入処理」を辞書で持つ方法です。if/switchを増やすより、追加・変更が読みやすく、レビューもしやすくなります。

AI→プロパティ代入の対応表を作る

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;

public sealed class ScanEntity
{
public int Id { get; set; }


public string? Gtin { get; set; }             // 01
public DateTime? ExpirationDate { get; set; } // 17
public string? LotNumber { get; set; }        // 10
public string? SerialNumber { get; set; }     // 21

public string Raw { get; set; } = "";         // 元の読み取り文字列(監査・調査用)
public string? ExtraJson { get; set; }        // 未対応AIなどを保存
public DateTime ScannedAt { get; set; } = DateTime.UtcNow;


}

public static class ScanEntityMapper
{
// AIごとの代入ルール(増えてもここに足すだけ)
private static readonly Dictionary> Map
= new(StringComparer.Ordinal)
{
["01"] = (e, v) => e.Gtin = v,
["10"] = (e, v) => e.LotNumber = v,
["21"] = (e, v) => e.SerialNumber = v,
["17"] = (e, v) => e.ExpirationDate = ParseGs1DateYYMMDD(v),
};


public static ScanEntity MapToEntity(IEnumerable<Gs1Element> elements, string raw)
{
    var entity = new ScanEntity { Raw = raw };

    // すぐ列追加できない/したくない場合でも、情報を落とさないように退避
    var extras = new Dictionary<string, string>(StringComparer.Ordinal);

    foreach (var el in elements)
    {
        if (Map.TryGetValue(el.Ai, out var setter))
        {
            setter(entity, el.Value);
        }
        else
        {
            extras[el.Ai] = el.Value;
        }
    }

    entity.ExtraJson = extras.Count == 0 ? null : JsonSerializer.Serialize(extras);
    return entity;
}

// YYMMDD を DateTime に(基準年は業務で決める:ここでは 2000〜2099 として扱う例)
private static DateTime ParseGs1DateYYMMDD(string yymmdd)
{
    if (string.IsNullOrWhiteSpace(yymmdd) || yymmdd.Length != 6)
        throw new FormatException($"Invalid GS1 date: {yymmdd}");

    if (!int.TryParse(yymmdd[..2], NumberStyles.None, CultureInfo.InvariantCulture, out var yy) ||
        !int.TryParse(yymmdd.Substring(2, 2), NumberStyles.None, CultureInfo.InvariantCulture, out var mm) ||
        !int.TryParse(yymmdd.Substring(4, 2), NumberStyles.None, CultureInfo.InvariantCulture, out var dd))
    {
        throw new FormatException($"Invalid GS1 date: {yymmdd}");
    }

    // 単純に 20YY とするルール。必要ならピボット年で 19YY/20YY を切り替える
    var year = 2000 + yy;

    return new DateTime(year, mm, dd);
}


}

この方式のメリットは、AIが増えたときに Mapに1行追加するだけで済むことです。期限のように型変換が必要な項目だけ個別関数に切り出し、その他は文字列のまま保持しておけば、DB設計も破綻しにくくなります。

AI→列の対応表を“見える化”しておく

コード上のMapに加えて、設計書としても把握しやすいように表で整理しておくと運用が楽です。

AIEntityプロパティ型変換/検証のポイント
01Gtinstring14桁固定。先頭ゼロが重要なので数値にしない
17ExpirationDateDateTime?YYMMDD。基準年(2000〜など)を業務要件で決める
10LotNumberstring可変長。末尾に制御文字が混ざる場合はTrimする
21SerialNumberstring可変長。桁や文字種の許容範囲は現場ルールに寄せる
(その他)ExtraJsonstring?未対応AIを落とさず保存しておくと、後追いで列追加しやすい

Entity Frameworkで“そのまま保存できる形”にするコツ

GS1系の値は、データとしては文字列が多いです。特にGTINのように先頭ゼロが意味を持つ識別子は、数値型にすると事故りやすいため、DBでも文字列で持つのが安全です。

EF Coreのモデル設定例(長さとインデックス)

EF Coreの場合は、列長(HasMaxLength)を決めておくと、想定外の値が入ったときに早めに気づけます。

// OnModelCreating の一例(EF Core)
modelBuilder.Entity<ScanEntity>(b =>
{
    b.Property(x => x.Gtin).HasMaxLength(14);
    b.Property(x => x.LotNumber).HasMaxLength(20);
    b.Property(x => x.SerialNumber).HasMaxLength(20);
    b.Property(x => x.Raw).HasMaxLength(256);
    b.Property(x => x.ExtraJson).HasMaxLength(2048);

    // よく検索するならインデックスを検討
    b.HasIndex(x => x.Gtin);
    b.HasIndex(x => new { x.Gtin, x.LotNumber, x.SerialNumber });
});

「可変長の最大値」をどこに置くかは、仕様だけでなく現場のルールにも左右されます。まずはラベル仕様書や取引先の運用を確認し、DBの桁数も合わせておくと、後工程(検索・突合・監査)がスムーズです。

実務でつまずきやすいポイントと対策

括弧付きAIは“読みやすい表現”であり、現場では別形式も混ざる

スキャナの設定やライブラリによっては、括弧が無い「生のエレメント文字列」が返ることもあります。たとえば 01003581607402131726051510... のようにAIが数字のまま続く形式です。括弧がある前提の正規表現は効かないので、運用で混ざるなら「入力の正規化」か「形式ごとにパーサを切り替える」設計が必要です。

可変長の終端に制御文字(区切り)が入る

可変長項目は、次の項目が続く場合に区切り(FNC1相当)で終端を示します。スキャナがこの区切りをASCII 29(0x1D)として出力することがあり、見た目では分からないまま文字列に混ざります。

  • ログ出力時は input.Replace("\u001D", "<GS>") のように可視化すると原因究明が早い
  • 抽出後は TrimEnd('\u001D') で末尾の区切りを落としておく

AIの重複・欠落・順番の変動

「01は必ずある」「17が無いと受入できない」など、業務ルールがあるなら、パース後に検証フェーズを入れるのが安全です。抽出自体は“広く受け入れる”ようにして、検証で“業務として許可するか”を判断すると、障害の切り分けがしやすくなります。

public static void ValidateForReceiving(Dictionary<string, string> dict)
{
    if (!dict.ContainsKey("01"))
        throw new InvalidOperationException("GTIN(01) が見つかりません。");

    // 期限必須など、要件に応じて追加
    // if (!dict.ContainsKey("17")) ...
}

括弧なしの「生GS1エレメント文字列」に対応する発展形

取引先や機器が増えると括弧なし形式も現実的に混ざります。その場合は、AI仕様(固定長/可変長)をテーブル化して、文字列を前から読んでいくパーサが安定します。

ポイントは「2桁・3桁・4桁のAI候補を長い方から当てる」ことです。AIが確定したら、固定長なら決まった桁数だけ読み、可変長なら区切り(ASCII 29)か末尾までを読む、という流れになります。

public sealed record AiSpec(string Ai, int? FixedLength); // null = 可変長

public static class RawGs1ElementStringParser
{
    // 必要なAIだけ登録していく(増えてもここを足すだけ)
    private static readonly Dictionary<string, AiSpec> Specs = new(StringComparer.Ordinal)
    {
        ["01"] = new AiSpec("01", 14),
        ["17"] = new AiSpec("17", 6),
        ["10"] = new AiSpec("10", null),
        ["21"] = new AiSpec("21", null),

        // 例:重量や数量など4桁AIが必要なら追加
        // ["3103"] = new AiSpec("3103", 6),
    };

    public static IReadOnlyList<Gs1Element> Parse(string input)
    {
        if (input is null) throw new ArgumentNullException(nameof(input));

        var list = new List<Gs1Element>();
        var i = 0;

        while (i < input.Length)
        {
            // ASCII 29 (GS) が混ざっていたら読み飛ばす
            if (input[i] == '\u001D')
            {
                i++;
                continue;
            }

            // 4桁→3桁→2桁の順にAIを試す
            AiSpec? spec = null;
            string? ai = null;

            for (var len = 4; len >= 2; len--)
            {
                if (i + len > input.Length) continue;
                var candidate = input.Substring(i, len);
                if (Specs.TryGetValue(candidate, out spec))
                {
                    ai = candidate;
                    i += len;
                    break;
                }
            }

            if (spec is null || ai is null)
                throw new FormatException($"Unknown AI at index {i}: {input.Substring(i, Math.Min(10, input.Length - i))}");

            // 値の読み取り
            string value;
            if (spec.FixedLength is int fixedLen)
            {
                if (i + fixedLen > input.Length)
                    throw new FormatException($"Value too short for AI {ai}.");

                value = input.Substring(i, fixedLen);
                i += fixedLen;
            }
            else
            {
                // 可変長:区切り(GS)まで、または末尾まで
                var start = i;
                while (i < input.Length && input[i] != '\u001D')
                    i++;

                value = input.Substring(start, i - start);
            }

            list.Add(new Gs1Element(ai, value));
        }

        return list;
    }
}

この方式のメリットは、括弧が無くても確実に切り出せること、そしてAI仕様を Specs に集約できることです。逆に、Specsに無いAIはパースできないため、現場で出るAIを洗い出して登録していく運用が前提になります。

テストを書いておくと“増えたAI”に強くなる

QRコード運用は、あとからAIが追加されることが珍しくありません。最初にテストを作っておくと、パーサやマッピングに手を入れても安心して拡張できます。

using Xunit;

public class Gs1LikeParserTests
{
    [Fact]
    public void Parse_Parenthesized_ExtractsPairs()
    {
        var input = "(01)00358160740213(17)260515(10)32PF3(21)5HZ6R1PX8C";
        var list = Gs1LikeParser.Parse(input);
        var dict = Gs1LikeParser.ToDictionaryLastWins(list);

        Assert.Equal("00358160740213", dict["01"]);
        Assert.Equal("260515", dict["17"]);
        Assert.Equal("32PF3", dict["10"]);
        Assert.Equal("5HZ6R1PX8C", dict["21"]);
    }

    [Fact]
    public void Parse_Parenthesized_TrimsGroupSeparator()
    {
        var input = "(10)LOT123\u001D(21)SERIAL";
        var list = Gs1LikeParser.Parse(input);

        Assert.Equal("LOT123", list[0].Value);
        Assert.Equal("SERIAL", list[1].Value);
    }
}

テストを追加するときは「順番が変わる」「未知のAIが混ざる」「区切り文字が入る」など、現場で起こりそうなパターンを意識すると効果的です。

運用で強い構成にするためのチェックリスト

  • 抽出と検証を分ける(抽出は広く、検証は要件に合わせて厳しく)
  • AIの追加は マッピング辞書の1行追加で済むようにしておく
  • 未対応AIは捨てずに ExtraJsonや別テーブルへ退避する
  • GTINなど識別子は 文字列で保持し、先頭ゼロを守る
  • 制御文字(ASCII 29)を想定し、ログで可視化できるようにする
  • 最小限のテストを用意し、AI追加時に壊れていないことを保証する

このあたりを押さえると、AIが増えても「解析ロジックはほぼ触らず、対応表を追加するだけ」で運用できるようになります。QRコードを読み取った後の工程(Entity Frameworkで保存、検索、突合、監査)もシンプルになるため、結果的に障害対応コストが下がります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次