diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryDeliveryGroups.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryDeliveryGroups.kt index af0521e..751fa45 100644 --- a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryDeliveryGroups.kt +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryDeliveryGroups.kt @@ -1,6 +1,7 @@ package jp.rimtty.codematch.core.export import jp.rimtty.codematch.core.matching.CodeMatcher +import jp.rimtty.codematch.core.matching.DensoKanbanQrRecord import jp.rimtty.codematch.core.matching.MoltenQrRecord import jp.rimtty.codematch.core.model.Destination import jp.rimtty.codematch.core.model.MatchEntry @@ -102,4 +103,16 @@ internal fun MatchEntry.moltenRecord(): MoltenQrRecord? { return MoltenQrRecord.parse(payload) } +/** + * Parses an entry's QR payload as a Denso kanban. + * The destination is detected first because [jp.rimtty.codematch.core.matching.KanbanQrRecord] + * parses leniently: a `JAMA...` payload satisfies its card-number rule, so a + * Denso kanban would otherwise be rendered as a Sawai slip. + */ +internal fun MatchEntry.densoRecord(): DensoKanbanQrRecord? { + val payload = qrPayload ?: return null + if (CodeMatcher.detectDestination(payload) != Destination.DENSO) return null + return DensoKanbanQrRecord.parse(payload) +} + private val FOUR_DIGITS = Regex("[0-9]{4}") 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 428833c..692b109 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 @@ -55,6 +55,17 @@ data class HistoryExportLabels( val instructionTime: String, val cumulativeQuantity: String, val pieceUnit: String, + val formType: String, + val packagingCode: String, + val nextProcess: String, + val instructionCode: String, + val kanbanSerial: String, + val managementNumber: String, + val deliveryDate: String, + val deliveryRun: String, + /** Denso item number; [itemNumber] is the Sawai slip's 品目番号. */ + val densoItemNumber: String, + val receivingCode: String, /** Singular and plural units are kept separately for natural English. */ val boxCountSingular: String = boxCount, val boxCountPlural: String = boxCount, @@ -132,6 +143,16 @@ object HistoryExportTextFormatter { instructionTime = "時刻", cumulativeQuantity = "累計", pieceUnit = "個", + formType = "帳票区分", + packagingCode = "包装", + nextProcess = "次区", + instructionCode = "指示", + kanbanSerial = "かんばん連番", + managementNumber = "管理番号", + deliveryDate = "納入日", + deliveryRun = "便", + densoItemNumber = "アイテムNo", + receivingCode = "受入", boxCountSingular = "箱", boxCountPlural = "箱", ) @@ -181,6 +202,16 @@ object HistoryExportTextFormatter { instructionTime = "Time", cumulativeQuantity = "Total", pieceUnit = "pcs", + formType = "Form type", + packagingCode = "Packaging", + nextProcess = "Next process", + instructionCode = "Instruction", + kanbanSerial = "Kanban serial", + managementNumber = "Management number", + deliveryDate = "Delivery date", + deliveryRun = "Delivery run", + densoItemNumber = "Item No.", + receivingCode = "Receiving", boxCountSingular = "box", boxCountPlural = "boxes", ) diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfContent.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfContent.kt index 9d8cc34..2983a5f 100644 --- a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfContent.kt +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfContent.kt @@ -148,17 +148,95 @@ object HistoryPdfContent { .mapNotNull { it.qrPayload } .firstOrNull() ?.let(CodeMatcher::detectDestination) - if (destination == Destination.MOLTEN) { - appendMoltenDeliveries(blocks, group, language, zoneId, labels) - } else { - appendSawaiDelivery(blocks, group, language, labels) - blocks += HistoryPdfBlock(labels.boxRecords, PdfTextStyle.SECTION, spacingAfter = 2f) - group.entries.forEachIndexed { boxIndex, entry -> - appendBoxRecord(blocks, entry, boxIndex + 1, null, language, zoneId, labels) + when (destination) { + Destination.MOLTEN -> appendMoltenDeliveries(blocks, group, language, zoneId, labels) + // KanbanQrRecord.parse is lenient enough to accept a Denso payload, + // so the destination - not the parse result - picks the branch. + Destination.DENSO -> appendDensoKanban(blocks, group, language, zoneId, labels) + else -> { + appendSawaiDelivery(blocks, group, language, labels) + blocks += HistoryPdfBlock(labels.boxRecords, PdfTextStyle.SECTION, spacingAfter = 2f) + group.entries.forEachIndexed { boxIndex, entry -> + appendBoxRecord(blocks, entry, boxIndex + 1, null, null, language, zoneId, labels) + } } } } + /** + * The kanban block of one Denso part number, then every box of it. + * + * A Denso kanban repeats the same part fields on every box and differs only + * in the kanban serial, so the shared fields are printed once from the first + * box and the serial travels with each box record. Boxes are counted per + * part number, exactly like Sawai. + */ + private fun appendDensoKanban( + blocks: MutableList, + group: GroupedMatchEntry, + language: AppLanguage, + zoneId: ZoneId, + labels: HistoryExportLabels, + ) { + val record = group.entries.asSequence() + .mapNotNull { it.densoRecord() } + .firstOrNull() + if (record != null) { + blocks += HistoryPdfBlock(labels.deliveryInformation, PdfTextStyle.SECTION, spacingAfter = 2f) + blocks += HistoryPdfBlock( + text = segments( + "${labels.moltenPartNumber}: " + + CodeMatcher.formatPartNumber(record.partNumber, Destination.DENSO), + "${labels.packQuantity}: " + + HistoryExportTextFormatter.integer(record.packQuantity, language), + record.instructedQuantity?.let { + "${labels.instructedQuantity}: " + + HistoryExportTextFormatter.integer(it, language) + }, + ), + style = PdfTextStyle.BODY, + spacingAfter = 2f, + ) + blocks += HistoryPdfBlock( + text = segments( + record.nextProcess?.let { "${labels.nextProcess}: $it" }, + record.instructionCode?.let { "${labels.instructionCode}: $it" }, + record.formattedDeliveryDate?.let { "${labels.deliveryDate}: $it" }, + record.deliveryRun?.let { "${labels.deliveryRun}: $it" }, + ), + style = PdfTextStyle.BODY, + spacingAfter = 2f, + ) + blocks += HistoryPdfBlock( + text = segments( + record.managementNumber?.let { "${labels.managementNumber}: $it" }, + record.itemNumber?.let { "${labels.densoItemNumber}: $it" }, + record.receivingCode?.let { "${labels.receivingCode}: $it" }, + ), + style = PdfTextStyle.BODY, + spacingAfter = 4f, + ) + } + + blocks += HistoryPdfBlock(labels.boxRecords, PdfTextStyle.SECTION, spacingAfter = 2f) + group.entries.forEachIndexed { boxIndex, entry -> + appendBoxRecord( + blocks, + entry, + boxIndex + 1, + null, + entry.densoRecord()?.kanbanSerial, + language, + zoneId, + labels, + ) + } + } + + /** Joins the present segments with the report's `; ` convention. */ + private fun segments(vararg values: String?): String = + values.filterNotNull().joinToString("; ") + private fun appendSawaiDelivery( blocks: MutableList, group: GroupedMatchEntry, @@ -173,7 +251,8 @@ object HistoryPdfContent { blocks += HistoryPdfBlock(labels.deliveryInformation, PdfTextStyle.SECTION, spacingAfter = 2f) val suffix = qr.partSuffix?.let { " (${labels.suffix} $it)" }.orEmpty() blocks += HistoryPdfBlock( - text = "${labels.itemNumber}: ${CodeMatcher.formatPartNumber(qr.partNumber)}$suffix; " + + text = "${labels.itemNumber}: " + + "${CodeMatcher.formatPartNumber(qr.partNumber, Destination.SAWAI)}$suffix; " + "${labels.cardNumber}: ${qr.cardNumber}", style = PdfTextStyle.BODY, spacingAfter = 2f, @@ -218,7 +297,8 @@ object HistoryPdfContent { spacingAfter = 2f, ) blocks += HistoryPdfBlock( - text = "${labels.moltenPartNumber}: ${CodeMatcher.formatPartNumber(record.partNumber)}; " + + text = "${labels.moltenPartNumber}: " + + "${CodeMatcher.formatPartNumber(record.partNumber, Destination.MOLTEN)}; " + "${labels.ordererCode}: ${record.ordererCode}", style = PdfTextStyle.BODY, spacingAfter = 2f, @@ -242,6 +322,7 @@ object HistoryPdfContent { entry, boxIndex + 1, entry.moltenRecord()?.packQuantity, + null, language, zoneId, labels, @@ -258,21 +339,23 @@ object HistoryPdfContent { if (remaining.isNotEmpty()) { blocks += HistoryPdfBlock(labels.boxRecords, PdfTextStyle.SECTION, spacingAfter = 2f) remaining.forEachIndexed { boxIndex, entry -> - appendBoxRecord(blocks, entry, boxIndex + 1, null, language, zoneId, labels) + appendBoxRecord(blocks, entry, boxIndex + 1, null, null, language, zoneId, labels) } } } /** - * One box: its match time, optional pack quantity, management code, and - * both raw payloads. [packQuantity] is null for a Sawai box, whose slip - * carries the quantity in the delivery block instead. + * One box: its match time, optional pack quantity, optional kanban serial, + * management code, and both raw payloads. [packQuantity] is null for a + * Sawai box, whose slip carries the quantity in the delivery block instead, + * and [kanbanSerial] is set only for Denso, where it identifies the box. */ private fun appendBoxRecord( blocks: MutableList, entry: MatchEntry, boxIndex: Int, packQuantity: Int?, + kanbanSerial: String?, language: AppLanguage, zoneId: ZoneId, labels: HistoryExportLabels, @@ -285,10 +368,12 @@ object HistoryPdfContent { val quantity = packQuantity ?.let { "${labels.packQuantity}: ${HistoryExportTextFormatter.integer(it, language)}; " } .orEmpty() + val serial = kanbanSerial?.let { "${labels.kanbanSerial}: $it; " }.orEmpty() blocks += HistoryPdfBlock( text = "${boxLabel(boxNumber, language, labels)} ${labels.matchTime}: " + "${HistoryExportTextFormatter.dateTime(entry.matchedAt, language, zoneId)}; " + quantity + + serial + "${labels.managementCode}: $managementCode", style = PdfTextStyle.BODY, spacingAfter = 2f, diff --git a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryDeliveryGroupsTest.kt b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryDeliveryGroupsTest.kt index 058a810..d09599b 100644 --- a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryDeliveryGroupsTest.kt +++ b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryDeliveryGroupsTest.kt @@ -17,6 +17,8 @@ class HistoryDeliveryGroupsTest { "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" private val sawaiQr = "DCLP675300BCJH5281GG020000120000001200L000000000000BLBDILLU92 0*" + // A real デンソー kanban; the runs of spaces are blank fields, not padding. + private val densoQr = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" @Test fun deliveryGroupsKeepFirstSeenOrderAndSumPackQuantities() { @@ -50,6 +52,29 @@ class HistoryDeliveryGroupsTest { assertTrue(entries.moltenDeliveryGroups().isEmpty()) } + @Test + fun densoEntriesProduceNoDeliveryGroups() { + assertEquals(221, densoQr.length) + val denso = moltenEntry("denso", densoQr, "860150-7722@1DZ50O") + val sawai = moltenEntry("sawai", sawaiQr, "BCJH-52-81GG@1N5X0C") + val molten = moltenEntry("molten", moltenQr2, "PAF1-15-422@0NKD3C") + + // Delivery numbers are a Molten concept only. + assertTrue(listOf(denso).moltenDeliveryGroups().isEmpty()) + assertEquals(Destination.DENSO, MatchSession(entries = listOf(denso)).resolvedDestination()) + + val record = denso.densoRecord() + assertEquals("8601507722", record?.partNumber) + assertEquals("0140", record?.kanbanSerial) + assertEquals(24, record?.packQuantity) + assertEquals(72, record?.instructedQuantity) + assertEquals("2026/09/08", record?.formattedDeliveryDate) + // A Sawai or Molten entry is never read at Denso field positions. + assertNull(sawai.densoRecord()) + assertNull(molten.densoRecord()) + assertNull(MatchEntry(id = "legacy", code = "860150-7722").densoRecord()) + } + @Test fun resolvedDestinationPrefersTheStoredValueThenFallsBackToTheEntries() { val legacySawai = MatchSession( diff --git a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryExportTextTest.kt b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryExportTextTest.kt index c8cc254..0d74a16 100644 --- a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryExportTextTest.kt +++ b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryExportTextTest.kt @@ -60,9 +60,11 @@ class HistoryExportTextTest { assertEquals("仕向地", japanese.destination) assertEquals("澤井製作所", japanese.destinationName(Destination.SAWAI)) assertEquals("モルテン", japanese.destinationName(Destination.MOLTEN)) + assertEquals("デンソー", japanese.destinationName(Destination.DENSO)) assertEquals("Ship-to", english.destination) assertEquals("Sawai Seisakusho", english.destinationName(Destination.SAWAI)) assertEquals("Molten", english.destinationName(Destination.MOLTEN)) + assertEquals("Denso", english.destinationName(Destination.DENSO)) } @Test diff --git a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryJsonExporterTest.kt b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryJsonExporterTest.kt index 73de3d6..1241f03 100644 --- a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryJsonExporterTest.kt +++ b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryJsonExporterTest.kt @@ -75,6 +75,38 @@ class HistoryJsonExporterTest { ), ) + private val densoSession = MatchSession( + id = "session-denso", + startedAt = Instant.parse("2026-09-07T02:00:00Z").toEpochMilli(), + endedAt = Instant.parse("2026-09-07T02:30:00Z").toEpochMilli(), + name = "デンソー 午後", + destination = Destination.DENSO, + entries = listOf( + MatchEntry( + id = "entry-denso-1", + code = "860150-7722", + matchedAt = Instant.parse("2026-09-07T02:05:00Z").toEpochMilli(), + qrPayload = DENSO_KANBAN_QR, + barcodePayload = "860150-7722@1DZ50O", + sequence = 0L, + ), + ), + ) + + @Test + fun densoSessionExportsItsDestinationIdAndKanbanPayloadByteForByte() { + val session = export(listOf(densoSession))["sessions"].asJsonArray[0].asJsonObject + + assertEquals("session-denso", session["id"].asString) + assertEquals("denso", session["destination"].asString) + val entry = session["entries"].asJsonArray[0].asJsonObject + assertEquals("860150-7722", entry["code"].asString) + assertEquals("860150-7722@1DZ50O", entry["barcodePayload"].asString) + // The blank fixed-width fields inside the kanban are data. + assertEquals(DENSO_KANBAN_QR, entry["qrPayload"].asString) + assertEquals(221, entry["qrPayload"].asString.length) + } + @Test fun documentCarriesSchemaPlatformVersionAndUtcExportTimestamp() { val root = export(listOf(moltenSession, sawaiLegacySession)) @@ -233,6 +265,9 @@ class HistoryJsonExporterTest { /** Real モルテン labels; see shared/test-fixtures/matching-cases.json. */ const val MOLTEN_PAF_QR = "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" + /** A real デンソー kanban; see the same fixture. */ + const val DENSO_KANBAN_QR = + "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" const val MOLTEN_TRAILING_SPACE_QR = "AK6805D10E50N10B U543820000MB S600700000020908 " } diff --git a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryPdfContentTest.kt b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryPdfContentTest.kt index 35d014f..c7ad544 100644 --- a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryPdfContentTest.kt +++ b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryPdfContentTest.kt @@ -21,6 +21,12 @@ class HistoryPdfContentTest { private val moltenQr2 = "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" + // Real デンソー kanbans; see shared/test-fixtures/matching-cases.json. The + // runs of spaces inside are blank fixed-width fields and are data. + private val densoQr0140 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + private val densoQr0141 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0141SWS 20260908S0010000720000009924543330454333M6" + private val densoQr0538 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507791000000192D860C01008D86045M 0538SWS 20260908S0010007680000009924543420454342R6" + @Test fun contentPreservesFirstSeenGroupsParsedFieldsManagementAndRawPayloads() { val session = MatchSession( @@ -193,6 +199,62 @@ class HistoryPdfContentTest { assertFalse(unresolvedText.contains("仕向地:")) } + @Test + fun densoReportPrintsKanbanBlockAndBoxesPerPartNumber() { + assertEquals(221, densoQr0140.length) + assertEquals(221, densoQr0141.length) + assertEquals(221, densoQr0538.length) + + val text = HistoryPdfContent.build(densoSession(), AppLanguage.JAPANESE) + .joinToString("\n") { it.text } + + assertTrue(text.contains("仕向地: デンソー")) + assertTrue(text.contains("検査箱数: 3箱(品番数: 2)")) + assertTrue(text.contains("#1 860150-7722 (2箱)")) + assertTrue(text.contains("部品番号: 860150-7722; 収容数: 24; 指示数: 72")) + assertTrue(text.contains("次区: D850; 指示: C01008-45; 納入日: 2026/09/08; 便: S001")) + assertTrue(text.contains("管理番号: SWS; アイテムNo: 9924543330; 受入: M6")) + // The kanban serial is what separates two boxes of the same part. + assertTrue(text.contains("かんばん連番: 0140; 管理コード: 1DZ50O")) + assertTrue(text.contains("かんばん連番: 0141; 管理コード: 1DZB0O")) + assertTrue(text.contains("#2 860150-7791 (1箱)")) + assertTrue(text.contains("部品番号: 860150-7791; 収容数: 192; 指示数: 768")) + assertTrue(text.contains("次区: D860; 指示: C01008-45; 納入日: 2026/09/08; 便: S001")) + assertTrue(text.contains("管理番号: SWS; アイテムNo: 9924543420; 受入: R6")) + assertTrue(text.contains("かんばん連番: 0538; 管理コード: 01335C")) + assertTrue(text.contains("QR全文: $densoQr0140")) + assertTrue(text.contains("QR全文: $densoQr0141")) + assertTrue(text.contains("QR全文: $densoQr0538")) + // KanbanQrRecord.parse accepts a Denso payload, so a missing + // destination guard would silently print Sawai fields here. + assertFalse(text.contains("カード番号")) + // Delivery numbers are a Molten concept; Denso counts boxes per part. + assertFalse(text.contains("納品番号数")) + } + + @Test + fun englishDensoReportUsesEnglishLabels() { + val text = HistoryPdfContent.build(densoSession(), AppLanguage.ENGLISH) + .joinToString("\n") { it.text } + + assertTrue(text.contains("Ship-to: Denso")) + assertTrue(text.contains("#1 860150-7722 (2 boxes)")) + assertTrue( + text.contains("Part number: 860150-7722; Pack quantity: 24; Instructed quantity: 72"), + ) + assertTrue( + text.contains( + "Next process: D850; Instruction: C01008-45; " + + "Delivery date: 2026/09/08; Delivery run: S001", + ), + ) + assertTrue(text.contains("Management number: SWS; Item No.: 9924543330; Receiving: M6")) + assertTrue(text.contains("Kanban serial: 0140; Management code: 1DZ50O")) + assertTrue(text.contains("Kanban serial: 0141; Management code: 1DZB0O")) + assertFalse(text.contains("Card number")) + assertFalse(text.contains("Delivery numbers")) + } + @Test fun oneBlockIsEmittedPerPayloadSoLongRawValuesCannotBeDropped() { val rawQr = "Q".repeat(2_000) @@ -209,6 +271,39 @@ class HistoryPdfContentTest { assertTrue(text.contains(rawBarcode)) } + /** Two boxes of one Denso part plus a single box of a second part. */ + private fun densoSession() = MatchSession( + startedAt = 1_700_000_000_000L, + endedAt = 1_700_000_120_000L, + destination = Destination.DENSO, + entries = listOf( + MatchEntry( + id = "denso-1", + code = "860150-7722", + matchedAt = 1_700_000_001_000L, + qrPayload = densoQr0140, + barcodePayload = "860150-7722@1DZ50O", + sequence = 0, + ), + MatchEntry( + id = "denso-2", + code = "860150-7722", + matchedAt = 1_700_000_002_000L, + qrPayload = densoQr0141, + barcodePayload = "860150-7722@1DZB0O", + sequence = 1, + ), + MatchEntry( + id = "denso-3", + code = "860150-7791", + matchedAt = 1_700_000_003_000L, + qrPayload = densoQr0538, + barcodePayload = "860150-7791@01335C", + sequence = 2, + ), + ), + ) + /** Two boxes of one delivery number plus a second part on another slip. */ private fun moltenSession() = MatchSession( startedAt = 1_700_000_000_000L, 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 b343f49..d17f7f4 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 @@ -155,7 +155,7 @@ object CodeMatcher { * 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, destination: Destination? = null): String { + fun formatPartNumber(partNumber: String, destination: Destination?): String { if (destination == Destination.DENSO) { if (partNumber.length != STANDARD_PART_NUMBER_LENGTH) return partNumber return partNumber.substring(0, 6) + "-" + partNumber.substring(6) 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 1af6abd..c93c89a 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 @@ -113,12 +113,12 @@ class CodeMatcherTest { @Test fun formatPartNumberUsesTheFourTwoFourDisplayShape() { - assertEquals("BCJH-52-81GG", CodeMatcher.formatPartNumber("BCJH5281GG")) + assertEquals("BCJH-52-81GG", CodeMatcher.formatPartNumber("BCJH5281GG", null)) // A nine-character Molten part number prints as 4-2-3. - assertEquals("PAF1-15-422", CodeMatcher.formatPartNumber("PAF115422")) - assertEquals("ABC", CodeMatcher.formatPartNumber("ABC")) - assertEquals("ABCDEFGHIJK", CodeMatcher.formatPartNumber("ABCDEFGHIJK")) - assertEquals("abcd-ef-ghij", CodeMatcher.formatPartNumber("abcdefghij")) + assertEquals("PAF1-15-422", CodeMatcher.formatPartNumber("PAF115422", null)) + assertEquals("ABC", CodeMatcher.formatPartNumber("ABC", null)) + assertEquals("ABCDEFGHIJK", CodeMatcher.formatPartNumber("ABCDEFGHIJK", null)) + assertEquals("abcd-ef-ghij", CodeMatcher.formatPartNumber("abcdefghij", null)) } @Test @@ -756,7 +756,6 @@ class CodeMatcherTest { 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. diff --git a/android/feature/history/src/androidTest/kotlin/jp/rimtty/codematch/feature/history/HistoryScreenTest.kt b/android/feature/history/src/androidTest/kotlin/jp/rimtty/codematch/feature/history/HistoryScreenTest.kt index 8b2cc7c..0d65a6b 100644 --- a/android/feature/history/src/androidTest/kotlin/jp/rimtty/codematch/feature/history/HistoryScreenTest.kt +++ b/android/feature/history/src/androidTest/kotlin/jp/rimtty/codematch/feature/history/HistoryScreenTest.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.hasText @@ -273,6 +274,79 @@ class HistoryScreenTest { composeRule.onAllNodesWithText("Card number").assertCountEquals(0) } + @Test + fun densoEntryDetailDisplaysAllParsedFields() { + assertEquals(221, DENSO_QR_0140.length) + val entry = MatchEntry( + code = "860150-7722", + qrPayload = DENSO_QR_0140, + barcodePayload = "860150-7722@1DZ50O", + ) + composeRule.setContent { + HistoryEntryDetail(entry = entry, language = AppLanguage.JAPANESE) + } + + composeRule.onNodeWithTag(HistoryTestTags.ENTRY_DETAIL).assertIsDisplayed() + // The detail is a LazyColumn; scroll the list itself so each row is + // fully inside the compact CI emulator viewport before asserting. + composeRule.onNodeWithTag(HistoryTestTags.ENTRY_DETAIL) + .performScrollToNode(hasText("860150-7722")) + composeRule.onAllNodesWithText("860150-7722").onFirst().assertIsDisplayed() + listOf("0140", "2026/09/08", "S001", "72", "SWS", "9924543330", "M6", "C01008-45") + .forEach { text -> + composeRule.onNodeWithTag(HistoryTestTags.ENTRY_DETAIL) + .performScrollToNode(hasText(text)) + composeRule.onNodeWithText(text).assertIsDisplayed() + } + // The lenient Sawai parser also accepts this payload, so its rows must + // never appear for a Denso kanban. + composeRule.onAllNodesWithText("カード番号").assertCountEquals(0) + } + + @Test + fun densoSessionDetailAndRowShowDestination() { + val session = MatchSession( + id = "denso-session", + startedAt = 1_000L, + endedAt = 2_000L, + destination = Destination.DENSO, + entries = listOf( + MatchEntry( + id = "box-1", + code = "860150-7722", + matchedAt = 1_100L, + qrPayload = DENSO_QR_0140, + barcodePayload = "860150-7722@1DZ50O", + ), + ), + ) + // The row and the overview are stacked at full width: the expanded + // layout's list pane is too narrow to display the row's own text. + composeRule.setContent { + Column(Modifier.fillMaxSize()) { + HistoryScreen( + sessions = listOf(session), + language = AppLanguage.JAPANESE, + modifier = Modifier.weight(1f), + ) + HistorySessionDetail( + session = session, + language = AppLanguage.JAPANESE, + modifier = Modifier.weight(1f), + ) + } + } + + composeRule.onNodeWithTag(HistoryTestTags.SESSION_DESTINATION, useUnmergedTree = true) + .assertIsDisplayed() + .assertTextEquals("デンソー") + composeRule.onNodeWithTag(HistoryTestTags.SESSION_DETAIL) + .performScrollToNode(hasText("仕向地")) + composeRule.onNodeWithText("仕向地").assertIsDisplayed() + // Delivery numbers stay Molten-only. + composeRule.onAllNodesWithText("納品番号数").assertCountEquals(0) + } + @Test fun moltenGroupDetailShowsPerDeliveryNumberSummary() { assertEquals(61, MOLTEN_QR_2.length) @@ -377,6 +451,9 @@ class HistoryScreenTest { // assertions in the tests fail first if they are ever trimmed away. const val MOLTEN_QR_2 = "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" + // A real デンソー kanban; see shared/test-fixtures/matching-cases.json. + const val DENSO_QR_0140 = + "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" const val MOLTEN_QR_3 = "AK6805PAF115422 UAG5561000FA2P5901FEM000006009080000" } diff --git a/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryScreen.kt b/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryScreen.kt index 2902f3f..b2ec787 100644 --- a/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryScreen.kt +++ b/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryScreen.kt @@ -66,6 +66,7 @@ import jp.rimtty.codematch.core.export.formatMoltenTime import jp.rimtty.codematch.core.export.moltenDeliveryGroups import jp.rimtty.codematch.core.export.resolvedDestination import jp.rimtty.codematch.core.matching.CodeMatcher +import jp.rimtty.codematch.core.matching.DensoKanbanQrRecord import jp.rimtty.codematch.core.matching.KanbanQrRecord import jp.rimtty.codematch.core.matching.MoltenQrRecord import jp.rimtty.codematch.core.matching.TagBarcodeRecord @@ -767,18 +768,22 @@ fun HistoryEntryDetail( ) { HistoryLocalized(language) { val labels = HistoryUiResources.labels() - // The two destinations put different fields at different positions, so - // the detected destination decides which record is read and shown. + // Each destination puts different fields at different positions, so the + // detected destination - never the parse result - decides which record + // is read and shown. KanbanQrRecord.parse is lenient enough to accept a + // Denso payload, so only this branch keeps them apart. val destination = entry.qrPayload?.let(CodeMatcher::detectDestination) - val moltenQr = if (destination == Destination.MOLTEN) { - entry.qrPayload?.let(MoltenQrRecord::parse) - } else { - null + val moltenQr = when (destination) { + Destination.MOLTEN -> entry.qrPayload?.let(MoltenQrRecord::parse) + else -> null } - val qr = if (destination == Destination.MOLTEN) { - null - } else { - entry.qrPayload?.let(KanbanQrRecord::parse) + val densoQr = when (destination) { + Destination.DENSO -> entry.qrPayload?.let(DensoKanbanQrRecord::parse) + else -> null + } + val qr = when (destination) { + Destination.MOLTEN, Destination.DENSO -> null + else -> entry.qrPayload?.let(KanbanQrRecord::parse) } val barcode = entry.barcodePayload?.let(TagBarcodeRecord::parse) LazyColumn( @@ -809,7 +814,7 @@ fun HistoryEntryDetail( SummaryRow(labels.ordererCode, moltenQr.ordererCode) SummaryRow( labels.moltenPartNumber, - CodeMatcher.formatPartNumber(moltenQr.partNumber), + CodeMatcher.formatPartNumber(moltenQr.partNumber, Destination.MOLTEN), ) SummaryRow(labels.deliveryNumber, moltenQr.deliveryNumber) SummaryRow(labels.deliveryDestination, moltenQr.deliveryDestination) @@ -834,6 +839,63 @@ fun HistoryEntryDetail( } } } + if (densoQr != null) { + item { + SectionCard(title = labels.qrParsed) { + SummaryRow( + labels.formType, + densoQr.formType ?: HistoryUiResources.notAvailable(), + ) + SummaryRow( + labels.moltenPartNumber, + CodeMatcher.formatPartNumber(densoQr.partNumber, Destination.DENSO), + ) + SummaryRow( + labels.packagingCode, + densoQr.packagingCode ?: HistoryUiResources.notAvailable(), + ) + SummaryRow( + labels.packQuantity, + HistoryExportTextFormatter.integer(densoQr.packQuantity, language), + ) + SummaryRow( + labels.nextProcess, + densoQr.nextProcess ?: HistoryUiResources.notAvailable(), + ) + SummaryRow( + labels.instructionCode, + densoQr.instructionCode ?: HistoryUiResources.notAvailable(), + ) + SummaryRow(labels.kanbanSerial, densoQr.kanbanSerial) + SummaryRow( + labels.managementNumber, + densoQr.managementNumber ?: HistoryUiResources.notAvailable(), + ) + SummaryRow( + labels.deliveryDate, + densoQr.formattedDeliveryDate ?: HistoryUiResources.notAvailable(), + ) + SummaryRow( + labels.deliveryRun, + densoQr.deliveryRun ?: HistoryUiResources.notAvailable(), + ) + SummaryRow( + labels.instructedQuantity, + densoQr.instructedQuantity + ?.let { HistoryExportTextFormatter.integer(it, language) } + ?: HistoryUiResources.notAvailable(), + ) + SummaryRow( + labels.densoItemNumber, + densoQr.itemNumber ?: HistoryUiResources.notAvailable(), + ) + SummaryRow( + labels.receivingCode, + densoQr.receivingCode ?: HistoryUiResources.notAvailable(), + ) + } + } + } if (qr != null) { item { SectionCard(title = labels.qrParsed) { @@ -842,10 +904,10 @@ fun HistoryEntryDetail( labels.itemNumber, qr.partSuffix?.let { HistoryUiResources.partWithSuffix( - CodeMatcher.formatPartNumber(qr.partNumber), + CodeMatcher.formatPartNumber(qr.partNumber, Destination.SAWAI), it, ) - } ?: CodeMatcher.formatPartNumber(qr.partNumber), + } ?: CodeMatcher.formatPartNumber(qr.partNumber, Destination.SAWAI), ) SummaryRow(labels.deliveryQuantity, HistoryUiText.quantity(qr.deliveryQuantity, language)) SummaryRow(labels.instructedQuantity, HistoryUiText.quantity(qr.instructedQuantity, language)) 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 f5e60b0..1f564f5 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 @@ -84,6 +84,16 @@ object HistoryUiResources { instructionDate = stringResource(R.string.history_instruction_date), instructionTime = stringResource(R.string.history_instruction_time), deliveryGroups = stringResource(R.string.history_delivery_groups), + formType = stringResource(R.string.history_form_type), + packagingCode = stringResource(R.string.history_packaging_code), + nextProcess = stringResource(R.string.history_next_process), + instructionCode = stringResource(R.string.history_instruction_code), + kanbanSerial = stringResource(R.string.history_kanban_serial), + managementNumber = stringResource(R.string.history_management_number), + deliveryDate = stringResource(R.string.history_delivery_date), + deliveryRun = stringResource(R.string.history_delivery_run), + densoItemNumber = stringResource(R.string.history_denso_item_number), + receivingCode = stringResource(R.string.history_receiving_code), ) /** The display name of the delivery destination a session was locked to. */ 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 2556b71..9d7268d 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 @@ -66,6 +66,17 @@ data class HistoryUiLabels( val instructionDate: String, val instructionTime: String, val deliveryGroups: String, + val formType: String, + val packagingCode: String, + val nextProcess: String, + val instructionCode: String, + val kanbanSerial: String, + val managementNumber: String, + val deliveryDate: String, + val deliveryRun: String, + /** Denso item number; [itemNumber] is the Sawai slip's 品目番号. */ + val densoItemNumber: String, + val receivingCode: String, ) object HistoryUiText { 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 47e6fd1..9349578 100644 --- a/android/feature/history/src/main/res/values-en/strings.xml +++ b/android/feature/history/src/main/res/values-en/strings.xml @@ -58,6 +58,16 @@ Instruction date (JUMP) Time Boxes per delivery number + Form type + Packaging + Next process + Instruction + Kanban serial + Management number + Delivery date + Delivery run + Item No. + Receiving - , diff --git a/android/feature/history/src/main/res/values/strings.xml b/android/feature/history/src/main/res/values/strings.xml index e2eb382..a0a7964 100644 --- a/android/feature/history/src/main/res/values/strings.xml +++ b/android/feature/history/src/main/res/values/strings.xml @@ -58,6 +58,16 @@ 納入指示日(JUMP) 時刻 納品番号ごとの箱 + 帳票区分 + 包装 + 次区 + 指示 + かんばん連番 + 管理番号 + 納入日 + 便 + アイテムNo + 受入 -