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" />
+
+
+
+
+
+
+
+
= 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 +159,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)
@@ -187,6 +192,59 @@ internal object HistoryPdfBridge {
HistoryPdfResult.Failure(HistoryPdfFailure.FILE_PROVIDER_FAILED)
}
+ /**
+ * The e-mail hand-off for a report: a mail app opens with the recipient,
+ * subject, body and the PDF attached, and the operator only has to send.
+ * When no mail app can take the intent the plain share chooser is used so
+ * the PDF can still leave the device.
+ */
+ fun createMailOrShareChooser(context: Context, file: File, mail: ReportMail): HistoryPdfResult =
+ 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 441f03d..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
@@ -28,6 +28,8 @@ 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.export.ReportMailContent
import jp.rimtty.codematch.core.model.AppLanguage
import jp.rimtty.codematch.core.model.MatchSession
import jp.rimtty.codematch.feature.history.HistoryContent
@@ -65,6 +67,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 +80,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 +98,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 +119,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 +128,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 +181,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 +218,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 +293,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 +379,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,12 +393,19 @@ 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)) {
- is HistoryPdfResult.Success -> HistoryPdfBridge.createShareChooser(context, cacheResult.value)
+ val result = when (
+ val cacheResult = HistoryPdfBridge.writeShareCache(context, session, language, kind = kind)
+ ) {
+ 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 652009c..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
@@ -10,6 +10,9 @@ 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.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
@@ -52,6 +55,23 @@ 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)
+
+ assertTrue(fileName, fileName.startsWith("InspectionReport_") && fileName.endsWith(".pdf"))
+ assertFalse(fileName.contains("morning"))
+ assertEquals(HistoryPdfBridge.PDF_MIME_TYPE, document.mimeType)
+ }
+
@Test(expected = IllegalArgumentException::class)
fun pendingDocumentRejectsPathTraversal() {
PendingHistoryPdf(
@@ -245,6 +265,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/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/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/HistoryExportText.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryExportText.kt
index 692b109..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
@@ -66,6 +66,27 @@ 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,
+ /** Pre-filled report e-mail: intro per report and headings. */
+ val mailIntroInspection: String,
+ val mailIntroHistory: String,
+ val mailSessionHeading: String,
+ val mailAttachmentHeading: String,
/** Singular and plural units are kept separately for natural English. */
val boxCountSingular: String = boxCount,
val boxCountPlural: String = boxCount,
@@ -100,7 +121,7 @@ object HistoryExportTextFormatter {
fun labels(language: AppLanguage): HistoryExportLabels = when (language) {
AppLanguage.JAPANESE -> HistoryExportLabels(
reportTitle = "照合履歴レポート",
- filePrefix = "照合履歴",
+ filePrefix = "照合履歴レポート",
sessionName = "セッション名",
start = "開始",
end = "終了",
@@ -153,13 +174,33 @@ object HistoryExportTextFormatter {
deliveryRun = "便",
densoItemNumber = "アイテムNo",
receivingCode = "受入",
+ inspectionTitle = "検品レポート",
+ inspectionFilePrefix = "検品レポート",
+ inspectionPartCount = "品番数",
+ inspectionPartCountBySuffix = "品番数(枝番別)",
+ columnNumber = "No",
+ columnPartNumber = "品番",
+ columnDeliveryNumber = "納品番号",
+ columnDeliveryDestination = "納入先",
+ columnBoxes = "箱数",
+ columnDeliveryQuantityPerBox = "納入数量/箱",
+ columnPackQuantityPerBox = "収容数/箱",
+ columnTotalQuantity = "数量計",
+ columnCumulativeQuantity = "累計",
+ columnCheck = "確認",
+ inspectionFooterNote =
+ "検品表にあってこの一覧にない品番は、このセッションで照合されていません。",
+ mailIntroInspection = "検品レポートをお送りします。",
+ mailIntroHistory = "照合履歴レポートをお送りします。",
+ mailSessionHeading = "■ セッション",
+ mailAttachmentHeading = "■ 添付",
boxCountSingular = "箱",
boxCountPlural = "箱",
)
AppLanguage.ENGLISH -> HistoryExportLabels(
reportTitle = "Match History Report",
- filePrefix = "MatchHistory",
+ filePrefix = "MatchHistoryReport",
sessionName = "Session name",
start = "Start",
end = "End",
@@ -212,6 +253,26 @@ 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.",
+ mailIntroInspection = "Please find the inspection report attached.",
+ mailIntroHistory = "Please find the match history report attached.",
+ mailSessionHeading = "Session",
+ mailAttachmentHeading = "Attachment",
boxCountSingular = "box",
boxCountPlural = "boxes",
)
@@ -269,19 +330,28 @@ 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(
+ fun reportFileName(
session: MatchSession,
language: AppLanguage,
zoneId: ZoneId = ZoneId.systemDefault(),
+ prefix: String,
): String {
val labels = labels(language)
- val source = session.displayName.ifBlank {
- dateTime(session.startedAt, language, zoneId)
- }
+ val destination = session.resolvedDestination()
+ 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 {
val safe = buildString(source.length) {
source.forEach { character ->
when {
@@ -296,8 +366,7 @@ object HistoryExportTextFormatter {
}.trim('_', '.', ' ')
.replace("..", "_")
.ifBlank { "session_${session.id.take(8)}" }
-
- return "${labels.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 3526062..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
@@ -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,15 @@ object HistoryPdfExporter {
session: MatchSession,
language: AppLanguage = AppLanguage.JAPANESE,
zoneId: ZoneId = ZoneId.systemDefault(),
- ): String = HistoryExportTextFormatter.fileName(session, language, zoneId)
+ kind: HistoryReportKind = HistoryReportKind.MATCH_HISTORY,
+ ): 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. */
const val CACHE_DIRECTORY: String = "codematch-pdf"
@@ -112,21 +71,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 +361,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
}
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/InspectionReportContent.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/InspectionReportContent.kt
new file mode 100644
index 0000000..cb1a6e8
--- /dev/null
+++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/InspectionReportContent.kt
@@ -0,0 +1,144 @@
+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 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
+ * 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,
+ )
+ }
+
+ // 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.partNumber, 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/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/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..64409f7
--- /dev/null
+++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ReportMailContent.kt
@@ -0,0 +1,107 @@
+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 = "takemoto1075@icloud.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()
+ // `検品レポート 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(title).append(' ')
+ append(HistoryExportTextFormatter.dateTime(session.startedAt, language, zoneId))
+ if (destination != null) append(" - ").append(labels.destinationName(destination))
+ }
+
+ val lines = mutableListOf()
+ 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
+
+ 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/HistoryExportTextTest.kt b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryExportTextTest.kt
index 0d74a16..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,34 +13,43 @@ class HistoryExportTextTest {
private val utc = ZoneId.of("UTC")
@Test
- fun fileNameKeepsDisplayNameButRemovesPathAndReservedCharacters() {
+ 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 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)
+ 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,
+ )
- assertTrue(japanese.startsWith("照合履歴_"))
- assertTrue(english.startsWith("MatchHistory_"))
- assertTrue(japanese.endsWith(".pdf"))
- assertTrue(english.endsWith(".pdf"))
- assertFalse(japanese.contains('/'))
- assertFalse(english.contains('/'))
+ assertEquals("照合履歴レポート_澤井製作所_$startJa.pdf", history)
+ assertEquals("検品レポート_澤井製作所_$startJa.pdf", inspection)
+ assertTrue(english, english.startsWith("MatchHistoryReport_Sawai_Seisakusho_"))
+ assertEquals("検品レポート_$startJa.pdf", noDestination)
+ 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/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 }
+}
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..5c678c3
--- /dev/null
+++ b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/InspectionReportContentTest.kt
@@ -0,0 +1,201 @@
+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"
+ // 納品番号 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"
+
+ @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 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(
+ 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,
+ )
+}
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..d54355a
--- /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("takemoto1075@icloud.com", ReportMailContent.RECIPIENT)
+ assertEquals(listOf("takemoto1075@icloud.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,
+ )
+
+ val start = HistoryExportTextFormatter.dateTime(1_700_000_000_000L, AppLanguage.JAPANESE, utc)
+ assertEquals("検品レポート $start - 澤井製作所", mail.subject)
+ assertFalse(mail.subject.contains("朝便"))
+ val expectedBody = listOf(
+ "検品レポートをお送りします。",
+ "",
+ "■ セッション",
+ "セッション名: 朝便",
+ "開始: ${HistoryExportTextFormatter.dateTime(1_700_000_000_000L, AppLanguage.JAPANESE, utc)}",
+ "終了: ${HistoryExportTextFormatter.dateTime(1_700_000_120_000L, AppLanguage.JAPANESE, utc)}",
+ "仕向地: 澤井製作所",
+ "検査箱数: 2箱",
+ "品番数(枝番別): 1",
+ "",
+ "■ 添付",
+ "検品レポート_朝便.pdf",
+ ).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("Match History Report $start - Molten", mail.subject)
+ 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"))
+ assertTrue(mail.body.contains("Delivery numbers: 1"))
+ assertTrue(mail.body.endsWith("Attachment\nMatchHistory_x.pdf"))
+ assertFalse(mail.body.contains("CodeMatch."))
+ 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,
+ )
+}
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..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)
}
@@ -446,6 +454,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で保存
共有する
+ 検品レポート
+ 照合履歴レポート
一致したコード
一致履歴はありません
このセッションではまだ一致したコードがありません。
diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md
index 26c9e41..7ee408a 100644
--- a/docs/PRODUCT_SPEC.md
+++ b/docs/PRODUCT_SPEC.md
@@ -60,6 +60,30 @@ 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の箱(旧履歴など)は、記録済みの品番をキーにした行として末尾に残します。行の箱数の合計は常にセッションの検査箱数と一致します。
+- 品名・不足数・完了判定は出しません。検品表との差異の判断は操作者が行います。
+- ファイル名は `検品レポート_<仕向地>_<開始日時>.pdf`(例 `検品レポート_澤井製作所_2026-09-07_2317.pdf`)で、セッション名は使いません。仕向地のない旧履歴では仕向地を省きます。照合履歴レポートも同じ規則で `照合履歴レポート_<仕向地>_<開始日時>.pdf` になります。
+
+### レポートのメール共有
+
+セッション詳細の「共有する」(検品レポート・照合履歴レポートの両方)は、共有先を選ぶ画面ではなく、宛先・件名・本文を埋めて PDF を添付したメール作成画面を開きます。送信は操作者がメールアプリで行い、アプリ自身は通信しません。
+
+- 宛先: 当面は `takemoto1075@icloud.com` に固定(両OSとも1か所の定数)。
+- 件名: `<レポート名> <開始日時> - <仕向地>`(例 `検品レポート 2026/09/07 23:17 - 澤井製作所`)。
+- 本文: 「検品レポートをお送りします。」の一文、セッション(セッション名・開始・終了または状態・仕向地・検査箱数・行数または品番数と納品番号数)、添付ファイル名の順。読取値の全文は載せません。
+- 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 1029b23..26b9c8c 100644
--- a/docs/android/STATUS.md
+++ b/docs/android/STATUS.md
@@ -60,6 +60,19 @@ 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`)。
+
+- `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..c3f4116 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` + `::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 |
+| 見出し(検査箱数、品番数(枝番別)/納品番号数/品番数)、列名、セルの値、脚注、日英 | `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..b1e23dd 100644
--- a/docs/ios/IMPLEMENTATION_GUIDE.md
+++ b/docs/ios/IMPLEMENTATION_GUIDE.md
@@ -111,6 +111,8 @@ Console.appで端末を選び、検索欄に `subsystem:jp.rimtty.CodeMatch` を
- [ ] デンソーの履歴詳細に帳票区分・部品番号・包装・収容数・次区・指示・かんばん連番・管理番号・納入日・便・指示数・アイテムNo・受入が出る
- [ ] デンソーのPDFに品番ごとのかんばん要約(部品番号・収容数・指示数/次区・指示・納入日・便/管理番号・アイテムNo・受入)と、箱ごとのかんばん連番・管理コードが出る。カード番号と納品番号数は出ない
- [ ] 履歴のJSON書き出しでデンソーのセッションが `"destination": "denso"` になる
+- [ ] セッション詳細に「検品レポート」の保存・共有の行が「照合履歴レポート」の行の上にあり、検品レポートPDFは1品番(澤井製作所は品番+枝番を `BCJH5281GG (02)` のように、モルテンは納品番号、デンソーは `6-4` の品番)が1行で品番順に並び、箱数・数量/箱・数量計・確認欄が出る。複数ページでは表ヘッダーが各ページに繰り返され、右下に「n / N」が付く
+- [ ] セッション詳細の「共有する」(検品レポート・照合履歴レポート)で「メール」の作成画面が開き、宛先 `takemoto1075@icloud.com`・件名 `検品レポート <開始日時> - <仕向地>`・定型本文・添付 `検品レポート_<仕向地>_<開始日時>.pdf` が埋まっている。「メール」にアカウントがない端末では共有シートが開く
- [ ] リセット後に値と結果が残らない(仕向地は同じセッション中は固定されたまま)
- [ ] 自動「次の照合」は初期設定がOFFで、設定画面と照合セッション中の両方からON/OFFできる
- [ ] 自動「次の照合」をONにすると、一致時だけ設定した1秒、3秒、または5秒の残り時間が表示され、0秒後に次のQR読み取りが始まる
diff --git a/ios/CodeMatch.xcodeproj/project.pbxproj b/ios/CodeMatch.xcodeproj/project.pbxproj
index 007683e..7196dec 100644
--- a/ios/CodeMatch.xcodeproj/project.pbxproj
+++ b/ios/CodeMatch.xcodeproj/project.pbxproj
@@ -28,6 +28,9 @@
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 */; };
+ 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 */; };
@@ -40,6 +43,11 @@
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 */; };
+ 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 */; };
@@ -85,6 +93,9 @@
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 = ""; };
+ 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; };
@@ -100,6 +111,11 @@
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 = ""; };
+ 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 */ = {
@@ -187,6 +203,7 @@
children = (
A20000000000000000000003 /* ScanModels.swift */,
A20000000000000000000014 /* HistoryModels.swift */,
+ A20000000000000000000021 /* InspectionReport.swift */,
);
path = Models;
sourceTree = "";
@@ -200,6 +217,10 @@
A2000000000000000000001B /* SessionPDFExporter.swift */,
A2000000000000000000001F /* HistoryExporter.swift */,
A20000000000000000000020 /* ScanLogStore.swift */,
+ A20000000000000000000022 /* PDFPageWriter.swift */,
+ A20000000000000000000023 /* InspectionPDFExporter.swift */,
+ A20000000000000000000024 /* ReportMailContent.swift */,
+ A20000000000000000000025 /* MailComposeView.swift */,
A2000000000000000000001C /* BluetoothScannerService.swift */,
);
path = Services;
@@ -241,6 +262,9 @@
A20000000000000000000107 /* SessionPDFExporterTests.swift */,
A20000000000000000000108 /* HistoryExporterTests.swift */,
A20000000000000000000109 /* ScanLogStoreTests.swift */,
+ A2000000000000000000010A /* InspectionReportTests.swift */,
+ A2000000000000000000010B /* InspectionPDFExporterTests.swift */,
+ A2000000000000000000010C /* ReportMailContentTests.swift */,
);
path = CodeMatchTests;
sourceTree = "";
@@ -452,6 +476,11 @@
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 */,
+ A1000000000000000000001E /* ReportMailContent.swift in Sources */,
+ A1000000000000000000001F /* MailComposeView.swift in Sources */,
A10000000000000000000016 /* BluetoothScannerService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -470,6 +499,9 @@
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 */,
+ A1000000000000000000010C /* ReportMailContentTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -503,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;
@@ -532,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;
diff --git a/ios/CodeMatch/Features/History/HistoryScreen.swift b/ios/CodeMatch/Features/History/HistoryScreen.swift
index b4715c9..a8ed228 100644
--- a/ios/CodeMatch/Features/History/HistoryScreen.swift
+++ b/ios/CodeMatch/Features/History/HistoryScreen.swift
@@ -168,6 +168,16 @@ 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?
+ /// 「共有する」で開くメール作成画面。「メール」が使えない端末では 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()
@@ -230,45 +240,33 @@ 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)
- }
- .buttonStyle(.plain)
- .foregroundStyle(.white)
- .background(AppTheme.green, in: RoundedRectangle(cornerRadius: 12))
- .accessibilityIdentifier("savePDFButton")
-
- Button {
- shareItem = (try? SessionPDFExporter.writeTemporaryPDF(for: session, locale: locale))
- .map { ShareItem(url: $0) }
- } label: {
- Label(
- AppLocalization.string("共有する"),
- systemImage: "square.and.arrow.up"
+ },
+ onShare: { shareReport(.inspection, session: session) }
+ )
+ 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)
)
- .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)
+ showsExporter = true
+ },
+ onShare: { shareReport(.matchHistory, session: session) }
+ )
}
Section(AppLocalization.string("一致したコード")) {
@@ -325,20 +323,57 @@ 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])
.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
@@ -626,3 +661,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)
+ }
+}
diff --git a/ios/CodeMatch/Models/InspectionReport.swift b/ios/CodeMatch/Models/InspectionReport.swift
new file mode 100644
index 0000000..cef563c
--- /dev/null
+++ b/ios/CodeMatch/Models/InspectionReport.swift
@@ -0,0 +1,170 @@
+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:
+ // モルテンの一覧表は品番ごとなので、品番を第1キーにして同じ品番の納品番号を並べる
+ if let record = entry.moltenRecord {
+ parsed.add(
+ key: SortKey(primary: record.partNumber, secondary: record.deliveryNumber),
+ 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..49aa60d 100644
--- a/ios/CodeMatch/Resources/Localizable.xcstrings
+++ b/ios/CodeMatch/Resources/Localizable.xcstrings
@@ -6072,6 +6072,310 @@
}
}
}
+ },
+ "検品レポート" : {
+ "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" : "検品表にあってこの一覧にない品番は、このセッションで照合されていません。"
+ }
+ }
+ }
+ },
+ "■ セッション" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Session"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "■ セッション"
+ }
+ }
+ }
+ },
+ "■ 添付" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Attachment"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "■ 添付"
+ }
+ }
+ }
+ },
+ "検品レポートをお送りします。" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Please find the inspection report attached."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "検品レポートをお送りします。"
+ }
+ }
+ }
+ },
+ "照合履歴レポートをお送りします。" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Please find the match history report attached."
+ }
+ },
+ "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..7cb90b4
--- /dev/null
+++ b/ios/CodeMatch/Services/InspectionPDFExporter.swift
@@ -0,0 +1,231 @@
+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
+
+ /// `検品レポート_<仕向地>_<開始日時>.pdf`。セッション名ではなく仕向地と開始日時で並ぶようにする。
+ static func fileName(for session: MatchSession, locale: Locale) -> String {
+ SessionPDFExporter.reportFileName(prefix: AppLocalization.string("検品レポート"), session: session, locale: locale)
+ }
+
+ /// ページ番号 `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/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/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/ReportMailContent.swift b/ios/CodeMatch/Services/ReportMailContent.swift
new file mode 100644
index 0000000..8741eaf
--- /dev/null
+++ b/ios/CodeMatch/Services/ReportMailContent.swift
@@ -0,0 +1,81 @@
+import Foundation
+
+/// セッション詳細から書き出す2種類のPDF。
+enum ReportKind {
+ case matchHistory
+ case inspection
+}
+
+/// レポートPDFをメールで共有するときの宛先・件名・本文。
+///
+/// 宛先は当面固定で、レポートは操作者自身の受信箱にだけ送る。本文にはレポートの見出し
+/// (セッション、仕向地、箱数・行数)を繰り返し、添付を開かなくても内容が分かるようにする。
+/// 送信そのものは操作者が使うメールアプリに任せる。Android の `ReportMailContent` と同じ規則。
+struct ReportMailContent: Equatable {
+ /// すべてのレポートメールの宛先。
+ static let recipients = ["takemoto1075@icloud.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
+ // 「検品レポート 2026/09/07 23:17 - 澤井製作所」。セッション名ではなく開始日時で並ぶようにする。
+ var subject = "\(title) \(appLanguage.formatDateTime(session.startedAt))"
+ if let destination {
+ subject += " - \(destination.displayName)"
+ }
+
+ var lines: [String] = []
+ switch kind {
+ case .matchHistory: lines.append(AppLocalization.string("照合履歴レポートをお送りします。"))
+ case .inspection: lines.append(AppLocalization.string("検品レポートをお送りします。"))
+ }
+ 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)
+
+ 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/CodeMatch/Services/SessionPDFExporter.swift b/ios/CodeMatch/Services/SessionPDFExporter.swift
index 1da6185..d2597b7 100644
--- a/ios/CodeMatch/Services/SessionPDFExporter.swift
+++ b/ios/CodeMatch/Services/SessionPDFExporter.swift
@@ -7,22 +7,32 @@ 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 {
- let appLanguage = AppLanguage(locale)
- let base = session.displayName.isEmpty
- ? appLanguage.formatDateTime(session.startedAt)
- : session.displayName
- let safe = base
+ reportFileName(prefix: AppLocalization.string("照合履歴レポート"), session: session, locale: locale)
+ }
+
+ /// `_<仕向地>_<開始日時>.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区画向けに `/`・`:`・空白を置き換える。
+ static func sanitizedFileNamePart(_ value: String) -> String {
+ value
.replacingOccurrences(of: "/", with: "-")
.replacingOccurrences(of: ":", with: "")
.replacingOccurrences(of: " ", with: "_")
- return "\(AppLocalization.string("照合履歴"))_\(safe).pdf"
}
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)
@@ -32,70 +42,30 @@ enum SessionPDFExporter {
let gray = UIColor(white: 0.38, alpha: 1)
return renderer.pdfData { context in
- var cursorY: CGFloat = 0
-
- 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()
+ // 描画カーソルは検品レポートと共通の PDFPageWriter。フッター帯は使わない(footerHeight 0)ので
+ // 改ページ位置は従来の閉包実装と同じ。
+ let writer = PDFPageWriter(context: context, pageSize: pageSize, margin: margin, footerHeight: 0)
+ writer.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,
@@ -103,7 +73,7 @@ enum SessionPDFExporter {
)
}
if let destination = session.resolvedDestination {
- draw(
+ writer.draw(
AppLocalization.string("仕向地: \(destination.displayName)"),
font: bodyFont,
color: gray,
@@ -112,7 +82,7 @@ enum SessionPDFExporter {
}
// モルテンは同じ品番でも納品番号ごとに納品書が分かれるため、種類数も添える
let showsDeliveryNumberCount = session.resolvedDestination == .molten
- draw(
+ writer.draw(
AppLocalization.string(
"検査箱数: \(session.matchedCount)箱(品番数: \(session.groupedEntries.count))"
),
@@ -121,17 +91,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 {
@@ -144,23 +114,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("記録なし(旧バージョンで照合)"))"
),
@@ -168,7 +138,7 @@ enum SessionPDFExporter {
color: gray,
spacing: 2
)
- draw(
+ writer.draw(
AppLocalization.string(
"Code 128全文: \(entry.barcodePayload ?? AppLocalization.string("記録なし(旧バージョンで照合)"))"
),
@@ -181,15 +151,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))"
),
@@ -198,7 +168,7 @@ enum SessionPDFExporter {
spacing: 4
)
} else {
- draw(
+ writer.draw(
AppLocalization.string("照合時刻: \(appLanguage.formatDateTime(group.firstMatchedAt))"),
font: bodyFont,
color: gray,
@@ -216,30 +186,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 ?? "-")"
),
@@ -247,12 +217,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
}
@@ -260,24 +230,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 ?? "-")"
),
@@ -285,7 +255,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,
@@ -293,28 +263,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 ?? "-")"
),
@@ -323,14 +293,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),
diff --git a/ios/CodeMatchTests/InspectionPDFExporterTests.swift b/ios/CodeMatchTests/InspectionPDFExporterTests.swift
new file mode 100644
index 0000000..914547e
--- /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)
+ }
+}
diff --git a/ios/CodeMatchTests/ReportMailContentTests.swift b/ios/CodeMatchTests/ReportMailContentTests.swift
new file mode 100644
index 0000000..91c3b06
--- /dev/null
+++ b/ios/CodeMatchTests/ReportMailContentTests.swift
@@ -0,0 +1,71 @@
+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, ["takemoto1075@icloud.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, "検品レポート \(appLanguage.formatDateTime(startedAt)) - 澤井製作所")
+ XCTAssertFalse(mail.subject.contains("朝便"))
+ let expectedBody = [
+ "検品レポートをお送りします。",
+ "",
+ "■ セッション",
+ "セッション名: 朝便",
+ "開始: \(appLanguage.formatDateTime(startedAt))",
+ "終了: \(appLanguage.formatDateTime(startedAt.addingTimeInterval(120)))",
+ "仕向地: 澤井製作所",
+ "検査箱数: 2箱",
+ "品番数(枝番別): 1",
+ "",
+ "■ 添付",
+ "検品レポート_朝便.pdf"
+ ].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, "照合履歴レポート \(start) - モルテン")
+ XCTAssertTrue(mail.body.hasPrefix("照合履歴レポートをお送りします。\n\n■ セッション\n"))
+ XCTAssertTrue(mail.body.contains("状態: 照合中"))
+ XCTAssertTrue(mail.body.contains("検査箱数: 1箱"))
+ XCTAssertTrue(mail.body.contains("品番数: 1"))
+ XCTAssertTrue(mail.body.contains("納品番号数: 1"))
+ XCTAssertTrue(mail.body.hasSuffix("■ 添付\n照合履歴レポート_モルテン_x.pdf"))
+ XCTAssertFalse(mail.body.contains("セッション名"))
+ }
+}