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
Expand Up @@ -43,6 +43,7 @@ data class HistoryExportLabels(
val destination: String,
val destinationSawai: String,
val destinationMolten: String,
val destinationDenso: String,
val deliveryNumberCount: String,
val ordererCode: String,
val moltenPartNumber: String,
Expand All @@ -62,6 +63,7 @@ data class HistoryExportLabels(
fun destinationName(destination: Destination): String = when (destination) {
Destination.SAWAI -> destinationSawai
Destination.MOLTEN -> destinationMolten
Destination.DENSO -> destinationDenso
}
}

Expand Down Expand Up @@ -118,6 +120,7 @@ object HistoryExportTextFormatter {
destination = "仕向地",
destinationSawai = "澤井製作所",
destinationMolten = "モルテン",
destinationDenso = "デンソー",
deliveryNumberCount = "納品番号数",
ordererCode = "受注者",
moltenPartNumber = "部品番号",
Expand Down Expand Up @@ -166,6 +169,7 @@ object HistoryExportTextFormatter {
destination = "Ship-to",
destinationSawai = "Sawai Seisakusho",
destinationMolten = "Molten",
destinationDenso = "Denso",
deliveryNumberCount = "Delivery numbers",
ordererCode = "Orderer",
moltenPartNumber = "Part number",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,17 @@ object CodeMatcher {
raw.trim { it == '\r' || it == '\n' || it == '\u0000' }

/**
* Decide which destination a QR payload belongs to, or null when it is
* neither destination's record.
* Decide which destination a QR payload belongs to, or null when it is no
* destination's record.
*
* Denso is probed first: [KanbanQrRecord.parse] is deliberately tolerant
* (twenty characters or more whose first ten match `[A-Z]{4}[0-9]{6}`), so
* it accepts `JAMA501195…` as a card number. A payload that parses as a
* JAMA self-describing record is always Denso.
*/
fun detectDestination(qrPayload: String): Destination? {
val payload = stripTransportTerminators(qrPayload)
if (isDensoRecord(payload)) return Destination.DENSO
if (isSawaiRecord(payload)) return Destination.SAWAI
if (MoltenQrRecord.isValidScanPayload(payload)) return Destination.MOLTEN

Expand All @@ -63,17 +69,24 @@ object CodeMatcher {
return if (isSawaiRecord(payload.trim())) Destination.SAWAI else null
}

/** The complete QR record length of a destination. */
fun expectedQrLength(destination: Destination): Int = when (destination) {
/**
* The complete QR record length of a destination, or null when the
* destination has no fixed length: a Denso kanban declares its own item
* layout, so its payload length varies and can never be checked.
*/
fun expectedQrLength(destination: Destination): Int? = when (destination) {
Destination.SAWAI -> KanbanQrRecord.REQUIRED_SCAN_PAYLOAD_LENGTH
Destination.MOLTEN -> MoltenQrRecord.RECORD_LENGTH
Destination.DENSO -> null
}

/**
* Normalize a QR payload for identity comparisons.
*
* A Molten payload is padded back to its full record length so a scan that
* dropped the trailing spaces still identifies the same slip.
* dropped the trailing spaces still identifies the same slip. Sawai and
* Denso payloads carry no edge padding, so trimming and uppercasing is
* enough for them.
*/
fun canonicalQrPayload(qrPayload: String): String =
when (detectDestination(qrPayload)) {
Expand All @@ -85,14 +98,15 @@ object CodeMatcher {
/**
* The key that tells one physical box from another within a session.
*
* A Sawai slip carries a card number, so its QR alone identifies the box. A
* Molten slip repeats for every box of the part, so the tag's management
* code has to be part of the key; without a tag there is no box identity.
* Returns null when the QR is not a valid record of either destination.
* A Sawai slip carries a card number and a Denso kanban carries a kanban
* serial (item 152), so their QR alone identifies the box. A Molten slip
* repeats for every box of the part, so the tag's management code has to be
* part of the key; without a tag there is no box identity. Returns null
* when the QR is not a valid record of any destination.
*/
fun boxIdentity(qrPayload: String, barcodePayload: String?): String? =
when (detectDestination(qrPayload)) {
Destination.SAWAI -> canonicalQrPayload(qrPayload)
Destination.SAWAI, Destination.DENSO -> canonicalQrPayload(qrPayload)
Destination.MOLTEN -> {
val tag = payloadIdentity(barcodePayload.orEmpty())
if (tag.isEmpty()) null else canonicalQrPayload(qrPayload) + "|" + tag
Expand All @@ -109,14 +123,15 @@ object CodeMatcher {
/**
* Extract the item number from a slip QR at its destination's fixed
* position: characters 11–20 for [Destination.SAWAI], characters 7–16 for
* [Destination.MOLTEN]. A payload that is neither destination's record
* returns null and can never produce a match.
* [Destination.MOLTEN], item 104 for [Destination.DENSO]. A payload that is
* no destination's record returns null and can never produce a match.
*/
fun partNumberFromQr(raw: String): String? {
val payload = stripTransportTerminators(raw)
return when (detectDestination(payload)) {
Destination.SAWAI -> KanbanQrRecord.parse(payload)?.partNumber
Destination.MOLTEN -> MoltenQrRecord.parse(payload)?.partNumber
Destination.DENSO -> DensoKanbanQrRecord.parse(payload)?.partNumber
null -> null
}
}
Expand All @@ -135,11 +150,17 @@ object CodeMatcher {
}

/**
* Format a part number the way the product tag prints it: 4-2-4 for a
* ten-character number, 4-2-3 for the nine-character Molten form. Values of
* any other length are returned unchanged.
* Format a part number the way the destination's product tag prints it:
* 6-4 for a ten-character Denso number, otherwise 4-2-4 for a
* ten-character number and 4-2-3 for the nine-character Molten form.
* Values of any other length are returned unchanged.
*/
fun formatPartNumber(partNumber: String): String {
fun formatPartNumber(partNumber: String, destination: Destination? = null): String {
if (destination == Destination.DENSO) {
if (partNumber.length != STANDARD_PART_NUMBER_LENGTH) return partNumber
return partNumber.substring(0, 6) + "-" + partNumber.substring(6)
}

if (partNumber.length !in SHORT_PART_NUMBER_LENGTH..STANDARD_PART_NUMBER_LENGTH) {
return partNumber
}
Expand All @@ -154,6 +175,10 @@ object CodeMatcher {
payload.length == KanbanQrRecord.REQUIRED_SCAN_PAYLOAD_LENGTH &&
KanbanQrRecord.parse(payload) != null

private fun isDensoRecord(payload: String): Boolean =
payload.startsWith(DensoKanbanQrRecord.FORMAT_PREFIX, ignoreCase = true) &&
DensoKanbanQrRecord.parse(payload) != null

private const val STANDARD_PART_NUMBER_LENGTH = 10
private const val SHORT_PART_NUMBER_LENGTH = 9
}
Expand Down Expand Up @@ -234,18 +259,26 @@ data class TagBarcodeRecord(
Regex("[A-Z0-9]{4}-[A-Z0-9]{2}-[A-Z0-9]{4}@[A-Z0-9]+")
private val moltenFormatPattern =
Regex("[A-Z0-9]{4}-[A-Z0-9]{2}-[A-Z0-9]{3,4}@[A-Z0-9]+")
private val densoFormatPattern =
Regex("[A-Z0-9]{6}-[A-Z0-9]{4}@[A-Z0-9]+")

/**
* Strict scanner-boundary validation for the product tag format of one
* destination: Sawai part numbers always end in a four-character block,
* Molten part numbers end in three or four. Lowercase input is accepted
* just as Swift's uppercase-before-regex implementation accepts it.
* destination: a Sawai part number is 4-2-4, a Molten part number is
* 4-2-3 or 4-2-4, and a Denso part number is 6-4. A null destination
* means the session has not locked one yet, so any of the three is
* accepted. Lowercase input is accepted just as Swift's
* uppercase-before-regex implementation accepts it.
*/
fun isValidScanPayload(payload: String, destination: Destination): Boolean {
fun isValidScanPayload(payload: String, destination: Destination?): Boolean {
val value = payload.trim().uppercase(Locale.ROOT)
return when (destination) {
Destination.SAWAI -> sawaiFormatPattern.matches(value)
Destination.MOLTEN -> moltenFormatPattern.matches(value)
Destination.DENSO -> densoFormatPattern.matches(value)
null -> sawaiFormatPattern.matches(value) ||
moltenFormatPattern.matches(value) ||
densoFormatPattern.matches(value)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package jp.rimtty.codematch.core.matching

import java.util.Locale

/** One item of a Denso kanban QR. The item id is always three digits. */
data class DensoKanbanItem(
val id: String,
val value: String,
)

/**
* A Denso kanban QR in the JAMA self-describing format.
*
* Record layout:
* `JAMA` + one version digit + a four-digit header length (L) + a ten-character
* preamble + (three-digit item id + two-digit length) * N + the data section.
*
* The header length spans from the header-length field itself to the end of the
* item definitions, so the header body is `payload[9 until 5 + L]` and the data
* section is `payload[5 + L ..]`. The declared item lengths must add up to the
* data section's length exactly.
*
* The parser deliberately does not hardcode the observed 221-character payload:
* the real kanban is L = 119 with 21 items over 97 data characters, but a
* kanban with a different item layout parses by the very same rules. Keep this
* identical to the Swift `DensoKanbanRecord`.
*/
data class DensoKanbanQrRecord(
/** The single version digit after `JAMA`. */
val version: String,
/** The first ten header characters, kept raw and never validated. */
val preamble: String,
/** 100: form type. */
val formType: String?,
/** 104: part number, without hyphens. */
val partNumber: String,
/** 111: packaging code. */
val packagingCode: String?,
/** 112: pack quantity. */
val packQuantity: Int,
/** 121: next process. */
val nextProcess: String?,
/** 124 (plus `-` plus 141): instruction code. */
val instructionCode: String?,
/** 152: kanban serial, unique per box. */
val kanbanSerial: String,
/** 402: management number. */
val managementNumber: String?,
/** 519: delivery date, `YYYYMMDD`. */
val deliveryDate: String?,
/** 520: delivery run. */
val deliveryRun: String?,
/** 521: instructed quantity. */
val instructedQuantity: Int?,
/** 523: item number. */
val itemNumber: String?,
/** 401: receiving code. */
val receivingCode: String?,
/** Every item's raw value, in the order the header declares them. */
val orderedItems: List<DensoKanbanItem>,
/** Every item's raw value, keyed by item id. */
val items: Map<String, String>,
/** Terminator-stripped, uppercased payload; the length is left as read. */
val canonicalPayload: String,
) {
/** The delivery date for display: `20260908` becomes `2026/09/08`. */
val formattedDeliveryDate: String?
get() {
val date = deliveryDate ?: return null
if (date.length != DELIVERY_DATE_LENGTH) return date
return "${date.substring(0, 4)}/${date.substring(4, 6)}/${date.substring(6)}"
}

companion object {
/** The fixed prefix of the format. */
const val FORMAT_PREFIX = "JAMA"

/** `JAMA` plus the version digit plus the four-digit header length. */
private const val HEADER_FIELDS_LENGTH = 9
private const val PREAMBLE_LENGTH = 10
private const val DESCRIPTOR_LENGTH = 5
private const val DELIVERY_DATE_LENGTH = 8

private val partNumberPattern = Regex("[A-Z0-9]{1,18}")

/** Accept a scanner payload as a Denso kanban QR. */
fun isValidScanPayload(payload: String): Boolean = parse(payload) != null

/** The canonical form of a payload, or null when it does not parse. */
fun canonicalPayload(payload: String): String? = parse(payload)?.canonicalPayload

fun parse(payload: String): DensoKanbanQrRecord? {
val record = CodeMatcher.stripTransportTerminators(payload).uppercase(Locale.ROOT)
if (record.length < HEADER_FIELDS_LENGTH) return null
if (!record.startsWith(FORMAT_PREFIX)) return null

val version = record.substring(4, 5)
if (!version.isAsciiDigits()) return null

val headerLengthField = record.substring(5, HEADER_FIELDS_LENGTH)
if (!headerLengthField.isAsciiDigits()) return null
val headerLength = headerLengthField.toIntOrNull() ?: return null

// The header length spans the header-length field itself, so the data
// section starts at 5 + L.
val dataStart = 5 + headerLength
if (record.length < dataStart) return null

val headerBody = record.substring(HEADER_FIELDS_LENGTH, dataStart)
if (headerBody.length < PREAMBLE_LENGTH) return null
val preamble = headerBody.substring(0, PREAMBLE_LENGTH)
val descriptors = headerBody.substring(PREAMBLE_LENGTH)
if (descriptors.isEmpty() || descriptors.length % DESCRIPTOR_LENGTH != 0) return null

val declared = mutableListOf<Pair<String, Int>>()
val seenIds = mutableSetOf<String>()
var cursor = 0
while (cursor < descriptors.length) {
val id = descriptors.substring(cursor, cursor + 3)
val lengthField = descriptors.substring(cursor + 3, cursor + DESCRIPTOR_LENGTH)
if (!id.isAsciiDigits() || !lengthField.isAsciiDigits()) return null
val length = lengthField.toIntOrNull() ?: return null
// A repeated item id has no defined interpretation.
if (!seenIds.add(id)) return null
declared += id to length
cursor += DESCRIPTOR_LENGTH
}

val data = record.substring(dataStart)
if (declared.sumOf { it.second } != data.length) return null

val orderedItems = mutableListOf<DensoKanbanItem>()
val items = mutableMapOf<String, String>()
var offset = 0
declared.forEach { (id, length) ->
val value = data.substring(offset, offset + length)
orderedItems += DensoKanbanItem(id = id, value = value)
items[id] = value
offset += length
}

// Required items: part number, pack quantity and kanban serial. A
// payload missing any of them is not treated as a kanban.
val partNumber = items["104"]?.trim().orEmpty()
if (!partNumberPattern.matches(partNumber)) return null
val packQuantityField = items["112"]?.trim().orEmpty()
if (!packQuantityField.isAsciiDigits()) return null
val packQuantity = packQuantityField.toIntOrNull() ?: return null
val kanbanSerial = items["152"]?.trim().orEmpty()
if (kanbanSerial.isEmpty()) return null

val instructionBase = items["124"].trimmedOrNull()
val instructionSuffix = items["141"].trimmedOrNull()
val instructionCode = instructionBase?.let { base ->
if (instructionSuffix != null) "$base-$instructionSuffix" else base
}

return DensoKanbanQrRecord(
version = version,
preamble = preamble,
formType = items["100"].trimmedOrNull(),
partNumber = partNumber,
packagingCode = items["111"].trimmedOrNull(),
packQuantity = packQuantity,
nextProcess = items["121"].trimmedOrNull(),
instructionCode = instructionCode,
kanbanSerial = kanbanSerial,
managementNumber = items["402"].trimmedOrNull(),
deliveryDate = items["519"].trimmedOrNull(),
deliveryRun = items["520"].trimmedOrNull(),
instructedQuantity = items["521"]?.trim()?.toIntOrNull(),
itemNumber = items["523"].trimmedOrNull(),
receivingCode = items["401"].trimmedOrNull(),
orderedItems = orderedItems.toList(),
items = items.toMap(),
canonicalPayload = record,
)
}

private fun String.isAsciiDigits(): Boolean =
isNotEmpty() && all { it in '0'..'9' }

private fun String?.trimmedOrNull(): String? = this?.trim()?.takeIf { it.isNotEmpty() }
}
}
Loading
Loading