From f3ee1b7d1b6d04dc474f7c5b56ec7c368a3bf07b Mon Sep 17 00:00:00 2001 From: rimtty Date: Mon, 7 Sep 2026 21:33:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(matching):=20add=20ship-to=20destination?= =?UTF-8?q?=20=E3=83=87=E3=83=B3=E3=82=BD=E3=83=BC=20to=20the=20shared=20f?= =?UTF-8?q?ixtures=20and=20both=20matching=20cores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Destination.denso / DENSO with a generic parser for the JAMA self-describing kanban QR (header length, item descriptor list, exact data-length check, required items 104/112/152), probes it before the lenient sawai/molten checks, validates the 6-4@code product tag, formats Denso part numbers as 6-4, and keys the box identity on the QR alone. The shared fixture grows from 35 to 47 cases with the seven real kanban/tag pairs plus cross-destination mismatches, and three denso test images are generated. Sawai and molten behaviour is unchanged. Refs #107 --- .../core/export/HistoryExportText.kt | 4 + .../codematch/core/matching/CodeMatcher.kt | 73 ++-- .../core/matching/DensoKanbanQrRecord.kt | 185 ++++++++++ .../core/matching/CodeMatcherTest.kt | 306 ++++++++++++++++- .../codematch/core/model/Destination.kt | 7 +- .../feature/history/HistoryUiResources.kt | 2 + .../feature/history/HistoryUiText.kt | 1 + .../src/main/res/values-en/strings.xml | 1 + .../history/src/main/res/values/strings.xml | 1 + .../codematch/feature/scan/ScanScreen.kt | 13 +- .../scan/src/main/res/values-en/strings.xml | 1 + .../scan/src/main/res/values/strings.xml | 1 + ios/CodeMatch/Models/ScanModels.swift | 237 ++++++++++++- ios/CodeMatch/Resources/Localizable.xcstrings | 16 + ios/CodeMatchTests/CodeMatcherTests.swift | 323 +++++++++++++++++- shared/README.md | 4 +- .../images/denso-code128-second-box.png | Bin 0 -> 9690 bytes shared/test-fixtures/images/denso-code128.png | Bin 0 -> 9692 bytes shared/test-fixtures/images/denso-qr.png | Bin 0 -> 12636 bytes shared/test-fixtures/matching-cases.json | 87 ++++- shared/tools/generate_test_codes.swift | 13 + 21 files changed, 1230 insertions(+), 45 deletions(-) create mode 100644 android/core/matching/src/main/kotlin/jp/rimtty/codematch/core/matching/DensoKanbanQrRecord.kt create mode 100644 shared/test-fixtures/images/denso-code128-second-box.png create mode 100644 shared/test-fixtures/images/denso-code128.png create mode 100644 shared/test-fixtures/images/denso-qr.png diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryExportText.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryExportText.kt index 669c1d4..428833c 100644 --- a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryExportText.kt +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryExportText.kt @@ -43,6 +43,7 @@ data class HistoryExportLabels( val destination: String, val destinationSawai: String, val destinationMolten: String, + val destinationDenso: String, val deliveryNumberCount: String, val ordererCode: String, val moltenPartNumber: String, @@ -62,6 +63,7 @@ data class HistoryExportLabels( fun destinationName(destination: Destination): String = when (destination) { Destination.SAWAI -> destinationSawai Destination.MOLTEN -> destinationMolten + Destination.DENSO -> destinationDenso } } @@ -118,6 +120,7 @@ object HistoryExportTextFormatter { destination = "仕向地", destinationSawai = "澤井製作所", destinationMolten = "モルテン", + destinationDenso = "デンソー", deliveryNumberCount = "納品番号数", ordererCode = "受注者", moltenPartNumber = "部品番号", @@ -166,6 +169,7 @@ object HistoryExportTextFormatter { destination = "Ship-to", destinationSawai = "Sawai Seisakusho", destinationMolten = "Molten", + destinationDenso = "Denso", deliveryNumberCount = "Delivery numbers", ordererCode = "Orderer", moltenPartNumber = "Part number", 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 01f0cff..b343f49 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 @@ -48,11 +48,17 @@ object CodeMatcher { raw.trim { it == '\r' || it == '\n' || it == '\u0000' } /** - * Decide which destination a QR payload belongs to, or null when it is - * neither destination's record. + * Decide which destination a QR payload belongs to, or null when it is no + * 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 + * it accepts `JAMA501195…` as a card number. A payload that parses as a + * JAMA self-describing record is always Denso. */ fun detectDestination(qrPayload: String): Destination? { val payload = stripTransportTerminators(qrPayload) + if (isDensoRecord(payload)) return Destination.DENSO if (isSawaiRecord(payload)) return Destination.SAWAI if (MoltenQrRecord.isValidScanPayload(payload)) return Destination.MOLTEN @@ -63,17 +69,24 @@ object CodeMatcher { return if (isSawaiRecord(payload.trim())) Destination.SAWAI else null } - /** The complete QR record length of a destination. */ - fun expectedQrLength(destination: Destination): Int = when (destination) { + /** + * The complete QR record length of a destination, or null when the + * destination has no fixed length: a Denso kanban declares its own item + * layout, so its payload length varies and can never be checked. + */ + fun expectedQrLength(destination: Destination): Int? = when (destination) { Destination.SAWAI -> KanbanQrRecord.REQUIRED_SCAN_PAYLOAD_LENGTH Destination.MOLTEN -> MoltenQrRecord.RECORD_LENGTH + Destination.DENSO -> null } /** * Normalize a QR payload for identity comparisons. * * A Molten payload is padded back to its full record length so a scan that - * dropped the trailing spaces still identifies the same slip. + * dropped the trailing spaces still identifies the same slip. Sawai and + * Denso payloads carry no edge padding, so trimming and uppercasing is + * enough for them. */ fun canonicalQrPayload(qrPayload: String): String = when (detectDestination(qrPayload)) { @@ -85,14 +98,15 @@ object CodeMatcher { /** * The key that tells one physical box from another within a session. * - * A Sawai slip carries a card number, so its QR alone identifies the box. A - * Molten slip repeats for every box of the part, so the tag's management - * code has to be part of the key; without a tag there is no box identity. - * Returns null when the QR is not a valid record of either destination. + * A Sawai slip carries a card number and a Denso kanban carries a kanban + * serial (item 152), so their QR alone identifies the box. A Molten slip + * repeats for every box of the part, so the tag's management code has to be + * part of the key; without a tag there is no box identity. Returns null + * when the QR is not a valid record of any destination. */ fun boxIdentity(qrPayload: String, barcodePayload: String?): String? = when (detectDestination(qrPayload)) { - Destination.SAWAI -> canonicalQrPayload(qrPayload) + Destination.SAWAI, Destination.DENSO -> canonicalQrPayload(qrPayload) Destination.MOLTEN -> { val tag = payloadIdentity(barcodePayload.orEmpty()) if (tag.isEmpty()) null else canonicalQrPayload(qrPayload) + "|" + tag @@ -109,14 +123,15 @@ object CodeMatcher { /** * Extract the item number from a slip QR at its destination's fixed * position: characters 11–20 for [Destination.SAWAI], characters 7–16 for - * [Destination.MOLTEN]. A payload that is neither destination's record - * returns null and can never produce a match. + * [Destination.MOLTEN], item 104 for [Destination.DENSO]. A payload that is + * no destination's record returns null and can never produce a match. */ fun partNumberFromQr(raw: String): String? { val payload = stripTransportTerminators(raw) return when (detectDestination(payload)) { Destination.SAWAI -> KanbanQrRecord.parse(payload)?.partNumber Destination.MOLTEN -> MoltenQrRecord.parse(payload)?.partNumber + Destination.DENSO -> DensoKanbanQrRecord.parse(payload)?.partNumber null -> null } } @@ -135,11 +150,17 @@ object CodeMatcher { } /** - * Format a part number the way the product tag prints it: 4-2-4 for a - * ten-character number, 4-2-3 for the nine-character Molten form. Values of - * any other length are returned unchanged. + * Format a part number the way the destination's product tag prints it: + * 6-4 for a ten-character Denso number, otherwise 4-2-4 for a + * ten-character number and 4-2-3 for the nine-character Molten form. + * Values of any other length are returned unchanged. */ - fun formatPartNumber(partNumber: String): String { + fun formatPartNumber(partNumber: String, destination: Destination? = null): String { + if (destination == Destination.DENSO) { + if (partNumber.length != STANDARD_PART_NUMBER_LENGTH) return partNumber + return partNumber.substring(0, 6) + "-" + partNumber.substring(6) + } + if (partNumber.length !in SHORT_PART_NUMBER_LENGTH..STANDARD_PART_NUMBER_LENGTH) { return partNumber } @@ -154,6 +175,10 @@ object CodeMatcher { payload.length == KanbanQrRecord.REQUIRED_SCAN_PAYLOAD_LENGTH && KanbanQrRecord.parse(payload) != null + private fun isDensoRecord(payload: String): Boolean = + payload.startsWith(DensoKanbanQrRecord.FORMAT_PREFIX, ignoreCase = true) && + DensoKanbanQrRecord.parse(payload) != null + private const val STANDARD_PART_NUMBER_LENGTH = 10 private const val SHORT_PART_NUMBER_LENGTH = 9 } @@ -234,18 +259,26 @@ data class TagBarcodeRecord( Regex("[A-Z0-9]{4}-[A-Z0-9]{2}-[A-Z0-9]{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 = + Regex("[A-Z0-9]{6}-[A-Z0-9]{4}@[A-Z0-9]+") /** * Strict scanner-boundary validation for the product tag format of one - * destination: Sawai part numbers always end in a four-character block, - * Molten part numbers end in three or four. Lowercase input is accepted - * just as Swift's uppercase-before-regex implementation accepts it. + * 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 + * 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. */ - fun isValidScanPayload(payload: String, destination: Destination): Boolean { + fun isValidScanPayload(payload: String, destination: Destination?): Boolean { val value = payload.trim().uppercase(Locale.ROOT) return when (destination) { Destination.SAWAI -> sawaiFormatPattern.matches(value) Destination.MOLTEN -> moltenFormatPattern.matches(value) + Destination.DENSO -> densoFormatPattern.matches(value) + null -> sawaiFormatPattern.matches(value) || + moltenFormatPattern.matches(value) || + densoFormatPattern.matches(value) } } diff --git a/android/core/matching/src/main/kotlin/jp/rimtty/codematch/core/matching/DensoKanbanQrRecord.kt b/android/core/matching/src/main/kotlin/jp/rimtty/codematch/core/matching/DensoKanbanQrRecord.kt new file mode 100644 index 0000000..150a0b6 --- /dev/null +++ b/android/core/matching/src/main/kotlin/jp/rimtty/codematch/core/matching/DensoKanbanQrRecord.kt @@ -0,0 +1,185 @@ +package jp.rimtty.codematch.core.matching + +import java.util.Locale + +/** One item of a Denso kanban QR. The item id is always three digits. */ +data class DensoKanbanItem( + val id: String, + val value: String, +) + +/** + * A Denso kanban QR in the JAMA self-describing format. + * + * Record layout: + * `JAMA` + one version digit + a four-digit header length (L) + a ten-character + * preamble + (three-digit item id + two-digit length) * N + the data section. + * + * The header length spans from the header-length field itself to the end of the + * item definitions, so the header body is `payload[9 until 5 + L]` and the data + * section is `payload[5 + L ..]`. The declared item lengths must add up to the + * data section's length exactly. + * + * The parser deliberately does not hardcode the observed 221-character payload: + * the real kanban is L = 119 with 21 items over 97 data characters, but a + * kanban with a different item layout parses by the very same rules. Keep this + * identical to the Swift `DensoKanbanRecord`. + */ +data class DensoKanbanQrRecord( + /** The single version digit after `JAMA`. */ + val version: String, + /** The first ten header characters, kept raw and never validated. */ + val preamble: String, + /** 100: form type. */ + val formType: String?, + /** 104: part number, without hyphens. */ + val partNumber: String, + /** 111: packaging code. */ + val packagingCode: String?, + /** 112: pack quantity. */ + val packQuantity: Int, + /** 121: next process. */ + val nextProcess: String?, + /** 124 (plus `-` plus 141): instruction code. */ + val instructionCode: String?, + /** 152: kanban serial, unique per box. */ + val kanbanSerial: String, + /** 402: management number. */ + val managementNumber: String?, + /** 519: delivery date, `YYYYMMDD`. */ + val deliveryDate: String?, + /** 520: delivery run. */ + val deliveryRun: String?, + /** 521: instructed quantity. */ + val instructedQuantity: Int?, + /** 523: item number. */ + val itemNumber: String?, + /** 401: receiving code. */ + val receivingCode: String?, + /** Every item's raw value, in the order the header declares them. */ + val orderedItems: List, + /** Every item's raw value, keyed by item id. */ + val items: Map, + /** Terminator-stripped, uppercased payload; the length is left as read. */ + val canonicalPayload: String, +) { + /** The delivery date for display: `20260908` becomes `2026/09/08`. */ + val formattedDeliveryDate: String? + get() { + val date = deliveryDate ?: return null + if (date.length != DELIVERY_DATE_LENGTH) return date + return "${date.substring(0, 4)}/${date.substring(4, 6)}/${date.substring(6)}" + } + + companion object { + /** The fixed prefix of the format. */ + const val FORMAT_PREFIX = "JAMA" + + /** `JAMA` plus the version digit plus the four-digit header length. */ + private const val HEADER_FIELDS_LENGTH = 9 + private const val PREAMBLE_LENGTH = 10 + private const val DESCRIPTOR_LENGTH = 5 + private const val DELIVERY_DATE_LENGTH = 8 + + private val partNumberPattern = Regex("[A-Z0-9]{1,18}") + + /** Accept a scanner payload as a Denso kanban QR. */ + fun isValidScanPayload(payload: String): Boolean = parse(payload) != null + + /** The canonical form of a payload, or null when it does not parse. */ + fun canonicalPayload(payload: String): String? = parse(payload)?.canonicalPayload + + fun parse(payload: String): DensoKanbanQrRecord? { + val record = CodeMatcher.stripTransportTerminators(payload).uppercase(Locale.ROOT) + if (record.length < HEADER_FIELDS_LENGTH) return null + if (!record.startsWith(FORMAT_PREFIX)) return null + + val version = record.substring(4, 5) + if (!version.isAsciiDigits()) return null + + val headerLengthField = record.substring(5, HEADER_FIELDS_LENGTH) + if (!headerLengthField.isAsciiDigits()) return null + val headerLength = headerLengthField.toIntOrNull() ?: return null + + // The header length spans the header-length field itself, so the data + // section starts at 5 + L. + val dataStart = 5 + headerLength + if (record.length < dataStart) return null + + val headerBody = record.substring(HEADER_FIELDS_LENGTH, dataStart) + if (headerBody.length < PREAMBLE_LENGTH) return null + val preamble = headerBody.substring(0, PREAMBLE_LENGTH) + val descriptors = headerBody.substring(PREAMBLE_LENGTH) + if (descriptors.isEmpty() || descriptors.length % DESCRIPTOR_LENGTH != 0) return null + + val declared = mutableListOf>() + val seenIds = mutableSetOf() + var cursor = 0 + while (cursor < descriptors.length) { + val id = descriptors.substring(cursor, cursor + 3) + val lengthField = descriptors.substring(cursor + 3, cursor + DESCRIPTOR_LENGTH) + if (!id.isAsciiDigits() || !lengthField.isAsciiDigits()) return null + val length = lengthField.toIntOrNull() ?: return null + // A repeated item id has no defined interpretation. + if (!seenIds.add(id)) return null + declared += id to length + cursor += DESCRIPTOR_LENGTH + } + + val data = record.substring(dataStart) + if (declared.sumOf { it.second } != data.length) return null + + val orderedItems = mutableListOf() + val items = mutableMapOf() + var offset = 0 + declared.forEach { (id, length) -> + val value = data.substring(offset, offset + length) + orderedItems += DensoKanbanItem(id = id, value = value) + items[id] = value + offset += length + } + + // Required items: part number, pack quantity and kanban serial. A + // payload missing any of them is not treated as a kanban. + val partNumber = items["104"]?.trim().orEmpty() + if (!partNumberPattern.matches(partNumber)) return null + val packQuantityField = items["112"]?.trim().orEmpty() + if (!packQuantityField.isAsciiDigits()) return null + val packQuantity = packQuantityField.toIntOrNull() ?: return null + val kanbanSerial = items["152"]?.trim().orEmpty() + if (kanbanSerial.isEmpty()) return null + + val instructionBase = items["124"].trimmedOrNull() + val instructionSuffix = items["141"].trimmedOrNull() + val instructionCode = instructionBase?.let { base -> + if (instructionSuffix != null) "$base-$instructionSuffix" else base + } + + return DensoKanbanQrRecord( + version = version, + preamble = preamble, + formType = items["100"].trimmedOrNull(), + partNumber = partNumber, + packagingCode = items["111"].trimmedOrNull(), + packQuantity = packQuantity, + nextProcess = items["121"].trimmedOrNull(), + instructionCode = instructionCode, + kanbanSerial = kanbanSerial, + managementNumber = items["402"].trimmedOrNull(), + deliveryDate = items["519"].trimmedOrNull(), + deliveryRun = items["520"].trimmedOrNull(), + instructedQuantity = items["521"]?.trim()?.toIntOrNull(), + itemNumber = items["523"].trimmedOrNull(), + receivingCode = items["401"].trimmedOrNull(), + orderedItems = orderedItems.toList(), + items = items.toMap(), + canonicalPayload = record, + ) + } + + private fun String.isAsciiDigits(): Boolean = + isNotEmpty() && all { it in '0'..'9' } + + private fun String?.trimmedOrNull(): String? = this?.trim()?.takeIf { it.isNotEmpty() } + } +} 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 f8c2ed4..1af6abd 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,22 @@ class CodeMatcherTest { "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" private val moltenShortPartBarcodePayload = "PAF1-15-422@0NKD3C" + // 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. + private val densoQrPayload = + "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + private val densoBarcodePayload = "860150-7722@1DZ50O" + + // Synthetic kanbans with a different item layout (three items, another + // header length). Both would be claimed by another destination if Denso + // were not probed first: 66 characters parse as a Sawai record and 61 + // characters parse as a complete Molten record. + private val densoSawaiLengthPayload = + "JAMA5002950000000031521010410112120140 8601507722000000000024" + private val densoMoltenLengthPayload = + "JAMA500295000000003104101120715210860150772200000240140123456" + @Test fun normalizeUppercasesAndKeepsOnlyAsciiLettersAndDigits() { assertEquals("ABC123", CodeMatcher.normalize(" a-B_c 123!")) @@ -180,7 +196,7 @@ class CodeMatcherTest { val fixture = SharedFixtureJson.decode(json) assertEquals(2, fixture.schemaVersion) - assertEquals(35, fixture.cases.size) + assertEquals(47, fixture.cases.size) assertEquals( "Shared fixture IDs must be unique", fixture.cases.size, @@ -365,6 +381,18 @@ class CodeMatcherTest { assertEquals(66, CodeMatcher.expectedQrLength(Destination.SAWAI)) assertEquals(61, CodeMatcher.expectedQrLength(Destination.MOLTEN)) + + // A JAMA self-describing record is Denso whatever its length is, and a + // Denso kanban has no fixed length to compare an incomplete read with. + assertEquals(Destination.DENSO, CodeMatcher.detectDestination(densoQrPayload)) + assertEquals(Destination.DENSO, CodeMatcher.detectDestination("$densoQrPayload\r\n")) + assertEquals( + Destination.DENSO, + CodeMatcher.detectDestination(densoQrPayload.lowercase()), + ) + // A truncated kanban no longer adds up, so it is no destination at all. + assertNull(CodeMatcher.detectDestination(densoQrPayload.dropLast(1))) + assertNull(CodeMatcher.expectedQrLength(Destination.DENSO)) } @Test @@ -382,7 +410,9 @@ class CodeMatcherTest { ) ) - Destination.entries.forEach { destination -> + // Denso is deliberately absent: its tags are 6-4, so the 4-2-4 and + // 4-2-3 payloads below are invalid in a Denso session. + listOf(Destination.SAWAI, Destination.MOLTEN).forEach { destination -> assertTrue(TagBarcodeRecord.isValidScanPayload(barcodePayload, destination)) assertTrue(TagBarcodeRecord.isValidScanPayload(moltenBarcodePayload, destination)) assertFalse(TagBarcodeRecord.isValidScanPayload("PAF1-15-42@0NKD3C", destination)) @@ -415,6 +445,16 @@ class CodeMatcherTest { assertNull(CodeMatcher.boxIdentity("PART:BCJH-52-81GG;QTY:12", barcodePayload)) assertNull(CodeMatcher.boxIdentity("", barcodePayload)) + + // A Denso kanban carries a per-box serial (item 152), so like Sawai its + // QR alone identifies the box and the tag never enters the key. + val densoIdentity = CodeMatcher.boxIdentity(densoQrPayload, densoBarcodePayload) + assertEquals(densoQrPayload, densoIdentity) + assertEquals(densoIdentity, CodeMatcher.boxIdentity(densoQrPayload, null)) + assertEquals( + densoIdentity, + CodeMatcher.boxIdentity(densoQrPayload, "860150-7722@1DZB0O"), + ) } @Test @@ -431,6 +471,18 @@ class CodeMatcherTest { MatchResult.MATCH, CodeMatcher.compare(" ${qrPayload.lowercase()}\n", barcodePayload) ) + + // Denso reads item 104 of the JAMA record, not a fixed position. + assertEquals("8601507722", CodeMatcher.partNumberFromQr(densoQrPayload)) + assertEquals("8601507722", CodeMatcher.partNumberFromQr(densoMoltenLengthPayload)) + assertEquals( + MatchResult.MATCH, + CodeMatcher.compare(densoQrPayload, densoBarcodePayload) + ) + assertEquals( + MatchResult.MISMATCH, + CodeMatcher.compare(densoQrPayload, "860150-7791@01335C") + ) } /** @@ -466,6 +518,256 @@ class CodeMatcherTest { } } + /** + * The seven Denso pairs (denso-NN) must pass the scan boundaries shared by + * camera and Bluetooth (a parseable JAMA kanban record, a 6-4@code tag), + * and the QR item number must equal the tag part number. Every kanban is a + * separate box because item 152 differs. + */ + @Test + fun sharedDensoFixturesPassBothScanBoundaries() { + 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 densoPairs = + fixture.cases.filter { it.id.startsWith("denso-") && it.expected == "match" } + assertEquals(7, densoPairs.size) + + densoPairs.forEach { pair -> + assertEquals(pair.id, 221, pair.qrPayload.length) + assertTrue(pair.id, DensoKanbanQrRecord.isValidScanPayload(pair.qrPayload)) + assertTrue( + pair.id, + TagBarcodeRecord.isValidScanPayload(pair.barcodePayload, Destination.DENSO) + ) + // The same tag is refused in a session locked to another destination. + assertFalse( + pair.id, + TagBarcodeRecord.isValidScanPayload(pair.barcodePayload, Destination.SAWAI) + ) + assertFalse( + pair.id, + TagBarcodeRecord.isValidScanPayload(pair.barcodePayload, Destination.MOLTEN) + ) + assertEquals(pair.id, Destination.DENSO, CodeMatcher.detectDestination(pair.qrPayload)) + val record = DensoKanbanQrRecord.parse(pair.qrPayload) + assertEquals( + pair.id, + CodeMatcher.partNumberFromBarcode(pair.barcodePayload), + record?.partNumber + ) + assertEquals(pair.id, 10, record?.partNumber?.length) + // The tag prints the part number 6-4. + assertEquals( + pair.id, + TagBarcodeRecord.parse(pair.barcodePayload)?.partNumber, + CodeMatcher.formatPartNumber(record?.partNumber.orEmpty(), Destination.DENSO) + ) + } + + // Seven kanban serials, therefore seven distinct boxes over two parts. + assertEquals( + 7, + densoPairs.mapNotNull { DensoKanbanQrRecord.parse(it.qrPayload)?.kanbanSerial } + .toSet() + .size + ) + assertEquals( + 7, + densoPairs.mapNotNull { CodeMatcher.boxIdentity(it.qrPayload, it.barcodePayload) } + .toSet() + .size + ) + assertEquals( + 2, + densoPairs.mapNotNull { CodeMatcher.partNumberFromQr(it.qrPayload) }.toSet().size + ) + } + + @Test + fun densoRecordParsesAllItemsFromRealPayload() { + assertEquals(221, densoQrPayload.length) + val record = DensoKanbanQrRecord.parse(densoQrPayload) + + assertNotNull(record) + assertEquals("5", record?.version) + assertEquals("5000001021", record?.preamble) + assertEquals(21, record?.orderedItems?.size) + assertEquals( + listOf( + "100", "104", "111", "112", "121", "124", "127", "141", "142", "144", "152", + "402", "515", "516", "519", "520", "521", "526", "523", "522", "401", + ), + record?.orderedItems?.map { it.id } + ) + assertEquals("20", record?.formType) + assertEquals("8601507722", record?.partNumber) + assertEquals("00", record?.packagingCode) + assertEquals(24, record?.packQuantity) + assertEquals("D850", record?.nextProcess) + assertEquals("C01008-45", record?.instructionCode) + assertEquals("0140", record?.kanbanSerial) + assertEquals("SWS", record?.managementNumber) + assertEquals("20260908", record?.deliveryDate) + assertEquals("2026/09/08", record?.formattedDeliveryDate) + assertEquals("S001", record?.deliveryRun) + assertEquals(72, record?.instructedQuantity) + assertEquals("9924543330", record?.itemNumber) + assertEquals("M6", record?.receivingCode) + // Blank item values keep their width in the raw item map. + assertEquals(" ", record?.items?.get("144")) + assertEquals("M", record?.items?.get("142")) + assertEquals(densoQrPayload, record?.canonicalPayload) + + // A lowercase callback is uppercased rather than rejected. + assertEquals( + densoQrPayload, + DensoKanbanQrRecord.parse(densoQrPayload.lowercase())?.canonicalPayload + ) + assertTrue(DensoKanbanQrRecord.isValidScanPayload(densoQrPayload)) + assertEquals(densoQrPayload, DensoKanbanQrRecord.canonicalPayload(densoQrPayload)) + assertFalse(DensoKanbanQrRecord.isValidScanPayload(qrPayload)) + assertFalse(DensoKanbanQrRecord.isValidScanPayload(moltenQrPayload)) + assertFalse(DensoKanbanQrRecord.isValidScanPayload(densoBarcodePayload)) + } + + @Test + fun densoRecordParsesADifferentItemLayout() { + // Three items in another order, a different header length, and a kanban + // serial padded with spaces. + val record = DensoKanbanQrRecord.parse(densoSawaiLengthPayload) + assertNotNull(record) + assertEquals(listOf("152", "104", "112"), record?.orderedItems?.map { it.id }) + assertEquals("8601507722", record?.partNumber) + assertEquals(24, record?.packQuantity) + assertEquals("0140", record?.kanbanSerial) + // Items the layout does not declare are simply absent. + assertNull(record?.formType) + assertNull(record?.deliveryDate) + assertNull(record?.formattedDeliveryDate) + assertNull(record?.instructedQuantity) + + // Both synthetic payloads are complete records of another destination, + // so they pin the detection order rather than the lengths. + assertEquals(66, densoSawaiLengthPayload.length) + assertTrue(KanbanQrRecord.isValidScanPayload(densoSawaiLengthPayload)) + assertEquals(Destination.DENSO, CodeMatcher.detectDestination(densoSawaiLengthPayload)) + + assertEquals(61, densoMoltenLengthPayload.length) + assertTrue(MoltenQrRecord.isValidScanPayload(densoMoltenLengthPayload)) + assertEquals(Destination.DENSO, CodeMatcher.detectDestination(densoMoltenLengthPayload)) + assertEquals("0140123456", DensoKanbanQrRecord.parse(densoMoltenLengthPayload)?.kanbanSerial) + } + + @Test + fun densoRecordRejectsBrokenHeaderOrDataLength() { + assertNull(DensoKanbanQrRecord.parse("")) + assertNull(DensoKanbanQrRecord.parse("JAMA")) + // Another format prefix, a non-numeric version, a non-numeric length. + assertNull(DensoKanbanQrRecord.parse("JAMB" + densoQrPayload.substring(4))) + assertNull(DensoKanbanQrRecord.parse("JAMAX" + densoQrPayload.substring(5))) + assertNull( + DensoKanbanQrRecord.parse( + densoQrPayload.substring(0, 5) + "01X9" + densoQrPayload.substring(9) + ) + ) + // A header longer than the payload. + assertNull( + DensoKanbanQrRecord.parse( + densoQrPayload.substring(0, 5) + "9999" + densoQrPayload.substring(9) + ) + ) + // 119 - 14 = 105 is a whole number of item definitions, 118 - 14 = 104 + // is not, and 114 - 14 = 100 is but then the lengths no longer add up. + assertNull( + DensoKanbanQrRecord.parse( + densoQrPayload.substring(0, 5) + "0118" + densoQrPayload.substring(9) + ) + ) + assertNull( + DensoKanbanQrRecord.parse( + densoQrPayload.substring(0, 5) + "0114" + densoQrPayload.substring(9) + ) + ) + // The declared lengths must equal the data section exactly. + assertNull(DensoKanbanQrRecord.parse(densoQrPayload.dropLast(1))) + assertNull(DensoKanbanQrRecord.parse(densoQrPayload + "0")) + + // A repeated item id has no defined interpretation. + val duplicateItemId = "JAMA5" + "0034" + "5000000004" + + "10410" + "10410" + "11207" + "15210" + + "8601507722" + "8601507722" + "0000024" + "0140123456" + assertNull(DensoKanbanQrRecord.parse(duplicateItemId)) + + // Required items: 104 (part number), 112 (pack quantity), 152 (serial). + val withoutPartNumber = "JAMA5" + "0024" + "5000000002" + + "11207" + "15210" + "0000024" + "0140123456" + assertNull(DensoKanbanQrRecord.parse(withoutPartNumber)) + val withoutPackQuantity = "JAMA5" + "0024" + "5000000002" + + "10410" + "15210" + "8601507722" + "0140123456" + assertNull(DensoKanbanQrRecord.parse(withoutPackQuantity)) + val nonNumericPackQuantity = "JAMA5" + "0029" + "5000000003" + + "15210" + "10410" + "11212" + + "0140 " + "8601507722" + "00000000002X" + assertNull(DensoKanbanQrRecord.parse(nonNumericPackQuantity)) + val blankKanbanSerial = "JAMA5" + "0029" + "5000000003" + + "15210" + "10410" + "11212" + + " " + "8601507722" + "000000000024" + assertNull(DensoKanbanQrRecord.parse(blankKanbanSerial)) + + // A broken kanban is not silently downgraded to another destination. + assertNull(CodeMatcher.detectDestination(densoQrPayload.dropLast(1))) + assertNull(CodeMatcher.partNumberFromQr(densoQrPayload.dropLast(1))) + assertNull(CodeMatcher.boxIdentity(densoQrPayload.dropLast(1), densoBarcodePayload)) + } + + @Test + fun tagValidationAllowsSixFourOnlyForDenso() { + assertTrue(TagBarcodeRecord.isValidScanPayload(densoBarcodePayload, Destination.DENSO)) + assertTrue(TagBarcodeRecord.isValidScanPayload("860150-7722@1dz50o", Destination.DENSO)) + // An unlocked session accepts every destination's tag format. + assertTrue(TagBarcodeRecord.isValidScanPayload(densoBarcodePayload, null)) + assertTrue(TagBarcodeRecord.isValidScanPayload(barcodePayload, null)) + assertTrue(TagBarcodeRecord.isValidScanPayload(moltenShortPartBarcodePayload, null)) + + // 6-4 is refused in a Sawai or Molten session, and 4-2-4 / 4-2-3 are + // refused in a Denso session. + assertFalse(TagBarcodeRecord.isValidScanPayload(densoBarcodePayload, Destination.SAWAI)) + assertFalse(TagBarcodeRecord.isValidScanPayload(densoBarcodePayload, Destination.MOLTEN)) + assertFalse(TagBarcodeRecord.isValidScanPayload(barcodePayload, Destination.DENSO)) + assertFalse( + TagBarcodeRecord.isValidScanPayload(moltenShortPartBarcodePayload, Destination.DENSO) + ) + + val destinations = listOf(Destination.SAWAI, Destination.MOLTEN, Destination.DENSO, null) + destinations.forEach { destination -> + assertFalse(TagBarcodeRecord.isValidScanPayload("860150-772@1DZ50O", destination)) + assertFalse(TagBarcodeRecord.isValidScanPayload("86015-7722@1DZ50O", destination)) + assertFalse(TagBarcodeRecord.isValidScanPayload("8601507722@1DZ50O", destination)) + assertFalse(TagBarcodeRecord.isValidScanPayload("860150-7722", destination)) + assertFalse(TagBarcodeRecord.isValidScanPayload(densoQrPayload, destination)) + } + } + + @Test + fun formatPartNumberIsDestinationAware() { + // Only Denso prints 6-4; every other destination keeps 4-2-4 / 4-2-3. + assertEquals("860150-7722", CodeMatcher.formatPartNumber("8601507722", Destination.DENSO)) + assertEquals("8601-50-7722", CodeMatcher.formatPartNumber("8601507722", Destination.SAWAI)) + assertEquals("8601-50-7722", CodeMatcher.formatPartNumber("8601507722", Destination.MOLTEN)) + assertEquals("8601-50-7722", CodeMatcher.formatPartNumber("8601507722", null)) + assertEquals("8601-50-7722", CodeMatcher.formatPartNumber("8601507722")) + assertEquals("BCJH-52-81GG", CodeMatcher.formatPartNumber("BCJH5281GG", Destination.SAWAI)) + assertEquals("PAF1-15-422", CodeMatcher.formatPartNumber("PAF115422", Destination.MOLTEN)) + // A Denso part number of any other length is printed unchanged. + assertEquals("PAF115422", CodeMatcher.formatPartNumber("PAF115422", Destination.DENSO)) + assertEquals("ABC", CodeMatcher.formatPartNumber("ABC", Destination.DENSO)) + assertEquals( + "ABCDEFGHIJK", + CodeMatcher.formatPartNumber("ABCDEFGHIJK", Destination.DENSO) + ) + } + private fun replaceCharAt(payload: String, index: Int, character: Char): String = payload.substring(0, index) + character + payload.substring(index + 1) diff --git a/android/core/model/src/main/kotlin/jp/rimtty/codematch/core/model/Destination.kt b/android/core/model/src/main/kotlin/jp/rimtty/codematch/core/model/Destination.kt index db9bd77..f418df6 100644 --- a/android/core/model/src/main/kotlin/jp/rimtty/codematch/core/model/Destination.kt +++ b/android/core/model/src/main/kotlin/jp/rimtty/codematch/core/model/Destination.kt @@ -3,13 +3,15 @@ package jp.rimtty.codematch.core.model /** * The delivery destination a slip QR belongs to. * - * The two destinations print incompatible QR records, so the matching rules, - * the accepted payload length, and the box identity all depend on which one a + * The destinations print incompatible QR records, so the matching rules, the + * accepted payload length, and the box identity all depend on which one a * payload came from: * * - [SAWAI] (澤井製作所): a 66-character kanban record carrying a card number. * - [MOLTEN] (モルテン): a 61-character delivery record whose trailing spaces * are significant data. + * - [DENSO] (デンソー): a JAMA self-describing kanban record whose header + * declares the item list, so its length varies with the item layout. * * [id] is the persisted representation. Keep it identical to the Swift * `Destination` raw value so saved settings and history stay portable. @@ -17,6 +19,7 @@ package jp.rimtty.codematch.core.model enum class Destination(val id: String) { SAWAI("sawai"), MOLTEN("molten"), + DENSO("denso"), ; companion object { diff --git a/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiResources.kt b/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiResources.kt index 7d23972..f5e60b0 100644 --- a/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiResources.kt +++ b/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiResources.kt @@ -73,6 +73,7 @@ object HistoryUiResources { destination = stringResource(R.string.history_destination), destinationSawai = stringResource(R.string.history_destination_sawai), destinationMolten = stringResource(R.string.history_destination_molten), + destinationDenso = stringResource(R.string.history_destination_denso), deliveryNumberCount = stringResource(R.string.history_delivery_number_count), ordererCode = stringResource(R.string.history_orderer_code), moltenPartNumber = stringResource(R.string.history_molten_part_number), @@ -92,6 +93,7 @@ object HistoryUiResources { return when (destination) { Destination.SAWAI -> labels.destinationSawai Destination.MOLTEN -> labels.destinationMolten + Destination.DENSO -> labels.destinationDenso } } diff --git a/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiText.kt b/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiText.kt index e69bbe7..2556b71 100644 --- a/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiText.kt +++ b/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiText.kt @@ -55,6 +55,7 @@ data class HistoryUiLabels( val destination: String, val destinationSawai: String, val destinationMolten: String, + val destinationDenso: String, val deliveryNumberCount: String, val ordererCode: String, val moltenPartNumber: String, diff --git a/android/feature/history/src/main/res/values-en/strings.xml b/android/feature/history/src/main/res/values-en/strings.xml index c4ec0e6..47e6fd1 100644 --- a/android/feature/history/src/main/res/values-en/strings.xml +++ b/android/feature/history/src/main/res/values-en/strings.xml @@ -47,6 +47,7 @@ Ship-to Sawai Seisakusho Molten + Denso Delivery numbers Orderer Part number diff --git a/android/feature/history/src/main/res/values/strings.xml b/android/feature/history/src/main/res/values/strings.xml index 8aff2a5..e2eb382 100644 --- a/android/feature/history/src/main/res/values/strings.xml +++ b/android/feature/history/src/main/res/values/strings.xml @@ -47,6 +47,7 @@ 仕向地 澤井製作所 モルテン + デンソー 納品番号数 受注者 部品番号 diff --git a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanScreen.kt b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanScreen.kt index 47bf415..af71623 100644 --- a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanScreen.kt +++ b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanScreen.kt @@ -1022,6 +1022,7 @@ private fun destinationName(destination: Destination): String = stringResource( when (destination) { Destination.SAWAI -> R.string.scan_destination_sawai Destination.MOLTEN -> R.string.scan_destination_molten + Destination.DENSO -> R.string.scan_destination_denso }, ) @@ -1188,14 +1189,16 @@ private fun ScanMessage(state: ScanUiState) { InvalidScanReason.WRONG_ORDER -> stringResource(R.string.scan_invalid_order) InvalidScanReason.EMPTY_PAYLOAD -> stringResource(R.string.scan_invalid_empty) // The expected length is only known once the session is locked to a - // destination; before that the message names the observed length alone. + // destination that has a fixed record length; before that — and for a + // Denso session, whose record length varies — the message names the + // observed length alone. InvalidScanReason.INCOMPLETE_QR_PAYLOAD -> state.lastInvalidPayloadLength?.let { observedLength -> - state.destination?.let { destination -> + state.destination?.let { CodeMatcher.expectedQrLength(it) }?.let { expectedLength -> stringResource( R.string.scan_invalid_qr_incomplete_length, observedLength, - CodeMatcher.expectedQrLength(destination), + expectedLength, ) } ?: stringResource( R.string.scan_invalid_qr_incomplete_length_unlocked, @@ -1204,11 +1207,11 @@ private fun ScanMessage(state: ScanUiState) { } ?: stringResource(R.string.scan_invalid_qr_incomplete) InvalidScanReason.OVERLONG_QR_PAYLOAD -> state.lastInvalidPayloadLength?.let { observedLength -> - state.destination?.let { destination -> + state.destination?.let { CodeMatcher.expectedQrLength(it) }?.let { expectedLength -> stringResource( R.string.scan_invalid_qr_overlong_length, observedLength, - CodeMatcher.expectedQrLength(destination), + expectedLength, ) } ?: stringResource( R.string.scan_invalid_qr_overlong_length_unlocked, diff --git a/android/feature/scan/src/main/res/values-en/strings.xml b/android/feature/scan/src/main/res/values-en/strings.xml index 4007eca..a8dfa1b 100644 --- a/android/feature/scan/src/main/res/values-en/strings.xml +++ b/android/feature/scan/src/main/res/values-en/strings.xml @@ -13,6 +13,7 @@ %1$d matches checked Sawai Seisakusho Molten + Denso Ship-to: %1$s Comparison session You can start without a name diff --git a/android/feature/scan/src/main/res/values/strings.xml b/android/feature/scan/src/main/res/values/strings.xml index 264d925..ccfbe70 100644 --- a/android/feature/scan/src/main/res/values/strings.xml +++ b/android/feature/scan/src/main/res/values/strings.xml @@ -11,6 +11,7 @@ %1$d件照合済み 澤井製作所 モルテン + デンソー 仕向地:%1$s 照合セッション 名前は空欄のままでも開始できます diff --git a/ios/CodeMatch/Models/ScanModels.swift b/ios/CodeMatch/Models/ScanModels.swift index 0ab0701..7c2f695 100644 --- a/ios/CodeMatch/Models/ScanModels.swift +++ b/ios/CodeMatch/Models/ScanModels.swift @@ -66,13 +66,15 @@ enum ExpectedCode: Equatable { } } -/// 仕向地。QRのレコード様式が仕向地ごとに異なるため、照合前にどちらかを判定する。 +/// 仕向地。QRのレコード様式が仕向地ごとに異なるため、照合前にどれかを判定する。 /// /// - `sawai` (澤井製作所): 66桁固定長の納品書兼現品票レコード。 /// - `molten` (モルテン): 61桁固定長の納品書レコード。末尾の空白も有効なデータ。 +/// - `denso` (デンソー): JAMA自己記述形式のかんばんレコード。ヘッダが項目定義を持つ可変長。 enum Destination: String, Codable, CaseIterable, Equatable { case sawai case molten + case denso /// 澤井製作所QRの桁数。`KanbanQRRecord` の受理条件と同じ値を使う。 private static let sawaiRecordLength = 66 @@ -94,9 +96,14 @@ enum Destination: String, Codable, CaseIterable, Equatable { return value } - /// QRペイロードの仕向地を判定する。どちらのレコード様式でもない場合は nil。 + /// QRペイロードの仕向地を判定する。いずれのレコード様式でもない場合は nil。 + /// + /// デンソーを最初に判定する: `KanbanQRRecord.parse` は寛容(先頭10桁が + /// `[A-Z]{4}[0-9]{6}`、20桁以上)なので、`JAMA501195…` をカード番号として + /// 受理してしまう。JAMA自己記述形式として解析できるQRは常にデンソーとする。 static func detect(qrPayload raw: String) -> Destination? { let payload = stripTransportTerminators(raw) + if isDensoRecord(payload) { return .denso } if isSawaiRecord(payload) { return .sawai } if MoltenQRRecord.isValidScanPayload(payload) { return .molten } @@ -110,6 +117,10 @@ enum Destination: String, Codable, CaseIterable, Equatable { private static func isSawaiRecord(_ payload: String) -> Bool { payload.count == sawaiRecordLength && KanbanQRRecord.parse(payload) != nil } + + private static func isDensoRecord(_ payload: String) -> Bool { + payload.prefix(4).uppercased() == "JAMA" && DensoKanbanRecord.parse(payload) != nil + } } extension Destination { @@ -118,6 +129,7 @@ extension Destination { switch self { case .sawai: AppLocalization.string("澤井製作所") case .molten: AppLocalization.string("モルテン") + case .denso: AppLocalization.string("デンソー") } } } @@ -156,6 +168,8 @@ enum CodeMatcher { return KanbanQRRecord.parse(payload)?.partNumber case .molten: return MoltenQRRecord.parse(payload)?.partNumber + case .denso: + return DensoKanbanRecord.parse(payload)?.partNumber case nil: return nil } @@ -172,16 +186,30 @@ enum CodeMatcher { return qrPart == barcodePart ? .match : .mismatch } - /// 品番を現品票の表記へ整形する。 + /// 品番を現品票の表記へ整形する(仕向地不明の従来規則)。 /// 10桁は4-2-4 (`BCJH5281GG` → `BCJH-52-81GG`)、9桁は4-2-3 (`PAF115422` → `PAF1-15-422`)。 /// それ以外の桁数はそのまま返す。 static func format(partNumber: String) -> String { + format(partNumber: partNumber, destination: nil) + } + + /// 品番を仕向地の現品票の表記へ整形する。 + /// デンソーの10桁は6-4 (`8601507722` → `860150-7722`)。 + /// 澤井製作所・モルテン・仕向地未確定は従来の長さ規則(10→4-2-4 / 9→4-2-3)。 + /// それ以外の桁数はそのまま返す。 + static func format(partNumber: String, destination: Destination?) -> String { + if destination == .denso { + guard partNumber.count == densoPartNumberLength else { return partNumber } + return "\(partNumber.prefix(6))-\(partNumber.dropFirst(6))" + } guard partNumber.count == 10 || partNumber.count == 9 else { return partNumber } let head = partNumber.prefix(4) let mid = partNumber.dropFirst(4).prefix(2) let tail = partNumber.dropFirst(6) return "\(head)-\(mid)-\(tail)" } + + private static let densoPartNumberLength = 10 } /// 仕向地 澤井製作所の納品書兼現品票QR(66桁固定長レコード)の解析結果。 @@ -346,9 +374,182 @@ extension MoltenQRRecord { } } +/// デンソーのかんばんQRが持つ項目1つ。項目番号は3桁の文字列。 +struct DensoKanbanItem: Equatable { + let id: String + let value: String +} + +/// 仕向地 デンソーのかんばんQR(JAMA自己記述形式)の解析結果。 +/// +/// レコード構造: +/// `JAMA` + 版1桁 + ヘッダ長4桁(L) + 前置き10桁 + (項目番号3桁 + 桁数2桁) × N + データ部 +/// +/// ヘッダ長 L は「ヘッダ長欄の先頭から項目定義の末尾まで」の桁数なので、 +/// ヘッダ本体は `payload[9 ..< 5+L]`、データ部は `payload[5+L...]`。 +/// 項目定義の桁数の合計はデータ部の長さと過不足なく一致しなければならない。 +/// +/// 221桁固定とは決めつけない: 実データは L=119・項目21・データ97桁だが、 +/// 項目構成が変わったかんばんも同じ規則で解析できるようにしてある。 +struct DensoKanbanRecord: Equatable { + let version: String // JAMAに続く版1桁 + let preamble: String // ヘッダ本体の先頭10桁(内容は検証しない生値) + let formType: String? // 100: 帳票区分 + let partNumber: String // 104: 部品番号(ハイフンなし10桁) + let packagingCode: String? // 111: 包装 + let packQuantity: Int // 112: 収容数 + let nextProcess: String? // 121: 次区 + let instructionCode: String? // 124(+`-`+141): 指示 + let kanbanSerial: String // 152: かんばん連番(箱ごとに固有) + let managementNumber: String? // 402: 管理番号 + let deliveryDate: String? // 519: 納入日 YYYYMMDD + let deliveryRun: String? // 520: 便 + let instructedQuantity: Int? // 521: 指示数 + let itemNumber: String? // 523: アイテムNo + let receivingCode: String? // 401: 受入 + let orderedItems: [DensoKanbanItem] // 全項目の生値(ヘッダの並び順) + let items: [String: String] // 項目番号 → 生値 + let canonicalPayload: String // 終端除去+大文字化した正規形(桁数はそのまま) + + /// 様式の先頭固定文字列。 + static let formatPrefix = "JAMA" + /// `JAMA` + 版1桁 + ヘッダ長4桁。 + private static let headerFieldsLength = 9 + /// ヘッダ本体の先頭に置かれる前置きの桁数。 + private static let preambleLength = 10 + /// 項目定義1つぶんの桁数(項目番号3桁 + 桁数2桁)。 + private static let descriptorLength = 5 + + /// Bluetoothスキャナ入力をデンソーのかんばんQRとして受理できるかを判定する。 + static func isValidScanPayload(_ payload: String) -> Bool { + parse(payload) != nil + } + + /// 読取値の正規形。解析できない値は nil。 + static func canonicalize(_ payload: String) -> String? { + parse(payload)?.canonicalPayload + } + + static func parse(_ payload: String) -> DensoKanbanRecord? { + let record = Destination.stripTransportTerminators(payload).uppercased() + let characters = Array(record) + guard characters.count >= headerFieldsLength else { return nil } + + func slice(_ range: Range) -> String { String(characters[range]) } + func isDigits(_ value: String) -> Bool { + !value.isEmpty && value.allSatisfy { $0.isASCII && $0.isNumber } + } + func trimmedOrNil(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespaces) + return (trimmed?.isEmpty ?? true) ? nil : trimmed + } + + guard slice(0..<4) == formatPrefix else { return nil } + let version = slice(4..<5) + guard isDigits(version) else { return nil } + + let headerLengthField = slice(5..= dataStart else { return nil } + + let headerBody = slice(headerFieldsLength..= preambleLength else { return nil } + let preamble = String(headerBody.prefix(preambleLength)) + let descriptorField = String(headerBody.dropFirst(preambleLength)) + guard !descriptorField.isEmpty, descriptorField.count % descriptorLength == 0 else { + return nil + } + + let descriptorCharacters = Array(descriptorField) + var lengths: [(id: String, length: Int)] = [] + var seenIDs = Set() + var index = 0 + while index < descriptorCharacters.count { + let id = String(descriptorCharacters[index..<(index + 3)]) + let lengthField = String(descriptorCharacters[(index + 3)..<(index + descriptorLength)]) + guard isDigits(id), isDigits(lengthField), let length = Int(lengthField) else { + return nil + } + // 同じ項目番号が2度出るレコードは解釈が定まらないので受理しない。 + guard seenIDs.insert(id).inserted else { return nil } + lengths.append((id: id, length: length)) + index += descriptorLength + } + + let dataCharacters = Array(characters[dataStart...]) + guard lengths.reduce(0, { $0 + $1.length }) == dataCharacters.count else { return nil } + + var orderedItems: [DensoKanbanItem] = [] + var items: [String: String] = [:] + var offset = 0 + for entry in lengths { + let value = String(dataCharacters[offset..<(offset + entry.length)]) + orderedItems.append(DensoKanbanItem(id: entry.id, value: value)) + items[entry.id] = value + offset += entry.length + } + + // 必須項目: 部品番号・収容数・かんばん連番。1つでも欠ければかんばんとして扱わない。 + guard + let partNumber = trimmedOrNil(items["104"]), + partNumber.range(of: "^[A-Z0-9]{1,18}$", options: .regularExpression) != nil, + let packQuantityField = items["112"]?.trimmingCharacters(in: .whitespaces), + isDigits(packQuantityField), + let packQuantity = Int(packQuantityField), + let kanbanSerial = trimmedOrNil(items["152"]) + else { return nil } + + let instructionBase = trimmedOrNil(items["124"]) + let instructionSuffix = trimmedOrNil(items["141"]) + let instructionCode: String? = instructionBase.map { base in + instructionSuffix.map { "\(base)-\($0)" } ?? base + } + let instructedQuantityField = items["521"]?.trimmingCharacters(in: .whitespaces) + + return DensoKanbanRecord( + version: version, + preamble: preamble, + formType: trimmedOrNil(items["100"]), + partNumber: partNumber, + packagingCode: trimmedOrNil(items["111"]), + packQuantity: packQuantity, + nextProcess: trimmedOrNil(items["121"]), + instructionCode: instructionCode, + kanbanSerial: kanbanSerial, + managementNumber: trimmedOrNil(items["402"]), + deliveryDate: trimmedOrNil(items["519"]), + deliveryRun: trimmedOrNil(items["520"]), + instructedQuantity: instructedQuantityField.flatMap { Int($0) }, + itemNumber: trimmedOrNil(items["523"]), + receivingCode: trimmedOrNil(items["401"]), + orderedItems: orderedItems, + items: items, + canonicalPayload: record + ) + } +} + +extension DensoKanbanRecord { + /// 納入日の表示形。`20260908` → `2026/09/08`。8桁でない値はそのまま返す。 + var formattedDeliveryDate: String? { + guard let deliveryDate else { return nil } + guard deliveryDate.count == 8 else { return deliveryDate } + let year = deliveryDate.prefix(4) + let month = deliveryDate.dropFirst(4).prefix(2) + let day = deliveryDate.dropFirst(6) + return "\(year)/\(month)/\(day)" + } +} + /// 「同じ箱を二重に検査していないか」を判定するための箱固有キー。 /// -/// 澤井製作所はカード番号がQRに含まれるためQR単体で箱を識別できるが、 +/// 澤井製作所はカード番号がQRに含まれるためQR単体で箱を識別でき、 +/// デンソーもかんばん連番(項目152)が箱ごとに違うのでQR単体で識別できる。 /// モルテンのQRは1品番1レコードで箱を区別しないため、現品票の管理コードまで含める。 enum BoxIdentity { static func make(qrPayload: String, barcodePayload: String?) -> String? { @@ -357,7 +558,7 @@ enum BoxIdentity { } switch Destination.detect(qrPayload: qrPayload) { - case .sawai: + case .sawai, .denso: let qr = identity(qrPayload) return qr.isEmpty ? nil : qr case .molten: @@ -375,17 +576,29 @@ 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-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。 + private static let densoFormatPattern = "^[A-Z0-9]{6}-[A-Z0-9]{4}@[A-Z0-9]+$" + /// 現品票Code 128の業務フォーマット(品番@管理コード)かを確認する。 /// 物理シンボル種別はSDK通知に含まれないため、QR文字列などを次工程で受理しない。 - /// 品番の末尾ブロックは澤井製作所が4桁固定、モルテンは3桁または4桁。 - /// 仕向地が未判定(nil)のときは、どちらの仕向地でも取りこぼさない緩い方を使う。 + /// 品番の区切りは澤井製作所が4-2-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() - let tailLength = destination == .sawai ? "{4}" : "{3,4}" - return value.range( - of: "^[A-Z0-9]{4}-[A-Z0-9]{2}-[A-Z0-9]\(tailLength)@[A-Z0-9]+$", - options: .regularExpression - ) != nil + let patterns: [String] + switch destination { + case .sawai: patterns = [sawaiFormatPattern] + case .molten: patterns = [moltenFormatPattern] + case .denso: patterns = [densoFormatPattern] + case nil: patterns = [sawaiFormatPattern, moltenFormatPattern, densoFormatPattern] + } + return patterns.contains { + value.range(of: $0, options: .regularExpression) != nil + } } static func parse(_ payload: String) -> TagBarcodeRecord? { diff --git a/ios/CodeMatch/Resources/Localizable.xcstrings b/ios/CodeMatch/Resources/Localizable.xcstrings index 62595bb..5a23a94 100644 --- a/ios/CodeMatch/Resources/Localizable.xcstrings +++ b/ios/CodeMatch/Resources/Localizable.xcstrings @@ -2284,6 +2284,22 @@ } } }, + "デンソー" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denso" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "デンソー" + } + } + } + }, "トリガーを1回押して読み取る" : { "localizations" : { "en" : { diff --git a/ios/CodeMatchTests/CodeMatcherTests.swift b/ios/CodeMatchTests/CodeMatcherTests.swift index 950c27b..b486f27 100644 --- a/ios/CodeMatchTests/CodeMatcherTests.swift +++ b/ios/CodeMatchTests/CodeMatcherTests.swift @@ -28,6 +28,20 @@ final class CodeMatcherTests: XCTestCase { private let moltenShortPartQRPayload = "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" private let moltenShortPartBarcodePayload = "PAF1-15-422@0NKD3C" + // 仕向地 デンソーの実データ(かんばん 0140・221桁)。 + // 項目144・402・515・516の空白の連なりもデータなので、詰めないこと。 + private let densoQRPayload = + "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + private let densoBarcodePayload = "860150-7722@1DZ50O" + + // 項目構成を変えた合成かんばん(項目3つ・ヘッダ長も別)。 + // デンソーを先に判定しないと、66桁は澤井製作所、61桁はモルテンとして + // 完全なレコードに見えてしまう。 + private let densoSawaiLengthPayload = + "JAMA5002950000000031521010410112120140 8601507722000000000024" + private let densoMoltenLengthPayload = + "JAMA500295000000003104101120715210860150772200000240140123456" + func testPartNumberFromBarcode() { XCTAssertEqual(CodeMatcher.partNumber(fromBarcode: barcodePayload), "BCJH5281GG") XCTAssertEqual(CodeMatcher.partNumber(fromBarcode: "KAAA-55-D86B@0Y5U0I"), "KAAA55D86B") @@ -68,7 +82,7 @@ final class CodeMatcherTests: XCTestCase { let fixtures = try loadSharedMatchingFixtures() XCTAssertEqual(fixtures.schemaVersion, 2) - XCTAssertEqual(fixtures.cases.count, 35) + XCTAssertEqual(fixtures.cases.count, 47) XCTAssertEqual( Set(fixtures.cases.map(\.id)).count, fixtures.cases.count, @@ -303,6 +317,12 @@ final class CodeMatcherTests: XCTestCase { let shifted = " " + String(moltenQRPayload.dropLast()) XCTAssertEqual(shifted.count, 61) XCTAssertNil(Destination.detect(qrPayload: shifted)) + + // JAMA自己記述形式は桁数に関係なくデンソーとして判定する + XCTAssertEqual(Destination.detect(qrPayload: densoQRPayload), .denso) + XCTAssertEqual(Destination.detect(qrPayload: densoQRPayload + "\r\n"), .denso) + XCTAssertEqual(Destination.detect(qrPayload: densoQRPayload.lowercased()), .denso) + XCTAssertNil(Destination.detect(qrPayload: String(densoQRPayload.dropLast()))) } func testMoltenQRRecordParsesAllFields() { @@ -398,6 +418,17 @@ final class CodeMatcherTests: XCTestCase { CodeMatcher.compare(qrPayload: " \(qrPayload.lowercased())\n", barcodePayload: barcodePayload), .match ) + + // デンソーは固定位置ではなく、JAMAレコードの項目104から取り出す。 + XCTAssertEqual(CodeMatcher.partNumber(fromQR: densoQRPayload), "8601507722") + XCTAssertEqual( + CodeMatcher.compare(qrPayload: densoQRPayload, barcodePayload: densoBarcodePayload), + .match + ) + XCTAssertEqual( + CodeMatcher.compare(qrPayload: densoQRPayload, barcodePayload: "860150-7791@01335C"), + .mismatch + ) } func testBoxIdentityPerDestination() { @@ -434,6 +465,296 @@ final class CodeMatcherTests: XCTestCase { XCTAssertNil( BoxIdentity.make(qrPayload: "PART:BCJH-52-81GG;QTY:12", barcodePayload: barcodePayload) ) + + // デンソーはかんばん連番(項目152)が箱ごとに違うので、澤井製作所と同じく + // QR単体で箱が決まり、現品票は箱の識別に使わない。 + let densoIdentity = BoxIdentity.make( + qrPayload: densoQRPayload, + barcodePayload: densoBarcodePayload + ) + XCTAssertEqual(densoIdentity, densoQRPayload) + XCTAssertEqual( + BoxIdentity.make(qrPayload: densoQRPayload, barcodePayload: nil), + densoIdentity + ) + XCTAssertEqual( + BoxIdentity.make(qrPayload: densoQRPayload, barcodePayload: "860150-7722@1DZB0O"), + densoIdentity + ) + } + + + // MARK: - 仕向地 デンソー + + /// 仕向地デンソーの実データ7組は、照合結果だけでなく読取境界 + /// (JAMA自己記述レコードの解析、6-4@管理コード検証)も通り、仕向地判定も安定する。 + func testSharedDensoPairsPassBothScanBoundaries() throws { + let densoPairs = try loadSharedMatchingFixtures().cases.filter { + $0.id.hasPrefix("denso-") && $0.expected == "match" + } + XCTAssertEqual(densoPairs.count, 7) + + for pair in densoPairs { + XCTAssertEqual(pair.qrPayload.count, 221, pair.id) + XCTAssertTrue(DensoKanbanRecord.isValidScanPayload(pair.qrPayload), pair.id) + XCTAssertTrue( + TagBarcodeRecord.isValidScanPayload(pair.barcodePayload, destination: .denso), + pair.id + ) + // 6-4の現品票は他の仕向地のセッションでは受理しない。 + XCTAssertFalse( + TagBarcodeRecord.isValidScanPayload(pair.barcodePayload, destination: .sawai), + pair.id + ) + XCTAssertFalse( + TagBarcodeRecord.isValidScanPayload(pair.barcodePayload, destination: .molten), + pair.id + ) + // 221桁のかんばんは澤井製作所(66桁)・モルテン(57〜61桁)には決して当てはまらない。 + XCTAssertFalse(KanbanQRRecord.isValidScanPayload(pair.qrPayload), pair.id) + XCTAssertFalse(MoltenQRRecord.isValidScanPayload(pair.qrPayload), pair.id) + XCTAssertEqual(Destination.detect(qrPayload: pair.qrPayload), .denso, pair.id) + + let record = DensoKanbanRecord.parse(pair.qrPayload) + XCTAssertEqual( + record?.partNumber, + CodeMatcher.partNumber(fromBarcode: pair.barcodePayload), + pair.id + ) + XCTAssertEqual(record?.partNumber.count, 10, pair.id) + // 現品票は品番を6-4で印字する。 + XCTAssertEqual( + CodeMatcher.format(partNumber: record?.partNumber ?? "", destination: .denso), + TagBarcodeRecord.parse(pair.barcodePayload)?.partNumber, + pair.id + ) + } + + // かんばん連番が箱ごとに違うので、7枚は7箱として区別できる(2品番)。 + let serials = Set(densoPairs.compactMap { DensoKanbanRecord.parse($0.qrPayload)?.kanbanSerial }) + XCTAssertEqual(serials.count, 7) + let identities = Set(densoPairs.compactMap { + BoxIdentity.make(qrPayload: $0.qrPayload, barcodePayload: $0.barcodePayload) + }) + XCTAssertEqual(identities.count, 7) + let partNumbers = Set(densoPairs.compactMap { CodeMatcher.partNumber(fromQR: $0.qrPayload) }) + XCTAssertEqual(partNumbers, ["8601507722", "8601507791"]) + } + + func testDensoKanbanRecordParsesAllItemsFromRealPayload() { + XCTAssertEqual(densoQRPayload.count, 221) + let record = DensoKanbanRecord.parse(densoQRPayload) + + XCTAssertEqual(record?.version, "5") + XCTAssertEqual(record?.preamble, "5000001021") + XCTAssertEqual(record?.orderedItems.count, 21) + XCTAssertEqual( + record?.orderedItems.map(\.id), + [ + "100", "104", "111", "112", "121", "124", "127", "141", "142", "144", "152", + "402", "515", "516", "519", "520", "521", "526", "523", "522", "401" + ] + ) + XCTAssertEqual(record?.formType, "20") + XCTAssertEqual(record?.partNumber, "8601507722") + XCTAssertEqual(record?.packagingCode, "00") + XCTAssertEqual(record?.packQuantity, 24) + XCTAssertEqual(record?.nextProcess, "D850") + XCTAssertEqual(record?.instructionCode, "C01008-45") + XCTAssertEqual(record?.kanbanSerial, "0140") + XCTAssertEqual(record?.managementNumber, "SWS") + XCTAssertEqual(record?.deliveryDate, "20260908") + XCTAssertEqual(record?.formattedDeliveryDate, "2026/09/08") + XCTAssertEqual(record?.deliveryRun, "S001") + XCTAssertEqual(record?.instructedQuantity, 72) + XCTAssertEqual(record?.itemNumber, "9924543330") + XCTAssertEqual(record?.receivingCode, "M6") + // 空欄の項目は桁数のまま生値で保持する。 + XCTAssertEqual(record?.items["144"], " ") + XCTAssertEqual(record?.items["142"], "M") + XCTAssertEqual(record?.canonicalPayload, densoQRPayload) + + // 小文字で通知された読取値は大文字化して受理する。 + XCTAssertEqual( + DensoKanbanRecord.parse(densoQRPayload.lowercased())?.canonicalPayload, + densoQRPayload + ) + XCTAssertEqual(DensoKanbanRecord.canonicalize(densoQRPayload), densoQRPayload) + XCTAssertTrue(DensoKanbanRecord.isValidScanPayload(densoQRPayload)) + XCTAssertFalse(DensoKanbanRecord.isValidScanPayload(qrPayload)) + XCTAssertFalse(DensoKanbanRecord.isValidScanPayload(moltenQRPayload)) + XCTAssertFalse(DensoKanbanRecord.isValidScanPayload(densoBarcodePayload)) + } + + /// 汎用パーサであること(項目数・並び・ヘッダ長が変わっても解析できる)と、 + /// デンソーを最初に判定していることを同時に固定する。 + func testDensoKanbanRecordParsesDifferentItemLayout() { + let record = DensoKanbanRecord.parse(densoSawaiLengthPayload) + XCTAssertEqual(record?.orderedItems.map(\.id), ["152", "104", "112"]) + XCTAssertEqual(record?.partNumber, "8601507722") + XCTAssertEqual(record?.packQuantity, 24) + XCTAssertEqual(record?.kanbanSerial, "0140") + // 宣言されていない項目は無いだけで、解析は成立する。 + XCTAssertNil(record?.formType) + XCTAssertNil(record?.deliveryDate) + XCTAssertNil(record?.formattedDeliveryDate) + XCTAssertNil(record?.instructedQuantity) + + // 66桁は澤井製作所の完全なレコードにも見えるが、デンソーとして判定する。 + XCTAssertEqual(densoSawaiLengthPayload.count, 66) + XCTAssertTrue(KanbanQRRecord.isValidScanPayload(densoSawaiLengthPayload)) + XCTAssertEqual(Destination.detect(qrPayload: densoSawaiLengthPayload), .denso) + + // 61桁はモルテンの完全なレコードにも見えるが、やはりデンソーとして判定する。 + XCTAssertEqual(densoMoltenLengthPayload.count, 61) + XCTAssertTrue(MoltenQRRecord.isValidScanPayload(densoMoltenLengthPayload)) + XCTAssertEqual(Destination.detect(qrPayload: densoMoltenLengthPayload), .denso) + XCTAssertEqual( + DensoKanbanRecord.parse(densoMoltenLengthPayload)?.kanbanSerial, + "0140123456" + ) + XCTAssertEqual(CodeMatcher.partNumber(fromQR: densoMoltenLengthPayload), "8601507722") + } + + func testDensoKanbanRecordRejectsBrokenHeaderOrDataLength() { + XCTAssertNil(DensoKanbanRecord.parse("")) + XCTAssertNil(DensoKanbanRecord.parse("JAMA")) + + func replacingHeader(_ value: String) -> String { + String(densoQRPayload.prefix(5)) + value + String(densoQRPayload.dropFirst(9)) + } + + // 様式が違う / 版とヘッダ長が数字でない。 + XCTAssertNil(DensoKanbanRecord.parse("JAMB" + densoQRPayload.dropFirst(4))) + XCTAssertNil(DensoKanbanRecord.parse("JAMAX" + densoQRPayload.dropFirst(5))) + XCTAssertNil(DensoKanbanRecord.parse(replacingHeader("01X9"))) + // ヘッダがペイロードより長い。 + XCTAssertNil(DensoKanbanRecord.parse(replacingHeader("9999"))) + // 119 - 14 = 105 は項目定義の整数個だが、118 - 14 = 104 は違う。 + XCTAssertNil(DensoKanbanRecord.parse(replacingHeader("0118"))) + // 整数個でも、桁数の合計がデータ部と合わなければ受理しない。 + XCTAssertNil(DensoKanbanRecord.parse(replacingHeader("0114"))) + // データ部の過不足。 + XCTAssertNil(DensoKanbanRecord.parse(String(densoQRPayload.dropLast()))) + XCTAssertNil(DensoKanbanRecord.parse(densoQRPayload + "0")) + + // 項目番号の重複は解釈が定まらない。 + let duplicateItemID = "JAMA5" + "0034" + "5000000004" + + "10410" + "10410" + "11207" + "15210" + + "8601507722" + "8601507722" + "0000024" + "0140123456" + XCTAssertNil(DensoKanbanRecord.parse(duplicateItemID)) + + // 必須項目: 104(部品番号)・112(収容数)・152(かんばん連番)。 + let withoutPartNumber = "JAMA5" + "0024" + "5000000002" + + "11207" + "15210" + "0000024" + "0140123456" + XCTAssertNil(DensoKanbanRecord.parse(withoutPartNumber)) + let withoutPackQuantity = "JAMA5" + "0024" + "5000000002" + + "10410" + "15210" + "8601507722" + "0140123456" + XCTAssertNil(DensoKanbanRecord.parse(withoutPackQuantity)) + let nonNumericPackQuantity = "JAMA5" + "0029" + "5000000003" + + "15210" + "10410" + "11212" + + "0140 " + "8601507722" + "00000000002X" + XCTAssertNil(DensoKanbanRecord.parse(nonNumericPackQuantity)) + let blankKanbanSerial = "JAMA5" + "0029" + "5000000003" + + "15210" + "10410" + "11212" + + " " + "8601507722" + "000000000024" + XCTAssertNil(DensoKanbanRecord.parse(blankKanbanSerial)) + + // 壊れたかんばんを他の仕向地へ降格させない。 + XCTAssertNil(Destination.detect(qrPayload: String(densoQRPayload.dropLast()))) + XCTAssertNil(CodeMatcher.partNumber(fromQR: String(densoQRPayload.dropLast()))) + XCTAssertNil( + BoxIdentity.make( + qrPayload: String(densoQRPayload.dropLast()), + barcodePayload: densoBarcodePayload + ) + ) + } + + func testTagBarcodeRecordAcceptsSixFourOnlyForDenso() { + XCTAssertTrue( + TagBarcodeRecord.isValidScanPayload(densoBarcodePayload, destination: .denso) + ) + XCTAssertTrue( + TagBarcodeRecord.isValidScanPayload("860150-7722@1dz50o", destination: .denso) + ) + // 仕向地未判定のときは、どの仕向地の現品票も取りこぼさない。 + XCTAssertTrue(TagBarcodeRecord.isValidScanPayload(densoBarcodePayload, destination: nil)) + XCTAssertTrue(TagBarcodeRecord.isValidScanPayload(barcodePayload, destination: nil)) + XCTAssertTrue( + TagBarcodeRecord.isValidScanPayload(moltenShortPartBarcodePayload, destination: nil) + ) + + // 6-4は澤井製作所・モルテンのセッションでは拒否し、 + // 4-2-4 / 4-2-3はデンソーのセッションでは拒否する。 + XCTAssertFalse( + TagBarcodeRecord.isValidScanPayload(densoBarcodePayload, destination: .sawai) + ) + XCTAssertFalse( + TagBarcodeRecord.isValidScanPayload(densoBarcodePayload, destination: .molten) + ) + XCTAssertFalse(TagBarcodeRecord.isValidScanPayload(barcodePayload, destination: .denso)) + XCTAssertFalse( + TagBarcodeRecord.isValidScanPayload(moltenShortPartBarcodePayload, destination: .denso) + ) + + let destinations: [Destination?] = [.sawai, .molten, .denso, nil] + for destination in destinations { + XCTAssertFalse( + TagBarcodeRecord.isValidScanPayload("860150-772@1DZ50O", destination: destination) + ) + XCTAssertFalse( + TagBarcodeRecord.isValidScanPayload("86015-7722@1DZ50O", destination: destination) + ) + XCTAssertFalse( + TagBarcodeRecord.isValidScanPayload("8601507722@1DZ50O", destination: destination) + ) + XCTAssertFalse( + TagBarcodeRecord.isValidScanPayload("860150-7722", destination: destination) + ) + XCTAssertFalse( + TagBarcodeRecord.isValidScanPayload(densoQRPayload, destination: destination) + ) + } + } + + func testFormatPartNumberIsDestinationAware() { + // 6-4になるのはデンソーだけ。他の仕向地は従来の4-2-4 / 4-2-3のまま。 + XCTAssertEqual( + CodeMatcher.format(partNumber: "8601507722", destination: .denso), + "860150-7722" + ) + XCTAssertEqual( + CodeMatcher.format(partNumber: "8601507722", destination: .sawai), + "8601-50-7722" + ) + XCTAssertEqual( + CodeMatcher.format(partNumber: "8601507722", destination: .molten), + "8601-50-7722" + ) + XCTAssertEqual( + CodeMatcher.format(partNumber: "8601507722", destination: nil), + "8601-50-7722" + ) + XCTAssertEqual(CodeMatcher.format(partNumber: "8601507722"), "8601-50-7722") + XCTAssertEqual( + CodeMatcher.format(partNumber: "BCJH5281GG", destination: .sawai), + "BCJH-52-81GG" + ) + XCTAssertEqual( + CodeMatcher.format(partNumber: "PAF115422", destination: .molten), + "PAF1-15-422" + ) + // デンソーは10桁以外を整形しない。 + XCTAssertEqual( + CodeMatcher.format(partNumber: "PAF115422", destination: .denso), + "PAF115422" + ) + XCTAssertEqual(CodeMatcher.format(partNumber: "ABC", destination: .denso), "ABC") + XCTAssertEqual( + CodeMatcher.format(partNumber: "ABCDEFGHIJK", destination: .denso), + "ABCDEFGHIJK" + ) } private func replacing(_ payload: String, at index: Int, with character: Character) -> String { diff --git a/shared/README.md b/shared/README.md index f40fd5f..a1489af 100644 --- a/shared/README.md +++ b/shared/README.md @@ -2,8 +2,8 @@ このディレクトリにはプラットフォーム非依存の仕様確認資源だけを置きます。Swift、Kotlin、AVFoundation、CameraX、各社Bluetooth SDKなどの実装コードは置きません。 -- `test-fixtures/matching-cases.json`: Swift/Kotlin共通の照合入力と期待結果。`schemaVersion` 2、35ケースで、各ケースはQRの仕向地を示す`destination`(`sawai` / `molten`)を持つ。どちらの様式でもないQRを扱う2ケースだけが`destination`を持たない -- `test-fixtures/images/`: 実機またはエミュレーターへ表示するQR・Code 128画像。両仕向地ぶんを置き、モルテンは `molten-` で始まる5点 +- `test-fixtures/matching-cases.json`: Swift/Kotlin共通の照合入力と期待結果。`schemaVersion` 2、47ケースで、各ケースは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で画像を再生成する補助ツール 照合ルールを変える場合は、先に共通仕様とfixtureを更新し、両プラットフォームのテストへ同じ変更を反映します。 diff --git a/shared/test-fixtures/images/denso-code128-second-box.png b/shared/test-fixtures/images/denso-code128-second-box.png new file mode 100644 index 0000000000000000000000000000000000000000..47d863914e52e72012dbf34e5c474e853b2442d2 GIT binary patch literal 9690 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