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回続けて弾かれた記録が残っていた。

+
+ + + + + + +
観点アプリが想定していた書式現場の実データ影響
QR カード番号(1〜10桁)^[A-Z]{4}[0-9]{6}$
英字4+数字6(例 DCLP675300)
DAH4093870
英数4桁のコード+数字6桁の連番
写真27ラベル全てを QR 段階で拒否
QR 品目番号欄(11〜20桁)^[A-Z0-9]{10}$
常に10桁
BCJH5281F␣
9桁を左詰め、末尾は空白
9桁品番の9ラベルを拒否
Code 128 の品番区切り(澤井製作所)4-2-4 のみBCJH-52-81F@01R95K
4-2-3 もある
同じ9ラベルを Code 128 段階でも拒否
デンソー対応の処理JAMA 自己記述レコードの解析、6-4 の現品票無関係(澤井の QR は JAMA で始まらない)
+
+
「デンソー対応ビルドから壊れた」ように見えたのは、厳格化(#46 / #75 / #78 / #93)が build 4(2026-09-06)で入り、build 5・6・8 が短期間に続いたため、現場が最初に触った厳格ビルドがたまたまデンソー対応版だったからである。
+
+ +
+

2. 根本原因

+
    +
  1. サンプルへの過学習。2026-08-17 時点で手元にあった12組(カード番号は DCLP / DAYA / DAAL+6桁)から「英字4+数字6」「品目番号は10桁」と定め、それを受理条件として固定した。12組は同じ工場・同じ時期の連続したラベルで、書式の全体像を代表していなかった。
  2. +
  3. 吸収していた緩さを、別の目的で取り除いた。もともとカメラの QR は内容検証をせず、照合も「QR 全文の正規化文字列にバーコードの品番が含まれるか」という部分一致で成立していた。逆順入力や無関係な QR を弾くために内容検証を加え(#46 / #75 / #78)、部分一致フォールバックを撤去した(#93)ことで、正規表現の狭さがそのまま拒否になった。個々の変更は正しいが、合わせると「実データより狭い規則が唯一の判定」になった。
  4. +
  5. テストが思い込みを固定した。モルテン対応で 4-2-3 を追加したとき、「4-2-3 はモルテンだけ、澤井製作所では受理しない」というテストを書いた(TEST_PARITY 行77・86)。実データの裏付けがない排他条件をテストで固定すると、後から直す人が「仕様」だと誤解する。
  6. +
  7. 現場との検証が Code 128 中心だった。Bluetooth 経路の QR 検証は以前から存在したが、現場はカメラ運用だったため問題が隠れていた。TestFlight での検証もフィクスチャの12組で行っていた。
  8. +
+
+ +
+

3. 現在の受理条件(2026-09-08 以降)

+

iOS ios/CodeMatch/Models/ScanModels.swift と Android android/core/matching/.../CodeMatcher.kt が同じ規則を持つ。正本は docs/PRODUCT_SPEC.md

+
+ + + + + +
仕向地QRCode 128(品番@管理コード)箱の識別
澤井製作所 sawai66桁固定長。カード番号 ^[A-Z0-9]{4}[0-9]{6}$、品目番号欄 ^[A-Z0-9]{9}[A-Z0-9 ]$(末尾空白を除いた値が品番)、枝番は空白可4-2-4 または 4-2-3QR 全文(カード番号が箱ごとに違う)
モルテン molten61桁固定長(57〜61桁は空白で補完)。部品番号は 7〜16桁目、左詰め4-2-4 または 4-2-3QR 正規形+Code 128
デンソー densoJAMA 自己記述形式(可変長)。項目104・112・152 必須6-4QR 全文(かんばん連番が箱ごとに違う)
+
+

仕向地の判定はデンソー → 澤井製作所 → モルテン → 空白除去した澤井製作所の順。仕向地未確定のときの Code 128 は3パターンの OR で受理する。

+
+ +
+

4. 受理条件を決めるときの原則

+ +
CLAUDE.md にも記した: 今回緩めた規則を12組のサンプルに合わせて狭め直さないこと。
+
+ +
+

5. 新しいバリエーションが来たときの手順

+

STEP 0現場から証拠を回収する

+ + +

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規則を「実データで裏付けられる構造」まで緩める

+ + +

STEP 4共通フィクスチャとテストに固定する

+ + +

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
+ +
+ +
+

6. チェックリスト

+ +
+ +
+

7. 関連

+ +
+
+ + 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))]") + } +}