Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package jp.rimtty.codematch.core.export

import jp.rimtty.codematch.core.matching.CodeMatcher
import jp.rimtty.codematch.core.matching.DensoKanbanQrRecord
import jp.rimtty.codematch.core.matching.MoltenQrRecord
import jp.rimtty.codematch.core.model.Destination
import jp.rimtty.codematch.core.model.MatchEntry
Expand Down Expand Up @@ -102,4 +103,16 @@ internal fun MatchEntry.moltenRecord(): MoltenQrRecord? {
return MoltenQrRecord.parse(payload)
}

/**
* Parses an entry's QR payload as a Denso kanban.
* The destination is detected first because [jp.rimtty.codematch.core.matching.KanbanQrRecord]
* parses leniently: a `JAMA...` payload satisfies its card-number rule, so a
* Denso kanban would otherwise be rendered as a Sawai slip.
*/
internal fun MatchEntry.densoRecord(): DensoKanbanQrRecord? {
val payload = qrPayload ?: return null
if (CodeMatcher.detectDestination(payload) != Destination.DENSO) return null
return DensoKanbanQrRecord.parse(payload)
}

private val FOUR_DIGITS = Regex("[0-9]{4}")
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ data class HistoryExportLabels(
val instructionTime: String,
val cumulativeQuantity: String,
val pieceUnit: String,
val formType: String,
val packagingCode: String,
val nextProcess: String,
val instructionCode: String,
val kanbanSerial: String,
val managementNumber: String,
val deliveryDate: String,
val deliveryRun: String,
/** Denso item number; [itemNumber] is the Sawai slip's 品目番号. */
val densoItemNumber: String,
val receivingCode: String,
/** Singular and plural units are kept separately for natural English. */
val boxCountSingular: String = boxCount,
val boxCountPlural: String = boxCount,
Expand Down Expand Up @@ -132,6 +143,16 @@ object HistoryExportTextFormatter {
instructionTime = "時刻",
cumulativeQuantity = "累計",
pieceUnit = "個",
formType = "帳票区分",
packagingCode = "包装",
nextProcess = "次区",
instructionCode = "指示",
kanbanSerial = "かんばん連番",
managementNumber = "管理番号",
deliveryDate = "納入日",
deliveryRun = "便",
densoItemNumber = "アイテムNo",
receivingCode = "受入",
boxCountSingular = "箱",
boxCountPlural = "箱",
)
Expand Down Expand Up @@ -181,6 +202,16 @@ object HistoryExportTextFormatter {
instructionTime = "Time",
cumulativeQuantity = "Total",
pieceUnit = "pcs",
formType = "Form type",
packagingCode = "Packaging",
nextProcess = "Next process",
instructionCode = "Instruction",
kanbanSerial = "Kanban serial",
managementNumber = "Management number",
deliveryDate = "Delivery date",
deliveryRun = "Delivery run",
densoItemNumber = "Item No.",
receivingCode = "Receiving",
boxCountSingular = "box",
boxCountPlural = "boxes",
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,17 +148,95 @@ object HistoryPdfContent {
.mapNotNull { it.qrPayload }
.firstOrNull()
?.let(CodeMatcher::detectDestination)
if (destination == Destination.MOLTEN) {
appendMoltenDeliveries(blocks, group, language, zoneId, labels)
} else {
appendSawaiDelivery(blocks, group, language, labels)
blocks += HistoryPdfBlock(labels.boxRecords, PdfTextStyle.SECTION, spacingAfter = 2f)
group.entries.forEachIndexed { boxIndex, entry ->
appendBoxRecord(blocks, entry, boxIndex + 1, null, language, zoneId, labels)
when (destination) {
Destination.MOLTEN -> appendMoltenDeliveries(blocks, group, language, zoneId, labels)
// KanbanQrRecord.parse is lenient enough to accept a Denso payload,
// so the destination - not the parse result - picks the branch.
Destination.DENSO -> appendDensoKanban(blocks, group, language, zoneId, labels)
else -> {
appendSawaiDelivery(blocks, group, language, labels)
blocks += HistoryPdfBlock(labels.boxRecords, PdfTextStyle.SECTION, spacingAfter = 2f)
group.entries.forEachIndexed { boxIndex, entry ->
appendBoxRecord(blocks, entry, boxIndex + 1, null, null, language, zoneId, labels)
}
}
}
}

/**
* The kanban block of one Denso part number, then every box of it.
*
* A Denso kanban repeats the same part fields on every box and differs only
* in the kanban serial, so the shared fields are printed once from the first
* box and the serial travels with each box record. Boxes are counted per
* part number, exactly like Sawai.
*/
private fun appendDensoKanban(
blocks: MutableList<HistoryPdfBlock>,
group: GroupedMatchEntry,
language: AppLanguage,
zoneId: ZoneId,
labels: HistoryExportLabels,
) {
val record = group.entries.asSequence()
.mapNotNull { it.densoRecord() }
.firstOrNull()
if (record != null) {
blocks += HistoryPdfBlock(labels.deliveryInformation, PdfTextStyle.SECTION, spacingAfter = 2f)
blocks += HistoryPdfBlock(
text = segments(
"${labels.moltenPartNumber}: " +
CodeMatcher.formatPartNumber(record.partNumber, Destination.DENSO),
"${labels.packQuantity}: " +
HistoryExportTextFormatter.integer(record.packQuantity, language),
record.instructedQuantity?.let {
"${labels.instructedQuantity}: " +
HistoryExportTextFormatter.integer(it, language)
},
),
style = PdfTextStyle.BODY,
spacingAfter = 2f,
)
blocks += HistoryPdfBlock(
text = segments(
record.nextProcess?.let { "${labels.nextProcess}: $it" },
record.instructionCode?.let { "${labels.instructionCode}: $it" },
record.formattedDeliveryDate?.let { "${labels.deliveryDate}: $it" },
record.deliveryRun?.let { "${labels.deliveryRun}: $it" },
),
style = PdfTextStyle.BODY,
spacingAfter = 2f,
)
blocks += HistoryPdfBlock(
text = segments(
record.managementNumber?.let { "${labels.managementNumber}: $it" },
record.itemNumber?.let { "${labels.densoItemNumber}: $it" },
record.receivingCode?.let { "${labels.receivingCode}: $it" },
),
style = PdfTextStyle.BODY,
spacingAfter = 4f,
)
}

blocks += HistoryPdfBlock(labels.boxRecords, PdfTextStyle.SECTION, spacingAfter = 2f)
group.entries.forEachIndexed { boxIndex, entry ->
appendBoxRecord(
blocks,
entry,
boxIndex + 1,
null,
entry.densoRecord()?.kanbanSerial,
language,
zoneId,
labels,
)
}
}

/** Joins the present segments with the report's `; ` convention. */
private fun segments(vararg values: String?): String =
values.filterNotNull().joinToString("; ")

private fun appendSawaiDelivery(
blocks: MutableList<HistoryPdfBlock>,
group: GroupedMatchEntry,
Expand All @@ -173,7 +251,8 @@ object HistoryPdfContent {
blocks += HistoryPdfBlock(labels.deliveryInformation, PdfTextStyle.SECTION, spacingAfter = 2f)
val suffix = qr.partSuffix?.let { " (${labels.suffix} $it)" }.orEmpty()
blocks += HistoryPdfBlock(
text = "${labels.itemNumber}: ${CodeMatcher.formatPartNumber(qr.partNumber)}$suffix; " +
text = "${labels.itemNumber}: " +
"${CodeMatcher.formatPartNumber(qr.partNumber, Destination.SAWAI)}$suffix; " +
"${labels.cardNumber}: ${qr.cardNumber}",
style = PdfTextStyle.BODY,
spacingAfter = 2f,
Expand Down Expand Up @@ -218,7 +297,8 @@ object HistoryPdfContent {
spacingAfter = 2f,
)
blocks += HistoryPdfBlock(
text = "${labels.moltenPartNumber}: ${CodeMatcher.formatPartNumber(record.partNumber)}; " +
text = "${labels.moltenPartNumber}: " +
"${CodeMatcher.formatPartNumber(record.partNumber, Destination.MOLTEN)}; " +
"${labels.ordererCode}: ${record.ordererCode}",
style = PdfTextStyle.BODY,
spacingAfter = 2f,
Expand All @@ -242,6 +322,7 @@ object HistoryPdfContent {
entry,
boxIndex + 1,
entry.moltenRecord()?.packQuantity,
null,
language,
zoneId,
labels,
Expand All @@ -258,21 +339,23 @@ object HistoryPdfContent {
if (remaining.isNotEmpty()) {
blocks += HistoryPdfBlock(labels.boxRecords, PdfTextStyle.SECTION, spacingAfter = 2f)
remaining.forEachIndexed { boxIndex, entry ->
appendBoxRecord(blocks, entry, boxIndex + 1, null, language, zoneId, labels)
appendBoxRecord(blocks, entry, boxIndex + 1, null, null, language, zoneId, labels)
}
}
}

/**
* One box: its match time, optional pack quantity, management code, and
* both raw payloads. [packQuantity] is null for a Sawai box, whose slip
* carries the quantity in the delivery block instead.
* One box: its match time, optional pack quantity, optional kanban serial,
* management code, and both raw payloads. [packQuantity] is null for a
* Sawai box, whose slip carries the quantity in the delivery block instead,
* and [kanbanSerial] is set only for Denso, where it identifies the box.
*/
private fun appendBoxRecord(
blocks: MutableList<HistoryPdfBlock>,
entry: MatchEntry,
boxIndex: Int,
packQuantity: Int?,
kanbanSerial: String?,
language: AppLanguage,
zoneId: ZoneId,
labels: HistoryExportLabels,
Expand All @@ -285,10 +368,12 @@ object HistoryPdfContent {
val quantity = packQuantity
?.let { "${labels.packQuantity}: ${HistoryExportTextFormatter.integer(it, language)}; " }
.orEmpty()
val serial = kanbanSerial?.let { "${labels.kanbanSerial}: $it; " }.orEmpty()
blocks += HistoryPdfBlock(
text = "${boxLabel(boxNumber, language, labels)} ${labels.matchTime}: " +
"${HistoryExportTextFormatter.dateTime(entry.matchedAt, language, zoneId)}; " +
quantity +
serial +
"${labels.managementCode}: $managementCode",
style = PdfTextStyle.BODY,
spacingAfter = 2f,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ class HistoryDeliveryGroupsTest {
"AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000"
private val sawaiQr =
"DCLP675300BCJH5281GG020000120000001200L000000000000BLBDILLU92 0*"
// A real デンソー kanban; the runs of spaces are blank fields, not padding.
private val densoQr = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6"

@Test
fun deliveryGroupsKeepFirstSeenOrderAndSumPackQuantities() {
Expand Down Expand Up @@ -50,6 +52,29 @@ class HistoryDeliveryGroupsTest {
assertTrue(entries.moltenDeliveryGroups().isEmpty())
}

@Test
fun densoEntriesProduceNoDeliveryGroups() {
assertEquals(221, densoQr.length)
val denso = moltenEntry("denso", densoQr, "860150-7722@1DZ50O")
val sawai = moltenEntry("sawai", sawaiQr, "BCJH-52-81GG@1N5X0C")
val molten = moltenEntry("molten", moltenQr2, "PAF1-15-422@0NKD3C")

// Delivery numbers are a Molten concept only.
assertTrue(listOf(denso).moltenDeliveryGroups().isEmpty())
assertEquals(Destination.DENSO, MatchSession(entries = listOf(denso)).resolvedDestination())

val record = denso.densoRecord()
assertEquals("8601507722", record?.partNumber)
assertEquals("0140", record?.kanbanSerial)
assertEquals(24, record?.packQuantity)
assertEquals(72, record?.instructedQuantity)
assertEquals("2026/09/08", record?.formattedDeliveryDate)
// A Sawai or Molten entry is never read at Denso field positions.
assertNull(sawai.densoRecord())
assertNull(molten.densoRecord())
assertNull(MatchEntry(id = "legacy", code = "860150-7722").densoRecord())
}

@Test
fun resolvedDestinationPrefersTheStoredValueThenFallsBackToTheEntries() {
val legacySawai = MatchSession(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,11 @@ class HistoryExportTextTest {
assertEquals("仕向地", japanese.destination)
assertEquals("澤井製作所", japanese.destinationName(Destination.SAWAI))
assertEquals("モルテン", japanese.destinationName(Destination.MOLTEN))
assertEquals("デンソー", japanese.destinationName(Destination.DENSO))
assertEquals("Ship-to", english.destination)
assertEquals("Sawai Seisakusho", english.destinationName(Destination.SAWAI))
assertEquals("Molten", english.destinationName(Destination.MOLTEN))
assertEquals("Denso", english.destinationName(Destination.DENSO))
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,38 @@ class HistoryJsonExporterTest {
),
)

private val densoSession = MatchSession(
id = "session-denso",
startedAt = Instant.parse("2026-09-07T02:00:00Z").toEpochMilli(),
endedAt = Instant.parse("2026-09-07T02:30:00Z").toEpochMilli(),
name = "デンソー 午後",
destination = Destination.DENSO,
entries = listOf(
MatchEntry(
id = "entry-denso-1",
code = "860150-7722",
matchedAt = Instant.parse("2026-09-07T02:05:00Z").toEpochMilli(),
qrPayload = DENSO_KANBAN_QR,
barcodePayload = "860150-7722@1DZ50O",
sequence = 0L,
),
),
)

@Test
fun densoSessionExportsItsDestinationIdAndKanbanPayloadByteForByte() {
val session = export(listOf(densoSession))["sessions"].asJsonArray[0].asJsonObject

assertEquals("session-denso", session["id"].asString)
assertEquals("denso", session["destination"].asString)
val entry = session["entries"].asJsonArray[0].asJsonObject
assertEquals("860150-7722", entry["code"].asString)
assertEquals("860150-7722@1DZ50O", entry["barcodePayload"].asString)
// The blank fixed-width fields inside the kanban are data.
assertEquals(DENSO_KANBAN_QR, entry["qrPayload"].asString)
assertEquals(221, entry["qrPayload"].asString.length)
}

@Test
fun documentCarriesSchemaPlatformVersionAndUtcExportTimestamp() {
val root = export(listOf(moltenSession, sawaiLegacySession))
Expand Down Expand Up @@ -233,6 +265,9 @@ class HistoryJsonExporterTest {
/** Real モルテン labels; see shared/test-fixtures/matching-cases.json. */
const val MOLTEN_PAF_QR =
"AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000"
/** A real デンソー kanban; see the same fixture. */
const val DENSO_KANBAN_QR =
"JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6"
const val MOLTEN_TRAILING_SPACE_QR =
"AK6805D10E50N10B U543820000MB S600700000020908 "
}
Expand Down
Loading
Loading