diff --git a/CLAUDE.md b/CLAUDE.md
index 5ccda62..f9d9662 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,7 +9,7 @@ Native mobile monorepo for `code-match`: an operator scans a QR code (delivery s
- `ios/`: SwiftUI/AVFoundation app (iOS 17+, Swift 5, bundle `jp.rimtty.CodeMatch`). Feature-complete; camera and Inateck BCST-47 Bluetooth input.
- `android/`: independent Gradle project (Kotlin, Jetpack Compose, Material 3, minSdk 31, compile/target SDK 37). Feature parity with iOS; camera (CameraX + bundled ML Kit) and Inateck BLE via the official Android SDK.
-The cross-platform behavior contract lives in [docs/PRODUCT_SPEC.md](docs/PRODUCT_SPEC.md). iOS design rationale and acceptance checklist: [docs/ios/IMPLEMENTATION_GUIDE.md](docs/ios/IMPLEMENTATION_GUIDE.md). Android porting plan and status: [docs/android/IMPLEMENTATION_PLAN.md](docs/android/IMPLEMENTATION_PLAN.md), [docs/android/STATUS.md](docs/android/STATUS.md), [docs/android/TEST_PARITY.md](docs/android/TEST_PARITY.md). Read the relevant ones before changing scanner flow or UI behavior.
+The cross-platform behavior contract lives in [docs/PRODUCT_SPEC.md](docs/PRODUCT_SPEC.md). [docs/label-variation-playbook.html](docs/label-variation-playbook.html) is the runbook for a new label variation in the field (what broke on 2026-09-08, the acceptance-rule principles, and the decode → verify → fixture → release steps). iOS design rationale and acceptance checklist: [docs/ios/IMPLEMENTATION_GUIDE.md](docs/ios/IMPLEMENTATION_GUIDE.md). Android porting plan and status: [docs/android/IMPLEMENTATION_PLAN.md](docs/android/IMPLEMENTATION_PLAN.md), [docs/android/STATUS.md](docs/android/STATUS.md), [docs/android/TEST_PARITY.md](docs/android/TEST_PARITY.md). Read the relevant ones before changing scanner flow or UI behavior.
## Project policy (as of 2026-09)
@@ -17,7 +17,8 @@ The cross-platform behavior contract lives in [docs/PRODUCT_SPEC.md](docs/PRODUC
- **iOS is complete.** The "scanner stops reading after continuous use" audit (#58, closed 2026-09-06) landed as #62–#66: setting writes retry and recover instead of parking the session on the camera, `.inactive` no longer rewrites symbologies, the FF04 helper link has a deadline, the command timeout is 10 s with SDK link liveness checks, and the diagnostics log (300 events) can be shared from Settings. Verified on HPRT-4F5F; BCST-47 verification continues through TestFlight 1.0 (4).
- **Scanner tuning is applied on connect by both apps** (#59, closed): illumination `lighting_lamp_control` = 2 with a Settings toggle, plus a fixed profile (multi-code `*_read_more_code`/`*_read_multi` = 0, inverse `read_inverse_color`/`*_read_phase` = 0, red-light time `auto_close_mode` = 20 ≈ 4 s) written only when the inventory differs and confirmed by readback. Values persist on the scanner and are not restored on disconnect. The inventory-name ↔ generic-flag table is in `docs/ios/IMPLEMENTATION_GUIDE.md`. Per-step symbology on iOS (#39) is deferred.
- **Customer-facing data spec**: `docs/qr-barcode-spec-analysis.html` / `.pdf` are deliberately git-ignored (local only). They now cover all three ship-to destinations: the 澤井製作所 QR field table with the 12 real label pairs, §6 for モルテン (§6.6 holds the 2026-09-06 decisions), and §7 for デンソー (§7.5 holds the 2026-09-07 decisions). Keep them in sync when matching or scan-acceptance rules change, and regenerate the PDF from the HTML with headless Chrome.
-- The 2026-09-06 spec audit follow-ups landed: camera Code 128 gets the same `4-2-4@code` format validation as BLE on both platforms (#78), and `shared/test-fixtures/matching-cases.json` pins the real label pairs plus same-series mix-ups as mismatches (#79). The fixture is now `schemaVersion` 2 with 47 cases, each carrying the `destination` its QR belongs to: the 12 real 66-char 澤井製作所 decodes, two boxes of one part (`box-a` / `box-b`) that ground the duplicate rule, the real 61-char モルテン records, the real 221-char デンソー kanbans, and two destination-less cases that must never match. The label photos live in git-ignored `tmp/sawai-labels/` (customer data, never commit); decode them with a Vision `VNDetectBarcodesRequest` script when the label set changes.
+- The 2026-09-06 spec audit follow-ups landed: camera Code 128 gets the same business-format validation as BLE on both platforms (#78), and `shared/test-fixtures/matching-cases.json` pins the real label pairs plus same-series mix-ups as mismatches (#79). The fixture is now `schemaVersion` 2 with 63 cases, each carrying the `destination` its QR belongs to: the 12 real 66-char 澤井製作所 decodes, two boxes of one part (`box-a` / `box-b`) that ground the duplicate rule, the 16 `sawai-2026-09-08-` field-label cases (#129), the real 61-char モルテン records, the real 221-char デンソー kanbans, and two destination-less cases that must never match. The label photos live in git-ignored `tmp/` (customer data, never commit); decode them with `shared/tools/decode_label_photos.swift` (Vision `VNDetectBarcodesRequest`, prints one TSV line per symbol) when the label set changes.
+- **澤井製作所 card numbers and 9-char part numbers (2026-09-08, #129).** The first strict TestFlight build in the field rejected every 澤井製作所 slip because `KanbanQRRecord` required the card number to be `[A-Z]{4}[0-9]{6}` while real slips also carry `DAH4093870` (an alphanumeric 4-char code + 6-digit serial), and because five parts are nine characters (`BCJH5281F ` left-justified in the 10-wide item field, suffix blank or `02`, tag `BCJH-52-81F@01R95K`). Both platforms now accept card `[A-Z0-9]{4}[0-9]{6}`, item field `[A-Z0-9]{9}[A-Z0-9 ]` (stored trimmed), and a `4-2-3` or `4-2-4` sawai tag — the same tag pattern as molten. Do not tighten these back to the 12-label sample; a rejected real slip stops production, an accepted junk 66-char QR only yields a mismatch. Denso is unaffected. The procedure for the next label variation (collect the scan log, decode photos with `shared/tools/decode_label_photos.swift`, run the pairs through the shipped rules with `ios/scripts/verify_label_pairs.sh`, extend the fixture, invert tests, update docs) is written up in `docs/label-variation-playbook.html`.
- **Ship-to destinations (仕向地) landed 2026-09-07** (#84; PRs #93–#100). A slip QR is 澤井製作所 (`sawai`, 66 chars), モルテン (`molten`, 61 chars whose trailing spaces are data) or, since #106, デンソー. The first accepted QR locks the session's destination, and a QR of another destination is rejected until the operator ends the session — no mixed sessions. The duplicate key is the QR alone for sawai and QR + Code 128 for molten; molten boxes are counted per 納品番号 with a cumulative 収容数, and neither app judges completion. Android persists the lock in Room v3 (`sessions.destination`, `scan_checkpoints.destination`, `MIGRATION_2_3`). Existing user-facing strings were left unchanged.
- **仕向地 デンソー landed 2026-09-07** (#106; PRs #113–#116, iOS history/PDF #117). Its kanban QR is a JAMA self-describing record — `JAMA` + version digit + 4-digit header length + 10-char preamble + N × (3-digit item id + 2-digit length) + fixed-width data whose lengths must sum exactly — so it is parsed generically, not by length (the real kanban is 221 chars, header 119, 21 items, 97 data chars). Items 104 (10-digit part number), 112 (収容数) and 152 (かんばん連番, unique per box) are required. `Destination.detect` probes denso **first**, because the lenient sawai parse would otherwise accept `JAMA5011…` as a card number. The product tag is `6-4@code` (`860150-7722@1DZ50O`), and part numbers print `6-4` through the destination-aware `format(partNumber:destination:)` / `formatPartNumber(partNumber, destination)` — the 1-arg / defaulted form is gone on both platforms so the compiler enumerates every call site. Duplicates key on the QR alone and boxes are counted per part number, exactly like sawai; there is no planned-box-count display and no completion judgement. No schema change: Room stays v3 with `denso` stored as the `destination` string, and iOS history JSON keeps decoding it leniently.
- Work items that should be tracked go into GitHub Issues (`gh issue create`).
@@ -93,12 +94,12 @@ Flow: `CodeMatchApp` → `RootTabView` (owns the single `HistoryStore`, `Bluetoo
- **`BluetoothScannerService`** wraps the Inateck iOS SDK (`#if INATECK_SDK`, simulator mock otherwise): discovery, connect/auto-reconnect, and symbology restriction. During a session it writes all symbology areas (flag 2001–2028) to 0 except `qrcode_on` / `code128_on`, using the `area`/`name` the device reported (never hardcoded areas), snapshots the original values, and restores them on session end/disconnect. All `setSettingInfo` traffic (session symbologies, illumination, tuning) goes through one gate (`settingCommandInFlight`, generation counters, 10 s timeout that closes the link and reconnects); failed writes retry twice and then recover from a fresh `getSettingInfo` every 5 s (max 3). After the session symbologies are ready the service applies illumination OFF, then the tuning profile, each confirmed by readback. Diagnostics keep 300 trace lines (no payloads) and `diagnosticLogText()` feeds the Settings share sheet.
- **`HistoryStore`** (`@MainActor`) holds `[MatchSession]` newest-first; at most one session has `endedAt == nil` (`activeSession`). Persists to `Application Support/CodeMatch/match-history.json` with `.completeFileProtection`, excluded from backup. `init(storageURL:)` exists so tests can point at a temp directory.
- **`ScanLogStore`** (`@MainActor`) is the scan log: one JSON line per event appended to `Application Support/CodeMatch/scan-log.jsonl` (`.completeFileProtection`, directory excluded from backup, `FileHandle` opened and closed per write), trimmed back to the newest 5,000 once the count passes 5,500. `ScannerViewModel.log(...)` records `qr_accepted` / `barcode_candidate` / `barcode_accepted` / `match` / `mismatch` / `duplicate` / `rejected` (reasons `invalid_format`, `wrong_order`, `wrong_destination`, `wrong_symbology`, `result_pending`), `SessionStartView` records `session_start` and `prepareForSessionEnd` records `session_end`. Events carry the raw QR / Code 128 payloads, so the log is device-local and leaves only through the Settings share sheet (`exportText` / `writeTemporaryExport` write a header line plus every event as `codematch-scan-log-yyyyMMdd-HHmm.jsonl`; the schema is shared with Android). This is separate from the BLE diagnostics log, which must stay payload-free. `init(storageURL:arguments:)` exists so tests can point at a temp directory.
-- **`CodeMatcher`** is the whole comparison rule, and it is destination-aware. `Destination.detect(qrPayload:)` strips only CR/LF/NUL and decides between `denso` (variable-length JAMA `DensoKanbanRecord`, part number from item 104), `sawai` (66-char `KanbanQRRecord`, part number at chars 11–20) and `molten` (61-char `MoltenQRRecord`, part number at chars 7–16; a payload of 57–61 chars is padded back to 61 because its trailing spaces are data). Denso is probed first — the lenient `KanbanQRRecord.parse` would read `JAMA5011…` as a card number — and sawai is retried on the trimmed payload only after molten is ruled out. The Code 128 is `PART-NO@code`; both sides are normalized and compared exactly — the old substring-containment fallback is gone, so a QR that parses as no record can never match. `format(partNumber:destination:)` prints denso's 10 chars as 6-4, and otherwise 10 chars as 4-2-4 and 9 chars as 4-2-3; there is no 1-arg overload, so every call site must pass the destination it holds. `BoxIdentity.make(qrPayload:barcodePayload:)` builds the per-box key: the QR text for sawai and denso (denso's item 152 かんばん連番 differs per box), canonical QR + `|` + Code 128 for molten. Tests pin this with real label payloads of all three destinations through the shared fixtures.
+- **`CodeMatcher`** is the whole comparison rule, and it is destination-aware. `Destination.detect(qrPayload:)` strips only CR/LF/NUL and decides between `denso` (variable-length JAMA `DensoKanbanRecord`, part number from item 104), `sawai` (66-char `KanbanQRRecord`, card number `[A-Z0-9]{4}[0-9]{6}`, part number at chars 11–20 — 10 chars, or 9 chars left-justified with a trailing space that is trimmed) and `molten` (61-char `MoltenQRRecord`, part number at chars 7–16; a payload of 57–61 chars is padded back to 61 because its trailing spaces are data). Denso is probed first — the lenient `KanbanQRRecord.parse` would read `JAMA5011…` as a card number — and sawai is retried on the trimmed payload only after molten is ruled out. The Code 128 is `PART-NO@code`; both sides are normalized and compared exactly — the old substring-containment fallback is gone, so a QR that parses as no record can never match. `format(partNumber:destination:)` prints denso's 10 chars as 6-4, and otherwise 10 chars as 4-2-4 and 9 chars as 4-2-3; there is no 1-arg overload, so every call site must pass the destination it holds. `BoxIdentity.make(qrPayload:barcodePayload:)` builds the per-box key: the QR text for sawai and denso (denso's item 152 かんばん連番 differs per box), canonical QR + `|` + Code 128 for molten. Tests pin this with real label payloads of all three destinations through the shared fixtures.
Two misread defenses that are intentional and easy to break accidentally:
- `scanLocked` suppresses reads for 250ms after accepting a QR value.
-- Code 128 from the camera requires the *same* value seen twice within 1.5s (`barcodeCandidate`) before it is accepted; only then does the camera stop and comparison run. A BCST-47 trigger is accepted once. Camera and BLE Code 128 are both validated against the locked destination's business format (`TagBarcodeRecord.isValidScanPayload(_:destination:)` — `4-2-4@code` for sawai, `4-2-3` or `4-2-4` for molten, `6-4` for denso; before the lock any of the three) before the candidate logic runs; an unrelated Code 128 is rejected with a message and a 2 s repeat suppression, exactly like an unrelated QR (#78).
+- Code 128 from the camera requires the *same* value seen twice within 1.5s (`barcodeCandidate`) before it is accepted; only then does the camera stop and comparison run. A BCST-47 trigger is accepted once. Camera and BLE Code 128 are both validated against the locked destination's business format (`TagBarcodeRecord.isValidScanPayload(_:destination:)` — `4-2-3` or `4-2-4@code` for sawai and molten, `6-4` for denso; before the lock any of the three) before the candidate logic runs; an unrelated Code 128 is rejected with a message and a 2 s repeat suppression, exactly like an unrelated QR (#78).
Audio/haptics: `FeedbackPlayer` synthesizes PCM tones via `AVAudioEngine` (ambient category, `.mixWithOthers`). It is a single shared instance (`FeedbackPlayer.shared`) — engine setup is expensive and per-view instances caused a re-render loop, so keep it that way. Individual code acceptance is haptic-only plus a short chirp; the success chime and the 4× failure alert fire only at the final verdict. Success is delayed ~0.28s after a barcode accept so the chirp and chime don't collide.
diff --git a/android/core/matching/src/main/kotlin/jp/rimtty/codematch/core/matching/CodeMatcher.kt b/android/core/matching/src/main/kotlin/jp/rimtty/codematch/core/matching/CodeMatcher.kt
index d17f7f4..3ad4b6e 100644
--- a/android/core/matching/src/main/kotlin/jp/rimtty/codematch/core/matching/CodeMatcher.kt
+++ b/android/core/matching/src/main/kotlin/jp/rimtty/codematch/core/matching/CodeMatcher.kt
@@ -52,7 +52,7 @@ object CodeMatcher {
* destination's record.
*
* Denso is probed first: [KanbanQrRecord.parse] is deliberately tolerant
- * (twenty characters or more whose first ten match `[A-Z]{4}[0-9]{6}`), so
+ * (twenty characters or more whose first ten match `[A-Z0-9]{4}[0-9]{6}`), so
* it accepts `JAMA501195…` as a card number. A payload that parses as a
* JAMA self-describing record is always Denso.
*/
@@ -186,6 +186,12 @@ object CodeMatcher {
/**
* A 66-character delivery-slip/kanban QR record.
*
+ * The card number (characters 1-10) is a four-character alphanumeric code
+ * followed by a six-digit serial: both `DCLP675300` and `DAH4093870` occur on
+ * real slips (#129). The item-number field (characters 11-20) is ten wide; a
+ * nine-character part number is left-justified with a trailing space
+ * (`BCJH5281F `) and [partNumber] holds it trimmed.
+ *
* Numeric quantities are encoded as integer hundredths (for example `00001200`
* becomes `12.0`). Parsing is intentionally tolerant of an incomplete payload
* because [parse] is also useful for displaying old saved entries; the scanner
@@ -205,8 +211,8 @@ data class KanbanQrRecord(
companion object {
const val REQUIRED_SCAN_PAYLOAD_LENGTH = 66
- private val cardNumberPattern = Regex("[A-Z]{4}[0-9]{6}")
- private val partNumberPattern = Regex("[A-Z0-9]{10}")
+ private val cardNumberPattern = Regex("[A-Z0-9]{4}[0-9]{6}")
+ private val partFieldPattern = Regex("[A-Z0-9]{9}[A-Z0-9 ]")
/** Accept only a complete standard QR record at a scanner boundary. */
fun isValidScanPayload(payload: String): Boolean {
@@ -229,13 +235,13 @@ data class KanbanQrRecord(
value?.trim()?.toDoubleOrNull()?.div(100.0)
val cardNumber = slice(0, 10)
- val partNumber = slice(10, 20)
+ val partField = slice(10, 20)
if (cardNumber == null || !cardNumberPattern.matches(cardNumber)) return null
- if (partNumber == null || !partNumberPattern.matches(partNumber)) return null
+ if (partField == null || !partFieldPattern.matches(partField)) return null
return KanbanQrRecord(
cardNumber = cardNumber,
- partNumber = partNumber,
+ partNumber = partField.trim(),
partSuffix = trimmedOrNull(slice(20, 22)),
deliveryQuantity = quantity(slice(22, 30)),
instructedQuantity = quantity(slice(30, 38)),
@@ -256,7 +262,7 @@ data class TagBarcodeRecord(
) {
companion object {
private val sawaiFormatPattern =
- Regex("[A-Z0-9]{4}-[A-Z0-9]{2}-[A-Z0-9]{4}@[A-Z0-9]+")
+ Regex("[A-Z0-9]{4}-[A-Z0-9]{2}-[A-Z0-9]{3,4}@[A-Z0-9]+")
private val moltenFormatPattern =
Regex("[A-Z0-9]{4}-[A-Z0-9]{2}-[A-Z0-9]{3,4}@[A-Z0-9]+")
private val densoFormatPattern =
@@ -264,8 +270,9 @@ data class TagBarcodeRecord(
/**
* Strict scanner-boundary validation for the product tag format of one
- * destination: a Sawai part number is 4-2-4, a Molten part number is
- * 4-2-3 or 4-2-4, and a Denso part number is 6-4. A null destination
+ * destination: a Sawai or Molten part number is 4-2-4 (ten characters)
+ * or 4-2-3 (nine characters, e.g. `BCJH-52-81F`), and a Denso part
+ * number is 6-4. A null destination
* means the session has not locked one yet, so any of the three is
* accepted. Lowercase input is accepted just as Swift's
* uppercase-before-regex implementation accepts it.
diff --git a/android/core/matching/src/test/kotlin/jp/rimtty/codematch/core/matching/CodeMatcherTest.kt b/android/core/matching/src/test/kotlin/jp/rimtty/codematch/core/matching/CodeMatcherTest.kt
index c93c89a..ff8138f 100644
--- a/android/core/matching/src/test/kotlin/jp/rimtty/codematch/core/matching/CodeMatcherTest.kt
+++ b/android/core/matching/src/test/kotlin/jp/rimtty/codematch/core/matching/CodeMatcherTest.kt
@@ -28,6 +28,19 @@ class CodeMatcherTest {
"AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000"
private val moltenShortPartBarcodePayload = "PAF1-15-422@0NKD3C"
+ // Field labels of 2026-09-08 (destination Sawai, #129): the card number is
+ // `DAH4` + a six-digit serial (an alphanumeric four-character code), in the
+ // same 66-character record as the `DCLP675300` labels above.
+ private val sawaiAlnumCardQrPayload =
+ "DAH4093870BCJH558SHE020001600000016000H 000000BHB01LHA29 0*"
+ private val sawaiAlnumCardBarcodePayload = "BCJH-55-8SHE@02C04G"
+
+ // A nine-character Sawai part number: the item field is left-justified
+ // with a trailing space, the suffix field is blank, and the tag is 4-2-3.
+ private val sawaiShortPartQrPayload =
+ "DAH4093540BCJH5281F 0002000000020000H 000000BHB01LHA28 0*"
+ private val sawaiShortPartBarcodePayload = "BCJH-52-81F@01R95K"
+
// Destination Denso, the real 221-character kanban of box 0140 (part
// 860150-7722). The runs of spaces are item values (144, 402, 515, 516),
// so never let an editor collapse them.
@@ -161,6 +174,59 @@ class CodeMatcherTest {
assertTrue(KanbanQrRecord.isValidScanPayload("$qrPayload "))
}
+ /**
+ * Field labels of 2026-09-08 (#129): a card number whose fourth character
+ * is a digit (`DAH4093870`) is accepted, and a nine-character part number
+ * is stored without the trailing space of its ten-wide field. The suffix
+ * field may be blank or `02`.
+ */
+ @Test
+ fun kanbanRecordAcceptsAlphanumericCardCodeAndNineCharacterPart() {
+ val tenCharacter = KanbanQrRecord.parse(sawaiAlnumCardQrPayload)
+ assertEquals("DAH4093870", tenCharacter?.cardNumber)
+ assertEquals("BCJH558SHE", tenCharacter?.partNumber)
+ assertEquals("02", tenCharacter?.partSuffix)
+ assertEquals(160.0, tenCharacter?.deliveryQuantity ?: 0.0, 0.001)
+ assertEquals(160.0, tenCharacter?.instructedQuantity ?: 0.0, 0.001)
+ assertEquals("H", tenCharacter?.factoryCode)
+ assertEquals("BHB01", tenCharacter?.warehouseCode)
+ assertEquals("LHA29", tenCharacter?.supplyPointCode)
+
+ val nineCharacter = KanbanQrRecord.parse(sawaiShortPartQrPayload)
+ assertEquals("DAH4093540", nineCharacter?.cardNumber)
+ assertEquals("BCJH5281F", nineCharacter?.partNumber)
+ assertNull(nineCharacter?.partSuffix)
+ assertEquals(200.0, nineCharacter?.deliveryQuantity ?: 0.0, 0.001)
+
+ val nineCharacterWithSuffix = KanbanQrRecord.parse(
+ "DAH4094390BEME5581F 020002000000020000H 000000BHB01LHA29 0*"
+ )
+ assertEquals("BEME5581F", nineCharacterWithSuffix?.partNumber)
+ assertEquals("02", nineCharacterWithSuffix?.partSuffix)
+
+ // The scan boundary, the destination detector and the comparison all pass.
+ assertTrue(KanbanQrRecord.isValidScanPayload(sawaiAlnumCardQrPayload))
+ assertTrue(KanbanQrRecord.isValidScanPayload(sawaiShortPartQrPayload))
+ assertEquals(Destination.SAWAI, CodeMatcher.detectDestination(sawaiAlnumCardQrPayload))
+ assertEquals(Destination.SAWAI, CodeMatcher.detectDestination(sawaiShortPartQrPayload))
+ assertEquals(
+ MatchResult.MATCH,
+ CodeMatcher.compare(sawaiAlnumCardQrPayload, sawaiAlnumCardBarcodePayload)
+ )
+ assertEquals(
+ MatchResult.MATCH,
+ CodeMatcher.compare(sawaiShortPartQrPayload, sawaiShortPartBarcodePayload)
+ )
+ assertEquals("BCJH-52-81F", CodeMatcher.formatPartNumber("BCJH5281F", Destination.SAWAI))
+
+ // A space inside the item field, a letter in the serial, a shifted
+ // record and an unrelated 66-character payload are still rejected.
+ assertNull(KanbanQrRecord.parse("DAH4093540BCJH52 1F 0002000000020000H 000000BHB01LHA28 0*"))
+ assertNull(KanbanQrRecord.parse("DAH409387ABCJH558SHE020001600000016000H 000000BHB01LHA29 0*"))
+ assertNull(KanbanQrRecord.parse("DAH4093540 BCJH5281F 0002000000020000H 000000BHB01LHA28 0*"))
+ assertNull(CodeMatcher.detectDestination("X".repeat(66)))
+ }
+
@Test
fun tagRecordValidationRejectsReverseOrderAndWrongShape() {
assertTrue(TagBarcodeRecord.isValidScanPayload(barcodePayload, Destination.SAWAI))
@@ -196,7 +262,7 @@ class CodeMatcherTest {
val fixture = SharedFixtureJson.decode(json)
assertEquals(2, fixture.schemaVersion)
- assertEquals(47, fixture.cases.size)
+ assertEquals(63, fixture.cases.size)
assertEquals(
"Shared fixture IDs must be unique",
fixture.cases.size,
@@ -357,6 +423,64 @@ class CodeMatcherTest {
assertTrue(MoltenQrRecord.isValidScanPayload(moltenQrPayload))
}
+ /**
+ * The field labels of 2026-09-08 (sawai-2026-09-08-NN, #129): `DAH4`-shaped
+ * card numbers and five nine-character part numbers with 4-2-3 tags. Every
+ * pair passes both scan boundaries and the QR item number equals the tag
+ * part number.
+ */
+ @Test
+ fun sharedFieldLabels20260908PassBothScanBoundaries() {
+ val resource = javaClass.getResourceAsStream("/matching-cases.json")
+ assertNotNull("matching-cases.json must be on the test runtime classpath", resource)
+ val fixture = SharedFixtureJson.decode(resource!!.bufferedReader().use { it.readText() })
+ val pairs = fixture.cases.filter {
+ it.id.startsWith("sawai-2026-09-08-") && it.expected == "match"
+ }
+ assertEquals(12, pairs.size)
+
+ val cardNumberShape = Regex("[A-Z0-9]{4}[0-9]{6}")
+ var nineCharacterParts = 0
+ pairs.forEach { pair ->
+ assertTrue(pair.id, KanbanQrRecord.isValidScanPayload(pair.qrPayload))
+ assertTrue(
+ pair.id,
+ TagBarcodeRecord.isValidScanPayload(pair.barcodePayload, Destination.SAWAI)
+ )
+ assertFalse(pair.id, MoltenQrRecord.isValidScanPayload(pair.qrPayload))
+ assertEquals(pair.id, Destination.SAWAI, CodeMatcher.detectDestination(pair.qrPayload))
+ val record = KanbanQrRecord.parse(pair.qrPayload)
+ assertEquals(pair.id, CodeMatcher.partNumberFromBarcode(pair.barcodePayload), record?.partNumber)
+ assertTrue(pair.id, cardNumberShape.matches(record?.cardNumber.orEmpty()))
+ assertTrue(pair.id, record?.cardNumber.orEmpty().startsWith("DAH4"))
+ assertEquals(pair.id, "H", record?.factoryCode)
+ assertTrue(pair.id, record?.partSuffix == null || record?.partSuffix == "02")
+ if (record?.partNumber?.length == 9) nineCharacterParts += 1
+ }
+ assertEquals(5, nineCharacterParts)
+
+ fun pair(suffix: String) = pairs.single { it.id == "sawai-2026-09-08-$suffix" }
+ // Nine-character parts occur with a blank suffix and with suffix 02.
+ val blankSuffix = KanbanQrRecord.parse(pair("06-BCJH-52-81F").qrPayload)
+ assertEquals("BCJH5281F", blankSuffix?.partNumber)
+ assertNull(blankSuffix?.partSuffix)
+ val numberedSuffix = KanbanQrRecord.parse(pair("07-BEME-55-81F").qrPayload)
+ assertEquals("BEME5581F", numberedSuffix?.partNumber)
+ assertEquals("02", numberedSuffix?.partSuffix)
+
+ // Two boxes of one part: same tag, consecutive card numbers, and
+ // therefore two different box identities.
+ val boxA = pair("box-a-KAAA-45-81MB")
+ val boxB = pair("box-b-KAAA-45-81MB")
+ assertEquals(boxA.barcodePayload, boxB.barcodePayload)
+ assertEquals("DAH4094520", KanbanQrRecord.parse(boxA.qrPayload)?.cardNumber)
+ assertEquals("DAH4094530", KanbanQrRecord.parse(boxB.qrPayload)?.cardNumber)
+ assertNotEquals(
+ CodeMatcher.boxIdentity(boxA.qrPayload, boxA.barcodePayload),
+ CodeMatcher.boxIdentity(boxB.qrPayload, boxB.barcodePayload)
+ )
+ }
+
@Test
fun detectDestinationSeparatesSawaiAndMoltenAndRejectsOthers() {
assertEquals(Destination.SAWAI, CodeMatcher.detectDestination(qrPayload))
@@ -396,18 +520,19 @@ class CodeMatcherTest {
}
@Test
- fun tagValidationAllowsFourTwoThreeOnlyForMolten() {
- assertTrue(
- TagBarcodeRecord.isValidScanPayload(
- moltenShortPartBarcodePayload,
- Destination.MOLTEN
+ fun tagValidationAllowsFourTwoThreeForSawaiAndMolten() {
+ // A nine-character part number (4-2-3 tag) exists on Sawai labels as
+ // well (#129); only Denso's 6-4 tags are a different shape.
+ listOf(Destination.SAWAI, Destination.MOLTEN).forEach { destination ->
+ assertTrue(
+ TagBarcodeRecord.isValidScanPayload(moltenShortPartBarcodePayload, destination)
)
- )
- assertFalse(
- TagBarcodeRecord.isValidScanPayload(
- moltenShortPartBarcodePayload,
- Destination.SAWAI
+ assertTrue(
+ TagBarcodeRecord.isValidScanPayload(sawaiShortPartBarcodePayload, destination)
)
+ }
+ assertFalse(
+ TagBarcodeRecord.isValidScanPayload(sawaiShortPartBarcodePayload, Destination.DENSO)
)
// Denso is deliberately absent: its tags are 6-4, so the 4-2-4 and
diff --git a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanReducer.kt b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanReducer.kt
index 940caf1..0213307 100644
--- a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanReducer.kt
+++ b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanReducer.kt
@@ -465,8 +465,8 @@ class ScanReducer(
}
// A Code 128 symbol likewise only proves the symbology. Camera and
// Bluetooth input must both carry the product-tag business format
- // (a 4-2-4 part number for Sawai, 4-2-3 or 4-2-4 for Molten, 6-4
- // for Denso, followed by @management code) before comparison runs.
+ // (a 4-2-4 or 4-2-3 part number for Sawai and Molten, 6-4 for
+ // Denso, followed by @management code) before comparison runs.
// Before the lock any of the three is accepted; a QR is always read
// first, so in practice the destination is known here.
payload.format == ScanFormat.CODE_128 ->
diff --git a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinator.kt b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinator.kt
index 1f64d59..ac61c15 100644
--- a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinator.kt
+++ b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinator.kt
@@ -201,7 +201,7 @@ class ScanSessionCoordinator(
// stabilization: the reducer rejects it on the first frame and it
// never occupies the two-observation candidate slot (#78). The format
// depends on the locked destination, so a 4-2-3 tag reaches the
- // stabilizer only in a Molten session and a 6-4 tag only in a Denso one.
+ // stabilizer in a Sawai or Molten session and a 6-4 tag only in a Denso one.
val payloadToDispatch = if (
payload.source == InputSource.CAMERA &&
payload.format == ScanFormat.CODE_128 &&
diff --git a/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanReducerTest.kt b/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanReducerTest.kt
index c37a2a5..d9ff8c1 100644
--- a/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanReducerTest.kt
+++ b/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanReducerTest.kt
@@ -24,6 +24,13 @@ class ScanReducerTest {
"DAAL134140BCJH5581GG020000120000001200A 000000BAB15LAB07 0*"
private val sharedBoxBarcode = "BCJH-55-81GG@1KVQ0C"
+ // A Sawai label with a nine-character part number (2026-09-08 field
+ // labels, #129): `DAH4`-shaped card number, left-justified item field with
+ // a trailing space, blank suffix, and a 4-2-3 tag.
+ private val sawaiShortPartQr =
+ "DAH4093540BCJH5281F 0002000000020000H 000000BHB01LHA28 0*"
+ private val sawaiShortPartTag = "BCJH-52-81F@01R95K"
+
// Destination Molten. The trailing spaces are record data, so these
// literals must never be reformatted or trimmed by an editor.
private val moltenQr1 =
@@ -631,7 +638,7 @@ class ScanReducerTest {
}
@Test
- fun moltenSessionAcceptsFourTwoFourTagAndSawaiSessionRejectsFourTwoThreeTag() {
+ fun sawaiAndMoltenSessionsAcceptFourTwoFourAndFourTwoThreeTags() {
val reducer = ScanReducer()
var molten = reducer.reduce(ScanSessionState(), ScanEvent.StartSession).state
molten = reducer.reduce(molten, ScanEvent.PayloadReceived(ScanPayload.qr(moltenQr1))).state
@@ -642,21 +649,32 @@ class ScanReducerTest {
assertEquals(ScanPhase.RESULT, fourTwoFour.state.phase)
assertEquals(MatchResult.MATCH, fourTwoFour.state.result)
+ // A Sawai label with a nine-character part number matches its 4-2-3
+ // tag (#129) and prints the part as 4-2-3.
var sawai = reducer.reduce(ScanSessionState(), ScanEvent.StartSession).state
- sawai = reducer.reduce(sawai, ScanEvent.PayloadReceived(ScanPayload.qr(qrPayload))).state
+ sawai = reducer.reduce(sawai, ScanEvent.PayloadReceived(ScanPayload.qr(sawaiShortPartQr))).state
+ assertEquals(Destination.SAWAI, sawai.destination)
+ assertEquals(ScanPhase.WAITING_CODE_128, sawai.phase)
val fourTwoThree = reducer.reduce(
sawai,
- ScanEvent.PayloadReceived(ScanPayload.code128(moltenTag2FirstBox)),
+ ScanEvent.PayloadReceived(ScanPayload.code128(sawaiShortPartTag)),
)
- assertEquals(sawai, fourTwoThree.state)
- assertEquals(
- ScanEffect.InvalidScan(
- ScanFormat.CODE_128,
- InvalidScanReason.INVALID_PAYLOAD,
- moltenTag2FirstBox.length,
- ),
- fourTwoThree.effects.single(),
+ assertEquals(ScanPhase.RESULT, fourTwoThree.state.phase)
+ assertEquals(MatchResult.MATCH, fourTwoThree.state.result)
+ assertEquals(1, fourTwoThree.state.matchedCount)
+
+ // A 4-2-3 tag of another part is a valid tag in a Sawai session too,
+ // so it reaches the comparison and is a mismatch rather than a
+ // rejected scan.
+ var tenCharacter = reducer.reduce(ScanSessionState(), ScanEvent.StartSession).state
+ tenCharacter = reducer.reduce(tenCharacter, ScanEvent.PayloadReceived(ScanPayload.qr(qrPayload))).state
+ val otherPart = reducer.reduce(
+ tenCharacter,
+ ScanEvent.PayloadReceived(ScanPayload.code128(moltenTag2FirstBox)),
)
+ assertEquals(ScanPhase.RESULT, otherPart.state.phase)
+ assertEquals(MatchResult.MISMATCH, otherPart.state.result)
+ assertTrue(otherPart.effects.none { it is ScanEffect.InvalidScan })
}
@Test
diff --git a/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinatorTest.kt b/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinatorTest.kt
index d7a7625..33dd95e 100644
--- a/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinatorTest.kt
+++ b/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinatorTest.kt
@@ -55,6 +55,12 @@ class ScanSessionCoordinatorTest {
"AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000"
private val moltenShortPartBarcode = "PAF1-15-422@0NKD3C"
+ // A Sawai label with a nine-character part number and a 4-2-3 tag
+ // (2026-09-08 field labels, #129).
+ private val sawaiShortPartQrPayload =
+ "DAH4093540BCJH5281F 0002000000020000H 000000BHB01LHA28 0*"
+ private val sawaiShortPartBarcode = "BCJH-52-81F@01R95K"
+
// Destination Denso, whose product tag prints a 6-4 part number. The QR's
// runs of spaces are blank item values.
private val densoQrPayload =
@@ -586,7 +592,7 @@ class ScanSessionCoordinatorTest {
}
@Test
- fun cameraFourTwoThreeTagGoesThroughStabilizerOnlyInMoltenSession() {
+ fun cameraFourTwoThreeTagGoesThroughStabilizerInSawaiAndMoltenSessions() {
val molten = ScanSessionCoordinator(TestScanner())
molten.startSession()
molten.submitScanPayload(ScanPayload.qr(moltenQrPayload, timestampMillis = 0L))
@@ -605,14 +611,22 @@ class ScanSessionCoordinatorTest {
assertEquals(ScanPhase.RESULT, accepted?.state?.phase)
assertEquals(MatchResult.MATCH, accepted?.state?.result)
+ // A Sawai label can carry a nine-character part number too (#129), so
+ // its 4-2-3 tag takes the same two-observation path in a Sawai session.
val sawai = ScanSessionCoordinator(TestScanner())
sawai.startSession()
- sawai.submitScanPayload(ScanPayload.qr(qrPayload, timestampMillis = 0L))
- val rejected = sawai.submitScanPayload(
- ScanPayload.code128(moltenShortPartBarcode, timestampMillis = 300L),
+ sawai.submitScanPayload(ScanPayload.qr(sawaiShortPartQrPayload, timestampMillis = 0L))
+ assertEquals(Destination.SAWAI, sawai.state.destination)
+ assertNull(
+ sawai.submitScanPayload(
+ ScanPayload.code128(sawaiShortPartBarcode, timestampMillis = 300L),
+ ),
)
- assertTrue(rejected?.effects?.single() is ScanEffect.InvalidScan)
- assertEquals(ScanPhase.WAITING_CODE_128, sawai.state.phase)
+ val sawaiAccepted = sawai.submitScanPayload(
+ ScanPayload.code128(sawaiShortPartBarcode, timestampMillis = 400L),
+ )
+ assertEquals(ScanPhase.RESULT, sawaiAccepted?.state?.phase)
+ assertEquals(MatchResult.MATCH, sawaiAccepted?.state?.result)
}
@Test
diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md
index f4fd717..26c9e41 100644
--- a/docs/PRODUCT_SPEC.md
+++ b/docs/PRODUCT_SPEC.md
@@ -19,17 +19,17 @@
QRのレコード様式は仕向地ごとに異なるため、先に仕向地を判定し、その様式が定める場所からだけ品番を取り出します。
-- 仕向地「澤井製作所」のQRは66桁固定長で、1〜10桁目がカード番号(英字4文字+数字6文字)、11〜20桁目が10桁の品目番号です。以降に枝番、納入数量、指示数、工場コード、受入部品庫、供給先が続きます。
+- 仕向地「澤井製作所」のQRは66桁固定長で、1〜10桁目がカード番号(英数4文字のコード+数字6文字の連番。`DCLP675300` 型と `DAH4093870` 型の両方が実在します)、11〜20桁目が品目番号の欄です。品目番号は10桁または9桁で、9桁のときは左詰めで末尾が空白(`BCJH5281F `)になり、空白を除いた値を品目番号として使います。以降に枝番(空白の場合あり)、納入数量、指示数、工場コード、受入部品庫、供給先が続きます。
- 仕向地「モルテン」のQRは61桁固定長で、7〜16桁目が左詰めの部品番号(9桁または10桁、余りは空白)です。ほかに受注者、納品番号、納入先、TYロケーション、供給先、収容数、納入指示日(JUMP)、時刻を固定位置に持ちます。末尾の空白も1レコードの一部なので、57〜61桁で届いた読取値は空白で61桁へ補完してから解析します。
- 仕向地「デンソー」のQRはJAMA自己記述形式のかんばんレコードで、桁数は固定しません。`JAMA` + 版1桁 + ヘッダ長4桁 + 前置き10桁 + 「項目番号3桁+桁数2桁」の並び + 固定長データ部で構成し、項目定義の桁数の合計がデータ部の長さと過不足なく一致することを確かめます。品番は項目104(ハイフンなし10桁)から取り出します。項目112(収容数)と項目152(かんばん連番)も必須です。実物のかんばんは221桁・ヘッダ長119・項目21個・データ部97桁ですが、桁数ではなくヘッダの宣言どおりに解析します。
-- Code 128は `品番@管理コード` 形式で、`@` より前を品番として使います。品番の表記は澤井製作所が `4-2-4`、モルテンは `4-2-3` または `4-2-4`、デンソーは `6-4` です。
+- Code 128は `品番@管理コード` 形式で、`@` より前を品番として使います。品番の表記は澤井製作所とモルテンが `4-2-4`(10桁)または `4-2-3`(9桁。例 `BCJH-52-81F`)、デンソーは `6-4` です。
- 品番は大文字化し、ハイフン・空白など英数字以外を除去して比較します。
- `@` 以降の管理コードは一致判定へ影響しません。
- いずれの様式としても解析できないQRからは品番を取り出しません。空の値、品番を抽出できない値、異なる品番は不一致です。
-仕向地の判定はデンソーを最初に試します。澤井製作所のレコード解析は先頭10桁が英字4文字+数字6文字であればよいという寛容な条件のため、`JAMA5011...` で始まるかんばんをカード番号として受理してしまうからです。デンソーとして解析できないときにだけ、澤井製作所(66桁)、モルテン(57〜61桁)、空白を落とした澤井製作所の順で判定します。
+仕向地の判定はデンソーを最初に試します。澤井製作所のレコード解析は先頭10桁が英数4文字+数字6文字であればよいという寛容な条件のため、`JAMA5011...` で始まるかんばんをカード番号として受理してしまうからです。デンソーとして解析できないときにだけ、澤井製作所(66桁)、モルテン(57〜61桁)、空白を落とした澤井製作所の順で判定します。
-QRの入力は、カメラ・Bluetoothとも伝送終端(CR/LF/NUL)だけを取り除いたうえで、仕向地ごとの桁数と必須フィールドを検証します。モルテンのレコードでは空白を取り除きません。デンソーのレコードも空白欄がデータなので取り除きません。Code 128の入力も、カメラ・Bluetoothとも `4-2-4@管理コード`(モルテンは `4-2-3@管理コード`、デンソーは `6-4@管理コード`)の業務形式を検証し、逆順入力や業務外のコードを受理しません。カメラのCode 128は形式検証を通った値だけを同一値2回の確定候補にします。
+QRの入力は、カメラ・Bluetoothとも伝送終端(CR/LF/NUL)だけを取り除いたうえで、仕向地ごとの桁数と必須フィールドを検証します。モルテンのレコードでは空白を取り除きません。デンソーのレコードも空白欄がデータなので取り除きません。Code 128の入力も、カメラ・Bluetoothとも `4-2-4@管理コード` または `4-2-3@管理コード`(デンソーは `6-4@管理コード`)の業務形式を検証し、逆順入力や業務外のコードを受理しません。カメラのCode 128は形式検証を通った値だけを同一値2回の確定候補にします。
## 重複判定
@@ -76,4 +76,4 @@ QRの入力は、カメラ・Bluetoothとも伝送終端(CR/LF/NUL)だけを
## 共通テスト資源
-機械可読な入力と期待結果は [`shared/test-fixtures/matching-cases.json`](../shared/test-fixtures/matching-cases.json)、実機・エミュレーターで表示できる画像は [`shared/test-fixtures/images`](../shared/test-fixtures/images) に置きます。fixtureは`schemaVersion` 2で47ケースあり、各ケースはQRの仕向地を示す`destination`(`sawai` / `molten` / `denso`)を持ちます。いずれの様式でもないQRを扱う`non-standard-qr-is-mismatch`と`empty-values`だけが`destination`を持ちません。画像は3仕向地のQRとCode 128を含み、モルテン分は `molten-` で始まる5点、デンソー分は `denso-` で始まる3点です。SwiftとKotlinの照合テストは、同じケースを満たすことを受け入れ条件とします。
+機械可読な入力と期待結果は [`shared/test-fixtures/matching-cases.json`](../shared/test-fixtures/matching-cases.json)、実機・エミュレーターで表示できる画像は [`shared/test-fixtures/images`](../shared/test-fixtures/images) に置きます。fixtureは`schemaVersion` 2で63ケースあり(`sawai-2026-09-08-` で始まる16ケースは2026-09-08の現場ラベルで、`DAH4` 型のカード番号と9桁品番を含みます)、各ケースはQRの仕向地を示す`destination`(`sawai` / `molten` / `denso`)を持ちます。いずれの様式でもないQRを扱う`non-standard-qr-is-mismatch`と`empty-values`だけが`destination`を持ちません。画像は3仕向地のQRとCode 128を含み、モルテン分は `molten-` で始まる5点、デンソー分は `denso-` で始まる3点です。SwiftとKotlinの照合テストは、同じケースを満たすことを受け入れ条件とします。
diff --git a/docs/android/IMPLEMENTATION_PLAN.md b/docs/android/IMPLEMENTATION_PLAN.md
index 8469513..cf30562 100644
--- a/docs/android/IMPLEMENTATION_PLAN.md
+++ b/docs/android/IMPLEMENTATION_PLAN.md
@@ -57,7 +57,7 @@ BLEを除いた中間成果物は開発・評価用として成立する。Swift
| 入力順序 | 逆順を拒否し、値を照合に使わない | ドメイン状態機械で拒否 | 無効音・案内表示、工程・保存件数が変化しない |
| 仕向地 | 澤井製作所 / モルテン / デンソーの3つ。最初に受理したQRでセッションの仕向地を固定し、別の仕向地のQRを拒否 | Reducerの`destination`とRoomの列 | 混在セッションを作らず、プロセス再生成後も固定が残る |
| QR解析 | 仕向地判定と各様式のフィールド(澤井製作所66文字 / モルテン61文字・末尾空白は有効 / デンソーはJAMA自己記述形式の可変長かんばん・項目104が部品番号) | 純Kotlin parser | Swiftの全fixtureとフィールド解析テストが一致 |
-| Code 128解析 | `4-2-4@管理コード`(モルテンは`4-2-3@管理コード`も、デンソーは`6-4@管理コード`)、`@`以前を照合 | 純Kotlin parser | 正規化、管理コード除外、逆順拒否、仕向地ごとの区切りが一致 |
+| Code 128解析 | `4-2-4@管理コード` または `4-2-3@管理コード`(澤井製作所・モルテン。デンソーは`6-4@管理コード`)、`@`以前を照合 | 純Kotlin parser | 正規化、管理コード除外、逆順拒否、仕向地ごとの区切りが一致 |
| 照合 | 大文字化、英数字以外除去、仕向地ごとの様式だけを使う完全一致(包含fallbackなし) | 純Kotlin matcher | 共通JSON fixtureをSwift/Kotlinの両方で通す |
| 結果 | 一致/不一致、両品番表示(品番表記は仕向地ごと。デンソーは`6-4`)、澤井製作所とデンソーは同一品番の箱番号、モルテンは納品番号ごとの箱番号と累計収容数 | Result composable | 不一致は保存せず、一致だけ重複を含め保存 |
| 自動次工程 | 初期OFF、1/3/5秒、可視カウントダウン | coroutine + `StateFlow` | 一致時のみ開始。設定OFF、手動、終了、背景化でキャンセル |
@@ -439,7 +439,7 @@ Gradle dependency verificationとSBOM/ライセンス出力は、現行のVersio
- 澤井製作所QR 66文字、必須フィールド、数量、枝番、空白
- モルテンQR 61文字、末尾空白の補完と正規形、収容数・納入指示日・時刻の桁検証、仕向地判定
- デンソーQRのJAMA自己記述解析(ヘッダ長・項目定義・データ部の桁合計、項目構成が異なる合成ペイロード、壊れたヘッダや項目番号重複の拒否、必須項目104/112/152)
-- Code 128業務形式、管理コード、逆順拒否、仕向地ごとの区切り(`4-2-4` / `4-2-3` / `6-4`)
+- Code 128業務形式、管理コード、逆順拒否、仕向地ごとの区切り(澤井製作所・モルテンは`4-2-4` / `4-2-3`、デンソーは`6-4`)
- 仕向地ごとの品番表記(`formatPartNumber`。デンソーの10桁は`6-4`)
- 状態機械の全遷移
- 仕向地の固定、別仕向地QRの拒否、固定あり/なしでの桁数分類、デンソーと`JAMA`接頭辞での長さ案内なし
diff --git a/docs/android/STATUS.md b/docs/android/STATUS.md
index 8996362..1029b23 100644
--- a/docs/android/STATUS.md
+++ b/docs/android/STATUS.md
@@ -15,7 +15,7 @@
| 領域 | 確認済み(自動test・artifact検査・Pixel 7実機) |
|---|---|
-| Domain / matching | 純Kotlin matcher/parser、仕向地判定(澤井製作所66桁 / モルテン61桁 / デンソーはJAMA自己記述形式の可変長かんばん)と仕向地ごとのCode 128形式・品番表記・箱固有キー、shared fixture(`matching-cases.json`、schemaVersion 2・47ケース・`destination`は`sawai`/`molten`/`denso`)、JVM test、Swiftの単体/UIテストとの意図対応表([`TEST_PARITY.md`](TEST_PARITY.md)) |
+| Domain / matching | 純Kotlin matcher/parser、仕向地判定(澤井製作所66桁 / モルテン61桁 / デンソーはJAMA自己記述形式の可変長かんばん)と仕向地ごとのCode 128形式・品番表記・箱固有キー、shared fixture(`matching-cases.json`、schemaVersion 2・63ケース・`destination`は`sawai`/`molten`/`denso`)、JVM test、Swiftの単体/UIテストとの意図対応表([`TEST_PARITY.md`](TEST_PARITY.md)) |
| UI / navigation | Composeの照合・履歴・設定、3 destination、system/predictive backの完了・無効・cancel境界、履歴選択のActivity再生成・destination往復・compact back stack、320dp/840dp・font scale 1.3/2.0の主要操作到達、動的案内・結果のpolite live region、emulatorでのQR待機・Code 128待機・一致結果のOS force-stop後UI復元。Pixel 7ではfont scale 1.3/2.0の主要表示・操作をユーザーが承認 |
| History / settings / PDF | Room(schema v4、セッションとcheckpointの仕向地列と`MIGRATION_2_3`、照合ログ`scan_log`と`MIGRATION_3_4`を含む)/DataStore、照合ログの記録・5,000件の切り詰め・件数表示・JSON Lines書き出し・消去、日英リソースとper-app locale双方向同期、0件破棄・名称変更・詳細・削除のapp E2E、履歴詳細とPDFのモルテン項目(納品番号ごとの箱数・累計収容数と解析全項目)とデンソー項目(履歴詳細のかんばん解析13項目、PDFのかんばん要約と箱ごとのかんばん連番)、A4複数ページPDFの実render、SAF保存/専用FileProvider共有の契約test。Pixel 7では日英切替、1ページ/複数ページPDFのDownloads保存と共有先での表示、音量0/通常音量の音・触覚をユーザーが承認 |
| Camera | CameraX/ML Kit adapter、工程別ROI、権限・lifecycle・focus・format切替の非同期境界test。Pixel 7縦画面で実ラベルのQR→Code 128一致、復帰後のCode 128、タップfocus、権限の拒否・恒久拒否・再許可、ガイド枠内外の読取境界、無関係QR拒否、不一致の表示・音・振動・非加算をユーザーが承認 |
@@ -71,6 +71,13 @@ JDK/SDKがない環境ではGradle結果を推測せず、実行不能として
証跡: `lintDebug testDebugUnitTest` 457件(失敗・error 0)、`:app:assembleRelease`、`verify-release-hardening.sh`(全項目通過)、Pixel 7(Android 16 / API 36)で`:core:data` 32件・`:feature:settings` 22件のinstrumentationが成功。`AppFlowInstrumentationTest`の一致→重複→設定画面の件数はCI emulator(API 36)で実行する。実スキャナーでの照合ログ書き出しと共有先での受け取りは未実施。
+### 2026-09-08 澤井製作所のカード番号 `DAH4` 型と9桁品番(#129)
+
+TestFlight 1.0 (6)/(8) を配った現場から、澤井製作所のラベルが QR 段階で拒否されると報告があった。写真67枚をデコードして確認した原因は2つで、デンソー固有の処理は無関係だった。
+
+- `KanbanQrRecord` のカード番号を `[A-Z]{4}[0-9]{6}` から `[A-Z0-9]{4}[0-9]{6}`(英数4桁のコード+6桁連番)へ、品目番号欄を `[A-Z0-9]{10}` から `[A-Z0-9]{9}[A-Z0-9 ]`(9桁品番は左詰め・末尾空白を除去して保持)へ緩めた。`TagBarcodeRecord` の澤井製作所パターンはモルテンと同じ `4-2-3` / `4-2-4` になった。`expectedQrLength`・`invalidPayloadReason`・履歴・PDF は変更なし(`formatPartNumber` が9桁を `4-2-3` で表記する)。
+- 共通fixture `matching-cases.json` を63ケースへ拡張した(`sawai-2026-09-08-` の16ケース: 一致12・不一致4。澤井製作所39・モルテン11・デンソー11・仕向地なし2)。「澤井製作所では `4-2-3` を受理しない」と固定していたテストは反転した([`TEST_PARITY.md`](TEST_PARITY.md) 行77・86)。
+
### 2026-09-07 仕向地デンソー対応
3つ目の仕向地デンソーを照合の前提に加えた(Issue #106、子issue #107〜#112)。
diff --git a/docs/android/TEST_PARITY.md b/docs/android/TEST_PARITY.md
index d9f6dc9..3e436c4 100644
--- a/docs/android/TEST_PARITY.md
+++ b/docs/android/TEST_PARITY.md
@@ -4,7 +4,7 @@
行番号は記載時点のものであり、その後の変更(#82 / #83 / #93 / #95 / #97 / #113 / #115)で前後している。行の同定はテスト名で行うこと。2026-09-07時点のSwiftソースは単体が約155本・UI 7本で、監査後に追加された仕向地関連のテストは末尾の「仕向地モルテンの追加テスト(2026-09-07)」と「仕向地デンソーの追加テスト(2026-09-07)」で対応付ける。
-共通照合データは [`matching-cases.json`](../../shared/test-fixtures/matching-cases.json)(schemaVersion 2、47ケース、各ケースにQRの`destination`)であり、Swift はファイルを直接読み、Kotlin は test runtime classpath から読む。`src/test` は JVM テスト、`src/androidTest` は端末・エミュレーター依存の証拠である。D/P は「実カメラ読取」や「対象 BLE scanner 通信」の成功を意味しない。
+共通照合データは [`matching-cases.json`](../../shared/test-fixtures/matching-cases.json)(schemaVersion 2、63ケース、各ケースにQRの`destination`)であり、Swift はファイルを直接読み、Kotlin は test runtime classpath から読む。`src/test` は JVM テスト、`src/androidTest` は端末・エミュレーター依存の証拠である。D/P は「実カメラ読取」や「対象 BLE scanner 通信」の成功を意味しない。
### Android 証拠ファイル(略記の正本)
@@ -158,7 +158,9 @@ Issue #84(PR #93 / #95 / #96 / #97 / #98 / #99)で追加した Swift テス
| 74 | 空欄の TY ロケーション・時刻を欠損として扱う(`CodeMatcherTests::testMoltenQRRecordHandlesBlankOptionalFields`) | `CodeMatcherTest.kt::moltenRecordParsesAllFieldsFromRealPayloads`(空欄を含む実レコード) | D |
| 75 | 末尾空白が落ちた読取値を61桁へ補完し、同じ正規形にする(`CodeMatcherTests::testMoltenQRRecordPadsStrippedTrailingSpaces`) | `CodeMatcherTest.kt::moltenRecordPadsShortPayloadAndCanonicalizesIdentity` | D |
| 76 | 桁数・収容数・日付・時刻・部品番号欄が不正なら受理しない(`CodeMatcherTests::testMoltenQRRecordRejectsInvalidFields`) | `CodeMatcherTest.kt::moltenRecordRejectsWrongLengthQuantityDateTimeAndPartField` | D |
-| 77 | `4-2-3` の Code 128 はモルテンだけで受理する(`CodeMatcherTests::testTagBarcodeRecordAcceptsFourTwoThreeOnlyForMolten`) | `CodeMatcherTest.kt::tagValidationAllowsFourTwoThreeOnlyForMolten` | D |
+| 77 | `4-2-3` の Code 128 は澤井製作所・モルテンで受理し、デンソーでは受理しない(`CodeMatcherTests::testTagBarcodeRecordAcceptsFourTwoThreeForSawaiAndMolten`、#129 で反転) | `CodeMatcherTest.kt::tagValidationAllowsFourTwoThreeForSawaiAndMolten` | D |
+| 77a | カード番号の4桁目が数字(`DAH4093870`)と9桁品番(品目番号欄の末尾空白を除去)の澤井製作所 QR を受理する(`CodeMatcherTests::testKanbanQRRecordAcceptsAlphanumericCardCodeAndNineCharacterPart`) | `CodeMatcherTest.kt::kanbanRecordAcceptsAlphanumericCardCodeAndNineCharacterPart` | D |
+| 77b | 2026-09-08 の現場ラベル fixture(`sawai-2026-09-08-`)がカメラ・Bluetooth 双方の受理境界を通り、9桁品番5組と同一品番2箱を含む(`CodeMatcherTests::testSharedFieldLabels20260908PassBothScanBoundaries`) | `CodeMatcherTest.kt::sharedFieldLabels20260908PassBothScanBoundaries` | D |
| 78 | 箱固有キーは澤井製作所が QR のみ、モルテンは QR+Code 128(`CodeMatcherTests::testBoxIdentityPerDestination`) | `CodeMatcherTest.kt::boxIdentityIncludesTagOnlyForMolten` | D |
| 79 | モルテンの共通 fixture がカメラ・Bluetooth 双方の受理境界を通る(`CodeMatcherTests::testSharedMoltenPairsPassBothScanBoundaries`) | `CodeMatcherTest.kt::sharedMoltenFixturesPassBothScanBoundaries` + `ScanReducerTest.kt::moltenFixturesKeepTheirPadding` | D |
| 80 | モルテンの QR→`4-2-3` バーコードが一致し、納品番号の箱数を報告する(`BluetoothScannerFlowTests::testMoltenQRThenFourTwoThreeBarcodeMatchesAndReportsDeliveryBox`) | `ScanReducerTest.kt::moltenQrThenTagMatchesWithNineCharPartAndDeliverySummary` + `ScanScreenTest.kt::moltenMatchResultShowsDeliveryBoxSummaryAndDestinationBadge` | D |
@@ -167,7 +169,7 @@ Issue #84(PR #93 / #95 / #96 / #97 / #98 / #99)で追加した Swift テス
| 83 | モルテンの不一致は計上しない(`BluetoothScannerFlowTests::testMoltenMismatchIsNotCounted`) | `ScanReducerTest.kt::mismatchRemainsVisibleAndNeverProducesRecordEffect`(記録effectなし)+ `CodeMatcherTest.kt::sharedMatchingFixturesHaveTheSameResultsAsSwift`(モルテンの不一致 fixture) | D |
| 84 | 末尾空白が落ちた読取値でも一致し、別の箱を作らない(`BluetoothScannerFlowTests::testStrippedTrailingSpacesStillMatchAndDoNotCreateSecondBox`) | `ScanReducerTest.kt::moltenQrWithTrailingSpacesStrippedIsAcceptedAndSharesIdentityWithPaddedForm` | D |
| 85 | 最初に受理した QR で仕向地を固定し、別仕向地の QR を拒否する(`BluetoothScannerFlowTests::testSessionLocksToFirstDestinationAndRejectsOtherDestinationQR`、`::testSawaiSessionRejectsMoltenQRViaCameraNamingSawai`) | `ScanReducerTest.kt::firstAcceptedQrLocksDestinationAndOtherDestinationQrIsRejected` + `ScanScreenTest.kt::wrongDestinationMessageNamesLockedDestinationOnCameraAndBluetooth` | D |
-| 86 | 澤井製作所のセッションでは `4-2-3` の Code 128 を受理しない(`BluetoothScannerFlowTests::testFourTwoThreeBarcodeIsRejectedInSawaiSession`) | `ScanReducerTest.kt::moltenSessionAcceptsFourTwoFourTagAndSawaiSessionRejectsFourTwoThreeTag` + `ScanSessionCoordinatorTest.kt::cameraFourTwoThreeTagGoesThroughStabilizerOnlyInMoltenSession` | D |
+| 86 | 澤井製作所のセッションでも 9桁品番の QR と `4-2-3` の Code 128 が一致し、カメラでは同一値2回の確定を通る(`BluetoothScannerFlowTests::testFourTwoThreeBarcodeMatchesInSawaiSession`、#129 で反転) | `ScanReducerTest.kt::sawaiAndMoltenSessionsAcceptFourTwoFourAndFourTwoThreeTags` + `ScanSessionCoordinatorTest.kt::cameraFourTwoThreeTagGoesThroughStabilizerInSawaiAndMoltenSessions` | D |
| 86a | 固定は不一致・手動の次工程でも解除されない(Swift は `::testSessionLocksToFirstDestinationAndRejectsOtherDestinationQR` の再読取で暗黙に確認) | `ScanReducerTest.kt::destinationLockSurvivesMismatchAndManualNext` | D |
| 87 | Code 128 待機中のモルテン QR は順序違いとして拒否(`BluetoothScannerFlowTests::testMoltenQRAtBarcodeStepIsWrongOrder`) | `ScanReducerTest.kt::reverseOrderAndInvalidPayloadAreRejectedWithoutChangingState` | D |
| 88 | ViewModel 再生成時に active session から仕向地を復元(`BluetoothScannerFlowTests::testDestinationIsRestoredFromActiveSessionOnViewModelCreation`) | `ScanSessionCoordinatorTest.kt::restoredCheckpointDestinationSeedsTheLock` + `::sessionDestinationSeedsTheLockWhenCheckpointHasNone` + `::recordedBoxesDeriveTheLockWhenNothingElseIsStored` + `ScanCheckpointMappingTest.kt::destinationRoundTripsInEveryPhase` + `::checkpointWithoutDestinationDerivesItFromAcceptedQr` + `ScanViewModelCheckpointInstrumentationTest.kt::moltenDestinationLockSurvivesIsolatedDatabaseReopen` | D |
diff --git a/docs/ios/IMPLEMENTATION_GUIDE.md b/docs/ios/IMPLEMENTATION_GUIDE.md
index 204b372..cffd02c 100644
--- a/docs/ios/IMPLEMENTATION_GUIDE.md
+++ b/docs/ios/IMPLEMENTATION_GUIDE.md
@@ -6,7 +6,7 @@ Web版 `/Users/rimd2r/rimtty/code-match/app/page.tsx` を基準にしました
1. 正方形のQRコードを読み取る。
2. 同じカメラセッションでCode 128バーコードを読み取る。
-3. 先にQRの仕向地を判定し、その様式から品目番号(部品番号)を取り出す。澤井製作所は66桁レコードの11〜20桁目、モルテンは61桁レコードの7〜16桁目(左詰め・空白埋め)、デンソーはJAMA自己記述形式のかんばん(可変長。ヘッダが宣言する項目定義を解析し、項目104から10桁の部品番号を取る)。判定はデンソーを最初に試す(澤井製作所の解析が寛容で `JAMA5011...` をカード番号として通してしまうため)。バーコードは`@`より前を品番とし、双方を正規化して完全一致で判定する(共通ルールは [`../PRODUCT_SPEC.md`](../PRODUCT_SPEC.md))。
+3. 先にQRの仕向地を判定し、その様式から品目番号(部品番号)を取り出す。澤井製作所は66桁レコードの11〜20桁目(10桁または9桁。9桁は左詰め・末尾空白)、モルテンは61桁レコードの7〜16桁目(左詰め・空白埋め)、デンソーはJAMA自己記述形式のかんばん(可変長。ヘッダが宣言する項目定義を解析し、項目104から10桁の部品番号を取る)。判定はデンソーを最初に試す(澤井製作所の解析が寛容で `JAMA5011...` をカード番号として通してしまうため)。バーコードは`@`より前を品番とし、双方を正規化して完全一致で判定する(共通ルールは [`../PRODUCT_SPEC.md`](../PRODUCT_SPEC.md))。
4. 最初に受理したQRで作業セッションの仕向地を固定し、以後は別の仕向地のQRを受理しない。「次のコードを照合」「QRを読み取りなおす」でも不一致でも解除せず、変更にはセッション終了を必要とする。
5. 一致は緑と成功フィードバック、不一致は赤と4回の警告音・触覚で通知する。
6. バーコード待機中にQRの取り違えへ気づいた場合は「QRを読み取りなおす」でQRだけを破棄し、同じ作業セッションのまま別のQRを読み取る。
@@ -55,7 +55,7 @@ App Iconは「Code 128を想起させる白いバーコード+ライムの照
3. 実機で初回カメラ権限、QR、Code 128、一致、不一致、バックグラウンド復帰を確認する。
4. Product > ArchiveでRelease Archiveを作る。
5. App Store Connectでアプリ名、説明、スクリーンショット、プライバシー回答を設定する。
-6. TestFlightへアップロードし、照明・距離・印刷品質が異なる現場ラベルで評価する。
+6. TestFlightへアップロードし、照明・距離・印刷品質が異なる現場ラベルで評価する。現場で新しい書式のラベルが拒否されたときの手順は [`../label-variation-playbook.html`](../label-variation-playbook.html)(写真のデコード `shared/tools/decode_label_photos.swift`、実ペアの検証 `ios/scripts/verify_label_pairs.sh`)。
### USB-C接続で実機確認する
@@ -99,7 +99,8 @@ Console.appで端末を選び、検索欄に `subsystem:jp.rimtty.CodeMatch` を
- [ ] バーコードの`@`以降(管理コード)の違いは判定に影響しない
- [ ] QRの枝番が空白でも品目番号を抽出できる
- [ ] モルテンの61桁QR(末尾空白が落ちた57〜61桁の読取値を含む)を受理し、部品番号を抽出できる
-- [ ] モルテンのセッションでは`4-2-3`のCode 128(例: PAF1-15-422@0NKD3C)を受理し、澤井製作所のセッションでは受理しない
+- [ ] 澤井製作所・モルテンのセッションでは`4-2-3`のCode 128(例: BCJH-52-81F@01R95K、PAF1-15-422@0NKD3C)を受理し、デンソーのセッションでは受理しない
+- [ ] カード番号の4桁目が数字の澤井製作所QR(`DAH4093870…`)と、9桁品番のQR(品目番号欄が `BCJH5281F ` で枝番が空白または `02`)を受理し、品番を `BCJH-52-81F` と表記する(#129)
- [ ] デンソーのかんばんQR(JAMA自己記述形式、実物は221桁)を受理し、項目104から部品番号を抽出できる
- [ ] デンソーのセッションでは`6-4`のCode 128(例: 860150-7722@1DZ50O)を受理し、品番を `860150-7722` と表記する。澤井製作所・モルテンのセッションでは`6-4`を受理しない
- [ ] 仕向地を固定した後に別の仕向地のQRを読むと、カメラ・Bluetoothとも「このセッションは仕向地「◯◯」で照合中です。…」と案内し、工程と件数が変わらない(デンソーで固定した場合は「デンソー」と出る)
diff --git a/docs/label-variation-playbook.html b/docs/label-variation-playbook.html
new file mode 100644
index 0000000..186eca6
--- /dev/null
+++ b/docs/label-variation-playbook.html
@@ -0,0 +1,166 @@
+
+
+
+
+
+ラベルバリエーション対応プレイブック — code-match
+
+
+
+
+ ラベルバリエーション対応プレイブック
+ 現場のラベルに新しい書式(カード番号の型、品番の桁数、Code 128 の区切り、新しい仕向地など)が現れたときに、何が起きるか・どう切り分けるか・どう直すかをまとめる。2026-09-08 の障害(#129)を教訓として書いた。顧客向けのデータ仕様書(docs/qr-barcode-spec-analysis.html、git-ignored)とは別に、リポジトリに残す運用文書である。
+
+
+ 1. 何が起きたか(2026-09-08)
+ TestFlight 1.0 (6)/(8) を配った直後、現場から「以前の QR と Code 128 が読めず照合できない」と報告があった。照合ログ(設定 → 照合ログの共有)には、カメラで読んだ澤井製作所の QR が rejected / invalid_format で4回続けて弾かれた記録が残っていた。
+
+ 「デンソー対応ビルドから壊れた」ように見えたのは、厳格化(#46 / #75 / #78 / #93)が build 4(2026-09-06)で入り、build 5・6・8 が短期間に続いたため、現場が最初に触った厳格ビルドがたまたまデンソー対応版だったからである。
+
+
+
+ 2. 根本原因
+
+ - サンプルへの過学習。2026-08-17 時点で手元にあった12組(カード番号は
DCLP / DAYA / DAAL+6桁)から「英字4+数字6」「品目番号は10桁」と定め、それを受理条件として固定した。12組は同じ工場・同じ時期の連続したラベルで、書式の全体像を代表していなかった。
+ - 吸収していた緩さを、別の目的で取り除いた。もともとカメラの QR は内容検証をせず、照合も「QR 全文の正規化文字列にバーコードの品番が含まれるか」という部分一致で成立していた。逆順入力や無関係な QR を弾くために内容検証を加え(#46 / #75 / #78)、部分一致フォールバックを撤去した(#93)ことで、正規表現の狭さがそのまま拒否になった。個々の変更は正しいが、合わせると「実データより狭い規則が唯一の判定」になった。
+ - テストが思い込みを固定した。モルテン対応で 4-2-3 を追加したとき、「4-2-3 はモルテンだけ、澤井製作所では受理しない」というテストを書いた(TEST_PARITY 行77・86)。実データの裏付けがない排他条件をテストで固定すると、後から直す人が「仕様」だと誤解する。
+ - 現場との検証が Code 128 中心だった。Bluetooth 経路の QR 検証は以前から存在したが、現場はカメラ運用だったため問題が隠れていた。TestFlight での検証もフィクスチャの12組で行っていた。
+
+
+
+
+ 3. 現在の受理条件(2026-09-08 以降)
+ iOS ios/CodeMatch/Models/ScanModels.swift と Android android/core/matching/.../CodeMatcher.kt が同じ規則を持つ。正本は docs/PRODUCT_SPEC.md。
+
+ 仕向地の判定はデンソー → 澤井製作所 → モルテン → 空白除去した澤井製作所の順。仕向地未確定のときの Code 128 は3パターンの OR で受理する。
+
+
+
+ 4. 受理条件を決めるときの原則
+
+ - 誤って弾く方が、誤って通すより高くつく。本物のラベルを弾くと生産が止まる。無関係な QR を通しても、その後の照合で「不一致」になるだけで履歴にも件数にも残らない。迷ったら緩い側に倒す。
+ - 構造で判定し、文字種の並びを決め打ちしない。固定長、数量欄が数字、品番欄が英数字、といった「レコードとしての構造」は安定している。「4文字目は必ず英字」のような観察は、サンプルが増えると覆る。
+ - 排他条件(「〜だけで受理」「〜では受理しない」)には実データの裏付けを要求する。裏付けがないなら共通の規則にする。今回の Code 128 は澤井製作所とモルテンで同一パターンになった。
+ - 部分一致フォールバックは復活させない。取り違え(同一シリーズの 52 / 55、9桁と10桁の同一接頭辞)を見逃す。緩めるのは「受理条件」であって「一致条件」ではない。
+ - 受理条件を狭めるときは、必ず現場の照合ログか写真の実測値で全ラベルを流してから。フィクスチャの既存ケースが通るだけでは足りない。
+ - 新しい実データは共通フィクスチャに追加してから規則を変える。写真そのものはリポジトリに置かず、デコード済みの文字列だけを入れる。
+
+ CLAUDE.md にも記した: 今回緩めた規則を12組のサンプルに合わせて狭め直さないこと。
+
+
+
+ 5. 新しいバリエーションが来たときの手順
+ STEP 0現場から証拠を回収する
+
+ - 設定 → 照合ログの共有(JSONL)。
rejected イベントに生の QR / Code 128 と reason(invalid_format / wrong_order / wrong_destination …)が残る。これだけで多くは切り分けられる。
+ - ラベルの写真(QR と Code 128 が同じ写真に写っているもの)。git-ignored の
tmp/ 配下に置き、絶対にコミットしない。
+
+
+ STEP 1写真を一括デコードする
+swiftc -O shared/tools/decode_label_photos.swift -o /tmp/decode_label_photos
+/tmp/decode_label_photos tmp/labels > tmp/decoded.tsv
+grep -c . tmp/decoded.tsv # 1シンボル1行。NONE の写真は全景など
+ Vision の VNDetectBarcodesRequest を使う。末尾空白や CR/LF は [ ] 内にそのまま残るので、桁数(len=)で固定長を確認できる。
+
+ STEP 2現行の照合コアに流し、何がどこで落ちるかを見る
+ios/scripts/verify_label_pairs.sh tmp/decoded.tsv
+ iOS の ScanModels.swift をそのままコンパイルして、写真ごとに仕向地判定・品番抽出・Code 128 の形式検証・照合結果・箱識別子を表にする。末尾の summary で「nil/…(仕向地判定で落ちた)」「…/tag=false/…(Code 128 の形式で落ちた)」を数える。Android は同じ規則をミラーしているので、原因の特定は iOS 側で足りる。
+
+ STEP 3規則を「実データで裏付けられる構造」まで緩める
+
+ - iOS:
KanbanQRRecord.parse / MoltenQRRecord.parse / DensoKanbanRecord.parse、TagBarcodeRecord.isValidScanPayload、Destination.detect。
+ - Android:
KanbanQrRecord / MoltenQrRecord / DensoKanbanQrRecord、TagBarcodeRecord.isValidScanPayload、CodeMatcher.detectDestination。ScanReducer.invalidPayloadReason は桁数の案内だけなので、固定長が変わらなければ触らない。
+ - 表記(
format(partNumber:destination:) / formatPartNumber)と履歴・PDF は品番の桁数から区切りを決めているので、桁数の種類が増えたときだけ見直す。
+ - 変更後に STEP 2 を再実行し、全ペアが
match、guards(X×66、逆順、他仕向地の区切り)が変わっていないことを確認する。
+
+
+ STEP 4共通フィクスチャとテストに固定する
+
+ shared/test-fixtures/matching-cases.json に、日付入りのプレフィックス(例 sawai-2026-09-08-NN-<品番>)でケースを追加する。一致ケースに加え、同一品番の2箱、隣接シリーズの取り違え、桁数違いの同一接頭辞を mismatch として入れる。notes に由来を1項目追加する。
+ - ケース数を固定しているテスト(iOS
CodeMatcherTests.testSharedMatchingFixtures、Android CodeMatcherTest.sharedMatchingFixturesHaveTheSameResultsAsSwift)と docs の「NNケース」を更新する。既存の label- プレフィックスは12組固定なので流用しない。
+ - 新プレフィックス向けに「読取境界を両方通る」テストを iOS / Android の両方に追加し、
docs/android/TEST_PARITY.md に行を足す。
+ - 「〜では受理しない」を固定しているテストがあれば、実データに合わせて反転する。
+
+
+ STEP 5検証してリリースする
+# iOS
+DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild test \
+ -project ios/CodeMatch.xcodeproj -scheme CodeMatch \
+ -destination 'platform=iOS Simulator,name=iPhone 17 Pro'
+# Android
+cd android && ./gradlew lintDebug testDebugUnitTest && ./gradlew :app:assembleRelease \
+ && bash scripts/verify-release-hardening.sh
+
+ - GitHub Issue を起票し(原因・実測値・決定事項)、PR で
docs/PRODUCT_SPEC.md、CLAUDE.md、docs/android/STATUS.md、docs/android/TEST_PARITY.md、顧客向け仕様書(ローカル)を同時に更新する。
+ - iOS はビルド番号を上げて TestFlight へ。Android は release APK を側面ロード。
+ - 配布後、現場の照合ログをもう一度回収し、
rejected / invalid_format が残っていないことを確認して閉じる。
+
+
+
+
+ 6. チェックリスト
+
+ - ☐ 照合ログか写真から、拒否された生ペイロードを得た(推測で直していない)
+ - ☐
verify_label_pairs.sh で「現行」と「修正後」の両方の結果を記録した
+ - ☐ 既存の3仕向地の全ペアと
guards が変わっていない
+ - ☐ 新しい実データを共通フィクスチャに追加した(写真は未コミット)
+ - ☐ 反転・追加したテストを TEST_PARITY に反映した
+ - ☐ PRODUCT_SPEC / CLAUDE.md / STATUS / 顧客向け仕様書を更新した
+ - ☐ iOS 単体・UI テスト、Android lint・JVM テスト、release ゲートが通った
+ - ☐ 配布後に現場の照合ログで
invalid_format が消えたことを確認した
+
+
+
+
+
+
+
diff --git a/ios/CodeMatch.xcodeproj/project.pbxproj b/ios/CodeMatch.xcodeproj/project.pbxproj
index e6bc041..007683e 100644
--- a/ios/CodeMatch.xcodeproj/project.pbxproj
+++ b/ios/CodeMatch.xcodeproj/project.pbxproj
@@ -503,7 +503,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 8;
+ CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = NHP8639NK4;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = CodeMatch/Resources/Info.plist;
@@ -532,7 +532,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 8;
+ CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = NHP8639NK4;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = CodeMatch/Resources/Info.plist;
diff --git a/ios/CodeMatch/Models/ScanModels.swift b/ios/CodeMatch/Models/ScanModels.swift
index 4427c13..bee74db 100644
--- a/ios/CodeMatch/Models/ScanModels.swift
+++ b/ios/CodeMatch/Models/ScanModels.swift
@@ -99,7 +99,7 @@ enum Destination: String, Codable, CaseIterable, Equatable {
/// QRペイロードの仕向地を判定する。いずれのレコード様式でもない場合は nil。
///
/// デンソーを最初に判定する: `KanbanQRRecord.parse` は寛容(先頭10桁が
- /// `[A-Z]{4}[0-9]{6}`、20桁以上)なので、`JAMA501195…` をカード番号として
+ /// `[A-Z0-9]{4}[0-9]{6}`、20桁以上)なので、`JAMA501195…` をカード番号として
/// 受理してしまう。JAMA自己記述形式として解析できるQRは常にデンソーとする。
static func detect(qrPayload raw: String) -> Destination? {
let payload = stripTransportTerminators(raw)
@@ -138,8 +138,10 @@ extension Destination {
///
/// - 現品票のCode 128: `品番(ハイフン付き)@管理コード` 例: `BCJH-52-81GG@1N5X0C`
/// - 澤井製作所の納品書兼現品票QR: 66桁固定長レコード。先頭からカード番号(10桁)、
-/// 品目番号(10桁・区切りなし)、枝番(2桁・空白の場合あり)、数量などが続く。
-/// 例: `DCLP675300` + `BCJH5281GG` + `02` + …
+/// 品目番号(10桁の欄・区切りなし・9桁品番は左詰めで末尾空白)、枝番(2桁・空白の場合あり)、
+/// 数量などが続く。例: `DCLP675300` + `BCJH5281GG` + `02` + …、
+/// 9桁品番の例: `DAH4093540` + `BCJH5281F ` + ` ` + …
+/// - 現品票のCode 128の品番は10桁品番が4-2-4、9桁品番が4-2-3(例: `BCJH-52-81F@01R95K`)。
/// - モルテンの納品書QR: 61桁固定長レコード。7-16桁が左詰めの部品番号(9桁または10桁)。
///
/// 2つのペイロードは文字列としては一致しないため、双方から品番を抽出して比較する。
@@ -209,8 +211,8 @@ enum CodeMatcher {
/// フィールド位置は docs/PRODUCT_SPEC.md と実データ解析に基づく。
/// もう一方の仕向地モルテン(61桁)は `MoltenQRRecord` が担当する。
struct KanbanQRRecord: Equatable {
- let cardNumber: String // 1-10桁: カード番号
- let partNumber: String // 11-20桁: 品目番号
+ let cardNumber: String // 1-10桁: カード番号(英数4桁のコード + 6桁の連番)
+ let partNumber: String // 11-20桁: 品目番号(9桁または10桁。9桁は左詰めで末尾空白を除去済み)
let partSuffix: String? // 21-22桁: 枝番(空白の場合あり)
let deliveryQuantity: Double? // 23-30桁: 納入数量(×100で記録)
let instructedQuantity: Double? // 31-38桁: 指示数(×100で記録)
@@ -249,14 +251,17 @@ struct KanbanQRRecord: Equatable {
guard
let cardNumber = slice(0..<10),
- cardNumber.range(of: "^[A-Z]{4}[0-9]{6}$", options: .regularExpression) != nil,
- let partNumber = slice(10..<20),
- partNumber.range(of: "^[A-Z0-9]{10}$", options: .regularExpression) != nil
+ // カード番号は英数4桁のコード + 6桁の連番。実データは `DCLP675300` 型と
+ // `DAH4093870` 型の両方があり、4桁目が数字でも受理する(#129)。
+ cardNumber.range(of: "^[A-Z0-9]{4}[0-9]{6}$", options: .regularExpression) != nil,
+ let partField = slice(10..<20),
+ // 品目番号欄は10桁。9桁品番は左詰めで末尾が空白(`BCJH5281F `)。
+ partField.range(of: "^[A-Z0-9]{9}[A-Z0-9 ]$", options: .regularExpression) != nil
else { return nil }
return KanbanQRRecord(
cardNumber: cardNumber,
- partNumber: partNumber,
+ partNumber: partField.trimmingCharacters(in: .whitespaces),
partSuffix: trimmedOrNil(slice(20..<22)),
deliveryQuantity: quantity(slice(22..<30)),
instructedQuantity: quantity(slice(30..<38)),
@@ -569,8 +574,8 @@ struct TagBarcodeRecord: Equatable {
let partNumber: String // ハイフン付き品番
let managementCode: String? // @以降の管理コード
- /// 澤井製作所の現品票: 品番は4-2-4。
- private static let sawaiFormatPattern = "^[A-Z0-9]{4}-[A-Z0-9]{2}-[A-Z0-9]{4}@[A-Z0-9]+$"
+ /// 澤井製作所の現品票: 品番は4-2-4(10桁)または4-2-3(9桁品番、例 `BCJH-52-81F`)。
+ private static let sawaiFormatPattern = "^[A-Z0-9]{4}-[A-Z0-9]{2}-[A-Z0-9]{3,4}@[A-Z0-9]+$"
/// モルテンの現品票: 品番は4-2-3または4-2-4。
private static let moltenFormatPattern = "^[A-Z0-9]{4}-[A-Z0-9]{2}-[A-Z0-9]{3,4}@[A-Z0-9]+$"
/// デンソーの現品票: 品番は6-4。
@@ -578,7 +583,7 @@ struct TagBarcodeRecord: Equatable {
/// 現品票Code 128の業務フォーマット(品番@管理コード)かを確認する。
/// 物理シンボル種別はSDK通知に含まれないため、QR文字列などを次工程で受理しない。
- /// 品番の区切りは澤井製作所が4-2-4、モルテンが4-2-3または4-2-4、デンソーが6-4。
+ /// 品番の区切りは澤井製作所・モルテンが4-2-3または4-2-4、デンソーが6-4。
/// 仕向地が未判定(nil)のときは、どの仕向地でも取りこぼさないよう3つのORで判定する。
static func isValidScanPayload(_ payload: String, destination: Destination?) -> Bool {
let value = payload.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
diff --git a/ios/CodeMatchTests/BluetoothScannerFlowTests.swift b/ios/CodeMatchTests/BluetoothScannerFlowTests.swift
index fe7f285..d00a5a5 100644
--- a/ios/CodeMatchTests/BluetoothScannerFlowTests.swift
+++ b/ios/CodeMatchTests/BluetoothScannerFlowTests.swift
@@ -935,32 +935,33 @@ final class BluetoothScannerFlowTests: XCTestCase {
XCTAssertEqual(context.store.activeSession?.matchedCount, 0)
}
- func testFourTwoThreeBarcodeIsRejectedInSawaiSession() async {
+ func testFourTwoThreeBarcodeMatchesInSawaiSession() async {
let context = makeContext()
defer { context.cleanup() }
let camera = context.viewModel.camera
+ // 2026-09-08 の現場ラベル(#129): カード番号は `DAH4` 型、品目番号欄は9桁品番を
+ // 左詰めにした末尾空白、枝番は空白。現品票の品番は4-2-3。
+ let qrPayload = "DAH4093540BCJH5281F 0002000000020000H 000000BHB01LHA28 0*"
+ let barcodePayload = "BCJH-52-81F@01R95K"
+ XCTAssertEqual(qrPayload.count, 66)
- context.viewModel.cameraScanner(camera, didRead: ScannerViewModel.sampleQRPayload, type: .qr)
+ context.viewModel.cameraScanner(camera, didRead: qrPayload, type: .qr)
XCTAssertEqual(context.viewModel.step, .barcode)
XCTAssertEqual(context.viewModel.destination, .sawai)
+ XCTAssertEqual(context.viewModel.qrPartNumber, "BCJH5281F")
try? await Task.sleep(for: .milliseconds(300))
- // 澤井製作所の現品票は品番末尾が4桁固定。モルテンの4-2-3品番は受理しない。
- context.viewModel.cameraScanner(
- camera,
- didRead: ScannerViewModel.sampleMoltenBarcodePayload,
- type: .code128
- )
- context.viewModel.cameraScanner(
- camera,
- didRead: ScannerViewModel.sampleMoltenBarcodePayload,
- type: .code128
- )
-
+ // 4-2-3の現品票も澤井製作所のセッションで受理し、カメラでは同一値2フレームで確定する。
+ context.viewModel.cameraScanner(camera, didRead: barcodePayload, type: .code128)
XCTAssertEqual(context.viewModel.step, .barcode)
XCTAssertTrue(context.viewModel.barcodeValue.isEmpty)
- XCTAssertTrue(context.viewModel.message.contains("Code 128バーコードではありません"))
- XCTAssertEqual(context.store.activeSession?.matchedCount, 0)
+ context.viewModel.cameraScanner(camera, didRead: barcodePayload, type: .code128)
+
+ XCTAssertEqual(context.viewModel.step, .result(.match))
+ XCTAssertEqual(context.viewModel.barcodePartNumber, "BCJH5281F")
+ XCTAssertEqual(CodeMatcher.format(partNumber: "BCJH5281F", destination: context.viewModel.destination), "BCJH-52-81F")
+ XCTAssertEqual(context.store.activeSession?.matchedCount, 1)
+ XCTAssertEqual(context.store.activeSession?.destination, .sawai)
}
func testMoltenQRAtBarcodeStepIsWrongOrder() async {
@@ -1231,7 +1232,7 @@ final class BluetoothScannerFlowTests: XCTestCase {
}
func testSixFourBarcodeIsRejectedInSawaiAndMoltenSessions() async {
- // 澤井製作所は4-2-4、モルテンは4-2-3/4-2-4。デンソーの6-4はどちらでも受理しない。
+ // 澤井製作所・モルテンは4-2-3/4-2-4。デンソーの6-4はどちらでも受理しない。
for (qrPayload, destination) in [
(ScannerViewModel.sampleQRPayload, Destination.sawai),
(ScannerViewModel.sampleMoltenQRPayload, Destination.molten)
diff --git a/ios/CodeMatchTests/CodeMatcherTests.swift b/ios/CodeMatchTests/CodeMatcherTests.swift
index 548db80..999614f 100644
--- a/ios/CodeMatchTests/CodeMatcherTests.swift
+++ b/ios/CodeMatchTests/CodeMatcherTests.swift
@@ -28,6 +28,14 @@ final class CodeMatcherTests: XCTestCase {
private let moltenShortPartQRPayload = "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000"
private let moltenShortPartBarcodePayload = "PAF1-15-422@0NKD3C"
+ // 2026-09-08 の現場ラベル(仕向地 澤井製作所、#129)。カード番号は `DAH4` + 6桁連番
+ // (英数4桁のコード)で、`DCLP675300` 型と同じ66桁レコード。
+ private let sawaiAlnumCardQRPayload = "DAH4093870BCJH558SHE020001600000016000H 000000BHB01LHA29 0*"
+ private let sawaiAlnumCardBarcodePayload = "BCJH-55-8SHE@02C04G"
+ // 9桁品番: 品目番号欄は左詰め+末尾空白、枝番欄は空白、現品票は4-2-3。
+ private let sawaiShortPartQRPayload = "DAH4093540BCJH5281F 0002000000020000H 000000BHB01LHA28 0*"
+ private let sawaiShortPartBarcodePayload = "BCJH-52-81F@01R95K"
+
// 仕向地 デンソーの実データ(かんばん 0140・221桁)。
// 項目144・402・515・516の空白の連なりもデータなので、詰めないこと。
private let densoQRPayload =
@@ -82,7 +90,7 @@ final class CodeMatcherTests: XCTestCase {
let fixtures = try loadSharedMatchingFixtures()
XCTAssertEqual(fixtures.schemaVersion, 2)
- XCTAssertEqual(fixtures.cases.count, 47)
+ XCTAssertEqual(fixtures.cases.count, 63)
XCTAssertEqual(
Set(fixtures.cases.map(\.id)).count,
fixtures.cases.count,
@@ -168,6 +176,65 @@ final class CodeMatcherTests: XCTestCase {
XCTAssertEqual(label12?.supplyPointCode, "LAB14")
}
+ /// 2026-09-08 の現場ラベル(sawai-2026-09-08-NN、#129): カード番号が `DAH4` 型で、
+ /// 9桁品番(4-2-3の現品票)を5品番含む。全てがカメラ・BLE共通の読取境界を通り、
+ /// QRの品目番号と現品票の品番が一致する。
+ func testSharedFieldLabels20260908PassBothScanBoundaries() throws {
+ let pairs = try loadSharedMatchingFixtures().cases.filter {
+ $0.id.hasPrefix("sawai-2026-09-08-") && $0.expected == "match"
+ }
+ XCTAssertEqual(pairs.count, 12)
+
+ var nineCharacterParts = 0
+ for pair in pairs {
+ XCTAssertTrue(KanbanQRRecord.isValidScanPayload(pair.qrPayload), pair.id)
+ XCTAssertTrue(
+ TagBarcodeRecord.isValidScanPayload(pair.barcodePayload, destination: .sawai),
+ pair.id
+ )
+ XCTAssertFalse(MoltenQRRecord.isValidScanPayload(pair.qrPayload), pair.id)
+ XCTAssertEqual(Destination.detect(qrPayload: pair.qrPayload), .sawai, pair.id)
+ let record = KanbanQRRecord.parse(pair.qrPayload)
+ XCTAssertEqual(
+ record?.partNumber,
+ CodeMatcher.partNumber(fromBarcode: pair.barcodePayload),
+ pair.id
+ )
+ // カード番号は英数4桁のコード + 6桁の連番
+ XCTAssertNotNil(
+ record?.cardNumber.range(of: "^[A-Z0-9]{4}[0-9]{6}$", options: .regularExpression),
+ pair.id
+ )
+ XCTAssertTrue(record?.cardNumber.hasPrefix("DAH4") ?? false, pair.id)
+ XCTAssertEqual(record?.factoryCode, "H", pair.id)
+ XCTAssertTrue(record?.partSuffix == nil || record?.partSuffix == "02", pair.id)
+ if record?.partNumber.count == 9 { nineCharacterParts += 1 }
+ }
+ XCTAssertEqual(nineCharacterParts, 5)
+
+ func pair(_ suffix: String) -> SharedMatchingCase? {
+ pairs.first { $0.id == "sawai-2026-09-08-\(suffix)" }
+ }
+ // 9桁品番: 枝番が空白のものと `02` のものの両方がある。
+ let blankSuffix = pair("06-BCJH-52-81F").flatMap { KanbanQRRecord.parse($0.qrPayload) }
+ XCTAssertEqual(blankSuffix?.partNumber, "BCJH5281F")
+ XCTAssertNil(blankSuffix?.partSuffix)
+ let numberedSuffix = pair("07-BEME-55-81F").flatMap { KanbanQRRecord.parse($0.qrPayload) }
+ XCTAssertEqual(numberedSuffix?.partNumber, "BEME5581F")
+ XCTAssertEqual(numberedSuffix?.partSuffix, "02")
+
+ // 同一品番2箱: 現品票は同じでカード番号が連番。箱固有キーはQR単体なので別の箱になる。
+ let boxA = pair("box-a-KAAA-45-81MB")
+ let boxB = pair("box-b-KAAA-45-81MB")
+ XCTAssertEqual(boxA?.barcodePayload, boxB?.barcodePayload)
+ XCTAssertEqual(KanbanQRRecord.parse(boxA?.qrPayload ?? "")?.cardNumber, "DAH4094520")
+ XCTAssertEqual(KanbanQRRecord.parse(boxB?.qrPayload ?? "")?.cardNumber, "DAH4094530")
+ XCTAssertNotEqual(
+ BoxIdentity.make(qrPayload: boxA?.qrPayload ?? "", barcodePayload: boxA?.barcodePayload),
+ BoxIdentity.make(qrPayload: boxB?.qrPayload ?? "", barcodePayload: boxB?.barcodePayload)
+ )
+ }
+
func testDifferentPartNumberMismatches() {
// BCJH-55-81GG (LH) と BCJH-52-81GG (RH) の取り違え
XCTAssertEqual(
@@ -247,6 +314,53 @@ final class CodeMatcherTests: XCTestCase {
XCTAssertNil(KanbanQRRecord.parse("SHORT"))
}
+ /// 2026-09-08 の現場ラベル(#129): カード番号の4桁目が数字(`DAH4093870`)でも受理し、
+ /// 9桁品番は品目番号欄の末尾空白を除いて保持する。枝番は空白でも `02` でもよい。
+ func testKanbanQRRecordAcceptsAlphanumericCardCodeAndNineCharacterPart() {
+ let tenCharacter = KanbanQRRecord.parse(sawaiAlnumCardQRPayload)
+ XCTAssertEqual(tenCharacter?.cardNumber, "DAH4093870")
+ XCTAssertEqual(tenCharacter?.partNumber, "BCJH558SHE")
+ XCTAssertEqual(tenCharacter?.partSuffix, "02")
+ XCTAssertEqual(tenCharacter?.deliveryQuantity ?? 0, 160, accuracy: 0.001)
+ XCTAssertEqual(tenCharacter?.instructedQuantity ?? 0, 160, accuracy: 0.001)
+ XCTAssertEqual(tenCharacter?.factoryCode, "H")
+ XCTAssertEqual(tenCharacter?.warehouseCode, "BHB01")
+ XCTAssertEqual(tenCharacter?.supplyPointCode, "LHA29")
+
+ let nineCharacter = KanbanQRRecord.parse(sawaiShortPartQRPayload)
+ XCTAssertEqual(nineCharacter?.cardNumber, "DAH4093540")
+ XCTAssertEqual(nineCharacter?.partNumber, "BCJH5281F")
+ XCTAssertNil(nineCharacter?.partSuffix)
+ XCTAssertEqual(nineCharacter?.deliveryQuantity ?? 0, 200, accuracy: 0.001)
+
+ let nineCharacterWithSuffix = KanbanQRRecord.parse(
+ "DAH4094390BEME5581F 020002000000020000H 000000BHB01LHA29 0*"
+ )
+ XCTAssertEqual(nineCharacterWithSuffix?.partNumber, "BEME5581F")
+ XCTAssertEqual(nineCharacterWithSuffix?.partSuffix, "02")
+
+ // 読取境界・仕向地判定・照合のすべてを通る。
+ XCTAssertTrue(KanbanQRRecord.isValidScanPayload(sawaiAlnumCardQRPayload))
+ XCTAssertTrue(KanbanQRRecord.isValidScanPayload(sawaiShortPartQRPayload))
+ XCTAssertEqual(Destination.detect(qrPayload: sawaiAlnumCardQRPayload), .sawai)
+ XCTAssertEqual(Destination.detect(qrPayload: sawaiShortPartQRPayload), .sawai)
+ XCTAssertEqual(
+ CodeMatcher.compare(qrPayload: sawaiAlnumCardQRPayload, barcodePayload: sawaiAlnumCardBarcodePayload),
+ .match
+ )
+ XCTAssertEqual(
+ CodeMatcher.compare(qrPayload: sawaiShortPartQRPayload, barcodePayload: sawaiShortPartBarcodePayload),
+ .match
+ )
+ XCTAssertEqual(CodeMatcher.format(partNumber: "BCJH5281F", destination: .sawai), "BCJH-52-81F")
+
+ // 品目番号欄の途中の空白、連番の英字、無関係な66桁は引き続き受理しない。
+ XCTAssertNil(KanbanQRRecord.parse("DAH4093540BCJH52 1F 0002000000020000H 000000BHB01LHA28 0*"))
+ XCTAssertNil(KanbanQRRecord.parse("DAH409387ABCJH558SHE020001600000016000H 000000BHB01LHA29 0*"))
+ XCTAssertNil(KanbanQRRecord.parse("DAH4093540 BCJH5281F 0002000000020000H 000000BHB01LHA28 0*"))
+ XCTAssertNil(Destination.detect(qrPayload: String(repeating: "X", count: 66)))
+ }
+
func testBluetoothScanPayloadValidationRejectsReverseOrderFormats() {
XCTAssertTrue(KanbanQRRecord.isValidScanPayload(qrPayload))
XCTAssertFalse(KanbanQRRecord.isValidScanPayload(barcodePayload))
@@ -391,12 +505,18 @@ final class CodeMatcherTests: XCTestCase {
)
}
- func testTagBarcodeRecordAcceptsFourTwoThreeOnlyForMolten() {
- XCTAssertTrue(
- TagBarcodeRecord.isValidScanPayload(moltenShortPartBarcodePayload, destination: .molten)
- )
+ func testTagBarcodeRecordAcceptsFourTwoThreeForSawaiAndMolten() {
+ // 9桁品番の現品票(4-2-3)は澤井製作所にもある(#129)。デンソーの6-4とは区別する。
+ for destination in [Destination.sawai, .molten] {
+ XCTAssertTrue(
+ TagBarcodeRecord.isValidScanPayload(moltenShortPartBarcodePayload, destination: destination)
+ )
+ XCTAssertTrue(
+ TagBarcodeRecord.isValidScanPayload(sawaiShortPartBarcodePayload, destination: destination)
+ )
+ }
XCTAssertFalse(
- TagBarcodeRecord.isValidScanPayload(moltenShortPartBarcodePayload, destination: .sawai)
+ TagBarcodeRecord.isValidScanPayload(sawaiShortPartBarcodePayload, destination: .denso)
)
// 仕向地未判定のときは、どちらの仕向地の現品票も取りこぼさない
XCTAssertTrue(
diff --git a/ios/scripts/verify_label_pairs.sh b/ios/scripts/verify_label_pairs.sh
new file mode 100755
index 0000000..3d62d59
--- /dev/null
+++ b/ios/scripts/verify_label_pairs.sh
@@ -0,0 +1,29 @@
+#!/bin/sh
+# Run decoded label pairs through the production matching core without Xcode.
+#
+# swiftc -O shared/tools/decode_label_photos.swift -o /tmp/decode_label_photos
+# /tmp/decode_label_photos tmp/labels > tmp/decoded.tsv # photos stay git-ignored
+# ios/scripts/verify_label_pairs.sh tmp/decoded.tsv
+#
+# Compiles ios/CodeMatch/Models/ScanModels.swift together with the driver in
+# ios/scripts/verify_label_pairs/main.swift, so the result reflects exactly the
+# rules the app ships. Run it once on master to see what the current build does
+# with a new label set, and again after changing the rules. See
+# docs/label-variation-playbook.html.
+set -eu
+
+if [ "$#" -ne 1 ]; then
+ echo "usage: $0 " >&2
+ exit 2
+fi
+
+script_directory=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+ios_root=$(CDPATH= cd -- "$script_directory/.." && pwd)
+build_directory=$(mktemp -d "${TMPDIR:-/tmp}/codematch-verify-labels.XXXXXX")
+trap 'rm -rf "$build_directory"' EXIT HUP INT TERM
+
+# swiftc treats only a file named main.swift as the program entry point.
+cp "$script_directory/verify_label_pairs/main.swift" "$build_directory/main.swift"
+cp "$ios_root/CodeMatch/Models/ScanModels.swift" "$build_directory/ScanModels.swift"
+xcrun swiftc -O "$build_directory/ScanModels.swift" "$build_directory/main.swift" -o "$build_directory/verify_label_pairs"
+"$build_directory/verify_label_pairs" "$1"
diff --git a/ios/scripts/verify_label_pairs/main.swift b/ios/scripts/verify_label_pairs/main.swift
new file mode 100644
index 0000000..3972a0b
--- /dev/null
+++ b/ios/scripts/verify_label_pairs/main.swift
@@ -0,0 +1,78 @@
+// Driver for ios/scripts/verify_label_pairs.sh.
+//
+// Reads the TSV written by shared/tools/decode_label_photos.swift, pairs the QR
+// and the Code 128 found in the same photo, and runs each pair through the
+// production matching core (ios/CodeMatch/Models/ScanModels.swift, compiled
+// into this executable unchanged). Photos that carry only a Code 128 are
+// reported as tag-only checks. The summary at the end is what to look at
+// first: every real pair must be "/tag=true/match".
+import Foundation
+
+// The app's localization helper is UI code; the matching core only needs the
+// identity mapping to compile outside the app target.
+enum AppLocalization {
+ static func string(_ key: String) -> String { key }
+ static func string(_ key: String.LocalizationValue) -> String { "\(key)" }
+}
+
+guard CommandLine.arguments.count >= 2 else {
+ FileHandle.standardError.write(Data("usage: verify_label_pairs \n".utf8))
+ exit(2)
+}
+
+let tsv = try String(contentsOfFile: CommandLine.arguments[1], encoding: .utf8)
+var qrs: [String: [String]] = [:]
+var tags: [String: [String]] = [:]
+var order: [String] = []
+for line in tsv.split(separator: "\n") {
+ let columns = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
+ guard columns.count == 4, columns[3].hasPrefix("["), columns[3].hasSuffix("]") else { continue }
+ let photo = columns[0]
+ let payload = String(columns[3].dropFirst().dropLast())
+ if !order.contains(photo) { order.append(photo) }
+ if columns[1].hasSuffix("QR") { qrs[photo, default: []].append(payload) }
+ if columns[1].hasSuffix("Code128") { tags[photo, default: []].append(payload) }
+}
+
+var summary: [String: Int] = [:]
+print("photo\tdestination\tqrPart\ttagValid\tcompare\tboxIdentity")
+for photo in order {
+ let qrList = qrs[photo] ?? []
+ let tagList = tags[photo] ?? []
+ if qrList.isEmpty {
+ for tag in tagList {
+ let valid = TagBarcodeRecord.isValidScanPayload(tag, destination: nil)
+ print("\(photo)\t(tag only)\t-\t\(valid)\t\(tag)\t-")
+ summary["tag-only/valid=\(valid)", default: 0] += 1
+ }
+ continue
+ }
+ for qr in qrList {
+ let destination = Destination.detect(qrPayload: qr)
+ let name = destination?.rawValue ?? "nil"
+ let part = CodeMatcher.partNumber(fromQR: qr) ?? "-"
+ let box = BoxIdentity.make(qrPayload: qr, barcodePayload: tagList.first).map { String($0.prefix(12)) } ?? "-"
+ if tagList.isEmpty {
+ print("\(photo)\t\(name)\t\(part)\t(no tag)\t-\t\(box)")
+ summary["qr-only/\(name)", default: 0] += 1
+ }
+ for tag in tagList {
+ let valid = TagBarcodeRecord.isValidScanPayload(tag, destination: destination)
+ let result = CodeMatcher.compare(qrPayload: qr, barcodePayload: tag)
+ print("\(photo)\t\(name)\t\(part)\t\(valid)\t\(result)\t\(box)")
+ summary["\(name)/tag=\(valid)/\(result)", default: 0] += 1
+ }
+ }
+}
+
+print("--- summary ---")
+for (key, count) in summary.sorted(by: { $0.key < $1.key }) {
+ print("\(key)\t\(count)")
+}
+
+// Guards that must keep holding whatever the acceptance rules become.
+print("--- guards ---")
+print("X*66 as QR:", Destination.detect(qrPayload: String(repeating: "X", count: 66)).map { $0.rawValue } ?? "nil")
+print("sawai tag as QR:", Destination.detect(qrPayload: "BCJH-52-81GG@1N5X0C").map { $0.rawValue } ?? "nil")
+print("6-4 tag in sawai:", TagBarcodeRecord.isValidScanPayload("860150-7722@1DZ50O", destination: .sawai))
+print("4-2-3 tag in denso:", TagBarcodeRecord.isValidScanPayload("PAF1-15-422@0NKD3C", destination: .denso))
diff --git a/shared/README.md b/shared/README.md
index a1489af..a587b12 100644
--- a/shared/README.md
+++ b/shared/README.md
@@ -2,8 +2,9 @@
このディレクトリにはプラットフォーム非依存の仕様確認資源だけを置きます。Swift、Kotlin、AVFoundation、CameraX、各社Bluetooth SDKなどの実装コードは置きません。
-- `test-fixtures/matching-cases.json`: Swift/Kotlin共通の照合入力と期待結果。`schemaVersion` 2、47ケースで、各ケースはQRの仕向地を示す`destination`(`sawai` / `molten` / `denso`)を持つ。いずれの様式でもないQRを扱う2ケースだけが`destination`を持たない
+- `test-fixtures/matching-cases.json`: Swift/Kotlin共通の照合入力と期待結果。`schemaVersion` 2、63ケースで、各ケースはQRの仕向地を示す`destination`(`sawai` / `molten` / `denso`)を持つ。いずれの様式でもないQRを扱う2ケースだけが`destination`を持たない
- `test-fixtures/images/`: 実機またはエミュレーターへ表示するQR・Code 128画像。3仕向地ぶんを置き、モルテンは `molten-` で始まる5点、デンソーは `denso-` で始まる3点
- `tools/generate_test_codes.swift`: macOSで画像を再生成する補助ツール
+- `tools/decode_label_photos.swift`: 現場ラベルの写真を Vision で一括デコードし、1シンボル1行の TSV を出す(写真は git-ignored の `tmp/` に置き、コミットしない)。`ios/scripts/verify_label_pairs.sh` と組み合わせて使う(手順は `docs/label-variation-playbook.html`)
照合ルールを変える場合は、先に共通仕様とfixtureを更新し、両プラットフォームのテストへ同じ変更を反映します。
diff --git a/shared/test-fixtures/matching-cases.json b/shared/test-fixtures/matching-cases.json
index 3efbefb..d73d778 100644
--- a/shared/test-fixtures/matching-cases.json
+++ b/shared/test-fixtures/matching-cases.json
@@ -5,7 +5,8 @@
"label-AA-qr-vs-label-BB-tag cases pair a QR with the tag of the neighbouring label of the same series (e.g. 52 vs 55) to pin the most likely field mix-up as a mismatch.",
"box-a / box-b are two boxes of the same part (BCJH-55-81GG) photographed with the same tag barcode and different card numbers (DAAL134140 / DAAL134150): the basis for the duplicate rule keying on the QR record, not the part number.",
"molten-NN cases are the Vision-decoded slip/label pairs of destination モルテン (spec §6.5, docs/qr-barcode-spec-analysis.html, git-ignored). QR payloads keep their trailing spaces and are exactly 61 characters; `destination` is the value the destination detector must return for the QR.",
- "denso-NN cases are the Vision-decoded kanban/tag pairs of destination デンソー (2026-09-07, spec §7). Each QR is the complete 221-character JAMA self-describing record whose header declares the item list; the box is identified by the QR alone because item 152 (kanban serial) differs per box. The tag part number prints 6-4."
+ "denso-NN cases are the Vision-decoded kanban/tag pairs of destination デンソー (2026-09-07, spec §7). Each QR is the complete 221-character JAMA self-describing record whose header declares the item list; the box is identified by the QR alone because item 152 (kanban serial) differs per box. The tag part number prints 6-4.",
+ "sawai-2026-09-08-NN cases are the field labels photographed on 2026-09-08 (Vision-decoded, destination 澤井製作所, #129). Their card numbers are the `DAH4` + 6-digit-serial shape (an alphanumeric 4-character code, not four letters), five of the part numbers are nine characters — the QR item field is left-justified with a trailing space (`BCJH5281F `), the suffix field is blank or `02`, and the tag prints 4-2-3 (`BCJH-52-81F@01R95K`). box-a / box-b are two boxes of KAAA-45-81MB with the same tag and consecutive card numbers. The `*-qr-vs-*-tag` cases pin the neighbouring-series mix-ups and the nine-vs-ten-character mix-ups of the same prefix as mismatches."
],
"cases": [
{
@@ -334,6 +335,118 @@
"barcodePayload": "860150-7722@1DZ50O",
"expected": "mismatch",
"destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-01-BCJH-55-8SHE",
+ "qrPayload": "DAH4093870BCJH558SHE020001600000016000H 000000BHB01LHA29 0*",
+ "barcodePayload": "BCJH-55-8SHE@02C04G",
+ "expected": "match",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-02-BCJH-45-81MC",
+ "qrPayload": "DAH4093150BCJH4581MC020003360000033600H 000000BHB01LHA27 0*",
+ "barcodePayload": "BCJH-45-81MC@01V29C",
+ "expected": "match",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-03-KAAA-52-81MB",
+ "qrPayload": "DAH4094610KAAA5281MB020003360000033600H 000000BHB03LHA24 0*",
+ "barcodePayload": "KAAA-52-81MB@01MV9C",
+ "expected": "match",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-04-BCJM-55-81MC",
+ "qrPayload": "DAH4094030BCJM5581MC020003360000033600H 000000BHB01LHA29 0*",
+ "barcodePayload": "BCJM-55-81MC@01QG9C",
+ "expected": "match",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-05-BEME-52-8SHD",
+ "qrPayload": "DAH4094130BEME528SHD020001600000016000H 000000BHB01LHA28 0*",
+ "barcodePayload": "BEME-52-8SHD@03Q04G",
+ "expected": "match",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-06-BCJH-52-81F",
+ "qrPayload": "DAH4093540BCJH5281F 0002000000020000H 000000BHB01LHA28 0*",
+ "barcodePayload": "BCJH-52-81F@01R95K",
+ "expected": "match",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-07-BEME-55-81F",
+ "qrPayload": "DAH4094390BEME5581F 020002000000020000H 000000BHB01LHA29 0*",
+ "barcodePayload": "BEME-55-81F@031U5K",
+ "expected": "match",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-08-BEME-52-81F",
+ "qrPayload": "DAH4094210BEME5281F 020002000000020000H 000000BHB01LHA28 0*",
+ "barcodePayload": "BEME-52-81F@02WG5K",
+ "expected": "match",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-09-BCJH-45-8SF",
+ "qrPayload": "DAH4093000BCJH458SF 0001000000010000H 000000BHB01LHA27 0*",
+ "barcodePayload": "BCJH-45-8SF@07CU2S",
+ "expected": "match",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-10-BCJH-42-8SF",
+ "qrPayload": "DAH4092630BCJH428SF 0001000000010000H 000000BHB01LHA26 0*",
+ "barcodePayload": "BCJH-42-8SF@079U2S",
+ "expected": "match",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-box-a-KAAA-45-81MB",
+ "qrPayload": "DAH4094520KAAA4581MB020003360000033600H 000000BHB03LHA23 0*",
+ "barcodePayload": "KAAA-45-81MB@01HP9C",
+ "expected": "match",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-box-b-KAAA-45-81MB",
+ "qrPayload": "DAH4094530KAAA4581MB020003360000033600H 000000BHB03LHA23 0*",
+ "barcodePayload": "KAAA-45-81MB@01HP9C",
+ "expected": "match",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-07-qr-vs-08-tag",
+ "qrPayload": "DAH4094390BEME5581F 020002000000020000H 000000BHB01LHA29 0*",
+ "barcodePayload": "BEME-52-81F@02WG5K",
+ "expected": "mismatch",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-09-qr-vs-10-tag",
+ "qrPayload": "DAH4093000BCJH458SF 0001000000010000H 000000BHB01LHA27 0*",
+ "barcodePayload": "BCJH-42-8SF@079U2S",
+ "expected": "mismatch",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-ten-char-qr-vs-nine-char-tag",
+ "qrPayload": "DAH4093500BCJH528SHE020001600000016000H 000000BHB01LHA28 0*",
+ "barcodePayload": "BCJH-52-81F@01R95K",
+ "expected": "mismatch",
+ "destination": "sawai"
+ },
+ {
+ "id": "sawai-2026-09-08-nine-char-qr-vs-ten-char-tag",
+ "qrPayload": "DAH4093540BCJH5281F 0002000000020000H 000000BHB01LHA28 0*",
+ "barcodePayload": "BCJH-52-8SHE@02NV4G",
+ "expected": "mismatch",
+ "destination": "sawai"
}
]
}
diff --git a/shared/tools/decode_label_photos.swift b/shared/tools/decode_label_photos.swift
new file mode 100644
index 0000000..1ab4fdc
--- /dev/null
+++ b/shared/tools/decode_label_photos.swift
@@ -0,0 +1,67 @@
+// Decode every barcode (QR / Code 128 / Code 39 …) in a directory of label photos
+// with Vision and print one TSV line per symbol:
+//
+// \t\tlen=\t[]
+//
+// Control characters are escaped (\r \n \0 \xNN) so trailing spaces and
+// transport terminators stay visible. Usage (macOS, Xcode installed):
+//
+// swiftc -O shared/tools/decode_label_photos.swift -o /tmp/decode_label_photos
+// /tmp/decode_label_photos > decoded.tsv
+//
+// The photos are customer data: keep them outside the repository (for example
+// the git-ignored tmp/ directory) and commit only the decoded payload strings,
+// as shared/test-fixtures/matching-cases.json does. See
+// docs/label-variation-playbook.html for the full procedure.
+import AppKit
+import Foundation
+import Vision
+
+guard CommandLine.arguments.count >= 2 else {
+ FileHandle.standardError.write(Data("usage: decode_label_photos \n".utf8))
+ exit(2)
+}
+
+let directory = URL(fileURLWithPath: CommandLine.arguments[1])
+let files = try FileManager.default.contentsOfDirectory(atPath: directory.path)
+ .filter { name in
+ let lower = name.lowercased()
+ return lower.hasSuffix(".jpg") || lower.hasSuffix(".jpeg") || lower.hasSuffix(".png") || lower.hasSuffix(".heic")
+ }
+ .sorted()
+
+func escape(_ value: String) -> String {
+ value.unicodeScalars.map { scalar -> String in
+ switch scalar {
+ case "\r": return "\\r"
+ case "\n": return "\\n"
+ case "\0": return "\\0"
+ default:
+ return scalar.value < 32 || scalar.value == 127
+ ? String(format: "\\x%02X", scalar.value)
+ : String(scalar)
+ }
+ }.joined()
+}
+
+for file in files {
+ let url = directory.appendingPathComponent(file)
+ guard let image = NSImage(contentsOf: url),
+ let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+ print("\(file)\tLOAD_FAIL")
+ continue
+ }
+ let request = VNDetectBarcodesRequest()
+ request.symbologies = [.qr, .code128, .code39, .code93, .ean13, .itf14, .dataMatrix, .pdf417]
+ let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up, options: [:])
+ try handler.perform([request])
+ let results = request.results ?? []
+ if results.isEmpty {
+ print("\(file)\tNONE")
+ continue
+ }
+ for result in results {
+ let payload = result.payloadStringValue ?? ""
+ print("\(file)\t\(result.symbology.rawValue)\tlen=\(payload.count)\t[\(escape(payload))]")
+ }
+}