From 6259caa9d2daf6056112bfb75dc28e13efd007cb Mon Sep 17 00:00:00 2001 From: rimtty Date: Mon, 7 Sep 2026 22:04:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(android-history):=20show=20and=20print=20?= =?UTF-8?q?=E3=83=87=E3=83=B3=E3=82=BD=E3=83=BC=20kanban=20fields=20in=20h?= =?UTF-8?q?istory=20and=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The history detail and the PDF report select the record by the session's destination instead of the lenient Kanban parse, add a Denso section with the kanban fields (form type, part number as 6-4, packaging, pack quantity, next process, instruction, kanban serial, management number, delivery date, run, instructed quantity, item number, receiving code), and print the kanban serial with every box. The default destination of formatPartNumber is removed so every caller names its destination. Sawai and Molten output is unchanged. Refs #111 --- .../core/export/HistoryDeliveryGroups.kt | 13 ++ .../core/export/HistoryExportText.kt | 31 +++++ .../core/export/HistoryPdfContent.kt | 111 ++++++++++++++++-- .../core/export/HistoryDeliveryGroupsTest.kt | 25 ++++ .../core/export/HistoryExportTextTest.kt | 2 + .../core/export/HistoryJsonExporterTest.kt | 35 ++++++ .../core/export/HistoryPdfContentTest.kt | 95 +++++++++++++++ .../codematch/core/matching/CodeMatcher.kt | 2 +- .../core/matching/CodeMatcherTest.kt | 11 +- .../feature/history/HistoryScreenTest.kt | 77 ++++++++++++ .../feature/history/HistoryScreen.kt | 88 ++++++++++++-- .../feature/history/HistoryUiResources.kt | 10 ++ .../feature/history/HistoryUiText.kt | 11 ++ .../src/main/res/values-en/strings.xml | 10 ++ .../history/src/main/res/values/strings.xml | 10 ++ 15 files changed, 498 insertions(+), 33 deletions(-) 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 + 受入 -