From c36471bc472df25e2b46651bd169f821e2581e92 Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 00:41:01 +0900 Subject: [PATCH 01/18] feat(android): build inspection report rows per destination from a session --- .../core/export/HistoryDeliveryGroups.kt | 13 ++ .../core/export/InspectionReportContent.kt | 142 +++++++++++++++ .../export/InspectionReportContentTest.kt | 171 ++++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/InspectionReportContent.kt create mode 100644 android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionReportContentTest.kt 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 751fa45..aa0f8a6 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 @@ -2,6 +2,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.KanbanQrRecord import jp.rimtty.codematch.core.matching.MoltenQrRecord import jp.rimtty.codematch.core.model.Destination import jp.rimtty.codematch.core.model.MatchEntry @@ -92,6 +93,18 @@ fun formatMoltenTime(hhmm: String?): String? { return if (FOUR_DIGITS.matches(value)) "${value.substring(0, 2)}:${value.substring(2, 4)}" else value } +/** + * Parses an entry's QR payload as a Sawai slip. + * The destination is detected first because [KanbanQrRecord.parse] is lenient: + * a Denso `JAMA...` payload satisfies its card-number rule and a Molten record + * would be read at the wrong positions. + */ +internal fun MatchEntry.sawaiRecord(): KanbanQrRecord? { + val payload = qrPayload ?: return null + if (CodeMatcher.detectDestination(payload) != Destination.SAWAI) return null + return KanbanQrRecord.parse(payload) +} + /** * Parses an entry's QR payload as a Molten record. * The destination is detected first so a Sawai payload can never be read at diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/InspectionReportContent.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/InspectionReportContent.kt new file mode 100644 index 0000000..a409ec3 --- /dev/null +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/InspectionReportContent.kt @@ -0,0 +1,142 @@ +package jp.rimtty.codematch.core.export + +import jp.rimtty.codematch.core.matching.CodeMatcher +import jp.rimtty.codematch.core.model.Destination +import jp.rimtty.codematch.core.model.MatchSession + +/** Which table the inspection report prints; one per destination. */ +enum class InspectionLayout { + SAWAI, + MOLTEN, + DENSO, +} + +/** + * One line of the inspection report. + * + * The row is the unit the operator ticks off on the paper inspection sheet: + * a part number (with its suffix) for Sawai, a delivery number for Molten and + * a part number for Denso. [keyText] is printed exactly as the customer's paper + * writes it — raw Sawai/Molten part numbers without hyphens, Denso as `6-4`. + */ +data class InspectionReportRow( + val keyText: String, + /** Molten only: the raw part number of the delivery number. */ + val partNumber: String? = null, + /** Molten only: the delivery point, which distinguishes two deliveries of one part. */ + val deliveryDestination: String? = null, + val boxCount: Int, + /** Quantity printed per box; null when the boxes disagree or a box has no quantity. */ + val quantityPerBox: Double?, + /** Sum over the boxes; null when any box has no quantity. */ + val totalQuantity: Double?, + /** True for a box whose QR could not be parsed for the session's destination. */ + val isUnparsed: Boolean = false, +) + +data class InspectionReport( + val layout: InspectionLayout, + val rows: List, +) { + val rowCount: Int + get() = rows.size +} + +/** + * Builds the inspection report rows of a session. + * + * Rows are sorted by their key (part number, then suffix or delivery number) + * so the operator can find each line of the paper sheet quickly; scan order is + * deliberately not offered. Boxes whose QR cannot be parsed for the locked + * destination are appended as trailing rows keyed by the recorded code, so + * the box count of the report always equals the session's box count. + * + * This mirrors Swift's `InspectionReport.make(session:)`. + */ +object InspectionReportContent { + fun build(session: MatchSession): InspectionReport { + val destination = session.resolvedDestination() + val layout = when (destination) { + Destination.MOLTEN -> InspectionLayout.MOLTEN + Destination.DENSO -> InspectionLayout.DENSO + Destination.SAWAI, null -> InspectionLayout.SAWAI + } + val parsed = LinkedHashMap() + val unparsed = LinkedHashMap() + + session.entries.forEach { entry -> + val placed = when (destination) { + Destination.SAWAI -> entry.sawaiRecord()?.let { record -> + parsed.add( + key = SortKey(record.partNumber, record.partSuffix.orEmpty()), + keyText = record.partSuffix?.let { "${record.partNumber} ($it)" } ?: record.partNumber, + quantity = record.deliveryQuantity, + ) + } + + Destination.MOLTEN -> entry.moltenRecord()?.let { record -> + parsed.add( + key = SortKey(record.deliveryNumber, ""), + keyText = record.deliveryNumber, + quantity = record.packQuantity.toDouble(), + partNumber = record.partNumber, + deliveryDestination = record.deliveryDestination, + ) + } + + Destination.DENSO -> entry.densoRecord()?.let { record -> + parsed.add( + key = SortKey(record.partNumber, ""), + keyText = CodeMatcher.formatPartNumber(record.partNumber, Destination.DENSO), + quantity = record.packQuantity.toDouble(), + ) + } + + null -> null + } + if (placed == null) { + unparsed.add(key = SortKey(entry.code, ""), keyText = entry.code, quantity = null) + } + } + + val rows = parsed.entries.sortedWith(compareBy({ it.key.primary }, { it.key.secondary })) + .map { it.value.toRow(isUnparsed = false) } + + unparsed.entries.sortedBy { it.key.primary } + .map { it.value.toRow(isUnparsed = true) } + return InspectionReport(layout = layout, rows = rows) + } + + private data class SortKey(val primary: String, val secondary: String) + + private class Bucket( + val keyText: String, + val partNumber: String?, + val deliveryDestination: String?, + ) { + val quantities = mutableListOf() + + fun toRow(isUnparsed: Boolean): InspectionReportRow { + val allPresent = quantities.none { it == null } + val perBox = quantities.firstOrNull() + return InspectionReportRow( + keyText = keyText, + partNumber = partNumber, + deliveryDestination = deliveryDestination, + boxCount = quantities.size, + quantityPerBox = perBox?.takeIf { allPresent && quantities.all { it == perBox } }, + totalQuantity = if (allPresent) quantities.sumOf { it ?: 0.0 } else null, + isUnparsed = isUnparsed, + ) + } + } + + private fun LinkedHashMap.add( + key: SortKey, + keyText: String, + quantity: Double?, + partNumber: String? = null, + deliveryDestination: String? = null, + ) { + getOrPut(key) { Bucket(keyText, partNumber, deliveryDestination) }.quantities += quantity + } +} diff --git a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionReportContentTest.kt b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionReportContentTest.kt new file mode 100644 index 0000000..6af00ae --- /dev/null +++ b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionReportContentTest.kt @@ -0,0 +1,171 @@ +package jp.rimtty.codematch.core.export + +import jp.rimtty.codematch.core.matching.KanbanQrRecord +import jp.rimtty.codematch.core.model.Destination +import jp.rimtty.codematch.core.model.MatchEntry +import jp.rimtty.codematch.core.model.MatchSession +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class InspectionReportContentTest { + // Real label payloads; see shared/test-fixtures/matching-cases.json. + private val sawaiQr5281 = + "DCLP675300BCJH5281GG020000120000001200L000000000000BLBDILLU92 0*" + private val sawaiQr5581 = + "DCLP675340BCJH5581GG020000120000001200L000000000000BLBDILLU93 0*" + private val sawaiQrNoSuffix = + "DAYA004770DFR55281GA 0001000000010000Y 000000BYBYTLYB15 0*" + private val moltenQrD10E = + "AK6805D10E50N10B U543820000MB S600700000020908 " + private val moltenQrPAF1 = + "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" + private val densoQr0140 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + private val densoQr0141 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0141SWS 20260908S0010000720000009924543330454333M6" + + @Test + fun sawaiRowsAreKeyedByPartNumberAndSuffixAndSortedByPartNumber() { + val session = MatchSession( + startedAt = 0L, + destination = Destination.SAWAI, + entries = listOf( + entry("one", "BCJH-55-81GG", sawaiQr5581, "BCJH-55-81GG@1KVV0C"), + entry("two", "BCJH-52-81GG", sawaiQr5281, "BCJH-52-81GG@1N5X0C"), + entry("three", "BCJH-52-81GG", sawaiQr5281, "BCJH-52-81GG@1N5X0D"), + entry("four", "DFR5-52-81GA", sawaiQrNoSuffix, "DFR5-52-81GA@001F2S"), + ), + ) + val expectedQuantity = KanbanQrRecord.parse(sawaiQr5281)?.deliveryQuantity + requireNotNull(expectedQuantity) + + val report = InspectionReportContent.build(session) + + assertEquals(InspectionLayout.SAWAI, report.layout) + assertEquals(listOf("BCJH5281GG (02)", "BCJH5581GG (02)", "DFR55281GA"), report.rows.map { it.keyText }) + val first = report.rows[0] + assertEquals(2, first.boxCount) + assertEquals(expectedQuantity, first.quantityPerBox) + assertEquals(expectedQuantity * 2, first.totalQuantity) + assertNull(first.partNumber) + assertNull(first.deliveryDestination) + assertTrue(report.rows.none { it.isUnparsed }) + assertEquals(session.matchedCount, report.rows.sumOf { it.boxCount }) + } + + @Test + fun moltenRowsAreOnePerDeliveryNumberWithRawPartAndDeliveryPoint() { + val session = MatchSession( + startedAt = 0L, + destination = Destination.MOLTEN, + entries = listOf( + entry("one", "PAF1-15-422", moltenQrPAF1, "PAF1-15-422@0NKD3C"), + entry("two", "PAF1-15-422", moltenQrPAF1, "PAF1-15-422@0NLL3C"), + entry("three", "D10E-50-N10B", moltenQrD10E, "D10E-50-N10B@0UBL00"), + ), + ) + + val report = InspectionReportContent.build(session) + + assertEquals(InspectionLayout.MOLTEN, report.layout) + assertEquals(listOf("U543820", "UAG5560"), report.rows.map { it.keyText }) + val paf1 = report.rows[1] + assertEquals("PAF115422", paf1.partNumber) + assertEquals("FA2", paf1.deliveryDestination) + assertEquals(2, paf1.boxCount) + assertEquals(120.0, paf1.quantityPerBox) + assertEquals(240.0, paf1.totalQuantity) + val d10e = report.rows[0] + assertEquals("D10E50N10B", d10e.partNumber) + assertEquals("MB", d10e.deliveryDestination) + assertEquals(1, d10e.boxCount) + assertEquals(2.0, d10e.totalQuantity) + } + + @Test + fun densoRowsAreOnePerPartNumberFormattedSixFour() { + val session = MatchSession( + startedAt = 0L, + destination = Destination.DENSO, + entries = listOf( + entry("one", "860150-7722", densoQr0140, "860150-7722@1DZ50O"), + entry("two", "860150-7722", densoQr0141, "860150-7722@1DZ50P"), + ), + ) + + val report = InspectionReportContent.build(session) + + assertEquals(InspectionLayout.DENSO, report.layout) + assertEquals(1, report.rowCount) + val row = report.rows.single() + assertEquals("860150-7722", row.keyText) + assertEquals(2, row.boxCount) + assertEquals(24.0, row.quantityPerBox) + assertEquals(48.0, row.totalQuantity) + } + + @Test + fun boxesWithoutAParsableQrTrailAsUnparsedRowsSoNoBoxIsDropped() { + val session = MatchSession( + startedAt = 0L, + destination = Destination.SAWAI, + entries = listOf( + entry("legacy", "ZZZZ-00-0000", qrPayload = null, barcodePayload = null), + entry("one", "BCJH-52-81GG", sawaiQr5281, "BCJH-52-81GG@1N5X0C"), + entry("garbled", "AAAA-11-1111", "legacy payload", "AAAA-11-1111@X"), + ), + ) + + val report = InspectionReportContent.build(session) + + assertEquals(listOf("BCJH5281GG (02)", "AAAA-11-1111", "ZZZZ-00-0000"), report.rows.map { it.keyText }) + val legacy = report.rows.last() + assertTrue(legacy.isUnparsed) + assertEquals(1, legacy.boxCount) + assertNull(legacy.quantityPerBox) + assertNull(legacy.totalQuantity) + assertEquals(session.matchedCount, report.rows.sumOf { it.boxCount }) + } + + @Test + fun sessionWithoutDestinationFallsBackToSawaiLayoutWithEveryBoxUnparsed() { + val session = MatchSession( + startedAt = 0L, + entries = listOf(entry("one", "PART-1", qrPayload = null, barcodePayload = null)), + ) + + val report = InspectionReportContent.build(session) + + assertEquals(InspectionLayout.SAWAI, report.layout) + assertTrue(report.rows.all { it.isUnparsed }) + } + + @Test + fun aDensoKanbanIsNeverReadAsASawaiSlip() { + // The lenient Sawai parser accepts a JAMA payload as a card number; the + // report must key on the destination, not on which parser succeeds. + val session = MatchSession( + startedAt = 0L, + destination = Destination.SAWAI, + entries = listOf(entry("one", "860150-7722", densoQr0140, "860150-7722@1DZ50O")), + ) + + val report = InspectionReportContent.build(session) + + assertEquals(listOf("860150-7722"), report.rows.map { it.keyText }) + assertTrue(report.rows.single().isUnparsed) + } + + private fun entry( + id: String, + code: String, + qrPayload: String?, + barcodePayload: String?, + ) = MatchEntry( + id = id, + code = code, + matchedAt = 1_700_000_000_000L, + qrPayload = qrPayload, + barcodePayload = barcodePayload, + ) +} From 46f778a1c74081b5f26411acf7ec906b69691bac Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 00:42:40 +0900 Subject: [PATCH 02/18] feat(android): add inspection report labels, table blocks and pure PDF content --- .../core/export/HistoryExportText.kt | 53 ++++- .../core/export/InspectionPdfContent.kt | 154 ++++++++++++++ .../rimtty/codematch/core/export/PdfTable.kt | 54 +++++ .../core/export/HistoryExportTextTest.kt | 20 ++ .../core/export/InspectionPdfContentTest.kt | 192 ++++++++++++++++++ 5 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/InspectionPdfContent.kt create mode 100644 android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/PdfTable.kt create mode 100644 android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionPdfContentTest.kt 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 692b109..e54fb89 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 @@ -66,6 +66,22 @@ data class HistoryExportLabels( /** Denso item number; [itemNumber] is the Sawai slip's 品目番号. */ val densoItemNumber: String, val receivingCode: String, + /** 検品レポート: title, file prefix, header counts, column titles and footer. */ + val inspectionTitle: String, + val inspectionFilePrefix: String, + val inspectionPartCount: String, + val inspectionPartCountBySuffix: String, + val columnNumber: String, + val columnPartNumber: String, + val columnDeliveryNumber: String, + val columnDeliveryDestination: String, + val columnBoxes: String, + val columnDeliveryQuantityPerBox: String, + val columnPackQuantityPerBox: String, + val columnTotalQuantity: String, + val columnCumulativeQuantity: String, + val columnCheck: String, + val inspectionFooterNote: String, /** Singular and plural units are kept separately for natural English. */ val boxCountSingular: String = boxCount, val boxCountPlural: String = boxCount, @@ -153,6 +169,22 @@ object HistoryExportTextFormatter { deliveryRun = "便", densoItemNumber = "アイテムNo", receivingCode = "受入", + inspectionTitle = "検品レポート", + inspectionFilePrefix = "検品レポート", + inspectionPartCount = "品番数", + inspectionPartCountBySuffix = "品番数(枝番別)", + columnNumber = "No", + columnPartNumber = "品番", + columnDeliveryNumber = "納品番号", + columnDeliveryDestination = "納入先", + columnBoxes = "箱数", + columnDeliveryQuantityPerBox = "納入数量/箱", + columnPackQuantityPerBox = "収容数/箱", + columnTotalQuantity = "数量計", + columnCumulativeQuantity = "累計", + columnCheck = "確認", + inspectionFooterNote = + "検品表にあってこの一覧にない品番は、このセッションで照合されていません。", boxCountSingular = "箱", boxCountPlural = "箱", ) @@ -212,6 +244,22 @@ object HistoryExportTextFormatter { deliveryRun = "Delivery run", densoItemNumber = "Item No.", receivingCode = "Receiving", + inspectionTitle = "Inspection Report", + inspectionFilePrefix = "InspectionReport", + inspectionPartCount = "Part numbers", + inspectionPartCountBySuffix = "Part numbers (by suffix)", + columnNumber = "No", + columnPartNumber = "Part no.", + columnDeliveryNumber = "Delivery no.", + columnDeliveryDestination = "Deliv. point", + columnBoxes = "Boxes", + columnDeliveryQuantityPerBox = "Qty / box", + columnPackQuantityPerBox = "Pack / box", + columnTotalQuantity = "Total qty", + columnCumulativeQuantity = "Total", + columnCheck = "Check", + inspectionFooterNote = + "Part numbers on the inspection sheet that are missing from this list were not matched in this session.", boxCountSingular = "box", boxCountPlural = "boxes", ) @@ -277,8 +325,11 @@ object HistoryExportTextFormatter { session: MatchSession, language: AppLanguage, zoneId: ZoneId = ZoneId.systemDefault(), + /** File-name prefix; the match history prefix unless a report supplies its own. */ + prefix: String? = null, ): String { val labels = labels(language) + val filePrefix = prefix ?: labels.filePrefix val source = session.displayName.ifBlank { dateTime(session.startedAt, language, zoneId) } @@ -297,7 +348,7 @@ object HistoryExportTextFormatter { .replace("..", "_") .ifBlank { "session_${session.id.take(8)}" } - return "${labels.filePrefix}_$safe.pdf" + return "${filePrefix}_$safe.pdf" } private fun locale(language: AppLanguage): Locale = when (language) { diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/InspectionPdfContent.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/InspectionPdfContent.kt new file mode 100644 index 0000000..af4de92 --- /dev/null +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/InspectionPdfContent.kt @@ -0,0 +1,154 @@ +package jp.rimtty.codematch.core.export + +import jp.rimtty.codematch.core.model.AppLanguage +import jp.rimtty.codematch.core.model.MatchSession +import java.time.ZoneId + +/** + * Pure 検品レポート content: a short header and one table row per + * [InspectionReportRow]. It is independent of `android.graphics.pdf` so the + * cells can be pinned by JVM tests, exactly like [HistoryPdfContent]. + */ +object InspectionPdfContent { + /** Column widths in points of the 507 pt content width, shared with iOS. */ + private const val CONTENT_WIDTH = 507f + + fun build( + session: MatchSession, + language: AppLanguage = AppLanguage.JAPANESE, + zoneId: ZoneId = ZoneId.systemDefault(), + ): InspectionPdfDocument { + val labels = HistoryExportTextFormatter.labels(language) + val report = InspectionReportContent.build(session) + val blocks = mutableListOf() + + fun text(text: String, style: PdfTextStyle, spacingAfter: Float) { + blocks += InspectionPdfBlock.Text(HistoryPdfBlock(text, style, spacingAfter)) + } + + text(labels.inspectionTitle, PdfTextStyle.TITLE, 8f) + if (session.displayName.isNotEmpty()) { + text("${labels.sessionName}: ${session.displayName}", PdfTextStyle.SECTION, 4f) + } + text( + "${labels.start}: ${HistoryExportTextFormatter.dateTime(session.startedAt, language, zoneId)}", + PdfTextStyle.MUTED, + 2f, + ) + val endedAt = session.endedAt + if (endedAt != null) { + text( + "${labels.end}: ${HistoryExportTextFormatter.dateTime(endedAt, language, zoneId)}", + PdfTextStyle.MUTED, + 2f, + ) + } else { + text("${labels.status}: ${labels.inProgress}", PdfTextStyle.MUTED, 2f) + } + val destination = session.resolvedDestination() + if (destination != null) { + text("${labels.destination}: ${labels.destinationName(destination)}", PdfTextStyle.MUTED, 2f) + } + text( + "${labels.inspectionBoxCount}: ${HistoryExportTextFormatter.boxCount(session.matchedCount, language)}", + PdfTextStyle.MUTED, + 2f, + ) + val countLabel = when (report.layout) { + InspectionLayout.SAWAI -> labels.inspectionPartCountBySuffix + InspectionLayout.MOLTEN -> labels.deliveryNumberCount + InspectionLayout.DENSO -> labels.inspectionPartCount + } + text( + "$countLabel: ${HistoryExportTextFormatter.integer(report.rowCount, language)}", + PdfTextStyle.MUTED, + 8f, + ) + blocks += InspectionPdfBlock.Text(HistoryPdfBlock("", PdfTextStyle.DIVIDER, spacingAfter = 6f)) + + if (report.rows.isEmpty()) { + text(labels.noMatches, PdfTextStyle.MUTED, 8f) + } else { + val columns = columns(report.layout, labels) + val checkboxColumn = columns.lastIndex + blocks += InspectionPdfBlock.TableHeader(columns) + report.rows.forEachIndexed { index, row -> + blocks += InspectionPdfBlock.TableRow( + cells = cells(row, index, report.layout, language), + columns = columns, + checkboxColumn = checkboxColumn, + ) + } + } + + return InspectionPdfDocument( + blocks = blocks, + footerNote = labels.inspectionFooterNote, + generatedNote = labels.generatedNote, + ) + } + + /** The column set of one layout; the 確認 column is always last. */ + fun columns(layout: InspectionLayout, labels: HistoryExportLabels): List { + fun column(title: String, points: Float, align: PdfAlign, style: PdfTextStyle = PdfTextStyle.BODY) = + PdfColumn(title, points / CONTENT_WIDTH, align, style) + return when (layout) { + InspectionLayout.SAWAI -> listOf( + column(labels.columnNumber, 28f, PdfAlign.RIGHT), + column(labels.columnPartNumber, 170f, PdfAlign.LEFT, PdfTextStyle.MONOSPACE_BOLD), + column(labels.columnBoxes, 52f, PdfAlign.RIGHT, PdfTextStyle.MONOSPACE_BOLD), + column(labels.columnDeliveryQuantityPerBox, 90f, PdfAlign.RIGHT), + column(labels.columnTotalQuantity, 90f, PdfAlign.RIGHT), + column(labels.columnCheck, 77f, PdfAlign.CENTER), + ) + + InspectionLayout.DENSO -> listOf( + column(labels.columnNumber, 28f, PdfAlign.RIGHT), + column(labels.columnPartNumber, 170f, PdfAlign.LEFT, PdfTextStyle.MONOSPACE_BOLD), + column(labels.columnBoxes, 52f, PdfAlign.RIGHT, PdfTextStyle.MONOSPACE_BOLD), + column(labels.columnPackQuantityPerBox, 90f, PdfAlign.RIGHT), + column(labels.columnTotalQuantity, 90f, PdfAlign.RIGHT), + column(labels.columnCheck, 77f, PdfAlign.CENTER), + ) + + InspectionLayout.MOLTEN -> listOf( + column(labels.columnNumber, 28f, PdfAlign.RIGHT), + column(labels.columnDeliveryNumber, 82f, PdfAlign.LEFT, PdfTextStyle.MONOSPACE_BOLD), + column(labels.columnPartNumber, 96f, PdfAlign.LEFT, PdfTextStyle.MONOSPACE_BOLD), + column(labels.columnDeliveryDestination, 52f, PdfAlign.LEFT), + column(labels.columnBoxes, 44f, PdfAlign.RIGHT, PdfTextStyle.MONOSPACE_BOLD), + column(labels.columnPackQuantityPerBox, 72f, PdfAlign.RIGHT), + column(labels.columnCumulativeQuantity, 72f, PdfAlign.RIGHT), + column(labels.columnCheck, 61f, PdfAlign.CENTER), + ) + } + } + + private fun cells( + row: InspectionReportRow, + index: Int, + layout: InspectionLayout, + language: AppLanguage, + ): List { + val number = HistoryExportTextFormatter.integer(index + 1, language) + val boxes = HistoryExportTextFormatter.integer(row.boxCount, language) + val perBox = HistoryExportTextFormatter.quantity(row.quantityPerBox, language) + val total = HistoryExportTextFormatter.quantity(row.totalQuantity, language) + return when (layout) { + InspectionLayout.SAWAI, + InspectionLayout.DENSO, + -> listOf(number, row.keyText, boxes, perBox, total, "") + + InspectionLayout.MOLTEN -> listOf( + number, + row.keyText, + row.partNumber ?: "-", + row.deliveryDestination ?: "-", + boxes, + perBox, + total, + "", + ) + } + } +} diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/PdfTable.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/PdfTable.kt new file mode 100644 index 0000000..8995dfd --- /dev/null +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/PdfTable.kt @@ -0,0 +1,54 @@ +package jp.rimtty.codematch.core.export + +/** Horizontal alignment of a table cell. */ +enum class PdfAlign { + LEFT, + RIGHT, + CENTER, +} + +/** + * One column of a PDF table. + * + * [weight] is the column's share of the page content width; the weights of a + * table sum to 1 so the renderer, not the content, owns the page size. + */ +data class PdfColumn( + val title: String, + val weight: Float, + val align: PdfAlign = PdfAlign.LEFT, + val style: PdfTextStyle = PdfTextStyle.BODY, +) + +/** One logical block of the inspection report before page layout. */ +sealed interface InspectionPdfBlock { + /** Plain text, rendered exactly like a history report block. */ + data class Text(val block: HistoryPdfBlock) : InspectionPdfBlock + + /** The column titles; the renderer repeats them at the top of every page. */ + data class TableHeader(val columns: List) : InspectionPdfBlock + + /** + * One table row. [cells] has one entry per column; [checkboxColumn] names + * the column drawn as an empty square for the operator's pen instead of text. + */ + data class TableRow( + val cells: List, + val columns: List, + val checkboxColumn: Int? = null, + ) : InspectionPdfBlock { + init { + require(cells.size == columns.size) { "row has ${cells.size} cells for ${columns.size} columns" } + } + } +} + +/** + * Pure inspection report content: the blocks plus the two footer lines the + * renderer prints on every page next to the page number. + */ +data class InspectionPdfDocument( + val blocks: List, + val footerNote: String, + val generatedNote: String, +) 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 0d74a16..6df9a2e 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 @@ -43,6 +43,26 @@ class HistoryExportTextTest { assertFalse(english.contains('/')) } + @Test + fun inspectionReportPrefixSharesTheHistoryFileNameSanitizing() { + val session = MatchSession( + id = "12345678-aaaa-bbbb-cccc-dddddddddddd", + startedAt = 0L, + name = " morning/09:00\\report..pdf? ", + ) + val japanese = HistoryExportTextFormatter.labels(AppLanguage.JAPANESE) + val english = HistoryExportTextFormatter.labels(AppLanguage.ENGLISH) + + assertEquals( + "検品レポート_morning-0900-report_pdf.pdf", + HistoryExportTextFormatter.fileName(session, AppLanguage.JAPANESE, utc, japanese.inspectionFilePrefix), + ) + assertTrue( + HistoryExportTextFormatter.fileName(session, AppLanguage.ENGLISH, utc, english.inspectionFilePrefix) + .startsWith("InspectionReport_"), + ) + } + @Test fun quantityAndTimeAreLocalizedAndNullQuantityIsDash() { assertEquals("12", HistoryExportTextFormatter.quantity(12.0, AppLanguage.JAPANESE)) diff --git a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionPdfContentTest.kt b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionPdfContentTest.kt new file mode 100644 index 0000000..a23fa3f --- /dev/null +++ b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionPdfContentTest.kt @@ -0,0 +1,192 @@ +package jp.rimtty.codematch.core.export + +import jp.rimtty.codematch.core.model.AppLanguage +import jp.rimtty.codematch.core.model.Destination +import jp.rimtty.codematch.core.model.MatchEntry +import jp.rimtty.codematch.core.model.MatchSession +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InspectionPdfContentTest { + private val utc = ZoneId.of("UTC") + + private val sawaiQr5281 = + "DCLP675300BCJH5281GG020000120000001200L000000000000BLBDILLU92 0*" + private val sawaiQr5581 = + "DCLP675340BCJH5581GG020000120000001200L000000000000BLBDILLU93 0*" + private val moltenQrD10E = + "AK6805D10E50N10B U543820000MB S600700000020908 " + private val moltenQrPAF1 = + "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" + private val densoQr0140 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + + @Test + fun sawaiReportHasHeaderCountsColumnsAndOneRowPerPartNumberWithSuffix() { + val document = InspectionPdfContent.build(sawaiSession(), AppLanguage.JAPANESE, utc) + val text = document.texts() + + assertTrue(text.contains("検品レポート")) + assertTrue(text.contains("セッション名: 朝便")) + assertTrue(text.contains("仕向地: 澤井製作所")) + assertTrue(text.contains("検査箱数: 3箱")) + assertTrue(text.contains("品番数(枝番別): 2")) + assertFalse(text.contains("納品番号数")) + + val header = document.blocks.filterIsInstance().single() + assertEquals( + listOf("No", "品番", "箱数", "納入数量/箱", "数量計", "確認"), + header.columns.map { it.title }, + ) + assertEquals(1f, header.columns.map { it.weight }.sum(), 0.001f) + + val rows = document.blocks.filterIsInstance() + assertEquals( + listOf( + listOf("1", "BCJH5281GG (02)", "2", "12", "24", ""), + listOf("2", "BCJH5581GG (02)", "1", "12", "12", ""), + ), + rows.map { it.cells }, + ) + assertTrue(rows.all { it.checkboxColumn == it.columns.lastIndex }) + assertFalse("parsed rows print the raw part number", text.contains("BCJH-52-81GG")) + assertEquals("検品表にあってこの一覧にない品番は、このセッションで照合されていません。", document.footerNote) + assertTrue(document.generatedNote.startsWith("CodeMatch により生成")) + } + + @Test + fun englishSawaiReportUsesEnglishLabels() { + val document = InspectionPdfContent.build(sawaiSession(), AppLanguage.ENGLISH, utc) + val text = document.texts() + + assertTrue(text.contains("Inspection Report")) + assertTrue(text.contains("Ship-to: Sawai Seisakusho")) + assertTrue(text.contains("Boxes: 3 boxes")) + assertTrue(text.contains("Part numbers (by suffix): 2")) + val header = document.blocks.filterIsInstance().single() + assertEquals( + listOf("No", "Part no.", "Boxes", "Qty / box", "Total qty", "Check"), + header.columns.map { it.title }, + ) + assertTrue(document.footerNote.startsWith("Part numbers on the inspection sheet")) + } + + @Test + fun moltenReportHasOneRowPerDeliveryNumberWithPartAndDeliveryPoint() { + val session = MatchSession( + startedAt = 1_700_000_000_000L, + endedAt = 1_700_000_120_000L, + destination = Destination.MOLTEN, + entries = listOf( + entry("one", "PAF1-15-422", moltenQrPAF1, "PAF1-15-422@0NKD3C"), + entry("two", "D10E-50-N10B", moltenQrD10E, "D10E-50-N10B@0UBL00"), + entry("three", "PAF1-15-422", moltenQrPAF1, "PAF1-15-422@0NLL3C"), + ), + ) + + val document = InspectionPdfContent.build(session, AppLanguage.JAPANESE, utc) + val text = document.texts() + + assertTrue(text.contains("仕向地: モルテン")) + assertTrue(text.contains("検査箱数: 3箱")) + assertTrue(text.contains("納品番号数: 2")) + assertFalse(text.contains("品番数")) + val header = document.blocks.filterIsInstance().single() + assertEquals( + listOf("No", "納品番号", "品番", "納入先", "箱数", "収容数/箱", "累計", "確認"), + header.columns.map { it.title }, + ) + assertEquals( + listOf( + listOf("1", "U543820", "D10E50N10B", "MB", "1", "2", "2", ""), + listOf("2", "UAG5560", "PAF115422", "FA2", "2", "120", "240", ""), + ), + document.blocks.filterIsInstance().map { it.cells }, + ) + } + + @Test + fun densoReportHasOneRowPerPartNumberAndNoInstructedQuantity() { + val session = MatchSession( + startedAt = 1_700_000_000_000L, + destination = Destination.DENSO, + entries = listOf(entry("one", "860150-7722", densoQr0140, "860150-7722@1DZ50O")), + ) + + val document = InspectionPdfContent.build(session, AppLanguage.JAPANESE, utc) + val text = document.texts() + + assertTrue(text.contains("仕向地: デンソー")) + assertTrue(text.contains("状態: 照合中")) + assertTrue(text.contains("品番数: 1")) + assertFalse(text.contains("枝番別")) + assertFalse(text.contains("指示数")) + val header = document.blocks.filterIsInstance().single() + assertEquals( + listOf("No", "品番", "箱数", "収容数/箱", "数量計", "確認"), + header.columns.map { it.title }, + ) + assertEquals( + listOf(listOf("1", "860150-7722", "1", "24", "24", "")), + document.blocks.filterIsInstance().map { it.cells }, + ) + } + + @Test + fun legacyBoxWithoutPayloadKeepsARowWithDashQuantities() { + val session = MatchSession( + startedAt = 1_700_000_000_000L, + destination = Destination.SAWAI, + entries = listOf( + entry("legacy", "KAAA-55-D86B", null, null), + entry("one", "BCJH-52-81GG", sawaiQr5281, "BCJH-52-81GG@1N5X0C"), + ), + ) + + val document = InspectionPdfContent.build(session, AppLanguage.JAPANESE, utc) + + assertTrue(document.texts().contains("検査箱数: 2箱")) + assertTrue(document.texts().contains("品番数(枝番別): 2")) + assertEquals( + listOf( + listOf("1", "BCJH5281GG (02)", "1", "12", "12", ""), + listOf("2", "KAAA-55-D86B", "1", "-", "-", ""), + ), + document.blocks.filterIsInstance().map { it.cells }, + ) + } + + @Test + fun emptySessionPrintsTheNoMatchesLineAndNoTable() { + val document = InspectionPdfContent.build(MatchSession(startedAt = 0L), AppLanguage.JAPANESE, utc) + + assertTrue(document.texts().contains("一致したコードはありません。")) + assertTrue(document.blocks.none { it is InspectionPdfBlock.TableHeader }) + assertTrue(document.blocks.none { it is InspectionPdfBlock.TableRow }) + } + + private fun sawaiSession() = MatchSession( + startedAt = 1_700_000_000_000L, + endedAt = 1_700_000_120_000L, + name = "朝便", + destination = Destination.SAWAI, + entries = listOf( + entry("one", "BCJH-55-81GG", sawaiQr5581, "BCJH-55-81GG@1KVV0C"), + entry("two", "BCJH-52-81GG", sawaiQr5281, "BCJH-52-81GG@1N5X0C"), + entry("three", "BCJH-52-81GG", sawaiQr5281, "BCJH-52-81GG@1N5X0D"), + ), + ) + + private fun entry(id: String, code: String, qrPayload: String?, barcodePayload: String?) = MatchEntry( + id = id, + code = code, + matchedAt = 1_700_000_001_000L, + qrPayload = qrPayload, + barcodePayload = barcodePayload, + ) + + private fun InspectionPdfDocument.texts(): String = + blocks.filterIsInstance().joinToString("\n") { it.block.text } +} From 8310dbd8bf669b2e66c4318840624ac080d2ec23 Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 00:44:35 +0900 Subject: [PATCH 03/18] feat(android): render the inspection report as a paged table with repeated header and page numbers --- .../HistoryPdfExporterInstrumentationTest.kt | 75 ++++ .../core/export/HistoryPdfExporter.kt | 354 ++++++++++++++---- 2 files changed, 361 insertions(+), 68 deletions(-) diff --git a/android/core/export/src/androidTest/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporterInstrumentationTest.kt b/android/core/export/src/androidTest/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporterInstrumentationTest.kt index 0f3c2ff..670106b 100644 --- a/android/core/export/src/androidTest/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporterInstrumentationTest.kt +++ b/android/core/export/src/androidTest/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporterInstrumentationTest.kt @@ -93,6 +93,81 @@ class HistoryPdfExporterInstrumentationTest { } } + @Test + fun inspectionReportRendersEveryPageOfAMultiPageTable() { + val bytes = HistoryPdfExporter.generate( + session = longSawaiSession(), + language = AppLanguage.JAPANESE, + zoneId = ZoneId.of("UTC"), + kind = HistoryReportKind.INSPECTION, + ) + + assertTrue(bytes.size > MINIMUM_PDF_BYTES) + assertArrayEquals(PDF_HEADER, bytes.copyOf(PDF_HEADER.size)) + + val pdf = temporaryPdf(bytes, "inspection") + try { + ParcelFileDescriptor.open(pdf, ParcelFileDescriptor.MODE_READ_ONLY).use { descriptor -> + PdfRenderer(descriptor).use { renderer -> + assertTrue("120 rows should span several pages", renderer.pageCount >= 3) + repeat(renderer.pageCount) { pageIndex -> + renderer.openPage(pageIndex).use { page -> + val bitmap = Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888) + try { + bitmap.eraseColor(Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + assertTrue("page $pageIndex should contain the table", bitmap.hasNonWhitePixel()) + } finally { + bitmap.recycle() + } + } + } + } + } + } finally { + pdf.delete() + } + } + + @Test + fun inspectionCacheWriteUsesTheInspectionPrefixBelowTheSameCacheDirectory() { + val file = HistoryPdfExporter.writeToCache( + context = context, + session = MatchSession(startedAt = 0L, name = "cache boundary"), + language = AppLanguage.ENGLISH, + zoneId = ZoneId.of("UTC"), + kind = HistoryReportKind.INSPECTION, + ) + try { + val directory = File(context.cacheDir, HistoryPdfExporter.CACHE_DIRECTORY).canonicalFile + assertTrue(file.isFile) + assertTrue(file.length() > MINIMUM_PDF_BYTES) + assertEquals(directory, file.canonicalFile.parentFile) + assertTrue(file.name.startsWith("InspectionReport_")) + assertTrue(file.name.endsWith(".pdf")) + } finally { + file.delete() + } + } + + /** 120 distinct Sawai slips: the card number differs per box so each is its own row. */ + private fun longSawaiSession(): MatchSession = MatchSession( + startedAt = 1_700_000_000_000L, + endedAt = 1_700_000_120_000L, + name = "Long inspection", + entries = (0 until 120).map { index -> + val part = "BCJH" + index.toString().padStart(4, '0') + "GG" + MatchEntry( + id = "entry-$index", + code = "BCJH-${part.substring(4, 6)}-${part.substring(6)}", + matchedAt = 1_700_000_001_000L + index * 1_000L, + qrPayload = "DCLP675300" + part + "020000120000001200L000000000000BLBDILLU92 0*", + barcodePayload = "BCJH-${part.substring(4, 6)}-${part.substring(6)}@1N5X0C", + sequence = index.toLong(), + ) + }, + ) + private fun temporaryPdf(bytes: ByteArray, name: String): File = File(context.cacheDir, "$name-${System.nanoTime()}.pdf").apply { writeBytes(bytes) diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporter.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporter.kt index 3526062..e86865d 100644 --- a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporter.kt +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporter.kt @@ -7,13 +7,24 @@ import android.graphics.Paint import android.graphics.Typeface import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfDocument.Page +import android.text.TextPaint +import android.text.TextUtils import java.io.ByteArrayOutputStream import java.io.File import java.time.ZoneId import jp.rimtty.codematch.core.model.AppLanguage import jp.rimtty.codematch.core.model.MatchSession -/** Android PdfDocument renderer for the history report. */ +/** The two PDFs a session can be exported as. */ +enum class HistoryReportKind { + /** 照合履歴レポート: every box with its raw payloads. */ + MATCH_HISTORY, + + /** 検品レポート: one table row per part number / delivery number. */ + INSPECTION, +} + +/** Android PdfDocument renderer for the history and inspection reports. */ object HistoryPdfExporter { /** A4 at 72 dpi, matching the iOS renderer's point dimensions. */ const val PAGE_WIDTH: Int = 595 @@ -25,70 +36,10 @@ object HistoryPdfExporter { session: MatchSession, language: AppLanguage = AppLanguage.JAPANESE, zoneId: ZoneId = ZoneId.systemDefault(), - ): ByteArray { - val document = PdfDocument() - val output = ByteArrayOutputStream() - var page: Page? = null - var canvas: Canvas? = null - var cursor = MARGIN - var pageNumber = 0 - - fun beginPage() { - page?.let(document::finishPage) - pageNumber += 1 - val info = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, pageNumber).create() - page = document.startPage(info) - canvas = page?.canvas - cursor = MARGIN - } - - fun finishCurrentPage() { - page?.let(document::finishPage) - page = null - canvas = null - } - - fun ensureLineSpace(lineHeight: Float) { - if (cursor + lineHeight > PAGE_HEIGHT - MARGIN) beginPage() - } - - try { - beginPage() - val contentWidth = PAGE_WIDTH - MARGIN * 2f - HistoryPdfContent.build(session, language, zoneId).forEach { block -> - if (block.style == PdfTextStyle.DIVIDER) { - ensureLineSpace(DIVIDER_HEIGHT) - val dividerY = cursor + DIVIDER_OFFSET - canvas?.drawLine( - MARGIN, - dividerY, - PAGE_WIDTH - MARGIN, - dividerY, - dividerPaint, - ) - cursor += DIVIDER_HEIGHT + block.spacingAfter - return@forEach - } - - val paint = paintFor(block.style) - val lineHeight = paint.textSize * LINE_HEIGHT_MULTIPLIER - wrap(block.text, paint, contentWidth).forEach { line -> - ensureLineSpace(lineHeight) - val baseline = cursor - paint.ascent() - canvas?.drawText(line, MARGIN, baseline, paint) - cursor += lineHeight - } - cursor += block.spacingAfter - } - finishCurrentPage() - document.writeTo(output) - return output.toByteArray() - } finally { - // PdfDocument.close is idempotent and also releases native state if - // rendering or writing fails before the normal finish path. - finishCurrentPage() - document.close() - } + kind: HistoryReportKind = HistoryReportKind.MATCH_HISTORY, + ): ByteArray = when (kind) { + HistoryReportKind.MATCH_HISTORY -> generateHistory(session, language, zoneId) + HistoryReportKind.INSPECTION -> generateInspection(session, language, zoneId) } /** Filename suitable for CreateDocument and the cache/share helper. */ @@ -96,7 +47,16 @@ object HistoryPdfExporter { session: MatchSession, language: AppLanguage = AppLanguage.JAPANESE, zoneId: ZoneId = ZoneId.systemDefault(), - ): String = HistoryExportTextFormatter.fileName(session, language, zoneId) + kind: HistoryReportKind = HistoryReportKind.MATCH_HISTORY, + ): String = HistoryExportTextFormatter.fileName( + session = session, + language = language, + zoneId = zoneId, + prefix = when (kind) { + HistoryReportKind.MATCH_HISTORY -> null + HistoryReportKind.INSPECTION -> HistoryExportTextFormatter.labels(language).inspectionFilePrefix + }, + ) /** Name of the only cache directory used for a shareable history report. */ const val CACHE_DIRECTORY: String = "codematch-pdf" @@ -112,21 +72,246 @@ object HistoryPdfExporter { session: MatchSession, language: AppLanguage = AppLanguage.JAPANESE, zoneId: ZoneId = ZoneId.systemDefault(), + kind: HistoryReportKind = HistoryReportKind.MATCH_HISTORY, ): File { val directory = File(context.cacheDir, CACHE_DIRECTORY) check(directory.isDirectory || directory.mkdirs()) { "History PDF cache directory could not be created" } - val output = File(directory, fileName(session, language, zoneId)) + val output = File(directory, fileName(session, language, zoneId, kind)) check(output.canonicalFile.parentFile == directory.canonicalFile) { "History PDF filename escaped its private cache directory" } output.outputStream().use { stream -> - stream.write(generate(session, language, zoneId)) + stream.write(generate(session, language, zoneId, kind)) } return output } + private fun generateHistory( + session: MatchSession, + language: AppLanguage, + zoneId: ZoneId, + ): ByteArray = render(footerHeight = 0f) { cursor -> + HistoryPdfContent.build(session, language, zoneId).forEach { block -> + cursor.drawBlock(block) + } + } + + /** + * The inspection report prints `n / N` on every page, and a PdfDocument + * page cannot be revisited once finished. The report is therefore + * rendered twice: the first pass only counts pages, the second prints the + * total. Every row has a fixed height, so both passes paginate identically. + */ + private fun generateInspection( + session: MatchSession, + language: AppLanguage, + zoneId: ZoneId, + ): ByteArray { + val document = InspectionPdfContent.build(session, language, zoneId) + var pageCount = 0 + render(footerHeight = FOOTER_HEIGHT) { cursor -> + renderInspection(cursor, document, totalPages = null) + pageCount = cursor.pageNumber + } + return render(footerHeight = FOOTER_HEIGHT) { cursor -> + renderInspection(cursor, document, totalPages = pageCount) + } + } + + private fun renderInspection( + cursor: PageCursor, + document: InspectionPdfDocument, + totalPages: Int?, + ) { + var currentHeader: List? = null + cursor.onPageStart = { currentHeader?.let(cursor::drawTableHeader) } + cursor.onPageEnd = { canvas, pageNumber -> + drawFooter(canvas, document, pageNumber, totalPages) + } + document.blocks.forEach { block -> + when (block) { + is InspectionPdfBlock.Text -> cursor.drawBlock(block.block) + is InspectionPdfBlock.TableHeader -> { + currentHeader = block.columns + cursor.drawTableHeader(block.columns) + } + is InspectionPdfBlock.TableRow -> cursor.drawTableRow(block) + } + } + } + + private fun drawFooter( + canvas: Canvas, + document: InspectionPdfDocument, + pageNumber: Int, + totalPages: Int?, + ) { + val paint = paintFor(PdfTextStyle.FOOTER) + val contentWidth = PAGE_WIDTH - MARGIN * 2f + val top = PAGE_HEIGHT - MARGIN - FOOTER_HEIGHT + val lineHeight = paint.textSize * LINE_HEIGHT_MULTIPLIER + val pageText = "$pageNumber / ${totalPages ?: pageNumber}" + val pageWidth = paint.measureText(pageText) + val noteWidth = contentWidth - pageWidth - PAGE_NUMBER_GAP + val firstBaseline = top + FOOTER_TOP_PADDING - paint.ascent() + canvas.drawText(ellipsize(document.footerNote, paint, noteWidth), MARGIN, firstBaseline, paint) + canvas.drawText( + ellipsize(document.generatedNote, paint, contentWidth), + MARGIN, + firstBaseline + lineHeight, + paint, + ) + val pagePaint = Paint(paint).apply { textAlign = Paint.Align.RIGHT } + canvas.drawText(pageText, PAGE_WIDTH - MARGIN, firstBaseline, pagePaint) + } + + private fun render(footerHeight: Float, body: (PageCursor) -> Unit): ByteArray { + val document = PdfDocument() + val output = ByteArrayOutputStream() + val cursor = PageCursor(document, footerHeight) + try { + cursor.beginPage() + body(cursor) + cursor.finishCurrentPage() + document.writeTo(output) + return output.toByteArray() + } finally { + // PdfDocument.close is idempotent and also releases native state if + // rendering or writing fails before the normal finish path. + cursor.finishCurrentPage() + document.close() + } + } + + /** + * Page state of one rendering pass: the current page and canvas, the + * vertical cursor, page breaks, and the hooks a report uses to repeat a + * table header or print a footer on every page. + */ + private class PageCursor( + private val document: PdfDocument, + private val footerHeight: Float, + ) { + private var page: Page? = null + private var canvas: Canvas? = null + var cursor = MARGIN + private set + var pageNumber = 0 + private set + var onPageStart: (() -> Unit)? = null + var onPageEnd: ((Canvas, Int) -> Unit)? = null + + val contentWidth = PAGE_WIDTH - MARGIN * 2f + private val contentBottom = PAGE_HEIGHT - MARGIN - footerHeight + + fun beginPage() { + finishCurrentPage() + pageNumber += 1 + val info = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, pageNumber).create() + val newPage = document.startPage(info) + page = newPage + canvas = newPage.canvas + cursor = MARGIN + onPageStart?.invoke() + } + + fun finishCurrentPage() { + val current = page ?: return + onPageEnd?.invoke(current.canvas, pageNumber) + document.finishPage(current) + page = null + canvas = null + } + + fun ensureSpace(height: Float) { + if (cursor + height > contentBottom) beginPage() + } + + fun drawBlock(block: HistoryPdfBlock) { + if (block.style == PdfTextStyle.DIVIDER) { + ensureSpace(DIVIDER_HEIGHT) + val dividerY = cursor + DIVIDER_OFFSET + canvas?.drawLine(MARGIN, dividerY, PAGE_WIDTH - MARGIN, dividerY, dividerPaint) + cursor += DIVIDER_HEIGHT + block.spacingAfter + return + } + + val paint = paintFor(block.style) + val lineHeight = paint.textSize * LINE_HEIGHT_MULTIPLIER + wrap(block.text, paint, contentWidth).forEach { line -> + ensureSpace(lineHeight) + val baseline = cursor - paint.ascent() + canvas?.drawText(line, MARGIN, baseline, paint) + cursor += lineHeight + } + cursor += block.spacingAfter + } + + fun drawTableHeader(columns: List) { + ensureSpace(TABLE_HEADER_HEIGHT) + val canvas = canvas ?: return + canvas.drawRect(MARGIN, cursor, PAGE_WIDTH - MARGIN, cursor + TABLE_HEADER_HEIGHT, headerFillPaint) + columns.forEachIndexed { index, column -> + drawCell(canvas, column.title, columns, index, tableHeaderPaint, TABLE_HEADER_HEIGHT) + } + val ruleY = cursor + TABLE_HEADER_HEIGHT + canvas.drawLine(MARGIN, ruleY, PAGE_WIDTH - MARGIN, ruleY, dividerPaint) + cursor += TABLE_HEADER_HEIGHT + } + + fun drawTableRow(row: InspectionPdfBlock.TableRow) { + ensureSpace(TABLE_ROW_HEIGHT) + val canvas = canvas ?: return + row.columns.forEachIndexed { index, column -> + if (index == row.checkboxColumn) { + drawCheckbox(canvas, row.columns, index) + } else { + drawCell(canvas, row.cells[index], row.columns, index, paintFor(column.style), TABLE_ROW_HEIGHT) + } + } + val ruleY = cursor + TABLE_ROW_HEIGHT + canvas.drawLine(MARGIN, ruleY, PAGE_WIDTH - MARGIN, ruleY, rowRulePaint) + cursor += TABLE_ROW_HEIGHT + } + + private fun drawCell( + canvas: Canvas, + text: String, + columns: List, + index: Int, + basePaint: Paint, + rowHeight: Float, + ) { + val left = MARGIN + columns.take(index).sumOf { it.weight.toDouble() }.toFloat() * contentWidth + val width = columns[index].weight * contentWidth + val paint = Paint(basePaint) + val visible = ellipsize(text, paint, width - CELL_PADDING * 2) + val baseline = cursor + (rowHeight - (paint.descent() - paint.ascent())) / 2f - paint.ascent() + val x = when (columns[index].align) { + PdfAlign.LEFT -> left + CELL_PADDING + PdfAlign.RIGHT -> left + width - CELL_PADDING + PdfAlign.CENTER -> left + width / 2f + } + paint.textAlign = when (columns[index].align) { + PdfAlign.LEFT -> Paint.Align.LEFT + PdfAlign.RIGHT -> Paint.Align.RIGHT + PdfAlign.CENTER -> Paint.Align.CENTER + } + canvas.drawText(visible, x, baseline, paint) + } + + private fun drawCheckbox(canvas: Canvas, columns: List, index: Int) { + val left = MARGIN + columns.take(index).sumOf { it.weight.toDouble() }.toFloat() * contentWidth + val width = columns[index].weight * contentWidth + val centerX = left + width / 2f + val centerY = cursor + TABLE_ROW_HEIGHT / 2f + val half = CHECKBOX_SIZE / 2f + canvas.drawRect(centerX - half, centerY - half, centerX + half, centerY + half, checkboxPaint) + } + } + private fun paintFor(style: PdfTextStyle): Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = when (style) { PdfTextStyle.MUTED -> Color.rgb(97, 97, 97) @@ -177,12 +362,45 @@ object HistoryPdfExporter { return lines.ifEmpty { listOf("") } } + /** A table cell is a single line; anything wider than its column is cut with an ellipsis. */ + private fun ellipsize(text: String, paint: Paint, width: Float): String = + TextUtils.ellipsize(text, TextPaint(paint), width.coerceAtLeast(0f), TextUtils.TruncateAt.END).toString() + private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(210, 210, 210) strokeWidth = 0.7f } + private val rowRulePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.rgb(225, 225, 225) + strokeWidth = 0.4f + } + + private val headerFillPaint = Paint().apply { + color = Color.rgb(237, 237, 237) + style = Paint.Style.FILL + } + + private val tableHeaderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + textSize = 11f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } + + private val checkboxPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.rgb(90, 90, 90) + style = Paint.Style.STROKE + strokeWidth = 0.8f + } + private const val LINE_HEIGHT_MULTIPLIER = 1.35f private const val DIVIDER_HEIGHT = 10f private const val DIVIDER_OFFSET = 4f + private const val FOOTER_HEIGHT = 30f + private const val FOOTER_TOP_PADDING = 4f + private const val PAGE_NUMBER_GAP = 12f + private const val TABLE_HEADER_HEIGHT = 20f + private const val TABLE_ROW_HEIGHT = 22f + private const val CELL_PADDING = 4f + private const val CHECKBOX_SIZE = 9f } From af9bd16baba567c09b4ee806329c121fed3ab9f8 Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 00:47:05 +0900 Subject: [PATCH 04/18] =?UTF-8?q?feat(android):=20add=20=E6=A4=9C=E5=93=81?= =?UTF-8?q?=E3=83=AC=E3=83=9D=E3=83=BC=E3=83=88=20save/share=20row=20above?= =?UTF-8?q?=20the=20match=20history=20PDF=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codematch/history/HistoryPdfBridge.kt | 9 +- .../rimtty/codematch/history/HistoryRoute.kt | 37 ++++--- .../codematch/history/HistoryPdfBridgeTest.kt | 17 +++ .../HistoryFontScaleAccessibilityTest.kt | 18 ++++ .../feature/history/HistoryScreenTest.kt | 43 ++++++++ .../feature/history/HistoryScreen.kt | 100 ++++++++++++++---- .../feature/history/HistoryUiResources.kt | 2 + .../feature/history/HistoryUiText.kt | 3 + .../src/main/res/values-en/strings.xml | 2 + .../history/src/main/res/values/strings.xml | 2 + 10 files changed, 197 insertions(+), 36 deletions(-) diff --git a/android/app/src/main/java/jp/rimtty/codematch/history/HistoryPdfBridge.kt b/android/app/src/main/java/jp/rimtty/codematch/history/HistoryPdfBridge.kt index a8965fd..cb3bf70 100644 --- a/android/app/src/main/java/jp/rimtty/codematch/history/HistoryPdfBridge.kt +++ b/android/app/src/main/java/jp/rimtty/codematch/history/HistoryPdfBridge.kt @@ -11,6 +11,7 @@ import java.io.File import java.io.OutputStream import java.time.ZoneId import jp.rimtty.codematch.core.export.HistoryPdfExporter +import jp.rimtty.codematch.core.export.HistoryReportKind import jp.rimtty.codematch.core.model.AppLanguage import jp.rimtty.codematch.core.model.MatchSession @@ -87,15 +88,16 @@ internal object HistoryPdfBridge { session: MatchSession, language: AppLanguage, zoneId: ZoneId = ZoneId.systemDefault(), + kind: HistoryReportKind = HistoryReportKind.MATCH_HISTORY, ): HistoryPdfResult = createDocument( session = session, language = language, zoneId = zoneId, generate = { currentSession, currentLanguage, currentZone -> - HistoryPdfExporter.generate(currentSession, currentLanguage, currentZone) + HistoryPdfExporter.generate(currentSession, currentLanguage, currentZone, kind) }, fileName = { currentSession, currentLanguage, currentZone -> - HistoryPdfExporter.fileName(currentSession, currentLanguage, currentZone) + HistoryPdfExporter.fileName(currentSession, currentLanguage, currentZone, kind) }, ) @@ -155,9 +157,10 @@ internal object HistoryPdfBridge { session: MatchSession, language: AppLanguage, zoneId: ZoneId = ZoneId.systemDefault(), + kind: HistoryReportKind = HistoryReportKind.MATCH_HISTORY, ): HistoryPdfResult = try { HistoryPdfResult.Success( - HistoryPdfExporter.writeToCache(context, session, language, zoneId), + HistoryPdfExporter.writeToCache(context, session, language, zoneId, kind), ) } catch (_: Exception) { HistoryPdfResult.Failure(HistoryPdfFailure.CACHE_WRITE_FAILED) diff --git a/android/app/src/main/java/jp/rimtty/codematch/history/HistoryRoute.kt b/android/app/src/main/java/jp/rimtty/codematch/history/HistoryRoute.kt index 441f03d..d99f374 100644 --- a/android/app/src/main/java/jp/rimtty/codematch/history/HistoryRoute.kt +++ b/android/app/src/main/java/jp/rimtty/codematch/history/HistoryRoute.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import jp.rimtty.codematch.R +import jp.rimtty.codematch.core.export.HistoryReportKind import jp.rimtty.codematch.core.model.AppLanguage import jp.rimtty.codematch.core.model.MatchSession import jp.rimtty.codematch.feature.history.HistoryContent @@ -65,6 +66,7 @@ fun HistoryRoute(modifier: Modifier = Modifier) { // intentionally not persisted; an interrupted picker flow is regenerated // from the current Room session when the user retries. var latestSaveSessionId by rememberSaveable { mutableStateOf(null) } + var latestSaveKind by rememberSaveable { mutableStateOf(HistoryReportKind.MATCH_HISTORY) } var exportGeneration by remember { mutableStateOf(0L) } var launchSavePicker: ((PendingHistoryPdf) -> Unit)? = null @@ -77,15 +79,17 @@ fun HistoryRoute(modifier: Modifier = Modifier) { feedback = HistoryPdfFeedback(message = message, retry = retry) } - fun startSave(session: MatchSession) { + fun startSave(session: MatchSession, kind: HistoryReportKind) { feedback = null latestSaveSessionId = session.id + latestSaveKind = kind val generation = exportGeneration + 1L exportGeneration = generation pendingDocument = null preparePdfForSave( session = session, language = state.language, + kind = kind, scope = scope, ) { result -> if (generation != exportGeneration) return@preparePdfForSave @@ -93,19 +97,19 @@ fun HistoryRoute(modifier: Modifier = Modifier) { is HistoryPdfResult.Success -> { val launch = launchSavePicker if (launch == null) { - reportPdfFailure(saveErrorMessage) { startSave(session) } + reportPdfFailure(saveErrorMessage) { startSave(session, kind) } } else { launch(result.value) } } is HistoryPdfResult.Failure -> { - reportPdfFailure(saveErrorMessage) { startSave(session) } + reportPdfFailure(saveErrorMessage) { startSave(session, kind) } } } } } - fun startShare(session: MatchSession) { + fun startShare(session: MatchSession, kind: HistoryReportKind) { feedback = null val generation = exportGeneration + 1L exportGeneration = generation @@ -114,6 +118,7 @@ fun HistoryRoute(modifier: Modifier = Modifier) { context = context, session = session, language = state.language, + kind = kind, scope = scope, ) { result -> if (generation != exportGeneration) return@preparePdfForShare @@ -122,12 +127,12 @@ fun HistoryRoute(modifier: Modifier = Modifier) { when (HistoryPdfBridge.launchShare(context, result.value)) { is HistoryPdfResult.Success -> Unit is HistoryPdfResult.Failure -> { - reportPdfFailure(shareErrorMessage) { startShare(session) } + reportPdfFailure(shareErrorMessage) { startShare(session, kind) } } } } is HistoryPdfResult.Failure -> { - reportPdfFailure(shareErrorMessage) { startShare(session) } + reportPdfFailure(shareErrorMessage) { startShare(session, kind) } } } } @@ -175,7 +180,8 @@ fun HistoryRoute(modifier: Modifier = Modifier) { } HistoryPdfPickerResult.MissingPendingDocument -> { val retrySession = state.sessions.firstOrNull { it.id == latestSaveSessionId } - reportPdfFailure(saveErrorMessage, retrySession?.let { session -> { startSave(session) } }) + val retryKind = latestSaveKind + reportPdfFailure(saveErrorMessage, retrySession?.let { session -> { startSave(session, retryKind) } }) } is HistoryPdfPickerResult.Selected -> { val generation = exportGeneration @@ -211,7 +217,8 @@ fun HistoryRoute(modifier: Modifier = Modifier) { is HistoryPdfResult.Failure -> { pendingDocument = null val retrySession = state.sessions.firstOrNull { it.id == latestSaveSessionId } - reportPdfFailure(saveErrorMessage, retrySession?.let { session -> { startSave(session) } }) + val retryKind = latestSaveKind + reportPdfFailure(saveErrorMessage, retrySession?.let { session -> { startSave(session, retryKind) } }) } } } @@ -285,8 +292,10 @@ fun HistoryRoute(modifier: Modifier = Modifier) { }, onEntrySelected = { entryId -> selectedEntryId = entryId }, onBack = goBack, - onSavePdf = ::startSave, - onSharePdf = ::startShare, + onSavePdf = { session -> startSave(session, HistoryReportKind.MATCH_HISTORY) }, + onSharePdf = { session -> startShare(session, HistoryReportKind.MATCH_HISTORY) }, + onSaveInspectionReport = { session -> startSave(session, HistoryReportKind.INSPECTION) }, + onShareInspectionReport = { session -> startShare(session, HistoryReportKind.INSPECTION) }, onShareAllHistory = ::startShareAllHistory, modifier = Modifier.fillMaxSize(), ) @@ -369,11 +378,12 @@ internal fun HistoryPdfFeedbackHost( private fun preparePdfForSave( session: MatchSession, language: AppLanguage, + kind: HistoryReportKind, scope: kotlinx.coroutines.CoroutineScope, onResult: (HistoryPdfResult) -> Unit, ) { scope.launch(Dispatchers.IO) { - val result = HistoryPdfBridge.createDocument(session, language) + val result = HistoryPdfBridge.createDocument(session, language, kind = kind) withContext(Dispatchers.Main.immediate) { onResult(result) } } } @@ -382,11 +392,14 @@ private fun preparePdfForShare( context: Context, session: MatchSession, language: AppLanguage, + kind: HistoryReportKind, scope: kotlinx.coroutines.CoroutineScope, onResult: (HistoryPdfResult) -> Unit, ) { scope.launch(Dispatchers.IO) { - val result = when (val cacheResult = HistoryPdfBridge.writeShareCache(context, session, language)) { + val result = when ( + val cacheResult = HistoryPdfBridge.writeShareCache(context, session, language, kind = kind) + ) { is HistoryPdfResult.Success -> HistoryPdfBridge.createShareChooser(context, cacheResult.value) is HistoryPdfResult.Failure -> HistoryPdfResult.Failure(cacheResult.reason) } diff --git a/android/app/src/test/java/jp/rimtty/codematch/history/HistoryPdfBridgeTest.kt b/android/app/src/test/java/jp/rimtty/codematch/history/HistoryPdfBridgeTest.kt index 652009c..e4c8d36 100644 --- a/android/app/src/test/java/jp/rimtty/codematch/history/HistoryPdfBridgeTest.kt +++ b/android/app/src/test/java/jp/rimtty/codematch/history/HistoryPdfBridgeTest.kt @@ -10,6 +10,7 @@ import java.io.ByteArrayOutputStream import java.io.File import java.time.ZoneId import jp.rimtty.codematch.core.export.HistoryPdfExporter +import jp.rimtty.codematch.core.export.HistoryReportKind import jp.rimtty.codematch.core.model.AppLanguage import jp.rimtty.codematch.core.model.MatchSession import org.junit.Assert.assertArrayEquals @@ -52,6 +53,22 @@ class HistoryPdfBridgeTest { assertFalse(fileName.contains("..")) } + @Test + fun inspectionReportDocumentUsesTheInspectionPrefixAndStaysAPdf() { + val session = MatchSession(startedAt = 0L, name = "morning") + + val fileName = HistoryPdfExporter.fileName( + session, + AppLanguage.ENGLISH, + ZoneId.of("UTC"), + HistoryReportKind.INSPECTION, + ) + val document = PendingHistoryPdf(bytes = "%PDF-test".toByteArray(), fileName = fileName) + + assertEquals("InspectionReport_morning.pdf", fileName) + assertEquals(HistoryPdfBridge.PDF_MIME_TYPE, document.mimeType) + } + @Test(expected = IllegalArgumentException::class) fun pendingDocumentRejectsPathTraversal() { PendingHistoryPdf( diff --git a/android/feature/history/src/androidTest/kotlin/jp/rimtty/codematch/feature/history/HistoryFontScaleAccessibilityTest.kt b/android/feature/history/src/androidTest/kotlin/jp/rimtty/codematch/feature/history/HistoryFontScaleAccessibilityTest.kt index 72ed44f..75fcd16 100644 --- a/android/feature/history/src/androidTest/kotlin/jp/rimtty/codematch/feature/history/HistoryFontScaleAccessibilityTest.kt +++ b/android/feature/history/src/androidTest/kotlin/jp/rimtty/codematch/feature/history/HistoryFontScaleAccessibilityTest.kt @@ -38,6 +38,8 @@ class HistoryFontScaleAccessibilityTest { val selectedGroups = mutableListOf() var saved = 0 var shared = 0 + var savedInspection = 0 + var sharedInspection = 0 val fontScale = mutableStateOf(FONT_SCALES.first()) val session = sampleSession() setCompactContent(fontScale) { @@ -48,6 +50,8 @@ class HistoryFontScaleAccessibilityTest { onGroupSelected = selectedGroups::add, onSavePdf = { saved += 1 }, onSharePdf = { shared += 1 }, + onSaveInspectionReport = { savedInspection += 1 }, + onShareInspectionReport = { sharedInspection += 1 }, ) } @@ -57,10 +61,22 @@ class HistoryFontScaleAccessibilityTest { fontScale.value = scale saved = 0 shared = 0 + savedInspection = 0 + sharedInspection = 0 selectedGroups.clear() } } val detail = composeRule.onNodeWithTag(HistoryTestTags.SESSION_DETAIL) + detail.performScrollToNode(hasTestTag(HistoryTestTags.SAVE_INSPECTION_REPORT)) + composeRule.onNodeWithTag(HistoryTestTags.SAVE_INSPECTION_REPORT) + .assertIsDisplayed() + .assertHeightIsAtLeast(48.dp) + .performClick() + detail.performScrollToNode(hasTestTag(HistoryTestTags.SHARE_INSPECTION_REPORT)) + composeRule.onNodeWithTag(HistoryTestTags.SHARE_INSPECTION_REPORT) + .assertIsDisplayed() + .assertHeightIsAtLeast(48.dp) + .performClick() detail.performScrollToNode(hasTestTag(HistoryTestTags.SAVE_PDF)) composeRule.onNodeWithTag(HistoryTestTags.SAVE_PDF) .assertIsDisplayed() @@ -79,6 +95,8 @@ class HistoryFontScaleAccessibilityTest { assertEquals("fontScale=$scale", 1, saved) assertEquals("fontScale=$scale", 1, shared) + assertEquals("fontScale=$scale", 1, savedInspection) + assertEquals("fontScale=$scale", 1, sharedInspection) assertEquals("fontScale=$scale", listOf("PART-1"), selectedGroups) } } 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 0d65a6b..e322c35 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 @@ -446,6 +446,49 @@ class HistoryScreenTest { composeRule.onNodeWithText("Delivery numbers").assertIsDisplayed() } + @Test + fun sessionDetailOffersInspectionAndMatchHistoryPdfRowsInJapanese() { + val session = MatchSession( + id = "sawai-session", + startedAt = 1_000L, + endedAt = 2_000L, + destination = Destination.SAWAI, + entries = listOf( + MatchEntry( + id = "box-1", + code = "BCJH-52-81GG", + matchedAt = 1_100L, + qrPayload = "DCLP675300BCJH5281GG020000120000001200L000000000000BLBDILLU92 0*", + barcodePayload = "BCJH-52-81GG@1N5X0C", + ), + ), + ) + val saved = mutableListOf() + val shared = mutableListOf() + composeRule.setContent { + HistorySessionDetail( + session = session, + language = AppLanguage.JAPANESE, + onSavePdf = { saved += "history" }, + onSharePdf = { shared += "history" }, + onSaveInspectionReport = { saved += "inspection" }, + onShareInspectionReport = { shared += "inspection" }, + ) + } + + val detail = composeRule.onNodeWithTag(HistoryTestTags.SESSION_DETAIL) + detail.performScrollToNode(hasText("検品レポート")) + composeRule.onNodeWithText("検品レポート").assertIsDisplayed() + detail.performScrollToNode(hasText("照合履歴レポート")) + composeRule.onNodeWithText("照合履歴レポート").assertIsDisplayed() + composeRule.onNodeWithTag(HistoryTestTags.SAVE_INSPECTION_REPORT).performScrollTo().performClick() + composeRule.onNodeWithTag(HistoryTestTags.SHARE_INSPECTION_REPORT).performScrollTo().performClick() + composeRule.onNodeWithTag(HistoryTestTags.SAVE_PDF).performScrollTo().performClick() + composeRule.onNodeWithTag(HistoryTestTags.SHARE_PDF).performScrollTo().performClick() + assertEquals(listOf("inspection", "history"), saved) + assertEquals(listOf("inspection", "history"), shared) + } + private companion object { // Trailing spaces are part of the fixed-position record; the length // assertions in the tests fail first if they are ever trimmed away. 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 b2ec787..dec23f2 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 @@ -98,6 +98,8 @@ object HistoryTestTags { const val SHARE_ALL = "shareAllHistoryButton" const val SAVE_PDF = "savePDFButton" const val SHARE_PDF = "sharePDFButton" + const val SAVE_INSPECTION_REPORT = "saveInspectionReportButton" + const val SHARE_INSPECTION_REPORT = "shareInspectionReportButton" } /** @@ -147,6 +149,8 @@ fun HistoryContent( onBack: () -> Unit = {}, onSavePdf: (MatchSession) -> Unit = {}, onSharePdf: (MatchSession) -> Unit = {}, + onSaveInspectionReport: (MatchSession) -> Unit = {}, + onShareInspectionReport: (MatchSession) -> Unit = {}, onShareAllHistory: () -> Unit = {}, modifier: Modifier = Modifier, ) { @@ -175,6 +179,8 @@ fun HistoryContent( onEntrySelected = onEntrySelected, onSavePdf = onSavePdf, onSharePdf = onSharePdf, + onSaveInspectionReport = onSaveInspectionReport, + onShareInspectionReport = onShareInspectionReport, modifier = detailModifier, ) } @@ -403,6 +409,8 @@ fun HistorySessionDetail( onEntrySelected: (String) -> Unit = {}, onSavePdf: (MatchSession) -> Unit = {}, onSharePdf: (MatchSession) -> Unit = {}, + onSaveInspectionReport: (MatchSession) -> Unit = {}, + onShareInspectionReport: (MatchSession) -> Unit = {}, modifier: Modifier = Modifier, ) { HistoryLocalized(language) { @@ -442,6 +450,8 @@ fun HistorySessionDetail( onGroupSelected = onGroupSelected, onSavePdf = onSavePdf, onSharePdf = onSharePdf, + onSaveInspectionReport = onSaveInspectionReport, + onShareInspectionReport = onShareInspectionReport, modifier = modifier, ) } @@ -458,6 +468,8 @@ private fun SessionOverview( onGroupSelected: (String) -> Unit, onSavePdf: (MatchSession) -> Unit, onSharePdf: (MatchSession) -> Unit, + onSaveInspectionReport: (MatchSession) -> Unit, + onShareInspectionReport: (MatchSession) -> Unit, modifier: Modifier = Modifier, ) { var editedName by remember(session.id, session.displayName) { @@ -528,28 +540,29 @@ private fun SessionOverview( } } } + // The inspection report comes first: it is the sheet the operator + // reconciles against, while the match history PDF is the audit trail. item { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Button( - onClick = { onSavePdf(session) }, - modifier = Modifier.weight(1f).heightIn(min = 48.dp).testTag(HistoryTestTags.SAVE_PDF), - ) { - Icon(Icons.Outlined.SaveAlt, contentDescription = null) - Spacer(Modifier.width(6.dp)) - Text(labels.savePdf) - } - OutlinedButton( - onClick = { onSharePdf(session) }, - modifier = Modifier.weight(1f).heightIn(min = 48.dp).testTag(HistoryTestTags.SHARE_PDF), - ) { - Icon(Icons.Outlined.Share, contentDescription = null) - Spacer(Modifier.width(6.dp)) - Text(labels.sharePdf) - } - } + PdfActionRow( + caption = labels.inspectionReport, + saveLabel = labels.savePdf, + shareLabel = labels.sharePdf, + saveTag = HistoryTestTags.SAVE_INSPECTION_REPORT, + shareTag = HistoryTestTags.SHARE_INSPECTION_REPORT, + onSave = { onSaveInspectionReport(session) }, + onShare = { onShareInspectionReport(session) }, + ) + } + item { + PdfActionRow( + caption = labels.matchHistoryReport, + saveLabel = labels.savePdf, + shareLabel = labels.sharePdf, + saveTag = HistoryTestTags.SAVE_PDF, + shareTag = HistoryTestTags.SHARE_PDF, + onSave = { onSavePdf(session) }, + onShare = { onSharePdf(session) }, + ) } item { Text( @@ -1045,3 +1058,48 @@ private fun BackButton(onBack: () -> Unit) { private fun sortSessions(sessions: List): List = sessions.sortedWith(compareByDescending { it.startedAt }.thenByDescending { it.id }) + +/** + * A captioned save/share pair for one PDF. Both reports use the same button + * labels, so the caption is what tells the operator which PDF a row exports. + */ +@Composable +private fun PdfActionRow( + caption: String, + saveLabel: String, + shareLabel: String, + saveTag: String, + shareTag: String, + onSave: () -> Unit, + onShare: () -> Unit, +) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { + Text( + text = caption, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 4.dp), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Button( + onClick = onSave, + modifier = Modifier.weight(1f).heightIn(min = 48.dp).testTag(saveTag), + ) { + Icon(Icons.Outlined.SaveAlt, contentDescription = null) + Spacer(Modifier.width(6.dp)) + Text(saveLabel) + } + OutlinedButton( + onClick = onShare, + modifier = Modifier.weight(1f).heightIn(min = 48.dp).testTag(shareTag), + ) { + Icon(Icons.Outlined.Share, contentDescription = null) + Spacer(Modifier.width(6.dp)) + Text(shareLabel) + } + } + } +} 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 1f564f5..337f622 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 @@ -44,6 +44,8 @@ object HistoryUiResources { shareAll = stringResource(R.string.history_share_all), savePdf = stringResource(R.string.history_save_pdf), sharePdf = stringResource(R.string.history_share_pdf), + inspectionReport = stringResource(R.string.history_inspection_report), + matchHistoryReport = stringResource(R.string.history_match_history_report), matchedCodes = stringResource(R.string.history_matched_codes), noMatchesTitle = stringResource(R.string.history_no_matches_title), noMatchesDescription = stringResource(R.string.history_no_matches_description), 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 9d7268d..ecd980b 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 @@ -26,6 +26,9 @@ data class HistoryUiLabels( val shareAll: String, val savePdf: String, val sharePdf: String, + /** Captions of the two PDF action rows in the session detail. */ + val inspectionReport: String, + val matchHistoryReport: String, val matchedCodes: String, val noMatchesTitle: String, val noMatchesDescription: 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 9349578..c803bb7 100644 --- a/android/feature/history/src/main/res/values-en/strings.xml +++ b/android/feature/history/src/main/res/values-en/strings.xml @@ -18,6 +18,8 @@ Share all history Save PDF Share + Inspection report + Match history report Matched codes No matches in this session No matched codes have been recorded in this session. diff --git a/android/feature/history/src/main/res/values/strings.xml b/android/feature/history/src/main/res/values/strings.xml index a0a7964..2f61bc4 100644 --- a/android/feature/history/src/main/res/values/strings.xml +++ b/android/feature/history/src/main/res/values/strings.xml @@ -18,6 +18,8 @@ 照合履歴をすべて共有 PDFで保存 共有する + 検品レポート + 照合履歴レポート 一致したコード 一致履歴はありません このセッションではまだ一致したコードがありません。 From cb9409ec43cf3edc2b975133fb206c6f6059f872 Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 01:05:38 +0900 Subject: [PATCH 05/18] =?UTF-8?q?feat(ios):=20add=20the=20=E6=A4=9C?= =?UTF-8?q?=E5=93=81=E3=83=AC=E3=83=9D=E3=83=BC=E3=83=88=20PDF=20=E2=80=94?= =?UTF-8?q?=20one=20table=20row=20per=20part=20number,=20delivery=20number?= =?UTF-8?q?=20or=20kanban=20part?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/CodeMatch.xcodeproj/project.pbxproj | 20 ++ ios/CodeMatch/Models/InspectionReport.swift | 169 ++++++++++++ ios/CodeMatch/Resources/Localizable.xcstrings | 240 ++++++++++++++++++ .../Services/InspectionPDFExporter.swift | 230 +++++++++++++++++ ios/CodeMatch/Services/PDFPageWriter.swift | 180 +++++++++++++ .../Services/SessionPDFExporter.swift | 8 +- .../InspectionPDFExporterTests.swift | 196 ++++++++++++++ .../InspectionReportTests.swift | 141 ++++++++++ 8 files changed, 1182 insertions(+), 2 deletions(-) create mode 100644 ios/CodeMatch/Models/InspectionReport.swift create mode 100644 ios/CodeMatch/Services/InspectionPDFExporter.swift create mode 100644 ios/CodeMatch/Services/PDFPageWriter.swift create mode 100644 ios/CodeMatchTests/InspectionPDFExporterTests.swift create mode 100644 ios/CodeMatchTests/InspectionReportTests.swift diff --git a/ios/CodeMatch.xcodeproj/project.pbxproj b/ios/CodeMatch.xcodeproj/project.pbxproj index 007683e..62b22bf 100644 --- a/ios/CodeMatch.xcodeproj/project.pbxproj +++ b/ios/CodeMatch.xcodeproj/project.pbxproj @@ -28,6 +28,8 @@ A10000000000000000000107 /* SessionPDFExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000107 /* SessionPDFExporterTests.swift */; }; A10000000000000000000108 /* HistoryExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000108 /* HistoryExporterTests.swift */; }; A10000000000000000000109 /* ScanLogStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000109 /* ScanLogStoreTests.swift */; }; + A1000000000000000000010A /* InspectionReportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000010A /* InspectionReportTests.swift */; }; + A1000000000000000000010B /* InspectionPDFExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000010B /* InspectionPDFExporterTests.swift */; }; A1000000000000000000000C /* CodeMatchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000D /* CodeMatchUITests.swift */; }; A1000000000000000000000D /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000013 /* RootTabView.swift */; }; A1000000000000000000000E /* HistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000014 /* HistoryModels.swift */; }; @@ -40,6 +42,9 @@ A10000000000000000000015 /* SessionPDFExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001B /* SessionPDFExporter.swift */; }; A10000000000000000000019 /* HistoryExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001F /* HistoryExporter.swift */; }; A1000000000000000000001A /* ScanLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000020 /* ScanLogStore.swift */; }; + A1000000000000000000001B /* InspectionReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000021 /* InspectionReport.swift */; }; + A1000000000000000000001C /* PDFPageWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000022 /* PDFPageWriter.swift */; }; + A1000000000000000000001D /* InspectionPDFExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000023 /* InspectionPDFExporter.swift */; }; A10000000000000000000016 /* BluetoothScannerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001C /* BluetoothScannerService.swift */; }; A10000000000000000000018 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001E /* Localizable.xcstrings */; }; A10000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000060 /* InfoPlist.strings */; }; @@ -85,6 +90,8 @@ A20000000000000000000107 /* SessionPDFExporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPDFExporterTests.swift; sourceTree = ""; }; A20000000000000000000108 /* HistoryExporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryExporterTests.swift; sourceTree = ""; }; A20000000000000000000109 /* ScanLogStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanLogStoreTests.swift; sourceTree = ""; }; + A2000000000000000000010A /* InspectionReportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectionReportTests.swift; sourceTree = ""; }; + A2000000000000000000010B /* InspectionPDFExporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectionPDFExporterTests.swift; sourceTree = ""; }; A2000000000000000000000D /* CodeMatchUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeMatchUITests.swift; sourceTree = ""; }; A20000000000000000000010 /* CodeMatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodeMatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; A20000000000000000000011 /* CodeMatchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CodeMatchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -100,6 +107,9 @@ A2000000000000000000001B /* SessionPDFExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPDFExporter.swift; sourceTree = ""; }; A2000000000000000000001F /* HistoryExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryExporter.swift; sourceTree = ""; }; A20000000000000000000020 /* ScanLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanLogStore.swift; sourceTree = ""; }; + A20000000000000000000021 /* InspectionReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectionReport.swift; sourceTree = ""; }; + A20000000000000000000022 /* PDFPageWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFPageWriter.swift; sourceTree = ""; }; + A20000000000000000000023 /* InspectionPDFExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectionPDFExporter.swift; sourceTree = ""; }; A2000000000000000000001C /* BluetoothScannerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothScannerService.swift; sourceTree = ""; }; A2000000000000000000001E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; A20000000000000000000060 /* InfoPlist.strings */ = { @@ -187,6 +197,7 @@ children = ( A20000000000000000000003 /* ScanModels.swift */, A20000000000000000000014 /* HistoryModels.swift */, + A20000000000000000000021 /* InspectionReport.swift */, ); path = Models; sourceTree = ""; @@ -200,6 +211,8 @@ A2000000000000000000001B /* SessionPDFExporter.swift */, A2000000000000000000001F /* HistoryExporter.swift */, A20000000000000000000020 /* ScanLogStore.swift */, + A20000000000000000000022 /* PDFPageWriter.swift */, + A20000000000000000000023 /* InspectionPDFExporter.swift */, A2000000000000000000001C /* BluetoothScannerService.swift */, ); path = Services; @@ -241,6 +254,8 @@ A20000000000000000000107 /* SessionPDFExporterTests.swift */, A20000000000000000000108 /* HistoryExporterTests.swift */, A20000000000000000000109 /* ScanLogStoreTests.swift */, + A2000000000000000000010A /* InspectionReportTests.swift */, + A2000000000000000000010B /* InspectionPDFExporterTests.swift */, ); path = CodeMatchTests; sourceTree = ""; @@ -452,6 +467,9 @@ A10000000000000000000015 /* SessionPDFExporter.swift in Sources */, A10000000000000000000019 /* HistoryExporter.swift in Sources */, A1000000000000000000001A /* ScanLogStore.swift in Sources */, + A1000000000000000000001B /* InspectionReport.swift in Sources */, + A1000000000000000000001C /* PDFPageWriter.swift in Sources */, + A1000000000000000000001D /* InspectionPDFExporter.swift in Sources */, A10000000000000000000016 /* BluetoothScannerService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -470,6 +488,8 @@ A10000000000000000000107 /* SessionPDFExporterTests.swift in Sources */, A10000000000000000000108 /* HistoryExporterTests.swift in Sources */, A10000000000000000000109 /* ScanLogStoreTests.swift in Sources */, + A1000000000000000000010A /* InspectionReportTests.swift in Sources */, + A1000000000000000000010B /* InspectionPDFExporterTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/CodeMatch/Models/InspectionReport.swift b/ios/CodeMatch/Models/InspectionReport.swift new file mode 100644 index 0000000..529d2bb --- /dev/null +++ b/ios/CodeMatch/Models/InspectionReport.swift @@ -0,0 +1,169 @@ +import Foundation + +/// 検品レポートの1行。紙の検品表で操作者がチェックする単位に対応する: +/// 澤井製作所は品番+枝番、モルテンは納品番号、デンソーは品番。 +/// `keyText` は紙の表記に合わせて出す(澤井・モルテンはハイフンなしの生の品番、デンソーは6-4)。 +struct InspectionReportRow: Equatable { + let keyText: String + /// モルテンのみ: その納品番号の生の部品番号。 + let partNumber: String? + /// モルテンのみ: 納入先。同じ品番の2つの納品を見分ける。 + let deliveryDestination: String? + let boxCount: Int + /// 1箱あたりの数量。箱ごとに値が違う、または数量のない箱があれば nil。 + let quantityPerBox: Double? + /// 各箱の数量の合計。数量のない箱が1つでもあれば nil。 + let totalQuantity: Double? + /// セッションの仕向地としてQRを解析できなかった箱(旧履歴など)は true。 + let isUnparsed: Bool + + init( + keyText: String, + partNumber: String? = nil, + deliveryDestination: String? = nil, + boxCount: Int, + quantityPerBox: Double?, + totalQuantity: Double?, + isUnparsed: Bool = false + ) { + self.keyText = keyText + self.partNumber = partNumber + self.deliveryDestination = deliveryDestination + self.boxCount = boxCount + self.quantityPerBox = quantityPerBox + self.totalQuantity = totalQuantity + self.isUnparsed = isUnparsed + } +} + +/// セッション1件分の検品レポートの行。 +/// +/// 行はキー(品番、次いで枝番または納品番号)の昇順に並べ、操作者が検品表の行を +/// すぐ見つけられるようにする。読取順は意図的に提供しない。仕向地として解析できない +/// QRの箱は記録済みの品番をキーにした行として末尾に残し、レポートの箱数の合計が +/// 常にセッションの箱数と一致するようにする。Android の `InspectionReportContent` と同じ規則。 +struct InspectionReport: Equatable { + enum Layout: Equatable { + case sawai + case molten + case denso + } + + let layout: Layout + let rows: [InspectionReportRow] + + var rowCount: Int { rows.count } + + static func make(session: MatchSession) -> InspectionReport { + let destination = session.resolvedDestination + let layout: Layout + switch destination { + case .molten: layout = .molten + case .denso: layout = .denso + case .sawai, nil: layout = .sawai + } + + var parsed = BucketList() + var unparsed = BucketList() + + for entry in session.entries { + var placed = false + switch destination { + case .sawai: + if let record = entry.kanbanRecord { + let suffix = record.partSuffix + parsed.add( + key: SortKey(primary: record.partNumber, secondary: suffix ?? ""), + keyText: suffix.map { "\(record.partNumber) (\($0))" } ?? record.partNumber, + quantity: record.deliveryQuantity + ) + placed = true + } + case .molten: + if let record = entry.moltenRecord { + parsed.add( + key: SortKey(primary: record.deliveryNumber, secondary: ""), + keyText: record.deliveryNumber, + quantity: Double(record.packQuantity), + partNumber: record.partNumber, + deliveryDestination: record.deliveryDestination + ) + placed = true + } + case .denso: + if let record = entry.densoRecord { + parsed.add( + key: SortKey(primary: record.partNumber, secondary: ""), + keyText: CodeMatcher.format(partNumber: record.partNumber, destination: .denso), + quantity: Double(record.packQuantity) + ) + placed = true + } + case nil: + break + } + if !placed { + unparsed.add(key: SortKey(primary: entry.code, secondary: ""), keyText: entry.code, quantity: nil) + } + } + + let rows = parsed.sortedRows(isUnparsed: false) + unparsed.sortedRows(isUnparsed: true) + return InspectionReport(layout: layout, rows: rows) + } + + private struct SortKey: Hashable, Comparable { + let primary: String + let secondary: String + + static func < (lhs: SortKey, rhs: SortKey) -> Bool { + if lhs.primary != rhs.primary { return lhs.primary < rhs.primary } + return lhs.secondary < rhs.secondary + } + } + + private struct Bucket { + let keyText: String + let partNumber: String? + let deliveryDestination: String? + var quantities: [Double?] = [] + + func row(isUnparsed: Bool) -> InspectionReportRow { + let allPresent = quantities.allSatisfy { $0 != nil } + let perBox = quantities.first ?? nil + let sameEverywhere = allPresent && quantities.allSatisfy { $0 == perBox } + return InspectionReportRow( + keyText: keyText, + partNumber: partNumber, + deliveryDestination: deliveryDestination, + boxCount: quantities.count, + quantityPerBox: sameEverywhere ? perBox : nil, + totalQuantity: allPresent ? quantities.reduce(0) { $0 + ($1 ?? 0) } : nil, + isUnparsed: isUnparsed + ) + } + } + + private struct BucketList { + private var buckets: [SortKey: Bucket] = [:] + + mutating func add( + key: SortKey, + keyText: String, + quantity: Double?, + partNumber: String? = nil, + deliveryDestination: String? = nil + ) { + var bucket = buckets[key] ?? Bucket( + keyText: keyText, + partNumber: partNumber, + deliveryDestination: deliveryDestination + ) + bucket.quantities.append(quantity) + buckets[key] = bucket + } + + func sortedRows(isUnparsed: Bool) -> [InspectionReportRow] { + buckets.keys.sorted().compactMap { buckets[$0]?.row(isUnparsed: isUnparsed) } + } + } +} diff --git a/ios/CodeMatch/Resources/Localizable.xcstrings b/ios/CodeMatch/Resources/Localizable.xcstrings index a18a4eb..9fb4697 100644 --- a/ios/CodeMatch/Resources/Localizable.xcstrings +++ b/ios/CodeMatch/Resources/Localizable.xcstrings @@ -6072,6 +6072,246 @@ } } } + }, + "検品レポート" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inspection report" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検品レポート" + } + } + } + }, + "検査箱数: %lld箱" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inspected boxes: %lld" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検査箱数: %lld箱" + } + } + } + }, + "品番数(枝番別): %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Part numbers (by suffix): %lld" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "品番数(枝番別): %lld" + } + } + } + }, + "品番数: %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Part numbers: %lld" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "品番数: %lld" + } + } + } + }, + "No" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "No" + } + } + } + }, + "品番(検品列)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Part no." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "品番" + } + } + } + }, + "納品番号(検品列)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delivery no." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "納品番号" + } + } + } + }, + "納入先(検品列)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deliv. point" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "納入先" + } + } + } + }, + "箱数" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boxes" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "箱数" + } + } + } + }, + "納入数量/箱" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Qty / box" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "納入数量/箱" + } + } + } + }, + "収容数/箱" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pack / box" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "収容数/箱" + } + } + } + }, + "数量計" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total qty" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "数量計" + } + } + } + }, + "累計" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "累計" + } + } + } + }, + "確認" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "確認" + } + } + } + }, + "検品表にあってこの一覧にない品番は、このセッションで照合されていません。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Part numbers on the inspection sheet that are missing from this list were not matched in this session." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検品表にあってこの一覧にない品番は、このセッションで照合されていません。" + } + } + } } }, "version" : "1.0" diff --git a/ios/CodeMatch/Services/InspectionPDFExporter.swift b/ios/CodeMatch/Services/InspectionPDFExporter.swift new file mode 100644 index 0000000..4d5f0db --- /dev/null +++ b/ios/CodeMatch/Services/InspectionPDFExporter.swift @@ -0,0 +1,230 @@ +import Foundation +import UIKit + +/// 検品レポート(1品番=1行の表)をA4縦のPDFへ書き出す。 +/// +/// 紙の検品表と突き合わせるための帳票なので、1箱ごとの証跡は `SessionPDFExporter` に任せ、 +/// ここでは箱数と数量だけを品番順に並べる。完了判定や不足数は出さない。 +enum InspectionPDFExporter { + private static let pageSize = CGSize(width: 595.2, height: 841.8) // A4 @72dpi + private static let margin: CGFloat = 44 + private static let footerHeight: CGFloat = 30 + + static func fileName(for session: MatchSession, locale: Locale) -> String { + "\(AppLocalization.string("検品レポート"))_\(SessionPDFExporter.sanitizedStem(for: session, locale: locale)).pdf" + } + + /// ページ番号 `n / N` を全ページに置くため2回描画する。1回目でページ数を数え、2回目で総数を印字する。 + /// 表の行は固定高さなので、両方の描画は同じ位置で改ページする。 + static func generatePDF(for session: MatchSession, locale: Locale) -> Data { + let report = InspectionReport.make(session: session) + let firstPass = render(report, session: session, locale: locale, totalPages: nil) + return render(report, session: session, locale: locale, totalPages: firstPass.pageCount).data + } + + /// 共有シート用にPDFを一時ファイルへ書き出してURLを返す。 + static func writeTemporaryPDF(for session: MatchSession, locale: Locale) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(fileName(for: session, locale: locale)) + try generatePDF(for: session, locale: locale).write(to: url, options: .atomic) + return url + } + + // MARK: - Rendering + + private static func render( + _ report: InspectionReport, + session: MatchSession, + locale: Locale, + totalPages: Int? + ) -> (data: Data, pageCount: Int) { + let appLanguage = AppLanguage(locale) + let renderer = UIGraphicsPDFRenderer(bounds: CGRect(origin: .zero, size: pageSize)) + + let titleFont = UIFont.boldSystemFont(ofSize: 20) + let headFont = UIFont.boldSystemFont(ofSize: 12) + let bodyFont = UIFont.systemFont(ofSize: 11) + let footerFont = UIFont.systemFont(ofSize: 8.5) + let gray = UIColor(white: 0.38, alpha: 1) + let footerGray = UIColor(white: 0.55, alpha: 1) + + var pageCount = 0 + let data = renderer.pdfData { context in + let writer = PDFPageWriter(context: context, pageSize: pageSize, margin: margin, footerHeight: footerHeight) + let columns = columns(for: report.layout) + + writer.onPageEnd = { writer in + let top = pageSize.height - margin - footerHeight + 4 + let pageText = "\(appLanguage.formatInteger(writer.pageNumber)) / \(appLanguage.formatInteger(totalPages ?? writer.pageNumber))" + let pageWidth = ceil((pageText as NSString).size(withAttributes: [.font: footerFont]).width) + 4 + let lineHeight = ceil(footerFont.lineHeight) + writer.drawSingleLine( + AppLocalization.string("検品表にあってこの一覧にない品番は、このセッションで照合されていません。"), + in: CGRect(x: margin - 4, y: top, width: writer.contentWidth - pageWidth - 12 + 8, height: lineHeight), + font: footerFont, + alignment: .leading, + color: footerGray + ) + writer.drawSingleLine( + AppLocalization.string("CodeMatch により生成 — このレポートは端末内のデータから作成されています。"), + in: CGRect(x: margin - 4, y: top + lineHeight + 2, width: writer.contentWidth + 8, height: lineHeight), + font: footerFont, + alignment: .leading, + color: footerGray + ) + writer.drawSingleLine( + pageText, + in: CGRect(x: pageSize.width - margin - pageWidth - 4, y: top, width: pageWidth + 8, height: lineHeight), + font: UIFont.systemFont(ofSize: 9), + alignment: .trailing, + color: footerGray + ) + } + + writer.beginPage() + + writer.draw(AppLocalization.string("検品レポート"), font: titleFont, spacing: 6) + if !session.displayName.isEmpty { + writer.draw("\(AppLocalization.string("セッション名")): \(session.displayName)", font: headFont, spacing: 4) + } + writer.draw( + "\(AppLocalization.string("開始")): \(appLanguage.formatDateTime(session.startedAt))", + font: bodyFont, + color: gray, + spacing: 2 + ) + if let endedAt = session.endedAt { + writer.draw( + "\(AppLocalization.string("終了")): \(appLanguage.formatDateTime(endedAt))", + font: bodyFont, + color: gray, + spacing: 2 + ) + } else { + writer.draw( + "\(AppLocalization.string("状態")): \(AppLocalization.string("照合中"))", + font: bodyFont, + color: gray, + spacing: 2 + ) + } + if let destination = session.resolvedDestination { + writer.draw( + AppLocalization.string("仕向地: \(destination.displayName)"), + font: bodyFont, + color: gray, + spacing: 2 + ) + } + writer.draw( + AppLocalization.string("検査箱数: \(session.matchedCount)箱"), + font: bodyFont, + color: gray, + spacing: 2 + ) + let countLine: String + switch report.layout { + case .sawai: countLine = AppLocalization.string("品番数(枝番別): \(report.rowCount)") + case .molten: countLine = AppLocalization.string("納品番号数: \(report.rowCount)") + case .denso: countLine = AppLocalization.string("品番数: \(report.rowCount)") + } + writer.draw(countLine, font: bodyFont, color: gray, spacing: 8) + writer.drawDivider() + + if report.rows.isEmpty { + writer.draw(AppLocalization.string("一致したコードはありません。"), font: bodyFont, color: gray) + } else { + writer.drawTableHeader(columns) + // 2ページ目以降は表ヘッダーを繰り返す。1ページ目は上で描いたので二重にならない。 + writer.onPageStart = { writer in writer.drawTableHeader(columns) } + let checkboxColumn = columns.count - 1 + for (index, row) in report.rows.enumerated() { + writer.drawTableRow( + cells(for: row, index: index, layout: report.layout, appLanguage: appLanguage), + columns: columns, + checkboxColumn: checkboxColumn + ) + } + writer.onPageStart = nil + } + + writer.finish() + pageCount = writer.pageNumber + } + return (data, pageCount) + } + + // MARK: - Columns + + /// 列幅はポイント。本文幅 507pt に収まるよう Android と同じ値を使う。 + private static func columns(for layout: InspectionReport.Layout) -> [PDFPageWriter.Column] { + let body = UIFont.systemFont(ofSize: 11) + let key = UIFont.monospacedSystemFont(ofSize: 12, weight: .bold) + func column( + _ title: String, + _ width: CGFloat, + _ alignment: PDFPageWriter.Column.Alignment, + font: UIFont? = nil + ) -> PDFPageWriter.Column { + PDFPageWriter.Column(title: title, width: width, alignment: alignment, font: font ?? body) + } + switch layout { + case .sawai: + return [ + column(AppLocalization.string("No"), 28, .trailing), + column(AppLocalization.string("品番(検品列)"), 170, .leading, font: key), + column(AppLocalization.string("箱数"), 52, .trailing, font: key), + column(AppLocalization.string("納入数量/箱"), 90, .trailing), + column(AppLocalization.string("数量計"), 90, .trailing), + column(AppLocalization.string("確認"), 77, .center), + ] + case .denso: + return [ + column(AppLocalization.string("No"), 28, .trailing), + column(AppLocalization.string("品番(検品列)"), 170, .leading, font: key), + column(AppLocalization.string("箱数"), 52, .trailing, font: key), + column(AppLocalization.string("収容数/箱"), 90, .trailing), + column(AppLocalization.string("数量計"), 90, .trailing), + column(AppLocalization.string("確認"), 77, .center), + ] + case .molten: + return [ + column(AppLocalization.string("No"), 28, .trailing), + column(AppLocalization.string("納品番号(検品列)"), 82, .leading, font: key), + column(AppLocalization.string("品番(検品列)"), 96, .leading, font: key), + column(AppLocalization.string("納入先(検品列)"), 52, .leading), + column(AppLocalization.string("箱数"), 44, .trailing, font: key), + column(AppLocalization.string("収容数/箱"), 72, .trailing), + column(AppLocalization.string("累計"), 72, .trailing), + column(AppLocalization.string("確認"), 61, .center), + ] + } + } + + private static func cells( + for row: InspectionReportRow, + index: Int, + layout: InspectionReport.Layout, + appLanguage: AppLanguage + ) -> [String] { + let number = appLanguage.formatInteger(index + 1) + let boxes = appLanguage.formatInteger(row.boxCount) + let perBox = row.quantityPerBox.map(appLanguage.formatQuantity) ?? "-" + let total = row.totalQuantity.map(appLanguage.formatQuantity) ?? "-" + switch layout { + case .sawai, .denso: + return [number, row.keyText, boxes, perBox, total, ""] + case .molten: + return [ + number, + row.keyText, + row.partNumber ?? "-", + row.deliveryDestination ?? "-", + boxes, + perBox, + total, + "", + ] + } + } +} diff --git a/ios/CodeMatch/Services/PDFPageWriter.swift b/ios/CodeMatch/Services/PDFPageWriter.swift new file mode 100644 index 0000000..57c840a --- /dev/null +++ b/ios/CodeMatch/Services/PDFPageWriter.swift @@ -0,0 +1,180 @@ +import UIKit + +/// A4縦1文書分の描画カーソル。ページ送り、本文テキスト、表の行、各ページのフッターをまとめて扱う。 +/// +/// `onPageStart` は2ページ目以降の先頭で(表ヘッダーの再描画に)、`onPageEnd` は各ページを閉じる +/// 直前で(フッターとページ番号に)呼ばれる。フッター帯 `footerHeight` には本文が入り込まない。 +final class PDFPageWriter { + struct Column { + enum Alignment { + case leading + case trailing + case center + } + + let title: String + let width: CGFloat + let alignment: Alignment + let font: UIFont + } + + private let context: UIGraphicsPDFRendererContext + let pageSize: CGSize + let margin: CGFloat + let footerHeight: CGFloat + private(set) var cursorY: CGFloat = 0 + /// 1始まり。`beginPage()` 前は 0。 + private(set) var pageNumber = 0 + + var onPageStart: ((PDFPageWriter) -> Void)? + var onPageEnd: ((PDFPageWriter) -> Void)? + + var contentWidth: CGFloat { pageSize.width - margin * 2 } + var contentBottom: CGFloat { pageSize.height - margin - footerHeight } + + init(context: UIGraphicsPDFRendererContext, pageSize: CGSize, margin: CGFloat, footerHeight: CGFloat) { + self.context = context + self.pageSize = pageSize + self.margin = margin + self.footerHeight = footerHeight + } + + func beginPage() { + if pageNumber > 0 { onPageEnd?(self) } + context.beginPage() + pageNumber += 1 + cursorY = margin + onPageStart?(self) + } + + /// 最後のページを閉じる。`onPageEnd` を最終ページにも適用する。 + func finish() { + if pageNumber > 0 { onPageEnd?(self) } + } + + func ensureSpace(_ height: CGFloat) { + if cursorY + height > contentBottom { + beginPage() + } + } + + @discardableResult + func draw(_ text: String, font: UIFont, color: UIColor = .black, spacing: CGFloat = 4) -> CGFloat { + let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color] + let attributed = NSAttributedString(string: text, attributes: attributes) + let bounds = attributed.boundingRect( + with: CGSize(width: contentWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + context: nil + ) + ensureSpace(bounds.height + spacing) + attributed.draw( + with: CGRect(x: margin, y: cursorY, width: contentWidth, height: ceil(bounds.height)), + options: [.usesLineFragmentOrigin, .usesFontLeading], + context: nil + ) + cursorY += ceil(bounds.height) + spacing + return bounds.height + } + + func drawDivider() { + ensureSpace(10) + let path = UIBezierPath() + path.move(to: CGPoint(x: margin, y: cursorY + 4)) + path.addLine(to: CGPoint(x: pageSize.width - margin, y: cursorY + 4)) + UIColor(white: 0.82, alpha: 1).setStroke() + path.lineWidth = 0.7 + path.stroke() + cursorY += 10 + } + + /// 表ヘッダー。薄いグレーの帯に列名を置き、下に罫線を引く。 + func drawTableHeader(_ columns: [Column], height: CGFloat = 20) { + ensureSpace(height) + let band = CGRect(x: margin, y: cursorY, width: contentWidth, height: height) + UIColor(white: 0.93, alpha: 1).setFill() + UIBezierPath(rect: band).fill() + let font = UIFont.boldSystemFont(ofSize: 11) + var x = margin + for column in columns { + drawSingleLine( + column.title, + in: CGRect(x: x, y: cursorY, width: column.width, height: height), + font: font, + alignment: column.alignment, + color: .black + ) + x += column.width + } + strokeRule(at: cursorY + height, color: UIColor(white: 0.82, alpha: 1), lineWidth: 0.7) + cursorY += height + } + + /// 表の1行。`checkboxColumn` の列は文字列の代わりに手書き用の空の四角を描く。 + func drawTableRow(_ cells: [String], columns: [Column], height: CGFloat = 22, checkboxColumn: Int? = nil) { + precondition(cells.count == columns.count, "row has \(cells.count) cells for \(columns.count) columns") + ensureSpace(height) + var x = margin + for (index, column) in columns.enumerated() { + let cell = CGRect(x: x, y: cursorY, width: column.width, height: height) + if index == checkboxColumn { + drawCheckbox(in: cell) + } else { + drawSingleLine(cells[index], in: cell, font: column.font, alignment: column.alignment, color: .black) + } + x += column.width + } + strokeRule(at: cursorY + height, color: UIColor(white: 0.88, alpha: 1), lineWidth: 0.4) + cursorY += height + } + + /// 1行だけを枠内に描き、収まらない分は末尾を省略する。カーソルは動かさない。 + func drawSingleLine( + _ text: String, + in rect: CGRect, + font: UIFont, + alignment: Column.Alignment, + color: UIColor + ) { + let paragraph = NSMutableParagraphStyle() + paragraph.lineBreakMode = .byTruncatingTail + switch alignment { + case .leading: paragraph.alignment = .left + case .trailing: paragraph.alignment = .right + case .center: paragraph.alignment = .center + } + let attributed = NSAttributedString( + string: text, + attributes: [.font: font, .foregroundColor: color, .paragraphStyle: paragraph] + ) + let inset = rect.insetBy(dx: Self.cellPadding, dy: 0) + let textHeight = ceil(font.lineHeight) + let y = inset.midY - textHeight / 2 + attributed.draw( + with: CGRect(x: inset.minX, y: y, width: inset.width, height: textHeight), + options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], + context: nil + ) + } + + private func drawCheckbox(in cell: CGRect) { + let size = Self.checkboxSize + let box = CGRect(x: cell.midX - size / 2, y: cell.midY - size / 2, width: size, height: size) + let path = UIBezierPath(rect: box) + UIColor(white: 0.35, alpha: 1).setStroke() + path.lineWidth = 0.8 + path.stroke() + } + + private func strokeRule(at y: CGFloat, color: UIColor, lineWidth: CGFloat) { + let path = UIBezierPath() + path.move(to: CGPoint(x: margin, y: y)) + path.addLine(to: CGPoint(x: pageSize.width - margin, y: y)) + color.setStroke() + path.lineWidth = lineWidth + path.stroke() + } + + private static let cellPadding: CGFloat = 4 + private static let checkboxSize: CGFloat = 9 +} diff --git a/ios/CodeMatch/Services/SessionPDFExporter.swift b/ios/CodeMatch/Services/SessionPDFExporter.swift index 1da6185..3bb43e8 100644 --- a/ios/CodeMatch/Services/SessionPDFExporter.swift +++ b/ios/CodeMatch/Services/SessionPDFExporter.swift @@ -8,15 +8,19 @@ enum SessionPDFExporter { private static let margin: CGFloat = 44 static func fileName(for session: MatchSession, locale: Locale) -> String { + "\(AppLocalization.string("照合履歴"))_\(sanitizedStem(for: session, locale: locale)).pdf" + } + + /// 表示名(未設定なら開始日時)をファイル名向けに整えた語幹。検品レポートも同じ規則を使う。 + static func sanitizedStem(for session: MatchSession, locale: Locale) -> String { let appLanguage = AppLanguage(locale) let base = session.displayName.isEmpty ? appLanguage.formatDateTime(session.startedAt) : session.displayName - let safe = base + return base .replacingOccurrences(of: "/", with: "-") .replacingOccurrences(of: ":", with: "") .replacingOccurrences(of: " ", with: "_") - return "\(AppLocalization.string("照合履歴"))_\(safe).pdf" } static func generatePDF(for session: MatchSession, locale: Locale) -> Data { diff --git a/ios/CodeMatchTests/InspectionPDFExporterTests.swift b/ios/CodeMatchTests/InspectionPDFExporterTests.swift new file mode 100644 index 0000000..20306a0 --- /dev/null +++ b/ios/CodeMatchTests/InspectionPDFExporterTests.swift @@ -0,0 +1,196 @@ +import PDFKit +import XCTest +@testable import CodeMatch + +/// 検品レポートPDFの本文を検証する。表のセルは1つずつ描くので、PDFKitの抽出テキストでは +/// 各セルの文字列を個別に突き合わせる(全角スペースは半角に、連続空白は1つにまとめられる)。 +final class InspectionPDFExporterTests: XCTestCase { + private let sawaiQR5281 = "DCLP675300BCJH5281GG020000120000001200L000000000000BLBDILLU92 0*" + private let sawaiQR5581 = "DCLP675340BCJH5581GG020000120000001200L000000000000BLBDILLU93 0*" + private let moltenQRD10E = "AK6805D10E50N10B U543820000MB S600700000020908 " + private let moltenQRPAF1 = "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" + private let densoQRKanban0140 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + + private let startedAt = Date(timeIntervalSince1970: 1_700_000_000) + private let locale = Locale(identifier: "ja_JP") + + func testSawaiReportPrintsHeaderCountsColumnsAndOneRowPerPartNumberWithSuffix() throws { + let session = MatchSession( + startedAt: startedAt, + endedAt: startedAt.addingTimeInterval(120), + entries: [ + entry("BCJH-55-81GG", sawaiQR5581, "BCJH-55-81GG@1KVV0C"), + entry("BCJH-52-81GG", sawaiQR5281, "BCJH-52-81GG@1N5X0C"), + entry("BCJH-52-81GG", sawaiQR5281, "BCJH-52-81GG@1N5X0D") + ], + name: "朝便", + destination: .sawai + ) + + let text = try pdfText(for: session) + + assertContains("検品レポート", in: text) + assertContains("セッション名: 朝便", in: text) + assertContains("仕向地: 澤井製作所", in: text) + assertContains("検査箱数: 3箱", in: text) + assertContains("品番数(枝番別): 2", in: text) + XCTAssertFalse(text.contains("納品番号数")) + for header in ["No", "品番", "箱数", "数量計", "確認"] { + assertContains(header, in: text) + } + assertContainsIgnoringSpaces("納入数量/箱", in: text) + assertContains("BCJH5281GG (02)", in: text) + assertContains("BCJH5581GG (02)", in: text) + XCTAssertFalse(text.contains("BCJH-52-81GG"), "解析できた行は紙の表記(ハイフンなし)で出す") + assertContains("検品表にあってこの一覧にない品番は、このセッションで照合されていません。", in: text) + assertContains("1 / 1", in: text) + // 2箱の行は 箱数 2・数量/箱 12・数量計 24 が並ぶ + let rowLine = try XCTUnwrap(text.split(separator: "\n").first { $0.contains("BCJH5281GG (02)") }.map(String.init)) + XCTAssertTrue(rowLine.contains("24"), "数量計が同じ行にありません: \(rowLine)") + } + + func testMoltenReportPrintsOneRowPerDeliveryNumberWithDeliveryPoint() throws { + let session = MatchSession( + startedAt: startedAt, + entries: [ + entry("PAF1-15-422", moltenQRPAF1, "PAF1-15-422@0NKD3C"), + entry("D10E-50-N10B", moltenQRD10E, "D10E-50-N10B@0UBL00"), + entry("PAF1-15-422", moltenQRPAF1, "PAF1-15-422@0NLL3C") + ], + destination: .molten + ) + + let text = try pdfText(for: session) + + assertContains("仕向地: モルテン", in: text) + assertContains("状態: 照合中", in: text) + assertContains("検査箱数: 3箱", in: text) + assertContains("納品番号数: 2", in: text) + XCTAssertFalse(text.contains("品番数")) + for header in ["納品番号", "納入先", "累計"] { + assertContains(header, in: text) + } + assertContainsIgnoringSpaces("収容数/箱", in: text) + assertContains("UAG5560", in: text) + assertContains("PAF115422", in: text) + assertContains("FA2", in: text) + assertContains("U543820", in: text) + assertContains("D10E50N10B", in: text) + assertContains("240", in: text) + } + + func testDensoReportPrintsOneRowPerPartNumberWithoutInstructedQuantity() throws { + let session = MatchSession( + startedAt: startedAt, + entries: [entry("860150-7722", densoQRKanban0140, "860150-7722@1DZ50O")], + destination: .denso + ) + + let text = try pdfText(for: session) + + assertContains("仕向地: デンソー", in: text) + assertContains("品番数: 1", in: text) + XCTAssertFalse(text.contains("枝番別")) + XCTAssertFalse(text.contains("指示数")) + assertContains("860150-7722", in: text) + assertContainsIgnoringSpaces("収容数/箱", in: text) + assertContains("24", in: text) + } + + func testEmptySessionPrintsTheNoMatchesLine() throws { + let text = try pdfText(for: MatchSession(startedAt: startedAt)) + + assertContains("検品レポート", in: text) + assertContains("一致したコードはありません。", in: text) + XCTAssertFalse(text.contains("確認")) + } + + func testLongReportRepeatsTheTableHeaderOnEveryPageAndNumbersPages() throws { + // 品番だけを変えた120枚の澤井の納品書。カード番号は同じでも品番が違えば別の行になる。 + let entries = (0..<120).map { index -> MatchHistoryEntry in + let part = "BCJH" + String(format: "%04d", index) + "GG" + let code = "\(part.prefix(4))-\(part.dropFirst(4).prefix(2))-\(part.dropFirst(6))" + return entry( + code, + "DCLP675300" + part + "020000120000001200L000000000000BLBDILLU92 0*", + "\(code)@1N5X0C" + ) + } + let session = MatchSession(startedAt: startedAt, entries: entries, destination: .sawai) + + let document = try pdfDocument(for: session) + + XCTAssertGreaterThanOrEqual(document.pageCount, 3) + for pageIndex in 0.. MatchHistoryEntry { + MatchHistoryEntry(code: code, matchedAt: startedAt, qrPayload: qrPayload, barcodePayload: barcodePayload) + } + + private func pdfDocument( + for session: MatchSession, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> PDFDocument { + let data = InspectionPDFExporter.generatePDF(for: session, locale: locale) + return try XCTUnwrap(PDFDocument(data: data), "PDFを解析できませんでした", file: file, line: line) + } + + private func pdfText( + for session: MatchSession, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> String { + let document = try pdfDocument(for: session, file: file, line: line) + return (0.. MatchHistoryEntry { + MatchHistoryEntry(code: code, matchedAt: startedAt, qrPayload: qrPayload, barcodePayload: barcodePayload) + } +} From a44e17be5f849f5c5197530d6c8637cfae21e5a3 Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 01:05:38 +0900 Subject: [PATCH 06/18] =?UTF-8?q?feat(ios):=20add=20=E6=A4=9C=E5=93=81?= =?UTF-8?q?=E3=83=AC=E3=83=9D=E3=83=BC=E3=83=88=20save/share=20row=20above?= =?UTF-8?q?=20the=20match=20history=20PDF=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/History/HistoryScreen.swift | 116 +++++++++++++----- 1 file changed, 83 insertions(+), 33 deletions(-) diff --git a/ios/CodeMatch/Features/History/HistoryScreen.swift b/ios/CodeMatch/Features/History/HistoryScreen.swift index b4715c9..48b7393 100644 --- a/ios/CodeMatch/Features/History/HistoryScreen.swift +++ b/ios/CodeMatch/Features/History/HistoryScreen.swift @@ -168,6 +168,8 @@ private struct SessionHistoryDetail: View { @State private var shareItem: ShareItem? @State private var showsExporter = false @State private var exportDocument: SessionPDFDocument? + /// fileExporter は1つなので、直前に選んだレポートのファイル名をここに持つ。 + @State private var exportFileName: String? private struct ShareItem: Identifiable { let id = UUID() @@ -230,45 +232,39 @@ private struct SessionHistoryDetail: View { } Section { - HStack(spacing: 10) { - Button { + // 検品レポートを先に置く。紙の検品表と突き合わせる帳票で、照合履歴PDFは証跡。 + PDFActionRow( + caption: AppLocalization.string("検品レポート"), + saveIdentifier: "saveInspectionReportButton", + shareIdentifier: "shareInspectionReportButton", + onSave: { + exportFileName = InspectionPDFExporter.fileName(for: session, locale: locale) exportDocument = SessionPDFDocument( - data: SessionPDFExporter.generatePDF(for: session, locale: locale) + data: InspectionPDFExporter.generatePDF(for: session, locale: locale) ) showsExporter = true - } label: { - Label( - AppLocalization.string("PDFで保存"), - systemImage: "arrow.down.doc.fill" - ) - .font(.subheadline.weight(.bold)) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) + }, + onShare: { + shareItem = (try? InspectionPDFExporter.writeTemporaryPDF(for: session, locale: locale)) + .map { ShareItem(url: $0) } } - .buttonStyle(.plain) - .foregroundStyle(.white) - .background(AppTheme.green, in: RoundedRectangle(cornerRadius: 12)) - .accessibilityIdentifier("savePDFButton") - - Button { + ) + PDFActionRow( + caption: AppLocalization.string("照合履歴レポート"), + saveIdentifier: "savePDFButton", + shareIdentifier: "sharePDFButton", + onSave: { + exportFileName = SessionPDFExporter.fileName(for: session, locale: locale) + exportDocument = SessionPDFDocument( + data: SessionPDFExporter.generatePDF(for: session, locale: locale) + ) + showsExporter = true + }, + onShare: { shareItem = (try? SessionPDFExporter.writeTemporaryPDF(for: session, locale: locale)) .map { ShareItem(url: $0) } - } label: { - Label( - AppLocalization.string("共有する"), - systemImage: "square.and.arrow.up" - ) - .font(.subheadline.weight(.bold)) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) } - .buttonStyle(.plain) - .foregroundStyle(AppTheme.green) - .background(AppTheme.green.opacity(0.1), in: RoundedRectangle(cornerRadius: 12)) - .accessibilityIdentifier("sharePDFButton") - } - .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) - .listRowBackground(Color.clear) + ) } Section(AppLocalization.string("一致したコード")) { @@ -325,9 +321,12 @@ private struct SessionHistoryDetail: View { isPresented: $showsExporter, document: exportDocument, contentType: .pdf, - defaultFilename: session.map { SessionPDFExporter.fileName(for: $0, locale: locale) } ?? "\(AppLocalization.string("照合履歴")).pdf" + defaultFilename: exportFileName + ?? session.map { SessionPDFExporter.fileName(for: $0, locale: locale) } + ?? "\(AppLocalization.string("照合履歴")).pdf" ) { _ in exportDocument = nil + exportFileName = nil } .sheet(item: $shareItem) { item in ActivityShareSheet(items: [item.url]) @@ -626,3 +625,54 @@ private struct PayloadText: View { } } } + + +/// 1種類のPDFの「保存」「共有」ボタンの組。2つのレポートがボタン文言を共有するので、 +/// どのPDFを書き出す行かは上のキャプションで示す。 +private struct PDFActionRow: View { + let caption: String + let saveIdentifier: String + let shareIdentifier: String + let onSave: () -> Void + let onShare: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(caption) + .font(.caption.weight(.bold)) + .foregroundStyle(AppTheme.muted) + .padding(.leading, 4) + HStack(spacing: 10) { + Button(action: onSave) { + Label( + AppLocalization.string("PDFで保存"), + systemImage: "arrow.down.doc.fill" + ) + .font(.subheadline.weight(.bold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .background(AppTheme.green, in: RoundedRectangle(cornerRadius: 12)) + .accessibilityIdentifier(saveIdentifier) + + Button(action: onShare) { + Label( + AppLocalization.string("共有する"), + systemImage: "square.and.arrow.up" + ) + .font(.subheadline.weight(.bold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + .foregroundStyle(AppTheme.green) + .background(AppTheme.green.opacity(0.1), in: RoundedRectangle(cornerRadius: 12)) + .accessibilityIdentifier(shareIdentifier) + } + } + .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) + .listRowBackground(Color.clear) + } +} From 177cbc5a868af7a06f9a8d948d8fa420b089d9df Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 01:21:56 +0900 Subject: [PATCH 07/18] refactor(ios): render the match history PDF through PDFPageWriter --- .../Services/SessionPDFExporter.swift | 127 ++++++------------ 1 file changed, 43 insertions(+), 84 deletions(-) diff --git a/ios/CodeMatch/Services/SessionPDFExporter.swift b/ios/CodeMatch/Services/SessionPDFExporter.swift index 3bb43e8..671ff0d 100644 --- a/ios/CodeMatch/Services/SessionPDFExporter.swift +++ b/ios/CodeMatch/Services/SessionPDFExporter.swift @@ -26,7 +26,6 @@ enum SessionPDFExporter { static func generatePDF(for session: MatchSession, locale: Locale) -> Data { let appLanguage = AppLanguage(locale) let renderer = UIGraphicsPDFRenderer(bounds: CGRect(origin: .zero, size: pageSize)) - let contentWidth = pageSize.width - margin * 2 let titleFont = UIFont.boldSystemFont(ofSize: 20) let headFont = UIFont.boldSystemFont(ofSize: 12) @@ -36,70 +35,30 @@ enum SessionPDFExporter { let gray = UIColor(white: 0.38, alpha: 1) return renderer.pdfData { context in - var cursorY: CGFloat = 0 + // 描画カーソルは検品レポートと共通の PDFPageWriter。フッター帯は使わない(footerHeight 0)ので + // 改ページ位置は従来の閉包実装と同じ。 + let writer = PDFPageWriter(context: context, pageSize: pageSize, margin: margin, footerHeight: 0) + writer.beginPage() - func beginPage() { - context.beginPage() - cursorY = margin - } - - func ensureSpace(_ height: CGFloat) { - if cursorY + height > pageSize.height - margin { - beginPage() - } - } - - @discardableResult - func draw(_ text: String, font: UIFont, color: UIColor = .black, spacing: CGFloat = 4) -> CGFloat { - let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color] - let attributed = NSAttributedString(string: text, attributes: attributes) - let bounds = attributed.boundingRect( - with: CGSize(width: contentWidth, height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading], - context: nil - ) - ensureSpace(bounds.height + spacing) - attributed.draw( - with: CGRect(x: margin, y: cursorY, width: contentWidth, height: ceil(bounds.height)), - options: [.usesLineFragmentOrigin, .usesFontLeading], - context: nil - ) - cursorY += ceil(bounds.height) + spacing - return bounds.height - } - - func drawDivider() { - ensureSpace(10) - let path = UIBezierPath() - path.move(to: CGPoint(x: margin, y: cursorY + 4)) - path.addLine(to: CGPoint(x: pageSize.width - margin, y: cursorY + 4)) - UIColor(white: 0.82, alpha: 1).setStroke() - path.lineWidth = 0.7 - path.stroke() - cursorY += 10 - } - - beginPage() - - draw(AppLocalization.string("照合履歴レポート"), font: titleFont, spacing: 6) + writer.draw(AppLocalization.string("照合履歴レポート"), font: titleFont, spacing: 6) if !session.displayName.isEmpty { - draw("\(AppLocalization.string("セッション名")): \(session.displayName)", font: headFont, spacing: 4) + writer.draw("\(AppLocalization.string("セッション名")): \(session.displayName)", font: headFont, spacing: 4) } - draw( + writer.draw( "\(AppLocalization.string("開始")): \(appLanguage.formatDateTime(session.startedAt))", font: bodyFont, color: gray, spacing: 2 ) if let endedAt = session.endedAt { - draw( + writer.draw( "\(AppLocalization.string("終了")): \(appLanguage.formatDateTime(endedAt))", font: bodyFont, color: gray, spacing: 2 ) } else { - draw( + writer.draw( "\(AppLocalization.string("状態")): \(AppLocalization.string("照合中"))", font: bodyFont, color: gray, @@ -107,7 +66,7 @@ enum SessionPDFExporter { ) } if let destination = session.resolvedDestination { - draw( + writer.draw( AppLocalization.string("仕向地: \(destination.displayName)"), font: bodyFont, color: gray, @@ -116,7 +75,7 @@ enum SessionPDFExporter { } // モルテンは同じ品番でも納品番号ごとに納品書が分かれるため、種類数も添える let showsDeliveryNumberCount = session.resolvedDestination == .molten - draw( + writer.draw( AppLocalization.string( "検査箱数: \(session.matchedCount)箱(品番数: \(session.groupedEntries.count))" ), @@ -125,17 +84,17 @@ enum SessionPDFExporter { spacing: showsDeliveryNumberCount ? 2 : 8 ) if showsDeliveryNumberCount { - draw( + writer.draw( AppLocalization.string("納品番号数: \(session.deliveryNumberCount)"), font: bodyFont, color: gray, spacing: 8 ) } - drawDivider() + writer.drawDivider() if session.entries.isEmpty { - draw(AppLocalization.string("一致したコードはありません。"), font: bodyFont, color: gray) + writer.draw(AppLocalization.string("一致したコードはありません。"), font: bodyFont, color: gray) } func quantityText(_ value: Double?) -> String { @@ -148,23 +107,23 @@ enum SessionPDFExporter { /// 澤井製作所・モルテンは従来どおり `nil` で、出力は変わらない。 func drawBoxEntry(_ entry: MatchHistoryEntry, number: Int, kanbanSerial: String? = nil) { // 箱ごとの見出し+全文2行はまとめて改ページ判定する - ensureSpace(kanbanSerial == nil ? 48 : 62) + writer.ensureSpace(kanbanSerial == nil ? 48 : 62) let managementCode = entry.barcodePayload.flatMap(TagBarcodeRecord.parse)?.managementCode if let kanbanSerial { - draw( + writer.draw( AppLocalization.string("かんばん連番: \(kanbanSerial)"), font: bodyFont, spacing: 2 ) } - draw( + writer.draw( AppLocalization.string( "\(number)箱目 照合時刻: \(appLanguage.formatDateTime(entry.matchedAt)) 管理コード: \(managementCode ?? "-")" ), font: bodyFont, spacing: 2 ) - draw( + writer.draw( AppLocalization.string( "QR全文: \(entry.qrPayload ?? AppLocalization.string("記録なし(旧バージョンで照合)"))" ), @@ -172,7 +131,7 @@ enum SessionPDFExporter { color: gray, spacing: 2 ) - draw( + writer.draw( AppLocalization.string( "Code 128全文: \(entry.barcodePayload ?? AppLocalization.string("記録なし(旧バージョンで照合)"))" ), @@ -185,15 +144,15 @@ enum SessionPDFExporter { // 同一品番は1グループにまとめ、何箱検査したかがひと目でわかるようにする for (index, group) in session.groupedEntries.enumerated() { // 1グループの見出し+詳細ブロックはまとめて改ページ判定する - ensureSpace(150) + writer.ensureSpace(150) let groupBoxCount = AppLocalization.string("\(group.boxCount)箱") - draw( + writer.draw( "#\(appLanguage.formatInteger(index + 1)) \(group.code) (\(groupBoxCount))", font: monoBoldFont, spacing: 2 ) if group.boxCount > 1 { - draw( + writer.draw( AppLocalization.string( "照合時刻: \(appLanguage.formatDateTime(group.firstMatchedAt)) 〜 \(appLanguage.formatDateTime(group.lastMatchedAt))" ), @@ -202,7 +161,7 @@ enum SessionPDFExporter { spacing: 4 ) } else { - draw( + writer.draw( AppLocalization.string("照合時刻: \(appLanguage.formatDateTime(group.firstMatchedAt))"), font: bodyFont, color: gray, @@ -220,30 +179,30 @@ enum SessionPDFExporter { // モルテンは同じ品番でも納品書(納品番号)ごとに納入先や指示日が変わる for deliveryGroup in deliveryGroups { // 納品番号1件分の見出し+納品書情報3行はまとめて改ページ判定する - ensureSpace(110) + writer.ensureSpace(110) let record = deliveryGroup.record - draw( + writer.draw( AppLocalization.string( "納品番号 \(deliveryGroup.deliveryNumber)(\(deliveryGroup.boxCount)箱・累計 \(deliveryGroup.totalQuantity)個)" ), font: headFont, spacing: 2 ) - draw( + writer.draw( AppLocalization.string( "受注者: \(record.ordererCode) 部品番号: \(CodeMatcher.format(partNumber: record.partNumber, destination: .molten)) 納品番号: \(record.deliveryNumber)" ), font: bodyFont, spacing: 2 ) - draw( + writer.draw( AppLocalization.string( "納入先: \(record.deliveryDestination) TYロケーション: \(record.tyLocation ?? "-") 供給先: \(record.supplyPoint)" ), font: bodyFont, spacing: 2 ) - draw( + writer.draw( AppLocalization.string( "収容数: \(record.packQuantity) 納入指示日(JUMP): \(record.formattedInstructionDate) 時刻: \(record.formattedInstructionTime ?? "-")" ), @@ -251,12 +210,12 @@ enum SessionPDFExporter { spacing: 4 ) - draw(AppLocalization.string("各箱の読み取り記録"), font: headFont, spacing: 2) + writer.draw(AppLocalization.string("各箱の読み取り記録"), font: headFont, spacing: 2) for (boxIndex, entry) in deliveryGroup.entries.enumerated() { drawBoxEntry(entry, number: boxIndex + 1) } } - drawDivider() + writer.drawDivider() continue } @@ -264,24 +223,24 @@ enum SessionPDFExporter { // 澤井製作所の解析は寛容でデンソーのQRも受理してしまうため、必ず先に判定する。 if let denso = group.entries.compactMap({ $0.densoRecord }).first { // かんばん情報3行はまとめて改ページ判定する - ensureSpace(70) + writer.ensureSpace(70) let instructedQuantity = denso.instructedQuantity .map { appLanguage.formatInteger($0) } ?? "-" - draw( + writer.draw( AppLocalization.string( "部品番号: \(CodeMatcher.format(partNumber: denso.partNumber, destination: .denso)) 収容数: \(denso.packQuantity) 指示数: \(instructedQuantity)" ), font: bodyFont, spacing: 2 ) - draw( + writer.draw( AppLocalization.string( "次区: \(denso.nextProcess ?? "-") 指示: \(denso.instructionCode ?? "-") 納入日: \(denso.formattedDeliveryDate ?? "-") 便: \(denso.deliveryRun ?? "-")" ), font: bodyFont, spacing: 2 ) - draw( + writer.draw( AppLocalization.string( "管理番号: \(denso.managementNumber ?? "-") アイテムNo: \(denso.itemNumber ?? "-") 受入: \(denso.receivingCode ?? "-")" ), @@ -289,7 +248,7 @@ enum SessionPDFExporter { spacing: 4 ) - draw(AppLocalization.string("各箱の読み取り記録"), font: headFont, spacing: 2) + writer.draw(AppLocalization.string("各箱の読み取り記録"), font: headFont, spacing: 2) for (boxIndex, entry) in group.entries.enumerated() { drawBoxEntry( entry, @@ -297,28 +256,28 @@ enum SessionPDFExporter { kanbanSerial: entry.densoRecord?.kanbanSerial ) } - drawDivider() + writer.drawDivider() continue } if let qr = group.entries.compactMap({ $0.kanbanRecord }).first { let suffix = qr.partSuffix.map { AppLocalization.string("(枝番 \($0))") } ?? "" - draw(AppLocalization.string("納品書情報"), font: headFont, spacing: 2) - draw( + writer.draw(AppLocalization.string("納品書情報"), font: headFont, spacing: 2) + writer.draw( AppLocalization.string( "品目番号: \(CodeMatcher.format(partNumber: qr.partNumber, destination: .sawai))\(suffix) カード番号: \(qr.cardNumber)" ), font: bodyFont, spacing: 2 ) - draw( + writer.draw( AppLocalization.string( "納入数量: \(quantityText(qr.deliveryQuantity)) 指示数: \(quantityText(qr.instructedQuantity))" ), font: bodyFont, spacing: 2 ) - draw( + writer.draw( AppLocalization.string( "工場: \(qr.factoryCode ?? "-") 受入部品庫: \(qr.warehouseCode ?? "-") 供給先: \(qr.supplyPointCode ?? "-")" ), @@ -327,14 +286,14 @@ enum SessionPDFExporter { ) } - draw(AppLocalization.string("各箱の読み取り記録"), font: headFont, spacing: 2) + writer.draw(AppLocalization.string("各箱の読み取り記録"), font: headFont, spacing: 2) for (boxIndex, entry) in group.entries.enumerated() { drawBoxEntry(entry, number: boxIndex + 1) } - drawDivider() + writer.drawDivider() } - draw( + writer.draw( AppLocalization.string("CodeMatch により生成 — このレポートは端末内のデータから作成されています。"), font: UIFont.systemFont(ofSize: 8.5), color: UIColor(white: 0.55, alpha: 1), From b9da97359cf4d973f656cd51044ca6decb2611dd Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 01:21:56 +0900 Subject: [PATCH 08/18] =?UTF-8?q?docs:=20describe=20the=20=E6=A4=9C?= =?UTF-8?q?=E5=93=81=E3=83=AC=E3=83=9D=E3=83=BC=E3=83=88=20PDF=20in=20the?= =?UTF-8?q?=20spec,=20parity=20table,=20status=20and=20iOS=20checklist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PRODUCT_SPEC.md | 13 +++++++++++++ docs/android/STATUS.md | 9 +++++++++ docs/android/TEST_PARITY.md | 20 ++++++++++++++++++++ docs/ios/IMPLEMENTATION_GUIDE.md | 1 + 4 files changed, 43 insertions(+) diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index 26c9e41..640cb7a 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -60,6 +60,19 @@ QRの入力は、カメラ・Bluetoothとも伝送終端(CR/LF/NUL)だけを - モルテン: セッションの納品番号数と、納品番号ごとの箱数・累計収容数。各記録の受注者、部品番号、納品番号、納入先、TYロケーション、供給先、収容数、納入指示日(JUMP)、時刻。 - デンソー: 履歴詳細には各記録の帳票区分、部品番号、包装、収容数、次区、指示、かんばん連番、管理番号、納入日、便、指示数、アイテムNo、受入を出します。PDFは品番ごとにかんばんの要約(部品番号・収容数・指示数、次区・指示・納入日・便、管理番号・アイテムNo・受入)を置き、その配下の各箱へかんばん連番と管理コードを並べます。納品番号ごとの集計はモルテンだけの出力であり、デンソーには出しません。 +### 検品レポート + +照合履歴レポートとは別に、紙の検品表(品番・箱数の一覧表)と突き合わせるための「検品レポート」PDFをセッション詳細から保存・共有できます。セッション詳細では検品レポートの保存・共有の行を上に、照合履歴レポートの行をその下に置き、それぞれにキャプションを付けます。 + +- 形式: A4縦の1つの表。見出しは開始・終了(照合中なら状態)、仕向地、検査箱数、行数。表ヘッダーは各ページに繰り返し、右下に「n / N」のページ番号、脚注に「検品表にあってこの一覧にない品番は、このセッションで照合されていません。」を印字します。 +- 澤井製作所: 1行 = 品番と枝番の組。品番は検品表と同じハイフンなしの生の値で、枝番があるときだけ `BCJH5281GG (02)` のように括弧で添えます。列は No・品番・箱数・納入数量/箱・数量計・確認(手書き用の空の四角)。行数の見出しは「品番数(枝番別)」。 +- モルテン: 1行 = 納品番号。列は No・納品番号・品番(QRの生の値、例 `PAF115422`)・納入先・箱数・収容数/箱・累計・確認。行数の見出しは「納品番号数」。 +- デンソー: 1行 = 品番(`6-4` 表記)。列は No・品番・箱数・収容数/箱・数量計・確認。指示数は出しません。行数の見出しは「品番数」。 +- 並び順は品番(澤井製作所は次いで枝番、モルテンは納品番号)の昇順で、読取順は選べません。1箱あたりの数量は同じ行の全箱で一致するときだけ出し、数量計は全箱に数量があるときだけ出します(それ以外は「-」)。 +- 仕向地として解析できないQRの箱(旧履歴など)は、記録済みの品番をキーにした行として末尾に残します。行の箱数の合計は常にセッションの検査箱数と一致します。 +- 品名・不足数・完了判定は出しません。検品表との差異の判断は操作者が行います。 +- ファイル名は照合履歴レポートと同じ語幹に「検品レポート_」を付けます。 + ## 照合ログ 現場でのデバッグのため、カメラ・Bluetoothどちらの入力でも照合の記録を端末内に残します。BLEの接続診断ログ(読取値を含まない)とは別の仕組みです。 diff --git a/docs/android/STATUS.md b/docs/android/STATUS.md index 1029b23..abda0aa 100644 --- a/docs/android/STATUS.md +++ b/docs/android/STATUS.md @@ -60,6 +60,15 @@ JDK/SDKがない環境ではGradle結果を推測せず、実行不能として ## 履歴 +### 2026-09-11 検品レポート PDF + +紙の検品表(品番・箱数の一覧)と突き合わせるための第2のPDF「検品レポート」を、セッション詳細の照合履歴レポートの行の上に追加した(iOS と同時、ブランチ `codex/inspection-report-poc`)。 + +- `core/export` に純 JVM の `InspectionReportContent`(1行 = 澤井製作所の品番+枝番/モルテンの納品番号(納入先つき)/デンソーの品番、品番順、解析できない箱は品番をキーに末尾へ)と `InspectionPdfContent`(見出し行と `PdfTable` の列・セル)を置き、`HistoryPdfExporter` に `HistoryReportKind`(`MATCH_HISTORY` / `INSPECTION`)と private の `PageCursor` を足した。検品レポートは固定高さの表を2回描画してページ総数を印字し、表ヘッダーを各ページに繰り返す。`File(` を作るのは従来どおり `HistoryPdfExporter` だけで、cache も `cache/codematch-pdf/` のまま(release gate は無変更)。 +- `HistoryPdfBridge` / `HistoryRoute` に `kind` を通し、`HistoryScreen` の PDF ボタンをキャプションつきの `PdfActionRow` 2行(`saveInspectionReportButton` / `shareInspectionReportButton` と既存の `savePDFButton` / `sharePDFButton`)にした。文言は `history_inspection_report` / `history_match_history_report` を日英へ追加し、PDF 内のラベルは `HistoryExportLabels` に追加した。ファイル名は「検品レポート_」(英語 `InspectionReport_`)+ 照合履歴と同じ語幹。 + +証跡: `lintDebug testDebugUnitTest assembleDebug`(全件成功)、`:app:assembleRelease` と `verify-release-hardening.sh`(全項目通過)、Pixel_7 emulator で `:core:export` 4件・`:feature:history` 18件の instrumentation が成功。紙の検品表との実突き合わせと共有先での受け取りは未実施。 + ### 2026-09-08 照合ログ 現場デバッグ用に、カメラ・BLEどちらの入力でも照合の結果と不受理を端末内へ記録し、設定画面の最下部から書き出せるようにした(Issue #120、Android側 #122)。 diff --git a/docs/android/TEST_PARITY.md b/docs/android/TEST_PARITY.md index 3e436c4..439e98d 100644 --- a/docs/android/TEST_PARITY.md +++ b/docs/android/TEST_PARITY.md @@ -25,6 +25,8 @@ | `HistoryUiTextTest` | `android/feature/history/src/test/kotlin/jp/rimtty/codematch/feature/history/HistoryUiTextTest.kt` | | `HistoryExportTextTest` | `android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryExportTextTest.kt` | | `HistoryPdfContentTest` | `android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryPdfContentTest.kt` | +| `InspectionReportContentTest` | `android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionReportContentTest.kt` | +| `InspectionPdfContentTest` | `android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionPdfContentTest.kt` | | `HistoryDeliveryGroupsTest` | `android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryDeliveryGroupsTest.kt` | | `HistoryJsonExporterTest` | `android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryJsonExporterTest.kt` | | `HistoryPdfBridgeTest` | `android/app/src/test/java/jp/rimtty/codematch/history/HistoryPdfBridgeTest.kt` | @@ -226,6 +228,24 @@ Issue #106(PR #113 / #114 / #115 / #116、iOS の履歴・PDF は #117)で 実スキャナーでの照合ログ書き出し、共有先アプリでの受け取り、5,000 件を実際に超えた運用は未実施である。 +## 検品レポート(2026-09-11) + +セッション詳細の第2のPDF「検品レポート」(1行 = 澤井製作所の品番+枝番/モルテンの納品番号/デンソーの品番、品番順、表ヘッダーの繰り返しとページ番号)。Swift と Kotlin の行の作り方は同じ規則で、両側とも現場ラベルの実データで固定する。 + +| 対象 | Swift | Android の証拠 | 分類 | +|---|---|---|---| +| 行のキーと並び(品番+枝番、`BCJH5281GG (02)` 表記、枝番なしは括弧なし)、箱数、1箱の納入数量、数量計 | `InspectionReportTests::testSawaiRowsAreKeyedByPartNumberAndSuffixAndSortedByPartNumber` | `InspectionReportContentTest.kt::sawaiRowsAreKeyedByPartNumberAndSuffixAndSortedByPartNumber` | D | +| モルテンは納品番号ごと、生の部品番号と納入先、収容数と累計 | `InspectionReportTests::testMoltenRowsAreOnePerDeliveryNumberWithRawPartAndDeliveryPoint` | `InspectionReportContentTest.kt::moltenRowsAreOnePerDeliveryNumberWithRawPartAndDeliveryPoint` | D | +| デンソーは品番ごと(`6-4`)、収容数と数量計 | `InspectionReportTests::testDensoRowsAreOnePerPartNumberFormattedSixFour` | `InspectionReportContentTest.kt::densoRowsAreOnePerPartNumberFormattedSixFour` | D | +| 解析できない箱は末尾の行に残り、箱数の合計がセッションの箱数と一致する。仕向地なしは全行未解析 | `InspectionReportTests::testBoxesWithoutAParsableQRTrailAsUnparsedRowsSoNoBoxIsDropped` + `::testSessionWithoutDestinationFallsBackToSawaiLayoutWithEveryBoxUnparsed` | `InspectionReportContentTest.kt::boxesWithoutAParsableQrTrailAsUnparsedRowsSoNoBoxIsDropped` + `::sessionWithoutDestinationFallsBackToSawaiLayoutWithEveryBoxUnparsed` | D | +| デンソーのかんばんを澤井製作所として読まない(寛容な解析器の誤認防止) | `InspectionReportTests::testADensoKanbanIsNeverReadAsASawaiSlip` | `InspectionReportContentTest.kt::aDensoKanbanIsNeverReadAsASawaiSlip` | D | +| 見出し(検査箱数、品番数(枝番別)/納品番号数/品番数)、列名、セルの値、脚注、日英 | `InspectionPDFExporterTests::testSawaiReportPrintsHeaderCountsColumnsAndOneRowPerPartNumberWithSuffix` + `::testMoltenReportPrintsOneRowPerDeliveryNumberWithDeliveryPoint` + `::testDensoReportPrintsOneRowPerPartNumberWithoutInstructedQuantity` + `::testEmptySessionPrintsTheNoMatchesLine` | `InspectionPdfContentTest.kt`(6 件、ja/en 対) | D | +| 複数ページで表ヘッダーが各ページに繰り返され、`n / N` が付く | `InspectionPDFExporterTests::testLongReportRepeatsTheTableHeaderOnEveryPageAndNumbersPages` | `HistoryPdfExporterInstrumentationTest.kt::inspectionReportRendersEveryPageOfAMultiPageTable`(全ページの実 render。ヘッダー繰り返しは pure content では表現できず render 側の責務) | P | +| ファイル名は「検品レポート_」+ 照合履歴と同じ語幹、同じ `cache/codematch-pdf/` | `InspectionPDFExporterTests::testFileNameUsesTheInspectionPrefixAndTheHistoryStem` | `HistoryExportTextTest.kt::inspectionReportPrefixSharesTheHistoryFileNameSanitizing` + `HistoryPdfBridgeTest.kt::inspectionReportDocumentUsesTheInspectionPrefixAndStaysAPdf` + `HistoryPdfExporterInstrumentationTest.kt::inspectionCacheWriteUsesTheInspectionPrefixBelowTheSameCacheDirectory` | D | +| セッション詳細に検品レポートの行が照合履歴の行の上にあり、4 つのボタンがそれぞれのコールバックを呼ぶ。font scale でも 48dp | (UI テストなし。識別子 `saveInspectionReportButton` / `shareInspectionReportButton` を保持) | `HistoryScreenTest.kt::sessionDetailOffersInspectionAndMatchHistoryPdfRowsInJapanese` + `HistoryFontScaleAccessibilityTest.kt::compactSessionDetailKeepsGroupsAndPdfActionsReachableAtLargeFontScales` | P | + +Swift 側は `PDFPageWriter`(表の行・ヘッダー再描画・フッター)を新設し、照合履歴レポートも同じ writer に載せ替えた(`SessionPDFExporterTests` は変更なしで通る)。Android 側は release gate の `File(` 許可リストを変えず、`HistoryPdfExporter` に `HistoryReportKind` を足して同じ cache 経路で書き出す。紙の検品表との実突き合わせは人手で行う。 + ## 残る物理・手動・未対応の証拠 2026-09-05のIssue #57で、この節に挙がる実機・手動ゲートのうち未実施のものは打ち切りとし、これ以上確認しません。打ち切りは検証成功を意味せず、`P`/`—`の分類は変更しません。一覧は[`STATUS.md`](STATUS.md)の「打ち切った確認項目」を参照してください。 diff --git a/docs/ios/IMPLEMENTATION_GUIDE.md b/docs/ios/IMPLEMENTATION_GUIDE.md index cffd02c..0056350 100644 --- a/docs/ios/IMPLEMENTATION_GUIDE.md +++ b/docs/ios/IMPLEMENTATION_GUIDE.md @@ -111,6 +111,7 @@ Console.appで端末を選び、検索欄に `subsystem:jp.rimtty.CodeMatch` を - [ ] デンソーの履歴詳細に帳票区分・部品番号・包装・収容数・次区・指示・かんばん連番・管理番号・納入日・便・指示数・アイテムNo・受入が出る - [ ] デンソーのPDFに品番ごとのかんばん要約(部品番号・収容数・指示数/次区・指示・納入日・便/管理番号・アイテムNo・受入)と、箱ごとのかんばん連番・管理コードが出る。カード番号と納品番号数は出ない - [ ] 履歴のJSON書き出しでデンソーのセッションが `"destination": "denso"` になる +- [ ] セッション詳細に「検品レポート」の保存・共有の行が「照合履歴レポート」の行の上にあり、検品レポートPDFは1品番(澤井製作所は品番+枝番を `BCJH5281GG (02)` のように、モルテンは納品番号、デンソーは `6-4` の品番)が1行で品番順に並び、箱数・数量/箱・数量計・確認欄が出る。複数ページでは表ヘッダーが各ページに繰り返され、右下に「n / N」が付く - [ ] リセット後に値と結果が残らない(仕向地は同じセッション中は固定されたまま) - [ ] 自動「次の照合」は初期設定がOFFで、設定画面と照合セッション中の両方からON/OFFできる - [ ] 自動「次の照合」をONにすると、一致時だけ設定した1秒、3秒、または5秒の残り時間が表示され、0秒後に次のQR読み取りが始まる From fedaa987450b5dd6fbf98bf86738b7e5a642dbf9 Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 01:35:26 +0900 Subject: [PATCH 09/18] fix(inspection-report): sort molten rows by part number, then delivery number --- .../core/export/InspectionReportContent.kt | 6 ++-- .../export/InspectionReportContentTest.kt | 30 +++++++++++++++++++ docs/PRODUCT_SPEC.md | 2 +- docs/android/TEST_PARITY.md | 2 +- ios/CodeMatch/Models/InspectionReport.swift | 5 ++-- .../InspectionReportTests.swift | 21 +++++++++++++ 6 files changed, 60 insertions(+), 6 deletions(-) diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/InspectionReportContent.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/InspectionReportContent.kt index a409ec3..cb1a6e8 100644 --- a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/InspectionReportContent.kt +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/InspectionReportContent.kt @@ -45,7 +45,7 @@ data class InspectionReport( /** * Builds the inspection report rows of a session. * - * Rows are sorted by their key (part number, then suffix or delivery number) + * Rows are sorted by part number, then by suffix (Sawai) or delivery number (Molten), * so the operator can find each line of the paper sheet quickly; scan order is * deliberately not offered. Boxes whose QR cannot be parsed for the locked * destination are appended as trailing rows keyed by the recorded code, so @@ -74,9 +74,11 @@ object InspectionReportContent { ) } + // A Molten sheet lists parts, so rows sort by part number first and + // the delivery numbers of one part stay together. Destination.MOLTEN -> entry.moltenRecord()?.let { record -> parsed.add( - key = SortKey(record.deliveryNumber, ""), + key = SortKey(record.partNumber, record.deliveryNumber), keyText = record.deliveryNumber, quantity = record.packQuantity.toDouble(), partNumber = record.partNumber, diff --git a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionReportContentTest.kt b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionReportContentTest.kt index 6af00ae..5c678c3 100644 --- a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionReportContentTest.kt +++ b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionReportContentTest.kt @@ -21,6 +21,11 @@ class InspectionReportContentTest { "AK6805D10E50N10B U543820000MB S600700000020908 " private val moltenQrPAF1 = "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" + // 納品番号 U009740 は U011230 より小さいが、品番 PAF115423 は D10E50N10B より後ろ。 + private val moltenQrPAF1423 = + "AK6805PAF115423 U009740000MDCU4TS6030000012009081330" + private val moltenQrD10EMDT = + "AK6805D10E50N10B U011230000MDTD S6030000002009081330" private val densoQr0140 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" private val densoQr0141 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0141SWS 20260908S0010000720000009924543330454333M6" @@ -82,6 +87,31 @@ class InspectionReportContentTest { assertEquals(2.0, d10e.totalQuantity) } + @Test + fun moltenRowsSortByPartNumberBeforeDeliveryNumberSoOnePartStaysTogether() { + val session = MatchSession( + startedAt = 0L, + destination = Destination.MOLTEN, + entries = listOf( + entry("one", "PAF1-15-423", moltenQrPAF1423, "PAF1-15-423@0N5R3C"), + entry("two", "PAF1-15-422", moltenQrPAF1, "PAF1-15-422@0NKD3C"), + entry("three", "D10E-50-N10B", moltenQrD10E, "D10E-50-N10B@0UBL00"), + entry("four", "D10E-50-N10B", moltenQrD10EMDT, "D10E-50-N10B@0UK60K"), + ), + ) + + val report = InspectionReportContent.build(session) + + assertEquals( + listOf("U011230", "U543820", "UAG5560", "U009740"), + report.rows.map { it.keyText }, + ) + assertEquals( + listOf("D10E50N10B", "D10E50N10B", "PAF115422", "PAF115423"), + report.rows.map { it.partNumber }, + ) + } + @Test fun densoRowsAreOnePerPartNumberFormattedSixFour() { val session = MatchSession( diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index 640cb7a..e55836a 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -68,7 +68,7 @@ QRの入力は、カメラ・Bluetoothとも伝送終端(CR/LF/NUL)だけを - 澤井製作所: 1行 = 品番と枝番の組。品番は検品表と同じハイフンなしの生の値で、枝番があるときだけ `BCJH5281GG (02)` のように括弧で添えます。列は No・品番・箱数・納入数量/箱・数量計・確認(手書き用の空の四角)。行数の見出しは「品番数(枝番別)」。 - モルテン: 1行 = 納品番号。列は No・納品番号・品番(QRの生の値、例 `PAF115422`)・納入先・箱数・収容数/箱・累計・確認。行数の見出しは「納品番号数」。 - デンソー: 1行 = 品番(`6-4` 表記)。列は No・品番・箱数・収容数/箱・数量計・確認。指示数は出しません。行数の見出しは「品番数」。 -- 並び順は品番(澤井製作所は次いで枝番、モルテンは納品番号)の昇順で、読取順は選べません。1箱あたりの数量は同じ行の全箱で一致するときだけ出し、数量計は全箱に数量があるときだけ出します(それ以外は「-」)。 +- 並び順は品番の昇順、同じ品番の中は澤井製作所が枝番、モルテンが納品番号の昇順で、読取順は選べません。1箱あたりの数量は同じ行の全箱で一致するときだけ出し、数量計は全箱に数量があるときだけ出します(それ以外は「-」)。 - 仕向地として解析できないQRの箱(旧履歴など)は、記録済みの品番をキーにした行として末尾に残します。行の箱数の合計は常にセッションの検査箱数と一致します。 - 品名・不足数・完了判定は出しません。検品表との差異の判断は操作者が行います。 - ファイル名は照合履歴レポートと同じ語幹に「検品レポート_」を付けます。 diff --git a/docs/android/TEST_PARITY.md b/docs/android/TEST_PARITY.md index 439e98d..c3f4116 100644 --- a/docs/android/TEST_PARITY.md +++ b/docs/android/TEST_PARITY.md @@ -235,7 +235,7 @@ Issue #106(PR #113 / #114 / #115 / #116、iOS の履歴・PDF は #117)で | 対象 | Swift | Android の証拠 | 分類 | |---|---|---|---| | 行のキーと並び(品番+枝番、`BCJH5281GG (02)` 表記、枝番なしは括弧なし)、箱数、1箱の納入数量、数量計 | `InspectionReportTests::testSawaiRowsAreKeyedByPartNumberAndSuffixAndSortedByPartNumber` | `InspectionReportContentTest.kt::sawaiRowsAreKeyedByPartNumberAndSuffixAndSortedByPartNumber` | D | -| モルテンは納品番号ごと、生の部品番号と納入先、収容数と累計 | `InspectionReportTests::testMoltenRowsAreOnePerDeliveryNumberWithRawPartAndDeliveryPoint` | `InspectionReportContentTest.kt::moltenRowsAreOnePerDeliveryNumberWithRawPartAndDeliveryPoint` | D | +| モルテンは納品番号ごと、生の部品番号と納入先、収容数と累計。並びは品番 → 納品番号で同じ品番の行が離れない | `InspectionReportTests::testMoltenRowsAreOnePerDeliveryNumberWithRawPartAndDeliveryPoint` + `::testMoltenRowsSortByPartNumberBeforeDeliveryNumberSoOnePartStaysTogether` | `InspectionReportContentTest.kt::moltenRowsAreOnePerDeliveryNumberWithRawPartAndDeliveryPoint` + `::moltenRowsSortByPartNumberBeforeDeliveryNumberSoOnePartStaysTogether` | D | | デンソーは品番ごと(`6-4`)、収容数と数量計 | `InspectionReportTests::testDensoRowsAreOnePerPartNumberFormattedSixFour` | `InspectionReportContentTest.kt::densoRowsAreOnePerPartNumberFormattedSixFour` | D | | 解析できない箱は末尾の行に残り、箱数の合計がセッションの箱数と一致する。仕向地なしは全行未解析 | `InspectionReportTests::testBoxesWithoutAParsableQRTrailAsUnparsedRowsSoNoBoxIsDropped` + `::testSessionWithoutDestinationFallsBackToSawaiLayoutWithEveryBoxUnparsed` | `InspectionReportContentTest.kt::boxesWithoutAParsableQrTrailAsUnparsedRowsSoNoBoxIsDropped` + `::sessionWithoutDestinationFallsBackToSawaiLayoutWithEveryBoxUnparsed` | D | | デンソーのかんばんを澤井製作所として読まない(寛容な解析器の誤認防止) | `InspectionReportTests::testADensoKanbanIsNeverReadAsASawaiSlip` | `InspectionReportContentTest.kt::aDensoKanbanIsNeverReadAsASawaiSlip` | D | diff --git a/ios/CodeMatch/Models/InspectionReport.swift b/ios/CodeMatch/Models/InspectionReport.swift index 529d2bb..cef563c 100644 --- a/ios/CodeMatch/Models/InspectionReport.swift +++ b/ios/CodeMatch/Models/InspectionReport.swift @@ -38,7 +38,7 @@ struct InspectionReportRow: Equatable { /// セッション1件分の検品レポートの行。 /// -/// 行はキー(品番、次いで枝番または納品番号)の昇順に並べ、操作者が検品表の行を +/// 行は品番、次いで枝番(澤井製作所)または納品番号(モルテン)の昇順に並べ、操作者が検品表の行を /// すぐ見つけられるようにする。読取順は意図的に提供しない。仕向地として解析できない /// QRの箱は記録済みの品番をキーにした行として末尾に残し、レポートの箱数の合計が /// 常にセッションの箱数と一致するようにする。Android の `InspectionReportContent` と同じ規則。 @@ -80,9 +80,10 @@ struct InspectionReport: Equatable { placed = true } case .molten: + // モルテンの一覧表は品番ごとなので、品番を第1キーにして同じ品番の納品番号を並べる if let record = entry.moltenRecord { parsed.add( - key: SortKey(primary: record.deliveryNumber, secondary: ""), + key: SortKey(primary: record.partNumber, secondary: record.deliveryNumber), keyText: record.deliveryNumber, quantity: Double(record.packQuantity), partNumber: record.partNumber, diff --git a/ios/CodeMatchTests/InspectionReportTests.swift b/ios/CodeMatchTests/InspectionReportTests.swift index dd1b7d3..22379ef 100644 --- a/ios/CodeMatchTests/InspectionReportTests.swift +++ b/ios/CodeMatchTests/InspectionReportTests.swift @@ -9,6 +9,9 @@ final class InspectionReportTests: XCTestCase { private let sawaiQRNoSuffix = "DAYA004770DFR55281GA 0001000000010000Y 000000BYBYTLYB15 0*" private let moltenQRD10E = "AK6805D10E50N10B U543820000MB S600700000020908 " private let moltenQRPAF1 = "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" + // 納品番号 U009740 は U011230 より小さいが、品番 PAF115423 は D10E50N10B より後ろ。 + private let moltenQRPAF1423 = "AK6805PAF115423 U009740000MDCU4TS6030000012009081330" + private let moltenQRD10EMDT = "AK6805D10E50N10B U011230000MDTD S6030000002009081330" private let densoQRKanban0140 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" private let densoQRKanban0141 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0141SWS 20260908S0010000720000009924543330454333M6" @@ -69,6 +72,24 @@ final class InspectionReportTests: XCTestCase { XCTAssertEqual(d10e.totalQuantity, 2) } + func testMoltenRowsSortByPartNumberBeforeDeliveryNumberSoOnePartStaysTogether() { + let session = MatchSession( + startedAt: startedAt, + entries: [ + entry("PAF1-15-423", moltenQRPAF1423, "PAF1-15-423@0N5R3C"), + entry("PAF1-15-422", moltenQRPAF1, "PAF1-15-422@0NKD3C"), + entry("D10E-50-N10B", moltenQRD10E, "D10E-50-N10B@0UBL00"), + entry("D10E-50-N10B", moltenQRD10EMDT, "D10E-50-N10B@0UK60K") + ], + destination: .molten + ) + + let report = InspectionReport.make(session: session) + + XCTAssertEqual(report.rows.map(\.keyText), ["U011230", "U543820", "UAG5560", "U009740"]) + XCTAssertEqual(report.rows.map(\.partNumber), ["D10E50N10B", "D10E50N10B", "PAF115422", "PAF115423"]) + } + func testDensoRowsAreOnePerPartNumberFormattedSixFour() throws { let session = MatchSession( startedAt: startedAt, From f4801aa1215db20763a1a5e42c08da9427e442f0 Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 01:49:37 +0900 Subject: [PATCH 10/18] feat(android): share a report as a pre-filled e-mail with the PDF attached --- android/app/src/main/AndroidManifest.xml | 9 ++ .../codematch/history/HistoryPdfBridge.kt | 55 +++++++++ .../rimtty/codematch/history/HistoryRoute.kt | 7 +- .../codematch/history/HistoryPdfBridgeTest.kt | 45 +++++++ .../core/export/HistoryExportText.kt | 19 +++ .../core/export/ReportMailContent.kt | 110 ++++++++++++++++++ .../core/export/ReportMailContentTest.kt | 103 ++++++++++++++++ 7 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt create mode 100644 android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7fede7a..80a1d67 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,15 @@ android:name="android.hardware.camera.any" android:required="false" /> + + + + + + + + = + createMailOrShareChooser( + file = file, + mail = mail, + uriForFile = { current -> + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", current) + }, + hasMailApp = { intent -> + // A resolver failure only means "no mail app to hand off to"; the + // share chooser must still open, so it is not a provider failure. + runCatching { context.packageManager.queryIntentActivities(intent, 0).isNotEmpty() } + .getOrDefault(false) + }, + ) + + /** Injectable URI and resolver seams so both branches are testable without a real FileProvider root. */ + internal fun createMailOrShareChooser( + file: File, + mail: ReportMail, + uriForFile: (File) -> Uri, + hasMailApp: (Intent) -> Boolean, + ): HistoryPdfResult = try { + val uri = uriForFile(file) + val mailIntent = createMailIntent(uri, mail) + HistoryPdfResult.Success( + if (hasMailApp(mailIntent)) mailIntent else Intent.createChooser(createShareIntent(uri), null), + ) + } catch (_: Exception) { + HistoryPdfResult.Failure(HistoryPdfFailure.FILE_PROVIDER_FAILED) + } + + /** + * `ACTION_SEND` carrying the PDF plus the mail fields, restricted to mail + * apps through a `mailto:` selector. The system resolver lets the operator + * pin one mail app ("always") when several are installed. + */ + internal fun createMailIntent(uri: Uri, mail: ReportMail): Intent = Intent(Intent.ACTION_SEND).apply { + type = PDF_MIME_TYPE + putExtra(Intent.EXTRA_EMAIL, ReportMailContent.recipients) + putExtra(Intent.EXTRA_SUBJECT, mail.subject) + putExtra(Intent.EXTRA_TEXT, mail.body) + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + clipData = ClipData.newRawUri(null, uri) + selector = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:")) + } + internal fun launchShare(context: Context, chooser: Intent): HistoryPdfResult = try { context.startActivity(chooser) HistoryPdfResult.Success(Unit) diff --git a/android/app/src/main/java/jp/rimtty/codematch/history/HistoryRoute.kt b/android/app/src/main/java/jp/rimtty/codematch/history/HistoryRoute.kt index d99f374..622a34f 100644 --- a/android/app/src/main/java/jp/rimtty/codematch/history/HistoryRoute.kt +++ b/android/app/src/main/java/jp/rimtty/codematch/history/HistoryRoute.kt @@ -29,6 +29,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import jp.rimtty.codematch.R import jp.rimtty.codematch.core.export.HistoryReportKind +import jp.rimtty.codematch.core.export.ReportMailContent import jp.rimtty.codematch.core.model.AppLanguage import jp.rimtty.codematch.core.model.MatchSession import jp.rimtty.codematch.feature.history.HistoryContent @@ -400,7 +401,11 @@ private fun preparePdfForShare( val result = when ( val cacheResult = HistoryPdfBridge.writeShareCache(context, session, language, kind = kind) ) { - is HistoryPdfResult.Success -> HistoryPdfBridge.createShareChooser(context, cacheResult.value) + is HistoryPdfResult.Success -> HistoryPdfBridge.createMailOrShareChooser( + context, + cacheResult.value, + ReportMailContent.build(session, kind, cacheResult.value.name, language), + ) is HistoryPdfResult.Failure -> HistoryPdfResult.Failure(cacheResult.reason) } withContext(Dispatchers.Main.immediate) { diff --git a/android/app/src/test/java/jp/rimtty/codematch/history/HistoryPdfBridgeTest.kt b/android/app/src/test/java/jp/rimtty/codematch/history/HistoryPdfBridgeTest.kt index e4c8d36..79e6274 100644 --- a/android/app/src/test/java/jp/rimtty/codematch/history/HistoryPdfBridgeTest.kt +++ b/android/app/src/test/java/jp/rimtty/codematch/history/HistoryPdfBridgeTest.kt @@ -11,6 +11,8 @@ import java.io.File import java.time.ZoneId import jp.rimtty.codematch.core.export.HistoryPdfExporter import jp.rimtty.codematch.core.export.HistoryReportKind +import jp.rimtty.codematch.core.export.ReportMail +import jp.rimtty.codematch.core.export.ReportMailContent import jp.rimtty.codematch.core.model.AppLanguage import jp.rimtty.codematch.core.model.MatchSession import org.junit.Assert.assertArrayEquals @@ -262,6 +264,49 @@ class HistoryPdfBridgeTest { ) } + @Test + fun mailIntentCarriesRecipientSubjectBodyAttachmentAndMailtoSelector() { + val uri = Uri.parse("content://${context.packageName}.fileprovider/history_pdf/report.pdf") + val mail = ReportMail(subject = "[CodeMatch] 検品レポート 朝便", body = "本文") + + val intent = HistoryPdfBridge.createMailIntent(uri, mail) + + assertEquals(Intent.ACTION_SEND, intent.action) + assertEquals(HistoryPdfBridge.PDF_MIME_TYPE, intent.type) + assertEquals(listOf(ReportMailContent.RECIPIENT), intent.getStringArrayExtra(Intent.EXTRA_EMAIL)?.toList()) + assertEquals(mail.subject, intent.getStringExtra(Intent.EXTRA_SUBJECT)) + assertEquals(mail.body, intent.getStringExtra(Intent.EXTRA_TEXT)) + @Suppress("DEPRECATION") + assertEquals(uri, intent.getParcelableExtra(Intent.EXTRA_STREAM)) + assertTrue(intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) + assertEquals(uri, intent.clipData?.getItemAt(0)?.uri) + val selector = requireNotNull(intent.selector) + assertEquals(Intent.ACTION_SENDTO, selector.action) + assertEquals("mailto", selector.data?.scheme) + } + + @Test + fun mailHandOffOpensTheMailIntentWhenAMailAppExistsAndTheChooserOtherwise() { + val uri = Uri.parse("content://${context.packageName}.fileprovider/history_pdf/report.pdf") + val file = File(context.cacheDir, "report.pdf") + val mail = ReportMail(subject = "s", body = "b") + + val withMailApp = HistoryPdfBridge.createMailOrShareChooser(file, mail, { uri }, { true }) + val withoutMailApp = HistoryPdfBridge.createMailOrShareChooser(file, mail, { uri }, { false }) + val providerFailure = HistoryPdfBridge.createMailOrShareChooser(file, mail, { error("no root") }, { true }) + + assertTrue("result=$withMailApp", withMailApp is HistoryPdfResult.Success) + val mailIntent = (withMailApp as HistoryPdfResult.Success).value + assertEquals(Intent.ACTION_SEND, mailIntent.action) + assertEquals(mail.subject, mailIntent.getStringExtra(Intent.EXTRA_SUBJECT)) + + assertTrue("result=$withoutMailApp", withoutMailApp is HistoryPdfResult.Success) + assertEquals(Intent.ACTION_CHOOSER, (withoutMailApp as HistoryPdfResult.Success).value.action) + + assertTrue(providerFailure is HistoryPdfResult.Failure) + assertEquals(HistoryPdfFailure.FILE_PROVIDER_FAILED, (providerFailure as HistoryPdfResult.Failure).reason) + } + @Test fun shareChooserUsesFileProviderUriPdfTypeClipDataAndReadGrant() { val directory = File(context.cacheDir, HistoryPdfExporter.CACHE_DIRECTORY) 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 e54fb89..2db0eaa 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 @@ -82,6 +82,13 @@ data class HistoryExportLabels( val columnCumulativeQuantity: String, val columnCheck: String, val inspectionFooterNote: String, + /** Pre-filled report e-mail: greeting, intro per report, headings and footer. */ + val mailGreeting: String, + val mailIntroInspection: String, + val mailIntroHistory: String, + val mailSessionHeading: String, + val mailAttachmentHeading: String, + val mailFooter: String, /** Singular and plural units are kept separately for natural English. */ val boxCountSingular: String = boxCount, val boxCountPlural: String = boxCount, @@ -185,6 +192,12 @@ object HistoryExportTextFormatter { columnCheck = "確認", inspectionFooterNote = "検品表にあってこの一覧にない品番は、このセッションで照合されていません。", + mailGreeting = "お疲れさまです。", + mailIntroInspection = "CodeMatch の検品レポートをお送りします。", + mailIntroHistory = "CodeMatch の照合履歴レポートをお送りします。", + mailSessionHeading = "■ セッション", + mailAttachmentHeading = "■ 添付", + mailFooter = "このメールは CodeMatch から作成しました。内容は端末内のデータのみです。", boxCountSingular = "箱", boxCountPlural = "箱", ) @@ -260,6 +273,12 @@ object HistoryExportTextFormatter { columnCheck = "Check", inspectionFooterNote = "Part numbers on the inspection sheet that are missing from this list were not matched in this session.", + mailGreeting = "Hello,", + mailIntroInspection = "Please find the CodeMatch inspection report attached.", + mailIntroHistory = "Please find the CodeMatch match history report attached.", + mailSessionHeading = "Session", + mailAttachmentHeading = "Attachment", + mailFooter = "Created by CodeMatch. The contents are on-device data only.", boxCountSingular = "box", boxCountPlural = "boxes", ) diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt new file mode 100644 index 0000000..7432097 --- /dev/null +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt @@ -0,0 +1,110 @@ +package jp.rimtty.codematch.core.export + +import jp.rimtty.codematch.core.model.AppLanguage +import jp.rimtty.codematch.core.model.Destination +import jp.rimtty.codematch.core.model.MatchSession +import java.time.ZoneId + +/** Subject and body of the e-mail a report is shared with. */ +data class ReportMail( + val subject: String, + val body: String, +) + +/** + * Builds the pre-filled e-mail for sharing a session's PDF. + * + * The recipient is fixed for now: the report is only ever sent to the + * operator's own mailbox. The body repeats the report header (session, + * destination, box and row counts) so the mail is readable without opening + * the attachment. Sending itself stays with the mail app the operator uses. + * + * This mirrors Swift's `ReportMailContent`. + */ +object ReportMailContent { + /** The one address every report mail is addressed to. */ + const val RECIPIENT: String = "ttyrim@gmail.com" + + val recipients: Array + get() = arrayOf(RECIPIENT) + + fun build( + session: MatchSession, + kind: HistoryReportKind, + fileName: String, + language: AppLanguage = AppLanguage.JAPANESE, + zoneId: ZoneId = ZoneId.systemDefault(), + ): ReportMail { + val labels = HistoryExportTextFormatter.labels(language) + val title = when (kind) { + HistoryReportKind.MATCH_HISTORY -> labels.reportTitle + HistoryReportKind.INSPECTION -> labels.inspectionTitle + } + val destination = session.resolvedDestination() + val sessionLabel = session.displayName.ifBlank { + HistoryExportTextFormatter.dateTime(session.startedAt, language, zoneId) + } + val subject = buildString { + append("[CodeMatch] ").append(title).append(' ').append(sessionLabel) + if (destination != null) append(" - ").append(labels.destinationName(destination)) + } + + val lines = mutableListOf() + lines += labels.mailGreeting + lines += when (kind) { + HistoryReportKind.MATCH_HISTORY -> labels.mailIntroHistory + HistoryReportKind.INSPECTION -> labels.mailIntroInspection + } + lines += "" + lines += labels.mailSessionHeading + if (session.displayName.isNotEmpty()) { + lines += "${labels.sessionName}: ${session.displayName}" + } + lines += "${labels.start}: ${HistoryExportTextFormatter.dateTime(session.startedAt, language, zoneId)}" + val endedAt = session.endedAt + lines += if (endedAt != null) { + "${labels.end}: ${HistoryExportTextFormatter.dateTime(endedAt, language, zoneId)}" + } else { + "${labels.status}: ${labels.inProgress}" + } + if (destination != null) { + lines += "${labels.destination}: ${labels.destinationName(destination)}" + } + lines += "${labels.inspectionBoxCount}: ${HistoryExportTextFormatter.boxCount(session.matchedCount, language)}" + lines += countLines(session, kind, destination, labels, language) + lines += "" + lines += labels.mailAttachmentHeading + lines += fileName + lines += "" + lines += labels.mailFooter + + return ReportMail(subject = subject, body = lines.joinToString("\n")) + } + + /** The same count lines the report's own header prints. */ + private fun countLines( + session: MatchSession, + kind: HistoryReportKind, + destination: Destination?, + labels: HistoryExportLabels, + language: AppLanguage, + ): List = when (kind) { + HistoryReportKind.INSPECTION -> { + val report = InspectionReportContent.build(session) + val label = when (report.layout) { + InspectionLayout.SAWAI -> labels.inspectionPartCountBySuffix + InspectionLayout.MOLTEN -> labels.deliveryNumberCount + InspectionLayout.DENSO -> labels.inspectionPartCount + } + listOf("$label: ${HistoryExportTextFormatter.integer(report.rowCount, language)}") + } + + HistoryReportKind.MATCH_HISTORY -> buildList { + add("${labels.inspectionPartCount}: ${HistoryExportTextFormatter.integer(session.groupedEntries.size, language)}") + if (destination == Destination.MOLTEN) { + val count = session.entries.moltenDeliveryGroups().size + add("${labels.deliveryNumberCount}: ${HistoryExportTextFormatter.integer(count, language)}") + } + } + } +} diff --git a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt new file mode 100644 index 0000000..1ee2e8a --- /dev/null +++ b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt @@ -0,0 +1,103 @@ +package jp.rimtty.codematch.core.export + +import jp.rimtty.codematch.core.model.AppLanguage +import jp.rimtty.codematch.core.model.Destination +import jp.rimtty.codematch.core.model.MatchEntry +import jp.rimtty.codematch.core.model.MatchSession +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReportMailContentTest { + private val utc = ZoneId.of("UTC") + private val sawaiQr = + "DCLP675300BCJH5281GG020000120000001200L000000000000BLBDILLU92 0*" + private val moltenQrPAF1 = + "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" + + @Test + fun recipientIsTheFixedOperatorAddress() { + assertEquals("ttyrim@gmail.com", ReportMailContent.RECIPIENT) + assertEquals(listOf("ttyrim@gmail.com"), ReportMailContent.recipients.toList()) + } + + @Test + fun inspectionMailRepeatsTheReportHeaderAndNamesTheAttachment() { + val session = MatchSession( + startedAt = 1_700_000_000_000L, + endedAt = 1_700_000_120_000L, + name = "朝便", + destination = Destination.SAWAI, + entries = listOf( + entry("one", "BCJH-52-81GG", sawaiQr, "BCJH-52-81GG@1N5X0C"), + entry("two", "BCJH-52-81GG", sawaiQr, "BCJH-52-81GG@1N5X0D"), + ), + ) + + val mail = ReportMailContent.build( + session, + HistoryReportKind.INSPECTION, + "検品レポート_朝便.pdf", + AppLanguage.JAPANESE, + utc, + ) + + assertEquals("[CodeMatch] 検品レポート 朝便 - 澤井製作所", mail.subject) + val expectedBody = listOf( + "お疲れさまです。", + "CodeMatch の検品レポートをお送りします。", + "", + "■ セッション", + "セッション名: 朝便", + "開始: ${HistoryExportTextFormatter.dateTime(1_700_000_000_000L, AppLanguage.JAPANESE, utc)}", + "終了: ${HistoryExportTextFormatter.dateTime(1_700_000_120_000L, AppLanguage.JAPANESE, utc)}", + "仕向地: 澤井製作所", + "検査箱数: 2箱", + "品番数(枝番別): 1", + "", + "■ 添付", + "検品レポート_朝便.pdf", + "", + "このメールは CodeMatch から作成しました。内容は端末内のデータのみです。", + ).joinToString("\n") + assertEquals(expectedBody, mail.body) + assertFalse("the mail never carries a raw payload", mail.body.contains(sawaiQr)) + } + + @Test + fun historyMailOfAMoltenSessionAddsTheDeliveryNumberCountAndUsesTheStartDateWhenUnnamed() { + val session = MatchSession( + startedAt = 1_700_000_000_000L, + destination = Destination.MOLTEN, + entries = listOf(entry("one", "PAF1-15-422", moltenQrPAF1, "PAF1-15-422@0NKD3C")), + ) + + val mail = ReportMailContent.build( + session, + HistoryReportKind.MATCH_HISTORY, + "MatchHistory_x.pdf", + AppLanguage.ENGLISH, + utc, + ) + + val start = HistoryExportTextFormatter.dateTime(1_700_000_000_000L, AppLanguage.ENGLISH, utc) + assertEquals("[CodeMatch] Match History Report $start - Molten", mail.subject) + assertTrue(mail.body.startsWith("Hello,\nPlease find the CodeMatch match history report attached.\n")) + assertTrue(mail.body.contains("Status: In progress")) + assertTrue(mail.body.contains("Boxes: 1 box")) + assertTrue(mail.body.contains("Part numbers: 1")) + assertTrue(mail.body.contains("Delivery numbers: 1")) + assertTrue(mail.body.contains("Attachment\nMatchHistory_x.pdf")) + assertFalse(mail.body.contains("Session name")) + } + + private fun entry(id: String, code: String, qrPayload: String?, barcodePayload: String?) = MatchEntry( + id = id, + code = code, + matchedAt = 1_700_000_001_000L, + qrPayload = qrPayload, + barcodePayload = barcodePayload, + ) +} From 5b1d1fb20440eb851ac5cf5d4ce463e448a58b54 Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 01:49:37 +0900 Subject: [PATCH 11/18] feat(ios): share a report through the Mail composer with recipient, subject, body and PDF --- ios/CodeMatch.xcodeproj/project.pbxproj | 12 +++ .../Features/History/HistoryScreen.swift | 52 ++++++++-- ios/CodeMatch/Resources/Localizable.xcstrings | 96 +++++++++++++++++++ ios/CodeMatch/Services/MailComposeView.swift | 54 +++++++++++ .../Services/ReportMailContent.swift | 86 +++++++++++++++++ .../ReportMailContentTests.swift | 73 ++++++++++++++ 6 files changed, 365 insertions(+), 8 deletions(-) create mode 100644 ios/CodeMatch/Services/MailComposeView.swift create mode 100644 ios/CodeMatch/Services/ReportMailContent.swift create mode 100644 ios/CodeMatchTests/ReportMailContentTests.swift diff --git a/ios/CodeMatch.xcodeproj/project.pbxproj b/ios/CodeMatch.xcodeproj/project.pbxproj index 62b22bf..54ce7ff 100644 --- a/ios/CodeMatch.xcodeproj/project.pbxproj +++ b/ios/CodeMatch.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ A10000000000000000000109 /* ScanLogStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000109 /* ScanLogStoreTests.swift */; }; A1000000000000000000010A /* InspectionReportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000010A /* InspectionReportTests.swift */; }; A1000000000000000000010B /* InspectionPDFExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000010B /* InspectionPDFExporterTests.swift */; }; + A1000000000000000000010C /* ReportMailContentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000010C /* ReportMailContentTests.swift */; }; A1000000000000000000000C /* CodeMatchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000D /* CodeMatchUITests.swift */; }; A1000000000000000000000D /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000013 /* RootTabView.swift */; }; A1000000000000000000000E /* HistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000014 /* HistoryModels.swift */; }; @@ -45,6 +46,8 @@ A1000000000000000000001B /* InspectionReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000021 /* InspectionReport.swift */; }; A1000000000000000000001C /* PDFPageWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000022 /* PDFPageWriter.swift */; }; A1000000000000000000001D /* InspectionPDFExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000023 /* InspectionPDFExporter.swift */; }; + A1000000000000000000001E /* ReportMailContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000024 /* ReportMailContent.swift */; }; + A1000000000000000000001F /* MailComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000025 /* MailComposeView.swift */; }; A10000000000000000000016 /* BluetoothScannerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001C /* BluetoothScannerService.swift */; }; A10000000000000000000018 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001E /* Localizable.xcstrings */; }; A10000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000060 /* InfoPlist.strings */; }; @@ -92,6 +95,7 @@ A20000000000000000000109 /* ScanLogStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanLogStoreTests.swift; sourceTree = ""; }; A2000000000000000000010A /* InspectionReportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectionReportTests.swift; sourceTree = ""; }; A2000000000000000000010B /* InspectionPDFExporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectionPDFExporterTests.swift; sourceTree = ""; }; + A2000000000000000000010C /* ReportMailContentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportMailContentTests.swift; sourceTree = ""; }; A2000000000000000000000D /* CodeMatchUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeMatchUITests.swift; sourceTree = ""; }; A20000000000000000000010 /* CodeMatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodeMatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; A20000000000000000000011 /* CodeMatchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CodeMatchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -110,6 +114,8 @@ A20000000000000000000021 /* InspectionReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectionReport.swift; sourceTree = ""; }; A20000000000000000000022 /* PDFPageWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFPageWriter.swift; sourceTree = ""; }; A20000000000000000000023 /* InspectionPDFExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectionPDFExporter.swift; sourceTree = ""; }; + A20000000000000000000024 /* ReportMailContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportMailContent.swift; sourceTree = ""; }; + A20000000000000000000025 /* MailComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailComposeView.swift; sourceTree = ""; }; A2000000000000000000001C /* BluetoothScannerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothScannerService.swift; sourceTree = ""; }; A2000000000000000000001E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; A20000000000000000000060 /* InfoPlist.strings */ = { @@ -213,6 +219,8 @@ A20000000000000000000020 /* ScanLogStore.swift */, A20000000000000000000022 /* PDFPageWriter.swift */, A20000000000000000000023 /* InspectionPDFExporter.swift */, + A20000000000000000000024 /* ReportMailContent.swift */, + A20000000000000000000025 /* MailComposeView.swift */, A2000000000000000000001C /* BluetoothScannerService.swift */, ); path = Services; @@ -256,6 +264,7 @@ A20000000000000000000109 /* ScanLogStoreTests.swift */, A2000000000000000000010A /* InspectionReportTests.swift */, A2000000000000000000010B /* InspectionPDFExporterTests.swift */, + A2000000000000000000010C /* ReportMailContentTests.swift */, ); path = CodeMatchTests; sourceTree = ""; @@ -470,6 +479,8 @@ A1000000000000000000001B /* InspectionReport.swift in Sources */, A1000000000000000000001C /* PDFPageWriter.swift in Sources */, A1000000000000000000001D /* InspectionPDFExporter.swift in Sources */, + A1000000000000000000001E /* ReportMailContent.swift in Sources */, + A1000000000000000000001F /* MailComposeView.swift in Sources */, A10000000000000000000016 /* BluetoothScannerService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -490,6 +501,7 @@ A10000000000000000000109 /* ScanLogStoreTests.swift in Sources */, A1000000000000000000010A /* InspectionReportTests.swift in Sources */, A1000000000000000000010B /* InspectionPDFExporterTests.swift in Sources */, + A1000000000000000000010C /* ReportMailContentTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/CodeMatch/Features/History/HistoryScreen.swift b/ios/CodeMatch/Features/History/HistoryScreen.swift index 48b7393..a8ed228 100644 --- a/ios/CodeMatch/Features/History/HistoryScreen.swift +++ b/ios/CodeMatch/Features/History/HistoryScreen.swift @@ -170,6 +170,14 @@ private struct SessionHistoryDetail: View { @State private var exportDocument: SessionPDFDocument? /// fileExporter は1つなので、直前に選んだレポートのファイル名をここに持つ。 @State private var exportFileName: String? + /// 「共有する」で開くメール作成画面。「メール」が使えない端末では shareItem に切り替える。 + @State private var mailItem: MailItem? + + private struct MailItem: Identifiable { + let id = UUID() + let content: ReportMailContent + let attachment: MailComposeView.Attachment + } private struct ShareItem: Identifiable { let id = UUID() @@ -244,10 +252,7 @@ private struct SessionHistoryDetail: View { ) showsExporter = true }, - onShare: { - shareItem = (try? InspectionPDFExporter.writeTemporaryPDF(for: session, locale: locale)) - .map { ShareItem(url: $0) } - } + onShare: { shareReport(.inspection, session: session) } ) PDFActionRow( caption: AppLocalization.string("照合履歴レポート"), @@ -260,10 +265,7 @@ private struct SessionHistoryDetail: View { ) showsExporter = true }, - onShare: { - shareItem = (try? SessionPDFExporter.writeTemporaryPDF(for: session, locale: locale)) - .map { ShareItem(url: $0) } - } + onShare: { shareReport(.matchHistory, session: session) } ) } @@ -332,12 +334,46 @@ private struct SessionHistoryDetail: View { ActivityShareSheet(items: [item.url]) .presentationDetents([.medium, .large]) } + .sheet(item: $mailItem) { item in + MailComposeView(content: item.content, attachment: item.attachment) { + mailItem = nil + } + .ignoresSafeArea() + } } private var session: MatchSession? { historyStore.sessions.first(where: { $0.id == sessionID }) } + /// 「共有する」: 宛先・件名・本文・PDF添付を埋めたメール作成画面を開く。 + /// 「メール」にアカウントがない端末では従来どおりの共有シートに切り替える。 + private func shareReport(_ kind: ReportKind, session: MatchSession) { + if MailComposeView.canSendMail { + let data: Data + let fileName: String + switch kind { + case .inspection: + data = InspectionPDFExporter.generatePDF(for: session, locale: locale) + fileName = InspectionPDFExporter.fileName(for: session, locale: locale) + case .matchHistory: + data = SessionPDFExporter.generatePDF(for: session, locale: locale) + fileName = SessionPDFExporter.fileName(for: session, locale: locale) + } + mailItem = MailItem( + content: ReportMailContent.make(session: session, kind: kind, fileName: fileName, locale: locale), + attachment: MailComposeView.Attachment(data: data, fileName: fileName) + ) + return + } + let url: URL? + switch kind { + case .inspection: url = try? InspectionPDFExporter.writeTemporaryPDF(for: session, locale: locale) + case .matchHistory: url = try? SessionPDFExporter.writeTemporaryPDF(for: session, locale: locale) + } + shareItem = url.map { ShareItem(url: $0) } + } + private var navigationTitleText: String { let name = session?.displayName ?? "" return name.isEmpty ? AppLocalization.string("セッション詳細") : name diff --git a/ios/CodeMatch/Resources/Localizable.xcstrings b/ios/CodeMatch/Resources/Localizable.xcstrings index 9fb4697..e329842 100644 --- a/ios/CodeMatch/Resources/Localizable.xcstrings +++ b/ios/CodeMatch/Resources/Localizable.xcstrings @@ -6312,6 +6312,102 @@ } } } + }, + "お疲れさまです。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hello," + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お疲れさまです。" + } + } + } + }, + "CodeMatch の検品レポートをお送りします。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please find the CodeMatch inspection report attached." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CodeMatch の検品レポートをお送りします。" + } + } + } + }, + "CodeMatch の照合履歴レポートをお送りします。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please find the CodeMatch match history report attached." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CodeMatch の照合履歴レポートをお送りします。" + } + } + } + }, + "■ セッション" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "■ セッション" + } + } + } + }, + "■ 添付" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attachment" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "■ 添付" + } + } + } + }, + "このメールは CodeMatch から作成しました。内容は端末内のデータのみです。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Created by CodeMatch. The contents are on-device data only." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このメールは CodeMatch から作成しました。内容は端末内のデータのみです。" + } + } + } } }, "version" : "1.0" diff --git a/ios/CodeMatch/Services/MailComposeView.swift b/ios/CodeMatch/Services/MailComposeView.swift new file mode 100644 index 0000000..dbc5158 --- /dev/null +++ b/ios/CodeMatch/Services/MailComposeView.swift @@ -0,0 +1,54 @@ +import MessageUI +import SwiftUI + +/// レポートPDFを添付したメール作成画面(Apple の「メール」)。 +/// +/// 宛先・件名・本文を埋めた状態で開き、送信は操作者が行う。「メール」にアカウントがなく +/// `canSendMail` が false の端末では呼び出し側が共有シートへ切り替える。 +struct MailComposeView: UIViewControllerRepresentable { + struct Attachment { + let data: Data + let fileName: String + } + + let content: ReportMailContent + let attachment: Attachment + let onFinish: () -> Void + + static var canSendMail: Bool { + MFMailComposeViewController.canSendMail() + } + + func makeUIViewController(context: Context) -> MFMailComposeViewController { + let controller = MFMailComposeViewController() + controller.mailComposeDelegate = context.coordinator + controller.setToRecipients(ReportMailContent.recipients) + controller.setSubject(content.subject) + controller.setMessageBody(content.body, isHTML: false) + controller.addAttachmentData(attachment.data, mimeType: "application/pdf", fileName: attachment.fileName) + return controller + } + + func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(onFinish: onFinish) + } + + final class Coordinator: NSObject, MFMailComposeViewControllerDelegate { + private let onFinish: () -> Void + + init(onFinish: @escaping () -> Void) { + self.onFinish = onFinish + } + + func mailComposeController( + _ controller: MFMailComposeViewController, + didFinishWith result: MFMailComposeResult, + error: Error? + ) { + // 送信・下書き保存・取り消しのいずれでも画面を閉じるだけ。結果はアプリに残さない。 + onFinish() + } + } +} diff --git a/ios/CodeMatch/Services/ReportMailContent.swift b/ios/CodeMatch/Services/ReportMailContent.swift new file mode 100644 index 0000000..231c550 --- /dev/null +++ b/ios/CodeMatch/Services/ReportMailContent.swift @@ -0,0 +1,86 @@ +import Foundation + +/// セッション詳細から書き出す2種類のPDF。 +enum ReportKind { + case matchHistory + case inspection +} + +/// レポートPDFをメールで共有するときの宛先・件名・本文。 +/// +/// 宛先は当面固定で、レポートは操作者自身の受信箱にだけ送る。本文にはレポートの見出し +/// (セッション、仕向地、箱数・行数)を繰り返し、添付を開かなくても内容が分かるようにする。 +/// 送信そのものは操作者が使うメールアプリに任せる。Android の `ReportMailContent` と同じ規則。 +struct ReportMailContent: Equatable { + /// すべてのレポートメールの宛先。 + static let recipients = ["ttyrim@gmail.com"] + + let subject: String + let body: String + + static func make(session: MatchSession, kind: ReportKind, fileName: String, locale: Locale) -> ReportMailContent { + let appLanguage = AppLanguage(locale) + let title: String + switch kind { + case .matchHistory: title = AppLocalization.string("照合履歴レポート") + case .inspection: title = AppLocalization.string("検品レポート") + } + let destination = session.resolvedDestination + let sessionLabel = session.displayName.isEmpty + ? appLanguage.formatDateTime(session.startedAt) + : session.displayName + var subject = "[CodeMatch] \(title) \(sessionLabel)" + if let destination { + subject += " - \(destination.displayName)" + } + + var lines: [String] = [] + lines.append(AppLocalization.string("お疲れさまです。")) + switch kind { + case .matchHistory: lines.append(AppLocalization.string("CodeMatch の照合履歴レポートをお送りします。")) + case .inspection: lines.append(AppLocalization.string("CodeMatch の検品レポートをお送りします。")) + } + lines.append("") + lines.append(AppLocalization.string("■ セッション")) + if !session.displayName.isEmpty { + lines.append("\(AppLocalization.string("セッション名")): \(session.displayName)") + } + lines.append("\(AppLocalization.string("開始")): \(appLanguage.formatDateTime(session.startedAt))") + if let endedAt = session.endedAt { + lines.append("\(AppLocalization.string("終了")): \(appLanguage.formatDateTime(endedAt))") + } else { + lines.append("\(AppLocalization.string("状態")): \(AppLocalization.string("照合中"))") + } + if let destination { + lines.append(AppLocalization.string("仕向地: \(destination.displayName)")) + } + lines.append(AppLocalization.string("検査箱数: \(session.matchedCount)箱")) + lines.append(contentsOf: countLines(session: session, kind: kind, destination: destination)) + lines.append("") + lines.append(AppLocalization.string("■ 添付")) + lines.append(fileName) + lines.append("") + lines.append(AppLocalization.string("このメールは CodeMatch から作成しました。内容は端末内のデータのみです。")) + + return ReportMailContent(subject: subject, body: lines.joined(separator: "\n")) + } + + /// レポート自身の見出しと同じ件数の行。 + private static func countLines(session: MatchSession, kind: ReportKind, destination: Destination?) -> [String] { + switch kind { + case .inspection: + let report = InspectionReport.make(session: session) + switch report.layout { + case .sawai: return [AppLocalization.string("品番数(枝番別): \(report.rowCount)")] + case .molten: return [AppLocalization.string("納品番号数: \(report.rowCount)")] + case .denso: return [AppLocalization.string("品番数: \(report.rowCount)")] + } + case .matchHistory: + var lines = [AppLocalization.string("品番数: \(session.groupedEntries.count)")] + if destination == .molten { + lines.append(AppLocalization.string("納品番号数: \(session.deliveryNumberCount)")) + } + return lines + } + } +} diff --git a/ios/CodeMatchTests/ReportMailContentTests.swift b/ios/CodeMatchTests/ReportMailContentTests.swift new file mode 100644 index 0000000..2a1e561 --- /dev/null +++ b/ios/CodeMatchTests/ReportMailContentTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import CodeMatch + +/// レポートをメールで共有するときの宛先・件名・本文を固定する。 +final class ReportMailContentTests: XCTestCase { + private let sawaiQR = "DCLP675300BCJH5281GG020000120000001200L000000000000BLBDILLU92 0*" + private let moltenQRPAF1 = "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" + private let startedAt = Date(timeIntervalSince1970: 1_700_000_000) + private let locale = Locale(identifier: "ja_JP") + + func testRecipientIsTheFixedOperatorAddress() { + XCTAssertEqual(ReportMailContent.recipients, ["ttyrim@gmail.com"]) + } + + func testInspectionMailRepeatsTheReportHeaderAndNamesTheAttachment() { + let session = MatchSession( + startedAt: startedAt, + endedAt: startedAt.addingTimeInterval(120), + entries: [ + MatchHistoryEntry(code: "BCJH-52-81GG", matchedAt: startedAt, qrPayload: sawaiQR, barcodePayload: "BCJH-52-81GG@1N5X0C"), + MatchHistoryEntry(code: "BCJH-52-81GG", matchedAt: startedAt, qrPayload: sawaiQR, barcodePayload: "BCJH-52-81GG@1N5X0D") + ], + name: "朝便", + destination: .sawai + ) + let appLanguage = AppLanguage(locale) + + let mail = ReportMailContent.make(session: session, kind: .inspection, fileName: "検品レポート_朝便.pdf", locale: locale) + + XCTAssertEqual(mail.subject, "[CodeMatch] 検品レポート 朝便 - 澤井製作所") + let expectedBody = [ + "お疲れさまです。", + "CodeMatch の検品レポートをお送りします。", + "", + "■ セッション", + "セッション名: 朝便", + "開始: \(appLanguage.formatDateTime(startedAt))", + "終了: \(appLanguage.formatDateTime(startedAt.addingTimeInterval(120)))", + "仕向地: 澤井製作所", + "検査箱数: 2箱", + "品番数(枝番別): 1", + "", + "■ 添付", + "検品レポート_朝便.pdf", + "", + "このメールは CodeMatch から作成しました。内容は端末内のデータのみです。" + ].joined(separator: "\n") + XCTAssertEqual(mail.body, expectedBody) + XCTAssertFalse(mail.body.contains(sawaiQR), "メール本文に生の読取値は載せない") + } + + func testHistoryMailOfAMoltenSessionAddsTheDeliveryNumberCountAndUsesTheStartDateWhenUnnamed() { + let session = MatchSession( + startedAt: startedAt, + entries: [ + MatchHistoryEntry(code: "PAF1-15-422", matchedAt: startedAt, qrPayload: moltenQRPAF1, barcodePayload: "PAF1-15-422@0NKD3C") + ], + destination: .molten + ) + let start = AppLanguage(locale).formatDateTime(startedAt) + + let mail = ReportMailContent.make(session: session, kind: .matchHistory, fileName: "照合履歴_x.pdf", locale: locale) + + XCTAssertEqual(mail.subject, "[CodeMatch] 照合履歴レポート \(start) - モルテン") + XCTAssertTrue(mail.body.hasPrefix("お疲れさまです。\nCodeMatch の照合履歴レポートをお送りします。\n")) + XCTAssertTrue(mail.body.contains("状態: 照合中")) + XCTAssertTrue(mail.body.contains("検査箱数: 1箱")) + XCTAssertTrue(mail.body.contains("品番数: 1")) + XCTAssertTrue(mail.body.contains("納品番号数: 1")) + XCTAssertTrue(mail.body.contains("■ 添付\n照合履歴_x.pdf")) + XCTAssertFalse(mail.body.contains("セッション名")) + } +} From a8c9130303b567ceb1c58236fd7ea749a544fc45 Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 01:49:37 +0900 Subject: [PATCH 12/18] docs: describe the report e-mail hand-off --- docs/PRODUCT_SPEC.md | 11 +++++++++++ docs/android/STATUS.md | 4 ++++ docs/ios/IMPLEMENTATION_GUIDE.md | 1 + 3 files changed, 16 insertions(+) diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index e55836a..04f5aba 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -73,6 +73,17 @@ QRの入力は、カメラ・Bluetoothとも伝送終端(CR/LF/NUL)だけを - 品名・不足数・完了判定は出しません。検品表との差異の判断は操作者が行います。 - ファイル名は照合履歴レポートと同じ語幹に「検品レポート_」を付けます。 +### レポートのメール共有 + +セッション詳細の「共有する」(検品レポート・照合履歴レポートの両方)は、共有先を選ぶ画面ではなく、宛先・件名・本文を埋めて PDF を添付したメール作成画面を開きます。送信は操作者がメールアプリで行い、アプリ自身は通信しません。 + +- 宛先: 当面は `ttyrim@gmail.com` に固定(両OSとも1か所の定数)。 +- 件名: `[CodeMatch] <レポート名> <セッション名または開始日時> - <仕向地>`。 +- 本文: 挨拶、レポート名、セッション(セッション名・開始・終了または状態・仕向地・検査箱数・行数または品番数と納品番号数)、添付ファイル名、「このメールは CodeMatch から作成しました。内容は端末内のデータのみです。」の順。読取値の全文は載せません。 +- iOS: Apple の「メール」の作成画面(`MFMailComposeViewController`)を使います。「メール」にアカウントがない端末では従来の共有シートに戻ります。 +- Android: `ACTION_SEND`(`application/pdf`、`mailto:` セレクタ)でメールアプリだけに渡します。複数あれば OS の選択画面で「常時」を選んで固定でき、メールアプリがなければ従来の共有チューザーに戻ります。 +- 「PDFで保存」と、設定画面の照合ログ・履歴全体の JSON 共有は変わりません。 + ## 照合ログ 現場でのデバッグのため、カメラ・Bluetoothどちらの入力でも照合の記録を端末内に残します。BLEの接続診断ログ(読取値を含まない)とは別の仕組みです。 diff --git a/docs/android/STATUS.md b/docs/android/STATUS.md index abda0aa..26b9c8c 100644 --- a/docs/android/STATUS.md +++ b/docs/android/STATUS.md @@ -60,6 +60,10 @@ JDK/SDKがない環境ではGradle結果を推測せず、実行不能として ## 履歴 +### 2026-09-11 レポートのメール共有 + +セッション詳細の「共有する」(検品レポート・照合履歴レポート)を、宛先固定(`ReportMailContent.RECIPIENT`)・件名・定型本文・PDF 添付を埋めたメールアプリの起動に変えた(ブランチ `codex/inspection-report-mail`、PoC ブランチの上に積んであり単独で巻き戻せる)。`core/export/ReportMailContent` が純 JVM で件名と本文を組み立て、`HistoryPdfBridge.createMailOrShareChooser` が `ACTION_SEND` + `mailto:` セレクタの Intent を作る。メールアプリが解決できなければ従来の共有チューザーに戻る。`AndroidManifest.xml` に `mailto:` の `` を足した以外、権限・FileProvider・release gate は無変更。証跡: `ReportMailContentTest` 3件、`HistoryPdfBridgeTest` にメール Intent とフォールバックの2件、`lintDebug testDebugUnitTest assembleDebug`、`:app:assembleRelease` と `verify-release-hardening.sh`。実メールアプリでの受け取りは Pixel 7 で確認する。 + ### 2026-09-11 検品レポート PDF 紙の検品表(品番・箱数の一覧)と突き合わせるための第2のPDF「検品レポート」を、セッション詳細の照合履歴レポートの行の上に追加した(iOS と同時、ブランチ `codex/inspection-report-poc`)。 diff --git a/docs/ios/IMPLEMENTATION_GUIDE.md b/docs/ios/IMPLEMENTATION_GUIDE.md index 0056350..cc32d82 100644 --- a/docs/ios/IMPLEMENTATION_GUIDE.md +++ b/docs/ios/IMPLEMENTATION_GUIDE.md @@ -112,6 +112,7 @@ Console.appで端末を選び、検索欄に `subsystem:jp.rimtty.CodeMatch` を - [ ] デンソーのPDFに品番ごとのかんばん要約(部品番号・収容数・指示数/次区・指示・納入日・便/管理番号・アイテムNo・受入)と、箱ごとのかんばん連番・管理コードが出る。カード番号と納品番号数は出ない - [ ] 履歴のJSON書き出しでデンソーのセッションが `"destination": "denso"` になる - [ ] セッション詳細に「検品レポート」の保存・共有の行が「照合履歴レポート」の行の上にあり、検品レポートPDFは1品番(澤井製作所は品番+枝番を `BCJH5281GG (02)` のように、モルテンは納品番号、デンソーは `6-4` の品番)が1行で品番順に並び、箱数・数量/箱・数量計・確認欄が出る。複数ページでは表ヘッダーが各ページに繰り返され、右下に「n / N」が付く +- [ ] セッション詳細の「共有する」(検品レポート・照合履歴レポート)で「メール」の作成画面が開き、宛先 `ttyrim@gmail.com`・件名 `[CodeMatch] …`・定型本文・PDF 添付が埋まっている。「メール」にアカウントがない端末では共有シートが開く - [ ] リセット後に値と結果が残らない(仕向地は同じセッション中は固定されたまま) - [ ] 自動「次の照合」は初期設定がOFFで、設定画面と照合セッション中の両方からON/OFFできる - [ ] 自動「次の照合」をONにすると、一致時だけ設定した1秒、3秒、または5秒の残り時間が表示され、0秒後に次のQR読み取りが始まる From dc96308d645b745a25f12202681fc1efd6b03c7d Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 01:56:09 +0900 Subject: [PATCH 13/18] feat(report-mail): subject and attachment named by destination and start time, drop the mail footer --- .../codematch/history/HistoryPdfBridgeTest.kt | 3 +- .../core/export/HistoryExportText.kt | 30 +++++++++++++++---- .../core/export/HistoryPdfExporter.kt | 13 +++----- .../core/export/ReportMailContent.kt | 10 +++---- .../core/export/HistoryExportTextTest.kt | 27 ++++++++++------- .../core/export/ReportMailContentTest.kt | 11 +++---- docs/PRODUCT_SPEC.md | 6 ++-- docs/ios/IMPLEMENTATION_GUIDE.md | 2 +- ios/CodeMatch/Resources/Localizable.xcstrings | 16 ---------- .../Services/InspectionPDFExporter.swift | 8 ++++- .../Services/ReportMailContent.swift | 8 ++--- .../Services/SessionPDFExporter.swift | 9 ++++-- .../InspectionPDFExporterTests.swift | 16 +++++----- .../ReportMailContentTests.swift | 11 ++++--- 14 files changed, 91 insertions(+), 79 deletions(-) diff --git a/android/app/src/test/java/jp/rimtty/codematch/history/HistoryPdfBridgeTest.kt b/android/app/src/test/java/jp/rimtty/codematch/history/HistoryPdfBridgeTest.kt index 79e6274..3a88642 100644 --- a/android/app/src/test/java/jp/rimtty/codematch/history/HistoryPdfBridgeTest.kt +++ b/android/app/src/test/java/jp/rimtty/codematch/history/HistoryPdfBridgeTest.kt @@ -67,7 +67,8 @@ class HistoryPdfBridgeTest { ) val document = PendingHistoryPdf(bytes = "%PDF-test".toByteArray(), fileName = fileName) - assertEquals("InspectionReport_morning.pdf", fileName) + assertTrue(fileName, fileName.startsWith("InspectionReport_") && fileName.endsWith(".pdf")) + assertFalse(fileName.contains("morning")) assertEquals(HistoryPdfBridge.PDF_MIME_TYPE, document.mimeType) } 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 2db0eaa..271d11d 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 @@ -88,7 +88,6 @@ data class HistoryExportLabels( val mailIntroHistory: String, val mailSessionHeading: String, val mailAttachmentHeading: String, - val mailFooter: String, /** Singular and plural units are kept separately for natural English. */ val boxCountSingular: String = boxCount, val boxCountPlural: String = boxCount, @@ -197,7 +196,6 @@ object HistoryExportTextFormatter { mailIntroHistory = "CodeMatch の照合履歴レポートをお送りします。", mailSessionHeading = "■ セッション", mailAttachmentHeading = "■ 添付", - mailFooter = "このメールは CodeMatch から作成しました。内容は端末内のデータのみです。", boxCountSingular = "箱", boxCountPlural = "箱", ) @@ -278,7 +276,6 @@ object HistoryExportTextFormatter { mailIntroHistory = "Please find the CodeMatch match history report attached.", mailSessionHeading = "Session", mailAttachmentHeading = "Attachment", - mailFooter = "Created by CodeMatch. The contents are on-device data only.", boxCountSingular = "box", boxCountPlural = "boxes", ) @@ -352,6 +349,30 @@ object HistoryExportTextFormatter { val source = session.displayName.ifBlank { dateTime(session.startedAt, language, zoneId) } + return "${filePrefix}_${sanitizeFileNamePart(source, session)}.pdf" + } + + /** + * `検品レポート_<仕向地>_<開始日時>.pdf`: the inspection report is filed by + * destination and start time, never by the session name, so the mail + * attachment and the saved file sort the same way as the paper sheets. + */ + fun inspectionFileName( + session: MatchSession, + language: AppLanguage, + zoneId: ZoneId = ZoneId.systemDefault(), + ): String { + val labels = labels(language) + val destination = session.resolvedDestination() + val prefix = buildString { + append(labels.inspectionFilePrefix) + if (destination != null) append('_').append(sanitizeFileNamePart(labels.destinationName(destination), session)) + } + val start = sanitizeFileNamePart(dateTime(session.startedAt, language, zoneId), session) + return "${prefix}_$start.pdf" + } + + private fun sanitizeFileNamePart(source: String, session: MatchSession): String { val safe = buildString(source.length) { source.forEach { character -> when { @@ -366,8 +387,7 @@ object HistoryExportTextFormatter { }.trim('_', '.', ' ') .replace("..", "_") .ifBlank { "session_${session.id.take(8)}" } - - return "${filePrefix}_$safe.pdf" + return safe } private fun locale(language: AppLanguage): Locale = when (language) { diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporter.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporter.kt index e86865d..fd532fe 100644 --- a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporter.kt +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporter.kt @@ -48,15 +48,10 @@ object HistoryPdfExporter { language: AppLanguage = AppLanguage.JAPANESE, zoneId: ZoneId = ZoneId.systemDefault(), kind: HistoryReportKind = HistoryReportKind.MATCH_HISTORY, - ): String = HistoryExportTextFormatter.fileName( - session = session, - language = language, - zoneId = zoneId, - prefix = when (kind) { - HistoryReportKind.MATCH_HISTORY -> null - HistoryReportKind.INSPECTION -> HistoryExportTextFormatter.labels(language).inspectionFilePrefix - }, - ) + ): String = when (kind) { + HistoryReportKind.MATCH_HISTORY -> HistoryExportTextFormatter.fileName(session, language, zoneId) + HistoryReportKind.INSPECTION -> HistoryExportTextFormatter.inspectionFileName(session, language, zoneId) + } /** Name of the only cache directory used for a shareable history report. */ const val CACHE_DIRECTORY: String = "codematch-pdf" diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt index 7432097..275e568 100644 --- a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt @@ -41,11 +41,11 @@ object ReportMailContent { HistoryReportKind.INSPECTION -> labels.inspectionTitle } val destination = session.resolvedDestination() - val sessionLabel = session.displayName.ifBlank { - HistoryExportTextFormatter.dateTime(session.startedAt, language, zoneId) - } + // `検品レポート 2026/09/07 23:17 - 澤井製作所`: the start time, never the + // session name, so subjects sort and read like the paper sheets. val subject = buildString { - append("[CodeMatch] ").append(title).append(' ').append(sessionLabel) + append(title).append(' ') + append(HistoryExportTextFormatter.dateTime(session.startedAt, language, zoneId)) if (destination != null) append(" - ").append(labels.destinationName(destination)) } @@ -75,8 +75,6 @@ object ReportMailContent { lines += "" lines += labels.mailAttachmentHeading lines += fileName - lines += "" - lines += labels.mailFooter return ReportMail(subject = subject, body = lines.joinToString("\n")) } 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 6df9a2e..82c5225 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 @@ -44,23 +44,30 @@ class HistoryExportTextTest { } @Test - fun inspectionReportPrefixSharesTheHistoryFileNameSanitizing() { + fun inspectionFileNameIsDestinationAndStartTimeNeverTheSessionName() { val session = MatchSession( id = "12345678-aaaa-bbbb-cccc-dddddddddddd", startedAt = 0L, name = " morning/09:00\\report..pdf? ", + destination = Destination.SAWAI, ) - val japanese = HistoryExportTextFormatter.labels(AppLanguage.JAPANESE) - val english = HistoryExportTextFormatter.labels(AppLanguage.ENGLISH) + val startJa = HistoryExportTextFormatter.dateTime(0L, AppLanguage.JAPANESE, utc) + .replace("/", "-").replace(":", "").replace(" ", "_") - assertEquals( - "検品レポート_morning-0900-report_pdf.pdf", - HistoryExportTextFormatter.fileName(session, AppLanguage.JAPANESE, utc, japanese.inspectionFilePrefix), - ) - assertTrue( - HistoryExportTextFormatter.fileName(session, AppLanguage.ENGLISH, utc, english.inspectionFilePrefix) - .startsWith("InspectionReport_"), + val japanese = HistoryExportTextFormatter.inspectionFileName(session, AppLanguage.JAPANESE, utc) + val english = HistoryExportTextFormatter.inspectionFileName(session, AppLanguage.ENGLISH, utc) + val noDestination = HistoryExportTextFormatter.inspectionFileName( + MatchSession(startedAt = 0L, name = "morning"), + AppLanguage.JAPANESE, + utc, ) + + assertEquals("検品レポート_澤井製作所_$startJa.pdf", japanese) + assertTrue(english, english.startsWith("InspectionReport_Sawai_Seisakusho_")) + assertFalse(japanese.contains("morning")) + assertFalse(japanese.contains('/')) + assertEquals("検品レポート_$startJa.pdf", noDestination) + assertFalse(noDestination.contains("morning")) } @Test diff --git a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt index 1ee2e8a..dcd74b0 100644 --- a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt +++ b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt @@ -44,7 +44,9 @@ class ReportMailContentTest { utc, ) - assertEquals("[CodeMatch] 検品レポート 朝便 - 澤井製作所", mail.subject) + val start = HistoryExportTextFormatter.dateTime(1_700_000_000_000L, AppLanguage.JAPANESE, utc) + assertEquals("検品レポート $start - 澤井製作所", mail.subject) + assertFalse(mail.subject.contains("朝便")) val expectedBody = listOf( "お疲れさまです。", "CodeMatch の検品レポートをお送りします。", @@ -59,8 +61,6 @@ class ReportMailContentTest { "", "■ 添付", "検品レポート_朝便.pdf", - "", - "このメールは CodeMatch から作成しました。内容は端末内のデータのみです。", ).joinToString("\n") assertEquals(expectedBody, mail.body) assertFalse("the mail never carries a raw payload", mail.body.contains(sawaiQr)) @@ -83,13 +83,14 @@ class ReportMailContentTest { ) val start = HistoryExportTextFormatter.dateTime(1_700_000_000_000L, AppLanguage.ENGLISH, utc) - assertEquals("[CodeMatch] Match History Report $start - Molten", mail.subject) + assertEquals("Match History Report $start - Molten", mail.subject) assertTrue(mail.body.startsWith("Hello,\nPlease find the CodeMatch match history report attached.\n")) assertTrue(mail.body.contains("Status: In progress")) assertTrue(mail.body.contains("Boxes: 1 box")) assertTrue(mail.body.contains("Part numbers: 1")) assertTrue(mail.body.contains("Delivery numbers: 1")) - assertTrue(mail.body.contains("Attachment\nMatchHistory_x.pdf")) + assertTrue(mail.body.endsWith("Attachment\nMatchHistory_x.pdf")) + assertFalse(mail.body.contains("CodeMatch.")) assertFalse(mail.body.contains("Session name")) } diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index 04f5aba..d0e7258 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -71,15 +71,15 @@ QRの入力は、カメラ・Bluetoothとも伝送終端(CR/LF/NUL)だけを - 並び順は品番の昇順、同じ品番の中は澤井製作所が枝番、モルテンが納品番号の昇順で、読取順は選べません。1箱あたりの数量は同じ行の全箱で一致するときだけ出し、数量計は全箱に数量があるときだけ出します(それ以外は「-」)。 - 仕向地として解析できないQRの箱(旧履歴など)は、記録済みの品番をキーにした行として末尾に残します。行の箱数の合計は常にセッションの検査箱数と一致します。 - 品名・不足数・完了判定は出しません。検品表との差異の判断は操作者が行います。 -- ファイル名は照合履歴レポートと同じ語幹に「検品レポート_」を付けます。 +- ファイル名は `検品レポート_<仕向地>_<開始日時>.pdf`(例 `検品レポート_澤井製作所_2026-09-07_2317.pdf`)で、セッション名は使いません。仕向地のない旧履歴では仕向地を省きます。 ### レポートのメール共有 セッション詳細の「共有する」(検品レポート・照合履歴レポートの両方)は、共有先を選ぶ画面ではなく、宛先・件名・本文を埋めて PDF を添付したメール作成画面を開きます。送信は操作者がメールアプリで行い、アプリ自身は通信しません。 - 宛先: 当面は `ttyrim@gmail.com` に固定(両OSとも1か所の定数)。 -- 件名: `[CodeMatch] <レポート名> <セッション名または開始日時> - <仕向地>`。 -- 本文: 挨拶、レポート名、セッション(セッション名・開始・終了または状態・仕向地・検査箱数・行数または品番数と納品番号数)、添付ファイル名、「このメールは CodeMatch から作成しました。内容は端末内のデータのみです。」の順。読取値の全文は載せません。 +- 件名: `<レポート名> <開始日時> - <仕向地>`(例 `検品レポート 2026/09/07 23:17 - 澤井製作所`)。 +- 本文: 挨拶、レポート名、セッション(セッション名・開始・終了または状態・仕向地・検査箱数・行数または品番数と納品番号数)、添付ファイル名の順。読取値の全文は載せません。 - iOS: Apple の「メール」の作成画面(`MFMailComposeViewController`)を使います。「メール」にアカウントがない端末では従来の共有シートに戻ります。 - Android: `ACTION_SEND`(`application/pdf`、`mailto:` セレクタ)でメールアプリだけに渡します。複数あれば OS の選択画面で「常時」を選んで固定でき、メールアプリがなければ従来の共有チューザーに戻ります。 - 「PDFで保存」と、設定画面の照合ログ・履歴全体の JSON 共有は変わりません。 diff --git a/docs/ios/IMPLEMENTATION_GUIDE.md b/docs/ios/IMPLEMENTATION_GUIDE.md index cc32d82..4350151 100644 --- a/docs/ios/IMPLEMENTATION_GUIDE.md +++ b/docs/ios/IMPLEMENTATION_GUIDE.md @@ -112,7 +112,7 @@ Console.appで端末を選び、検索欄に `subsystem:jp.rimtty.CodeMatch` を - [ ] デンソーのPDFに品番ごとのかんばん要約(部品番号・収容数・指示数/次区・指示・納入日・便/管理番号・アイテムNo・受入)と、箱ごとのかんばん連番・管理コードが出る。カード番号と納品番号数は出ない - [ ] 履歴のJSON書き出しでデンソーのセッションが `"destination": "denso"` になる - [ ] セッション詳細に「検品レポート」の保存・共有の行が「照合履歴レポート」の行の上にあり、検品レポートPDFは1品番(澤井製作所は品番+枝番を `BCJH5281GG (02)` のように、モルテンは納品番号、デンソーは `6-4` の品番)が1行で品番順に並び、箱数・数量/箱・数量計・確認欄が出る。複数ページでは表ヘッダーが各ページに繰り返され、右下に「n / N」が付く -- [ ] セッション詳細の「共有する」(検品レポート・照合履歴レポート)で「メール」の作成画面が開き、宛先 `ttyrim@gmail.com`・件名 `[CodeMatch] …`・定型本文・PDF 添付が埋まっている。「メール」にアカウントがない端末では共有シートが開く +- [ ] セッション詳細の「共有する」(検品レポート・照合履歴レポート)で「メール」の作成画面が開き、宛先 `ttyrim@gmail.com`・件名 `検品レポート <開始日時> - <仕向地>`・定型本文・添付 `検品レポート_<仕向地>_<開始日時>.pdf` が埋まっている。「メール」にアカウントがない端末では共有シートが開く - [ ] リセット後に値と結果が残らない(仕向地は同じセッション中は固定されたまま) - [ ] 自動「次の照合」は初期設定がOFFで、設定画面と照合セッション中の両方からON/OFFできる - [ ] 自動「次の照合」をONにすると、一致時だけ設定した1秒、3秒、または5秒の残り時間が表示され、0秒後に次のQR読み取りが始まる diff --git a/ios/CodeMatch/Resources/Localizable.xcstrings b/ios/CodeMatch/Resources/Localizable.xcstrings index e329842..555be52 100644 --- a/ios/CodeMatch/Resources/Localizable.xcstrings +++ b/ios/CodeMatch/Resources/Localizable.xcstrings @@ -6392,22 +6392,6 @@ } } } - }, - "このメールは CodeMatch から作成しました。内容は端末内のデータのみです。" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Created by CodeMatch. The contents are on-device data only." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "このメールは CodeMatch から作成しました。内容は端末内のデータのみです。" - } - } - } } }, "version" : "1.0" diff --git a/ios/CodeMatch/Services/InspectionPDFExporter.swift b/ios/CodeMatch/Services/InspectionPDFExporter.swift index 4d5f0db..5e576d4 100644 --- a/ios/CodeMatch/Services/InspectionPDFExporter.swift +++ b/ios/CodeMatch/Services/InspectionPDFExporter.swift @@ -10,8 +10,14 @@ enum InspectionPDFExporter { private static let margin: CGFloat = 44 private static let footerHeight: CGFloat = 30 + /// `検品レポート_<仕向地>_<開始日時>.pdf`。セッション名ではなく仕向地と開始日時で並ぶようにする。 static func fileName(for session: MatchSession, locale: Locale) -> String { - "\(AppLocalization.string("検品レポート"))_\(SessionPDFExporter.sanitizedStem(for: session, locale: locale)).pdf" + var parts = [AppLocalization.string("検品レポート")] + if let destination = session.resolvedDestination { + parts.append(SessionPDFExporter.sanitizedFileNamePart(destination.displayName)) + } + parts.append(SessionPDFExporter.sanitizedFileNamePart(AppLanguage(locale).formatDateTime(session.startedAt))) + return parts.joined(separator: "_") + ".pdf" } /// ページ番号 `n / N` を全ページに置くため2回描画する。1回目でページ数を数え、2回目で総数を印字する。 diff --git a/ios/CodeMatch/Services/ReportMailContent.swift b/ios/CodeMatch/Services/ReportMailContent.swift index 231c550..36acc8e 100644 --- a/ios/CodeMatch/Services/ReportMailContent.swift +++ b/ios/CodeMatch/Services/ReportMailContent.swift @@ -26,10 +26,8 @@ struct ReportMailContent: Equatable { case .inspection: title = AppLocalization.string("検品レポート") } let destination = session.resolvedDestination - let sessionLabel = session.displayName.isEmpty - ? appLanguage.formatDateTime(session.startedAt) - : session.displayName - var subject = "[CodeMatch] \(title) \(sessionLabel)" + // 「検品レポート 2026/09/07 23:17 - 澤井製作所」。セッション名ではなく開始日時で並ぶようにする。 + var subject = "\(title) \(appLanguage.formatDateTime(session.startedAt))" if let destination { subject += " - \(destination.displayName)" } @@ -59,8 +57,6 @@ struct ReportMailContent: Equatable { lines.append("") lines.append(AppLocalization.string("■ 添付")) lines.append(fileName) - lines.append("") - lines.append(AppLocalization.string("このメールは CodeMatch から作成しました。内容は端末内のデータのみです。")) return ReportMailContent(subject: subject, body: lines.joined(separator: "\n")) } diff --git a/ios/CodeMatch/Services/SessionPDFExporter.swift b/ios/CodeMatch/Services/SessionPDFExporter.swift index 671ff0d..e92bd54 100644 --- a/ios/CodeMatch/Services/SessionPDFExporter.swift +++ b/ios/CodeMatch/Services/SessionPDFExporter.swift @@ -11,13 +11,18 @@ enum SessionPDFExporter { "\(AppLocalization.string("照合履歴"))_\(sanitizedStem(for: session, locale: locale)).pdf" } - /// 表示名(未設定なら開始日時)をファイル名向けに整えた語幹。検品レポートも同じ規則を使う。 + /// 表示名(未設定なら開始日時)をファイル名向けに整えた語幹。 static func sanitizedStem(for session: MatchSession, locale: Locale) -> String { let appLanguage = AppLanguage(locale) let base = session.displayName.isEmpty ? appLanguage.formatDateTime(session.startedAt) : session.displayName - return base + return sanitizedFileNamePart(base) + } + + /// ファイル名の1区画向けに `/`・`:`・空白を置き換える。 + static func sanitizedFileNamePart(_ value: String) -> String { + value .replacingOccurrences(of: "/", with: "-") .replacingOccurrences(of: ":", with: "") .replacingOccurrences(of: " ", with: "_") diff --git a/ios/CodeMatchTests/InspectionPDFExporterTests.swift b/ios/CodeMatchTests/InspectionPDFExporterTests.swift index 20306a0..b4ffd86 100644 --- a/ios/CodeMatchTests/InspectionPDFExporterTests.swift +++ b/ios/CodeMatchTests/InspectionPDFExporterTests.swift @@ -133,19 +133,19 @@ final class InspectionPDFExporterTests: XCTestCase { XCTAssertTrue(lastPage.contains("BCJH0119GG")) } - func testFileNameUsesTheInspectionPrefixAndTheHistoryStem() { - let session = MatchSession(startedAt: startedAt, name: "morning/09:00 run") + func testFileNameIsDestinationAndStartTimeNeverTheSessionName() { + let session = MatchSession(startedAt: startedAt, name: "morning/09:00 run", destination: .sawai) + let start = SessionPDFExporter.sanitizedFileNamePart(AppLanguage(locale).formatDateTime(startedAt)) let inspection = InspectionPDFExporter.fileName(for: session, locale: locale) + let noDestination = InspectionPDFExporter.fileName(for: MatchSession(startedAt: startedAt, name: "morning"), locale: locale) let history = SessionPDFExporter.fileName(for: session, locale: locale) - XCTAssertTrue(inspection.hasPrefix("検品レポート_"), inspection) - XCTAssertTrue(history.hasPrefix("照合履歴_"), history) - XCTAssertEqual( - inspection.dropFirst("検品レポート_".count), - history.dropFirst("照合履歴_".count) - ) + XCTAssertEqual(inspection, "検品レポート_澤井製作所_\(start).pdf") + XCTAssertEqual(noDestination, "検品レポート_\(start).pdf") + XCTAssertFalse(inspection.contains("morning")) XCTAssertFalse(inspection.contains("/")) + XCTAssertTrue(history.hasPrefix("照合履歴_morning"), "照合履歴のファイル名は従来どおりセッション名") } // MARK: - Helpers diff --git a/ios/CodeMatchTests/ReportMailContentTests.swift b/ios/CodeMatchTests/ReportMailContentTests.swift index 2a1e561..fc4b9d3 100644 --- a/ios/CodeMatchTests/ReportMailContentTests.swift +++ b/ios/CodeMatchTests/ReportMailContentTests.swift @@ -27,7 +27,8 @@ final class ReportMailContentTests: XCTestCase { let mail = ReportMailContent.make(session: session, kind: .inspection, fileName: "検品レポート_朝便.pdf", locale: locale) - XCTAssertEqual(mail.subject, "[CodeMatch] 検品レポート 朝便 - 澤井製作所") + XCTAssertEqual(mail.subject, "検品レポート \(appLanguage.formatDateTime(startedAt)) - 澤井製作所") + XCTAssertFalse(mail.subject.contains("朝便")) let expectedBody = [ "お疲れさまです。", "CodeMatch の検品レポートをお送りします。", @@ -41,9 +42,7 @@ final class ReportMailContentTests: XCTestCase { "品番数(枝番別): 1", "", "■ 添付", - "検品レポート_朝便.pdf", - "", - "このメールは CodeMatch から作成しました。内容は端末内のデータのみです。" + "検品レポート_朝便.pdf" ].joined(separator: "\n") XCTAssertEqual(mail.body, expectedBody) XCTAssertFalse(mail.body.contains(sawaiQR), "メール本文に生の読取値は載せない") @@ -61,13 +60,13 @@ final class ReportMailContentTests: XCTestCase { let mail = ReportMailContent.make(session: session, kind: .matchHistory, fileName: "照合履歴_x.pdf", locale: locale) - XCTAssertEqual(mail.subject, "[CodeMatch] 照合履歴レポート \(start) - モルテン") + XCTAssertEqual(mail.subject, "照合履歴レポート \(start) - モルテン") XCTAssertTrue(mail.body.hasPrefix("お疲れさまです。\nCodeMatch の照合履歴レポートをお送りします。\n")) XCTAssertTrue(mail.body.contains("状態: 照合中")) XCTAssertTrue(mail.body.contains("検査箱数: 1箱")) XCTAssertTrue(mail.body.contains("品番数: 1")) XCTAssertTrue(mail.body.contains("納品番号数: 1")) - XCTAssertTrue(mail.body.contains("■ 添付\n照合履歴_x.pdf")) + XCTAssertTrue(mail.body.hasSuffix("■ 添付\n照合履歴_x.pdf")) XCTAssertFalse(mail.body.contains("セッション名")) } } From c1e11dbd9590c114c9c13ef5f17d81a7d9cc7a25 Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 01:59:36 +0900 Subject: [PATCH 14/18] feat(report-mail): address report mails to takemoto1075@icloud.com --- .../jp/rimtty/codematch/core/export/ReportMailContent.kt | 2 +- .../jp/rimtty/codematch/core/export/ReportMailContentTest.kt | 4 ++-- docs/PRODUCT_SPEC.md | 2 +- docs/ios/IMPLEMENTATION_GUIDE.md | 2 +- ios/CodeMatch/Services/ReportMailContent.swift | 2 +- ios/CodeMatchTests/ReportMailContentTests.swift | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt index 275e568..a535989 100644 --- a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt @@ -23,7 +23,7 @@ data class ReportMail( */ object ReportMailContent { /** The one address every report mail is addressed to. */ - const val RECIPIENT: String = "ttyrim@gmail.com" + const val RECIPIENT: String = "takemoto1075@icloud.com" val recipients: Array get() = arrayOf(RECIPIENT) diff --git a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt index dcd74b0..e207263 100644 --- a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt +++ b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt @@ -19,8 +19,8 @@ class ReportMailContentTest { @Test fun recipientIsTheFixedOperatorAddress() { - assertEquals("ttyrim@gmail.com", ReportMailContent.RECIPIENT) - assertEquals(listOf("ttyrim@gmail.com"), ReportMailContent.recipients.toList()) + assertEquals("takemoto1075@icloud.com", ReportMailContent.RECIPIENT) + assertEquals(listOf("takemoto1075@icloud.com"), ReportMailContent.recipients.toList()) } @Test diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index d0e7258..d91118e 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -77,7 +77,7 @@ QRの入力は、カメラ・Bluetoothとも伝送終端(CR/LF/NUL)だけを セッション詳細の「共有する」(検品レポート・照合履歴レポートの両方)は、共有先を選ぶ画面ではなく、宛先・件名・本文を埋めて PDF を添付したメール作成画面を開きます。送信は操作者がメールアプリで行い、アプリ自身は通信しません。 -- 宛先: 当面は `ttyrim@gmail.com` に固定(両OSとも1か所の定数)。 +- 宛先: 当面は `takemoto1075@icloud.com` に固定(両OSとも1か所の定数)。 - 件名: `<レポート名> <開始日時> - <仕向地>`(例 `検品レポート 2026/09/07 23:17 - 澤井製作所`)。 - 本文: 挨拶、レポート名、セッション(セッション名・開始・終了または状態・仕向地・検査箱数・行数または品番数と納品番号数)、添付ファイル名の順。読取値の全文は載せません。 - iOS: Apple の「メール」の作成画面(`MFMailComposeViewController`)を使います。「メール」にアカウントがない端末では従来の共有シートに戻ります。 diff --git a/docs/ios/IMPLEMENTATION_GUIDE.md b/docs/ios/IMPLEMENTATION_GUIDE.md index 4350151..b1e23dd 100644 --- a/docs/ios/IMPLEMENTATION_GUIDE.md +++ b/docs/ios/IMPLEMENTATION_GUIDE.md @@ -112,7 +112,7 @@ Console.appで端末を選び、検索欄に `subsystem:jp.rimtty.CodeMatch` を - [ ] デンソーのPDFに品番ごとのかんばん要約(部品番号・収容数・指示数/次区・指示・納入日・便/管理番号・アイテムNo・受入)と、箱ごとのかんばん連番・管理コードが出る。カード番号と納品番号数は出ない - [ ] 履歴のJSON書き出しでデンソーのセッションが `"destination": "denso"` になる - [ ] セッション詳細に「検品レポート」の保存・共有の行が「照合履歴レポート」の行の上にあり、検品レポートPDFは1品番(澤井製作所は品番+枝番を `BCJH5281GG (02)` のように、モルテンは納品番号、デンソーは `6-4` の品番)が1行で品番順に並び、箱数・数量/箱・数量計・確認欄が出る。複数ページでは表ヘッダーが各ページに繰り返され、右下に「n / N」が付く -- [ ] セッション詳細の「共有する」(検品レポート・照合履歴レポート)で「メール」の作成画面が開き、宛先 `ttyrim@gmail.com`・件名 `検品レポート <開始日時> - <仕向地>`・定型本文・添付 `検品レポート_<仕向地>_<開始日時>.pdf` が埋まっている。「メール」にアカウントがない端末では共有シートが開く +- [ ] セッション詳細の「共有する」(検品レポート・照合履歴レポート)で「メール」の作成画面が開き、宛先 `takemoto1075@icloud.com`・件名 `検品レポート <開始日時> - <仕向地>`・定型本文・添付 `検品レポート_<仕向地>_<開始日時>.pdf` が埋まっている。「メール」にアカウントがない端末では共有シートが開く - [ ] リセット後に値と結果が残らない(仕向地は同じセッション中は固定されたまま) - [ ] 自動「次の照合」は初期設定がOFFで、設定画面と照合セッション中の両方からON/OFFできる - [ ] 自動「次の照合」をONにすると、一致時だけ設定した1秒、3秒、または5秒の残り時間が表示され、0秒後に次のQR読み取りが始まる diff --git a/ios/CodeMatch/Services/ReportMailContent.swift b/ios/CodeMatch/Services/ReportMailContent.swift index 36acc8e..ace6d08 100644 --- a/ios/CodeMatch/Services/ReportMailContent.swift +++ b/ios/CodeMatch/Services/ReportMailContent.swift @@ -13,7 +13,7 @@ enum ReportKind { /// 送信そのものは操作者が使うメールアプリに任せる。Android の `ReportMailContent` と同じ規則。 struct ReportMailContent: Equatable { /// すべてのレポートメールの宛先。 - static let recipients = ["ttyrim@gmail.com"] + static let recipients = ["takemoto1075@icloud.com"] let subject: String let body: String diff --git a/ios/CodeMatchTests/ReportMailContentTests.swift b/ios/CodeMatchTests/ReportMailContentTests.swift index fc4b9d3..78d4a1c 100644 --- a/ios/CodeMatchTests/ReportMailContentTests.swift +++ b/ios/CodeMatchTests/ReportMailContentTests.swift @@ -9,7 +9,7 @@ final class ReportMailContentTests: XCTestCase { private let locale = Locale(identifier: "ja_JP") func testRecipientIsTheFixedOperatorAddress() { - XCTAssertEqual(ReportMailContent.recipients, ["ttyrim@gmail.com"]) + XCTAssertEqual(ReportMailContent.recipients, ["takemoto1075@icloud.com"]) } func testInspectionMailRepeatsTheReportHeaderAndNamesTheAttachment() { From 7ecb4a7834425cd6e2b5c0a4e3969f86e5fd515c Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 02:01:47 +0900 Subject: [PATCH 15/18] feat(report-mail): drop the greeting and the CodeMatch prefix from the mail body --- .../core/export/HistoryExportText.kt | 13 +++--- .../core/export/ReportMailContent.kt | 1 - .../core/export/ReportMailContentTest.kt | 5 +-- docs/PRODUCT_SPEC.md | 2 +- ios/CodeMatch/Resources/Localizable.xcstrings | 40 ++++++------------- .../Services/ReportMailContent.swift | 5 +-- .../ReportMailContentTests.swift | 5 +-- 7 files changed, 24 insertions(+), 47 deletions(-) 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 271d11d..c2c0764 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 @@ -82,8 +82,7 @@ data class HistoryExportLabels( val columnCumulativeQuantity: String, val columnCheck: String, val inspectionFooterNote: String, - /** Pre-filled report e-mail: greeting, intro per report, headings and footer. */ - val mailGreeting: String, + /** Pre-filled report e-mail: intro per report and headings. */ val mailIntroInspection: String, val mailIntroHistory: String, val mailSessionHeading: String, @@ -191,9 +190,8 @@ object HistoryExportTextFormatter { columnCheck = "確認", inspectionFooterNote = "検品表にあってこの一覧にない品番は、このセッションで照合されていません。", - mailGreeting = "お疲れさまです。", - mailIntroInspection = "CodeMatch の検品レポートをお送りします。", - mailIntroHistory = "CodeMatch の照合履歴レポートをお送りします。", + mailIntroInspection = "検品レポートをお送りします。", + mailIntroHistory = "照合履歴レポートをお送りします。", mailSessionHeading = "■ セッション", mailAttachmentHeading = "■ 添付", boxCountSingular = "箱", @@ -271,9 +269,8 @@ object HistoryExportTextFormatter { columnCheck = "Check", inspectionFooterNote = "Part numbers on the inspection sheet that are missing from this list were not matched in this session.", - mailGreeting = "Hello,", - mailIntroInspection = "Please find the CodeMatch inspection report attached.", - mailIntroHistory = "Please find the CodeMatch match history report attached.", + mailIntroInspection = "Please find the inspection report attached.", + mailIntroHistory = "Please find the match history report attached.", mailSessionHeading = "Session", mailAttachmentHeading = "Attachment", boxCountSingular = "box", diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt index a535989..64409f7 100644 --- a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt @@ -50,7 +50,6 @@ object ReportMailContent { } val lines = mutableListOf() - lines += labels.mailGreeting lines += when (kind) { HistoryReportKind.MATCH_HISTORY -> labels.mailIntroHistory HistoryReportKind.INSPECTION -> labels.mailIntroInspection diff --git a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt index e207263..d54355a 100644 --- a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt +++ b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ReportMailContentTest.kt @@ -48,8 +48,7 @@ class ReportMailContentTest { assertEquals("検品レポート $start - 澤井製作所", mail.subject) assertFalse(mail.subject.contains("朝便")) val expectedBody = listOf( - "お疲れさまです。", - "CodeMatch の検品レポートをお送りします。", + "検品レポートをお送りします。", "", "■ セッション", "セッション名: 朝便", @@ -84,7 +83,7 @@ class ReportMailContentTest { val start = HistoryExportTextFormatter.dateTime(1_700_000_000_000L, AppLanguage.ENGLISH, utc) assertEquals("Match History Report $start - Molten", mail.subject) - assertTrue(mail.body.startsWith("Hello,\nPlease find the CodeMatch match history report attached.\n")) + assertTrue(mail.body.startsWith("Please find the match history report attached.\n\nSession\n")) assertTrue(mail.body.contains("Status: In progress")) assertTrue(mail.body.contains("Boxes: 1 box")) assertTrue(mail.body.contains("Part numbers: 1")) diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index d91118e..6b921ec 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -79,7 +79,7 @@ QRの入力は、カメラ・Bluetoothとも伝送終端(CR/LF/NUL)だけを - 宛先: 当面は `takemoto1075@icloud.com` に固定(両OSとも1か所の定数)。 - 件名: `<レポート名> <開始日時> - <仕向地>`(例 `検品レポート 2026/09/07 23:17 - 澤井製作所`)。 -- 本文: 挨拶、レポート名、セッション(セッション名・開始・終了または状態・仕向地・検査箱数・行数または品番数と納品番号数)、添付ファイル名の順。読取値の全文は載せません。 +- 本文: 「検品レポートをお送りします。」の一文、セッション(セッション名・開始・終了または状態・仕向地・検査箱数・行数または品番数と納品番号数)、添付ファイル名の順。読取値の全文は載せません。 - iOS: Apple の「メール」の作成画面(`MFMailComposeViewController`)を使います。「メール」にアカウントがない端末では従来の共有シートに戻ります。 - Android: `ACTION_SEND`(`application/pdf`、`mailto:` セレクタ)でメールアプリだけに渡します。複数あれば OS の選択画面で「常時」を選んで固定でき、メールアプリがなければ従来の共有チューザーに戻ります。 - 「PDFで保存」と、設定画面の照合ログ・履歴全体の JSON 共有は変わりません。 diff --git a/ios/CodeMatch/Resources/Localizable.xcstrings b/ios/CodeMatch/Resources/Localizable.xcstrings index 555be52..49aa60d 100644 --- a/ios/CodeMatch/Resources/Localizable.xcstrings +++ b/ios/CodeMatch/Resources/Localizable.xcstrings @@ -6313,82 +6313,66 @@ } } }, - "お疲れさまです。" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hello," - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "お疲れさまです。" - } - } - } - }, - "CodeMatch の検品レポートをお送りします。" : { + "■ セッション" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Please find the CodeMatch inspection report attached." + "value" : "Session" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "CodeMatch の検品レポートをお送りします。" + "value" : "■ セッション" } } } }, - "CodeMatch の照合履歴レポートをお送りします。" : { + "■ 添付" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Please find the CodeMatch match history report attached." + "value" : "Attachment" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "CodeMatch の照合履歴レポートをお送りします。" + "value" : "■ 添付" } } } }, - "■ セッション" : { + "検品レポートをお送りします。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Session" + "value" : "Please find the inspection report attached." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "■ セッション" + "value" : "検品レポートをお送りします。" } } } }, - "■ 添付" : { + "照合履歴レポートをお送りします。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Attachment" + "value" : "Please find the match history report attached." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "■ 添付" + "value" : "照合履歴レポートをお送りします。" } } } diff --git a/ios/CodeMatch/Services/ReportMailContent.swift b/ios/CodeMatch/Services/ReportMailContent.swift index ace6d08..8741eaf 100644 --- a/ios/CodeMatch/Services/ReportMailContent.swift +++ b/ios/CodeMatch/Services/ReportMailContent.swift @@ -33,10 +33,9 @@ struct ReportMailContent: Equatable { } var lines: [String] = [] - lines.append(AppLocalization.string("お疲れさまです。")) switch kind { - case .matchHistory: lines.append(AppLocalization.string("CodeMatch の照合履歴レポートをお送りします。")) - case .inspection: lines.append(AppLocalization.string("CodeMatch の検品レポートをお送りします。")) + case .matchHistory: lines.append(AppLocalization.string("照合履歴レポートをお送りします。")) + case .inspection: lines.append(AppLocalization.string("検品レポートをお送りします。")) } lines.append("") lines.append(AppLocalization.string("■ セッション")) diff --git a/ios/CodeMatchTests/ReportMailContentTests.swift b/ios/CodeMatchTests/ReportMailContentTests.swift index 78d4a1c..80f9916 100644 --- a/ios/CodeMatchTests/ReportMailContentTests.swift +++ b/ios/CodeMatchTests/ReportMailContentTests.swift @@ -30,8 +30,7 @@ final class ReportMailContentTests: XCTestCase { XCTAssertEqual(mail.subject, "検品レポート \(appLanguage.formatDateTime(startedAt)) - 澤井製作所") XCTAssertFalse(mail.subject.contains("朝便")) let expectedBody = [ - "お疲れさまです。", - "CodeMatch の検品レポートをお送りします。", + "検品レポートをお送りします。", "", "■ セッション", "セッション名: 朝便", @@ -61,7 +60,7 @@ final class ReportMailContentTests: XCTestCase { let mail = ReportMailContent.make(session: session, kind: .matchHistory, fileName: "照合履歴_x.pdf", locale: locale) XCTAssertEqual(mail.subject, "照合履歴レポート \(start) - モルテン") - XCTAssertTrue(mail.body.hasPrefix("お疲れさまです。\nCodeMatch の照合履歴レポートをお送りします。\n")) + XCTAssertTrue(mail.body.hasPrefix("照合履歴レポートをお送りします。\n\n■ セッション\n")) XCTAssertTrue(mail.body.contains("状態: 照合中")) XCTAssertTrue(mail.body.contains("検査箱数: 1箱")) XCTAssertTrue(mail.body.contains("品番数: 1")) From b579cebdae44849bc2b9e22170f43a8430753c81 Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 02:04:49 +0900 Subject: [PATCH 16/18] feat(report-mail): name the match history PDF like the inspection report (prefix_destination_starttime) --- .../core/export/HistoryExportText.kt | 42 ++++--------- .../core/export/HistoryPdfExporter.kt | 10 ++- .../core/export/HistoryExportTextTest.kt | 62 +++++++------------ docs/PRODUCT_SPEC.md | 2 +- .../Services/InspectionPDFExporter.swift | 7 +-- .../Services/SessionPDFExporter.swift | 18 +++--- .../InspectionPDFExporterTests.swift | 2 +- .../ReportMailContentTests.swift | 4 +- 8 files changed, 56 insertions(+), 91 deletions(-) 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 c2c0764..e006adb 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 @@ -121,7 +121,7 @@ object HistoryExportTextFormatter { fun labels(language: AppLanguage): HistoryExportLabels = when (language) { AppLanguage.JAPANESE -> HistoryExportLabels( reportTitle = "照合履歴レポート", - filePrefix = "照合履歴", + filePrefix = "照合履歴レポート", sessionName = "セッション名", start = "開始", end = "終了", @@ -200,7 +200,7 @@ object HistoryExportTextFormatter { AppLanguage.ENGLISH -> HistoryExportLabels( reportTitle = "Match History Report", - filePrefix = "MatchHistory", + filePrefix = "MatchHistoryReport", sessionName = "Session name", start = "Start", end = "End", @@ -330,43 +330,25 @@ object HistoryExportTextFormatter { } /** - * Returns a filename safe to use below cache/document-provider roots. + * `_<仕向地>_<開始日時>.pdf`, e.g. `検品レポート_澤井製作所_2026-09-07_2317.pdf`. + * Both reports are filed by destination and start time, never by the + * session name, so the saved file and the mail attachment sort the same + * way as the paper sheets. A session without a destination omits it. * Unicode names are retained, but path separators, control characters, * reserved punctuation, and traversal sequences are removed. */ - fun fileName( - session: MatchSession, - language: AppLanguage, - zoneId: ZoneId = ZoneId.systemDefault(), - /** File-name prefix; the match history prefix unless a report supplies its own. */ - prefix: String? = null, - ): String { - val labels = labels(language) - val filePrefix = prefix ?: labels.filePrefix - val source = session.displayName.ifBlank { - dateTime(session.startedAt, language, zoneId) - } - return "${filePrefix}_${sanitizeFileNamePart(source, session)}.pdf" - } - - /** - * `検品レポート_<仕向地>_<開始日時>.pdf`: the inspection report is filed by - * destination and start time, never by the session name, so the mail - * attachment and the saved file sort the same way as the paper sheets. - */ - fun inspectionFileName( + fun reportFileName( session: MatchSession, language: AppLanguage, zoneId: ZoneId = ZoneId.systemDefault(), + prefix: String, ): String { val labels = labels(language) val destination = session.resolvedDestination() - val prefix = buildString { - append(labels.inspectionFilePrefix) - if (destination != null) append('_').append(sanitizeFileNamePart(labels.destinationName(destination), session)) - } - val start = sanitizeFileNamePart(dateTime(session.startedAt, language, zoneId), session) - return "${prefix}_$start.pdf" + val parts = mutableListOf(prefix) + if (destination != null) parts += sanitizeFileNamePart(labels.destinationName(destination), session) + parts += sanitizeFileNamePart(dateTime(session.startedAt, language, zoneId), session) + return parts.joinToString("_") + ".pdf" } private fun sanitizeFileNamePart(source: String, session: MatchSession): String { diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporter.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporter.kt index fd532fe..2df2afb 100644 --- a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporter.kt +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryPdfExporter.kt @@ -48,9 +48,13 @@ object HistoryPdfExporter { language: AppLanguage = AppLanguage.JAPANESE, zoneId: ZoneId = ZoneId.systemDefault(), kind: HistoryReportKind = HistoryReportKind.MATCH_HISTORY, - ): String = when (kind) { - HistoryReportKind.MATCH_HISTORY -> HistoryExportTextFormatter.fileName(session, language, zoneId) - HistoryReportKind.INSPECTION -> HistoryExportTextFormatter.inspectionFileName(session, language, zoneId) + ): String { + val labels = HistoryExportTextFormatter.labels(language) + val prefix = when (kind) { + HistoryReportKind.MATCH_HISTORY -> labels.filePrefix + HistoryReportKind.INSPECTION -> labels.inspectionFilePrefix + } + return HistoryExportTextFormatter.reportFileName(session, language, zoneId, prefix) } /** Name of the only cache directory used for a shareable history report. */ 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 82c5225..6a1a4d4 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 @@ -13,61 +13,43 @@ class HistoryExportTextTest { private val utc = ZoneId.of("UTC") @Test - fun fileNameKeepsDisplayNameButRemovesPathAndReservedCharacters() { - val session = MatchSession( - id = "12345678-aaaa-bbbb-cccc-dddddddddddd", - startedAt = 0L, - name = " morning/09:00\\report..pdf? ", - ) - - val fileName = HistoryExportTextFormatter.fileName(session, AppLanguage.JAPANESE, utc) - - assertEquals("照合履歴_morning-0900-report_pdf.pdf", fileName) - assertFalse(fileName.contains('/')) - assertFalse(fileName.contains('\\')) - assertFalse(fileName.contains("..")) - } - - @Test - fun unnamedSessionUsesLocalizedStartDateAsSafeFileNamePart() { - val session = MatchSession(startedAt = 0L) - - val japanese = HistoryExportTextFormatter.fileName(session, AppLanguage.JAPANESE, utc) - val english = HistoryExportTextFormatter.fileName(session, AppLanguage.ENGLISH, utc) - - assertTrue(japanese.startsWith("照合履歴_")) - assertTrue(english.startsWith("MatchHistory_")) - assertTrue(japanese.endsWith(".pdf")) - assertTrue(english.endsWith(".pdf")) - assertFalse(japanese.contains('/')) - assertFalse(english.contains('/')) - } - - @Test - fun inspectionFileNameIsDestinationAndStartTimeNeverTheSessionName() { + fun reportFileNameIsPrefixDestinationAndStartTimeNeverTheSessionName() { val session = MatchSession( id = "12345678-aaaa-bbbb-cccc-dddddddddddd", startedAt = 0L, name = " morning/09:00\\report..pdf? ", destination = Destination.SAWAI, ) + val labels = HistoryExportTextFormatter.labels(AppLanguage.JAPANESE) val startJa = HistoryExportTextFormatter.dateTime(0L, AppLanguage.JAPANESE, utc) .replace("/", "-").replace(":", "").replace(" ", "_") - val japanese = HistoryExportTextFormatter.inspectionFileName(session, AppLanguage.JAPANESE, utc) - val english = HistoryExportTextFormatter.inspectionFileName(session, AppLanguage.ENGLISH, utc) - val noDestination = HistoryExportTextFormatter.inspectionFileName( + val history = HistoryExportTextFormatter.reportFileName(session, AppLanguage.JAPANESE, utc, labels.filePrefix) + val inspection = HistoryExportTextFormatter.reportFileName(session, AppLanguage.JAPANESE, utc, labels.inspectionFilePrefix) + val english = HistoryExportTextFormatter.reportFileName( + session, + AppLanguage.ENGLISH, + utc, + HistoryExportTextFormatter.labels(AppLanguage.ENGLISH).filePrefix, + ) + val noDestination = HistoryExportTextFormatter.reportFileName( MatchSession(startedAt = 0L, name = "morning"), AppLanguage.JAPANESE, utc, + labels.inspectionFilePrefix, ) - assertEquals("検品レポート_澤井製作所_$startJa.pdf", japanese) - assertTrue(english, english.startsWith("InspectionReport_Sawai_Seisakusho_")) - assertFalse(japanese.contains("morning")) - assertFalse(japanese.contains('/')) + assertEquals("照合履歴レポート_澤井製作所_$startJa.pdf", history) + assertEquals("検品レポート_澤井製作所_$startJa.pdf", inspection) + assertTrue(english, english.startsWith("MatchHistoryReport_Sawai_Seisakusho_")) assertEquals("検品レポート_$startJa.pdf", noDestination) - assertFalse(noDestination.contains("morning")) + listOf(history, inspection, english, noDestination).forEach { name -> + assertFalse(name, name.contains("morning")) + assertFalse(name, name.contains('/')) + assertFalse(name, name.contains(':')) + assertFalse(name, name.contains(' ')) + assertFalse(name, name.contains("..")) + } } @Test diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index 6b921ec..7ee408a 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -71,7 +71,7 @@ QRの入力は、カメラ・Bluetoothとも伝送終端(CR/LF/NUL)だけを - 並び順は品番の昇順、同じ品番の中は澤井製作所が枝番、モルテンが納品番号の昇順で、読取順は選べません。1箱あたりの数量は同じ行の全箱で一致するときだけ出し、数量計は全箱に数量があるときだけ出します(それ以外は「-」)。 - 仕向地として解析できないQRの箱(旧履歴など)は、記録済みの品番をキーにした行として末尾に残します。行の箱数の合計は常にセッションの検査箱数と一致します。 - 品名・不足数・完了判定は出しません。検品表との差異の判断は操作者が行います。 -- ファイル名は `検品レポート_<仕向地>_<開始日時>.pdf`(例 `検品レポート_澤井製作所_2026-09-07_2317.pdf`)で、セッション名は使いません。仕向地のない旧履歴では仕向地を省きます。 +- ファイル名は `検品レポート_<仕向地>_<開始日時>.pdf`(例 `検品レポート_澤井製作所_2026-09-07_2317.pdf`)で、セッション名は使いません。仕向地のない旧履歴では仕向地を省きます。照合履歴レポートも同じ規則で `照合履歴レポート_<仕向地>_<開始日時>.pdf` になります。 ### レポートのメール共有 diff --git a/ios/CodeMatch/Services/InspectionPDFExporter.swift b/ios/CodeMatch/Services/InspectionPDFExporter.swift index 5e576d4..7cb90b4 100644 --- a/ios/CodeMatch/Services/InspectionPDFExporter.swift +++ b/ios/CodeMatch/Services/InspectionPDFExporter.swift @@ -12,12 +12,7 @@ enum InspectionPDFExporter { /// `検品レポート_<仕向地>_<開始日時>.pdf`。セッション名ではなく仕向地と開始日時で並ぶようにする。 static func fileName(for session: MatchSession, locale: Locale) -> String { - var parts = [AppLocalization.string("検品レポート")] - if let destination = session.resolvedDestination { - parts.append(SessionPDFExporter.sanitizedFileNamePart(destination.displayName)) - } - parts.append(SessionPDFExporter.sanitizedFileNamePart(AppLanguage(locale).formatDateTime(session.startedAt))) - return parts.joined(separator: "_") + ".pdf" + SessionPDFExporter.reportFileName(prefix: AppLocalization.string("検品レポート"), session: session, locale: locale) } /// ページ番号 `n / N` を全ページに置くため2回描画する。1回目でページ数を数え、2回目で総数を印字する。 diff --git a/ios/CodeMatch/Services/SessionPDFExporter.swift b/ios/CodeMatch/Services/SessionPDFExporter.swift index e92bd54..d2597b7 100644 --- a/ios/CodeMatch/Services/SessionPDFExporter.swift +++ b/ios/CodeMatch/Services/SessionPDFExporter.swift @@ -7,17 +7,19 @@ enum SessionPDFExporter { private static let pageSize = CGSize(width: 595.2, height: 841.8) // A4 @72dpi private static let margin: CGFloat = 44 + /// `照合履歴レポート_<仕向地>_<開始日時>.pdf`。検品レポートと同じ規則で並ぶようにする。 static func fileName(for session: MatchSession, locale: Locale) -> String { - "\(AppLocalization.string("照合履歴"))_\(sanitizedStem(for: session, locale: locale)).pdf" + reportFileName(prefix: AppLocalization.string("照合履歴レポート"), session: session, locale: locale) } - /// 表示名(未設定なら開始日時)をファイル名向けに整えた語幹。 - static func sanitizedStem(for session: MatchSession, locale: Locale) -> String { - let appLanguage = AppLanguage(locale) - let base = session.displayName.isEmpty - ? appLanguage.formatDateTime(session.startedAt) - : session.displayName - return sanitizedFileNamePart(base) + /// `_<仕向地>_<開始日時>.pdf`。セッション名は使わず、仕向地のない旧履歴では仕向地を省く。 + static func reportFileName(prefix: String, session: MatchSession, locale: Locale) -> String { + var parts = [prefix] + if let destination = session.resolvedDestination { + parts.append(sanitizedFileNamePart(destination.displayName)) + } + parts.append(sanitizedFileNamePart(AppLanguage(locale).formatDateTime(session.startedAt))) + return parts.joined(separator: "_") + ".pdf" } /// ファイル名の1区画向けに `/`・`:`・空白を置き換える。 diff --git a/ios/CodeMatchTests/InspectionPDFExporterTests.swift b/ios/CodeMatchTests/InspectionPDFExporterTests.swift index b4ffd86..914547e 100644 --- a/ios/CodeMatchTests/InspectionPDFExporterTests.swift +++ b/ios/CodeMatchTests/InspectionPDFExporterTests.swift @@ -143,9 +143,9 @@ final class InspectionPDFExporterTests: XCTestCase { XCTAssertEqual(inspection, "検品レポート_澤井製作所_\(start).pdf") XCTAssertEqual(noDestination, "検品レポート_\(start).pdf") + XCTAssertEqual(history, "照合履歴レポート_澤井製作所_\(start).pdf") XCTAssertFalse(inspection.contains("morning")) XCTAssertFalse(inspection.contains("/")) - XCTAssertTrue(history.hasPrefix("照合履歴_morning"), "照合履歴のファイル名は従来どおりセッション名") } // MARK: - Helpers diff --git a/ios/CodeMatchTests/ReportMailContentTests.swift b/ios/CodeMatchTests/ReportMailContentTests.swift index 80f9916..91c3b06 100644 --- a/ios/CodeMatchTests/ReportMailContentTests.swift +++ b/ios/CodeMatchTests/ReportMailContentTests.swift @@ -57,7 +57,7 @@ final class ReportMailContentTests: XCTestCase { ) let start = AppLanguage(locale).formatDateTime(startedAt) - let mail = ReportMailContent.make(session: session, kind: .matchHistory, fileName: "照合履歴_x.pdf", locale: locale) + let mail = ReportMailContent.make(session: session, kind: .matchHistory, fileName: "照合履歴レポート_モルテン_x.pdf", locale: locale) XCTAssertEqual(mail.subject, "照合履歴レポート \(start) - モルテン") XCTAssertTrue(mail.body.hasPrefix("照合履歴レポートをお送りします。\n\n■ セッション\n")) @@ -65,7 +65,7 @@ final class ReportMailContentTests: XCTestCase { XCTAssertTrue(mail.body.contains("検査箱数: 1箱")) XCTAssertTrue(mail.body.contains("品番数: 1")) XCTAssertTrue(mail.body.contains("納品番号数: 1")) - XCTAssertTrue(mail.body.hasSuffix("■ 添付\n照合履歴_x.pdf")) + XCTAssertTrue(mail.body.hasSuffix("■ 添付\n照合履歴レポート_モルテン_x.pdf")) XCTAssertFalse(mail.body.contains("セッション名")) } } From 26d96bb7aee3d666792d2cee2ec1f87fb38b10b4 Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 02:09:50 +0900 Subject: [PATCH 17/18] chore(ios): bump build number to 10 for TestFlight (App Store Connect already has 9) --- ios/CodeMatch.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/CodeMatch.xcodeproj/project.pbxproj b/ios/CodeMatch.xcodeproj/project.pbxproj index 54ce7ff..7196dec 100644 --- a/ios/CodeMatch.xcodeproj/project.pbxproj +++ b/ios/CodeMatch.xcodeproj/project.pbxproj @@ -535,7 +535,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = NHP8639NK4; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = CodeMatch/Resources/Info.plist; @@ -564,7 +564,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = NHP8639NK4; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = CodeMatch/Resources/Info.plist; From 4d412d61d67533ab8a8168ced0c54a07a10f7b30 Mon Sep 17 00:00:00 2001 From: rimtty Date: Fri, 11 Sep 2026 02:29:53 +0900 Subject: [PATCH 18/18] test(android): scroll the session detail list for the plural box-count rows on short screens --- .../feature/history/HistoryScreenTest.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 e322c35..9a135c8 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 @@ -237,13 +237,21 @@ class HistoryScreenTest { HistorySessionDetail(session = session, language = language.value) } - composeRule.onNodeWithText("1 box").performScrollTo().assertIsDisplayed() - composeRule.onNodeWithText("2 boxes").performScrollTo().assertIsDisplayed() + // The group rows sit below two PDF action rows in a LazyColumn, so on a + // short emulator screen they are not composed until the list scrolls to + // them; scroll the detail list itself rather than the (absent) node. + val detail = composeRule.onNodeWithTag(HistoryTestTags.SESSION_DETAIL) + detail.performScrollToNode(hasText("1 box")) + composeRule.onNodeWithText("1 box").assertIsDisplayed() + detail.performScrollToNode(hasText("2 boxes")) + composeRule.onNodeWithText("2 boxes").assertIsDisplayed() composeRule.runOnIdle { language.value = AppLanguage.JAPANESE } - composeRule.onNodeWithText("1箱").performScrollTo().assertIsDisplayed() - composeRule.onNodeWithText("2箱").performScrollTo().assertIsDisplayed() + detail.performScrollToNode(hasText("1箱")) + composeRule.onNodeWithText("1箱").assertIsDisplayed() + detail.performScrollToNode(hasText("2箱")) + composeRule.onNodeWithText("2箱").assertIsDisplayed() composeRule.onAllNodesWithText("1 box").assertCountEquals(0) composeRule.onAllNodesWithText("2 boxes").assertCountEquals(0) }