From 5dcb0a57a66f6ceeedf9365e3570c891c19b6172 Mon Sep 17 00:00:00 2001 From: rimtty Date: Mon, 7 Sep 2026 22:38:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(ios-history):=20show=20and=20print=20?= =?UTF-8?q?=E3=83=87=E3=83=B3=E3=82=BD=E3=83=BC=20kanban=20fields=20in=20h?= =?UTF-8?q?istory=20and=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The history detail and the PDF choose the record by destination instead of the lenient Kanban parse, add a Denso section with the kanban fields (form type, part number as 6-4, packaging, pack quantity, next process, instruction, kanban serial, management number, delivery date, run, instructed quantity, item number, receiving code) and print the kanban serial with every box. The 1-argument format(partNumber:) overload is removed so every caller names its destination. Sawai and Molten output is unchanged. Refs #109 --- .../Features/History/HistoryScreen.swift | 67 +++++- ios/CodeMatch/Models/HistoryModels.swift | 9 + ios/CodeMatch/Models/ScanModels.swift | 7 - ios/CodeMatch/Resources/Localizable.xcstrings | 224 ++++++++++++++++++ .../Services/SessionPDFExporter.swift | 60 ++++- ios/CodeMatchTests/CodeMatcherTests.swift | 18 +- ios/CodeMatchTests/HistoryExporterTests.swift | 37 +++ ios/CodeMatchTests/HistoryStoreTests.swift | 33 +++ .../SessionPDFExporterTests.swift | 57 +++++ ios/CodeMatchUITests/CodeMatchUITests.swift | 35 +++ 10 files changed, 525 insertions(+), 22 deletions(-) diff --git a/ios/CodeMatch/Features/History/HistoryScreen.swift b/ios/CodeMatch/Features/History/HistoryScreen.swift index 4c7108c..b4715c9 100644 --- a/ios/CodeMatch/Features/History/HistoryScreen.swift +++ b/ios/CodeMatch/Features/History/HistoryScreen.swift @@ -374,6 +374,8 @@ private struct GroupedMatchDetail: View { // 納品番号を持たない記録が1件でも混ざるグループは、記録を落とさないよう従来どおり1節にまとめる let showsDeliveryGroups = !deliveryGroups.isEmpty && deliveryGroups.reduce(0) { $0 + $1.entries.count } == group.entries.count + // デンソーは同じ品番の箱がかんばん連番でしか区別できないので、管理コードを添えて見分けやすくする + let showsManagementCodePerBox = group.entries.contains { $0.densoRecord != nil } List { Section { @@ -416,7 +418,11 @@ private struct GroupedMatchDetail: View { } else { Section(AppLocalization.string("各箱の照合記録")) { ForEach(Array(group.entries.enumerated()), id: \.element.id) { index, entry in - boxEntryRow(entry: entry, number: index + 1) + boxEntryRow( + entry: entry, + number: index + 1, + showsManagementCode: showsManagementCodePerBox + ) } } } @@ -425,7 +431,7 @@ private struct GroupedMatchDetail: View { .navigationBarTitleDisplayMode(.inline) } - /// 1箱分の行。モルテンは同じ納品番号の箱をQRでは区別できないため、管理コードを添える。 + /// 1箱分の行。モルテンとデンソーは箱の見分けが付きにくいため、管理コードを添える。 private func boxEntryRow( entry: MatchHistoryEntry, number: Int, @@ -485,7 +491,9 @@ private struct MatchEntryDetail: View { .textSelection(.enabled) } - if let qr = entry.qrPayload.flatMap(KanbanQRRecord.parse) { + // 澤井製作所の解析は寛容でデンソーのQRも受理してしまうため、 + // 仕向地ガード付きの `kanbanRecord` から順に分岐する。 + if let qr = entry.kanbanRecord { Section(AppLocalization.string("納品書情報(QR解析)")) { LabeledContent( AppLocalization.string("カード番号"), @@ -493,7 +501,7 @@ private struct MatchEntryDetail: View { ) LabeledContent( AppLocalization.string("品目番号"), - value: CodeMatcher.format(partNumber: qr.partNumber) + value: CodeMatcher.format(partNumber: qr.partNumber, destination: .sawai) + (qr.partSuffix.map { "(\(AppLocalization.string("枝番")) \($0))" } ?? "") ) LabeledContent(AppLocalization.string("納入数量"), value: quantityText(qr.deliveryQuantity)) @@ -507,7 +515,7 @@ private struct MatchEntryDetail: View { LabeledContent(AppLocalization.string("受注者"), value: qr.ordererCode) LabeledContent( AppLocalization.string("部品番号"), - value: CodeMatcher.format(partNumber: qr.partNumber) + value: CodeMatcher.format(partNumber: qr.partNumber, destination: .molten) ) LabeledContent(AppLocalization.string("納品番号"), value: qr.deliveryNumber) LabeledContent(AppLocalization.string("納入先"), value: qr.deliveryDestination) @@ -526,6 +534,55 @@ private struct MatchEntryDetail: View { value: qr.formattedInstructionTime ?? "-" ) } + } else if let qr = entry.densoRecord { + // デンソーのかんばんは項目構成が変わりうるので、無い項目は行ごと出さない。 + Section(AppLocalization.string("納品書情報(QR解析)")) { + if let formType = qr.formType { + LabeledContent(AppLocalization.string("帳票区分"), value: formType) + } + LabeledContent( + AppLocalization.string("部品番号"), + value: CodeMatcher.format(partNumber: qr.partNumber, destination: .denso) + ) + if let packagingCode = qr.packagingCode { + LabeledContent(AppLocalization.string("包装"), value: packagingCode) + } + LabeledContent( + AppLocalization.string("収容数"), + value: appLanguage.formatInteger(qr.packQuantity) + ) + if let nextProcess = qr.nextProcess { + LabeledContent(AppLocalization.string("次区"), value: nextProcess) + } + if let instructionCode = qr.instructionCode { + LabeledContent(AppLocalization.string("指示"), value: instructionCode) + } + LabeledContent( + AppLocalization.string("かんばん連番"), + value: qr.kanbanSerial + ) + if let managementNumber = qr.managementNumber { + LabeledContent(AppLocalization.string("管理番号"), value: managementNumber) + } + if let deliveryDate = qr.formattedDeliveryDate { + LabeledContent(AppLocalization.string("納入日"), value: deliveryDate) + } + if let deliveryRun = qr.deliveryRun { + LabeledContent(AppLocalization.string("便"), value: deliveryRun) + } + if let instructedQuantity = qr.instructedQuantity { + LabeledContent( + AppLocalization.string("指示数"), + value: appLanguage.formatInteger(instructedQuantity) + ) + } + if let itemNumber = qr.itemNumber { + LabeledContent(AppLocalization.string("アイテムNo"), value: itemNumber) + } + if let receivingCode = qr.receivingCode { + LabeledContent(AppLocalization.string("受入"), value: receivingCode) + } + } } if let tag = entry.barcodePayload.flatMap(TagBarcodeRecord.parse) { diff --git a/ios/CodeMatch/Models/HistoryModels.swift b/ios/CodeMatch/Models/HistoryModels.swift index 75a02ab..4b6735d 100644 --- a/ios/CodeMatch/Models/HistoryModels.swift +++ b/ios/CodeMatch/Models/HistoryModels.swift @@ -39,6 +39,15 @@ extension MatchHistoryEntry { return MoltenQRRecord.parse(qrPayload) } + /// デンソーのかんばんQR(JAMA自己記述形式)として解析した結果。 + /// 別の仕向地やQR全文を持たない旧履歴ではnil。 + /// `KanbanQRRecord.parse` は寛容なのでデンソーのQRも澤井製作所として解析できてしまう。 + /// 表示側は必ずこの仕向地ガード付きのプロパティを通す。 + var densoRecord: DensoKanbanRecord? { + guard let qrPayload, Destination.detect(qrPayload: qrPayload) == .denso else { return nil } + return DensoKanbanRecord.parse(qrPayload) + } + /// この記録がどの箱を検査したかを表す箱固有キー。仕向地ごとの作り方は `BoxIdentity` に従う。 /// QR全文を持たない旧履歴や、モルテンでCode 128全文を持たない記録ではnil。 var boxIdentity: String? { diff --git a/ios/CodeMatch/Models/ScanModels.swift b/ios/CodeMatch/Models/ScanModels.swift index 7c2f695..4427c13 100644 --- a/ios/CodeMatch/Models/ScanModels.swift +++ b/ios/CodeMatch/Models/ScanModels.swift @@ -186,13 +186,6 @@ enum CodeMatcher { return qrPart == barcodePart ? .match : .mismatch } - /// 品番を現品票の表記へ整形する(仕向地不明の従来規則)。 - /// 10桁は4-2-4 (`BCJH5281GG` → `BCJH-52-81GG`)、9桁は4-2-3 (`PAF115422` → `PAF1-15-422`)。 - /// それ以外の桁数はそのまま返す。 - static func format(partNumber: String) -> String { - format(partNumber: partNumber, destination: nil) - } - /// 品番を仕向地の現品票の表記へ整形する。 /// デンソーの10桁は6-4 (`8601507722` → `860150-7722`)。 /// 澤井製作所・モルテン・仕向地未確定は従来の長さ規則(10→4-2-4 / 9→4-2-3)。 diff --git a/ios/CodeMatch/Resources/Localizable.xcstrings b/ios/CodeMatch/Resources/Localizable.xcstrings index b22b84f..4506f3a 100644 --- a/ios/CodeMatch/Resources/Localizable.xcstrings +++ b/ios/CodeMatch/Resources/Localizable.xcstrings @@ -5704,6 +5704,230 @@ } } } + }, + "帳票区分" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Form type" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "帳票区分" + } + } + } + }, + "包装" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Packaging" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "包装" + } + } + } + }, + "次区" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next process" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "次区" + } + } + } + }, + "指示" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Instruction" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "指示" + } + } + } + }, + "かんばん連番" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanban serial" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "かんばん連番" + } + } + } + }, + "管理番号" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Management number" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理番号" + } + } + } + }, + "納入日" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delivery date" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "納入日" + } + } + } + }, + "便" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delivery run" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "便" + } + } + } + }, + "アイテムNo" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Item No." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アイテムNo" + } + } + } + }, + "受入" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Receiving" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "受入" + } + } + } + }, + "かんばん連番: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanban serial: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "かんばん連番: %@" + } + } + } + }, + "部品番号: %@ 収容数: %lld 指示数: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Part number: %1$@, pack quantity: %2$lld, instructed quantity: %3$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "部品番号: %1$@ 収容数: %2$lld 指示数: %3$@" + } + } + } + }, + "次区: %@ 指示: %@ 納入日: %@ 便: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next process: %1$@, instruction: %2$@, delivery date: %3$@, delivery run: %4$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "次区: %1$@ 指示: %2$@ 納入日: %3$@ 便: %4$@" + } + } + } + }, + "管理番号: %@ アイテムNo: %@ 受入: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Management number: %1$@, item No.: %2$@, receiving: %3$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理番号: %1$@ アイテムNo: %2$@ 受入: %3$@" + } + } + } } }, "version" : "1.0" diff --git a/ios/CodeMatch/Services/SessionPDFExporter.swift b/ios/CodeMatch/Services/SessionPDFExporter.swift index 6b91a40..1da6185 100644 --- a/ios/CodeMatch/Services/SessionPDFExporter.swift +++ b/ios/CodeMatch/Services/SessionPDFExporter.swift @@ -140,10 +140,19 @@ enum SessionPDFExporter { } /// 1箱分の読み取り記録。箱番号は呼び出し側の並び(品番ごと/納品番号ごと)に従う。 - func drawBoxEntry(_ entry: MatchHistoryEntry, number: Int) { + /// デンソーは箱ごとにかんばん連番が変わるので、`kanbanSerial` を渡すと1行添える。 + /// 澤井製作所・モルテンは従来どおり `nil` で、出力は変わらない。 + func drawBoxEntry(_ entry: MatchHistoryEntry, number: Int, kanbanSerial: String? = nil) { // 箱ごとの見出し+全文2行はまとめて改ページ判定する - ensureSpace(48) + ensureSpace(kanbanSerial == nil ? 48 : 62) let managementCode = entry.barcodePayload.flatMap(TagBarcodeRecord.parse)?.managementCode + if let kanbanSerial { + draw( + AppLocalization.string("かんばん連番: \(kanbanSerial)"), + font: bodyFont, + spacing: 2 + ) + } draw( AppLocalization.string( "\(number)箱目 照合時刻: \(appLanguage.formatDateTime(entry.matchedAt)) 管理コード: \(managementCode ?? "-")" @@ -218,7 +227,7 @@ enum SessionPDFExporter { ) draw( AppLocalization.string( - "受注者: \(record.ordererCode) 部品番号: \(CodeMatcher.format(partNumber: record.partNumber)) 納品番号: \(record.deliveryNumber)" + "受注者: \(record.ordererCode) 部品番号: \(CodeMatcher.format(partNumber: record.partNumber, destination: .molten)) 納品番号: \(record.deliveryNumber)" ), font: bodyFont, spacing: 2 @@ -247,12 +256,53 @@ enum SessionPDFExporter { continue } - if let qr = group.entries.compactMap({ $0.qrPayload.flatMap(KanbanQRRecord.parse) }).first { + // デンソーのかんばん。共通欄を3行にまとめ、箱ごとの記録にかんばん連番を添える。 + // 澤井製作所の解析は寛容でデンソーのQRも受理してしまうため、必ず先に判定する。 + if let denso = group.entries.compactMap({ $0.densoRecord }).first { + // かんばん情報3行はまとめて改ページ判定する + ensureSpace(70) + let instructedQuantity = denso.instructedQuantity + .map { appLanguage.formatInteger($0) } ?? "-" + draw( + AppLocalization.string( + "部品番号: \(CodeMatcher.format(partNumber: denso.partNumber, destination: .denso)) 収容数: \(denso.packQuantity) 指示数: \(instructedQuantity)" + ), + font: bodyFont, + spacing: 2 + ) + draw( + AppLocalization.string( + "次区: \(denso.nextProcess ?? "-") 指示: \(denso.instructionCode ?? "-") 納入日: \(denso.formattedDeliveryDate ?? "-") 便: \(denso.deliveryRun ?? "-")" + ), + font: bodyFont, + spacing: 2 + ) + draw( + AppLocalization.string( + "管理番号: \(denso.managementNumber ?? "-") アイテムNo: \(denso.itemNumber ?? "-") 受入: \(denso.receivingCode ?? "-")" + ), + font: bodyFont, + spacing: 4 + ) + + draw(AppLocalization.string("各箱の読み取り記録"), font: headFont, spacing: 2) + for (boxIndex, entry) in group.entries.enumerated() { + drawBoxEntry( + entry, + number: boxIndex + 1, + kanbanSerial: entry.densoRecord?.kanbanSerial + ) + } + 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( AppLocalization.string( - "品目番号: \(CodeMatcher.format(partNumber: qr.partNumber))\(suffix) カード番号: \(qr.cardNumber)" + "品目番号: \(CodeMatcher.format(partNumber: qr.partNumber, destination: .sawai))\(suffix) カード番号: \(qr.cardNumber)" ), font: bodyFont, spacing: 2 diff --git a/ios/CodeMatchTests/CodeMatcherTests.swift b/ios/CodeMatchTests/CodeMatcherTests.swift index b486f27..548db80 100644 --- a/ios/CodeMatchTests/CodeMatcherTests.swift +++ b/ios/CodeMatchTests/CodeMatcherTests.swift @@ -202,11 +202,20 @@ final class CodeMatcherTests: XCTestCase { } func testFormatPartNumber() { - XCTAssertEqual(CodeMatcher.format(partNumber: "BCJH5281GG"), "BCJH-52-81GG") + XCTAssertEqual( + CodeMatcher.format(partNumber: "BCJH5281GG", destination: nil), + "BCJH-52-81GG" + ) // モルテンの9桁品番は4-2-3で表記する - XCTAssertEqual(CodeMatcher.format(partNumber: "PAF115422"), "PAF1-15-422") - XCTAssertEqual(CodeMatcher.format(partNumber: "ABC"), "ABC") - XCTAssertEqual(CodeMatcher.format(partNumber: "ABCDEFGHIJK"), "ABCDEFGHIJK") + XCTAssertEqual( + CodeMatcher.format(partNumber: "PAF115422", destination: nil), + "PAF1-15-422" + ) + XCTAssertEqual(CodeMatcher.format(partNumber: "ABC", destination: nil), "ABC") + XCTAssertEqual( + CodeMatcher.format(partNumber: "ABCDEFGHIJK", destination: nil), + "ABCDEFGHIJK" + ) } func testKanbanQRRecordParsesAllFields() { @@ -736,7 +745,6 @@ final class CodeMatcherTests: XCTestCase { CodeMatcher.format(partNumber: "8601507722", destination: nil), "8601-50-7722" ) - XCTAssertEqual(CodeMatcher.format(partNumber: "8601507722"), "8601-50-7722") XCTAssertEqual( CodeMatcher.format(partNumber: "BCJH5281GG", destination: .sawai), "BCJH-52-81GG" diff --git a/ios/CodeMatchTests/HistoryExporterTests.swift b/ios/CodeMatchTests/HistoryExporterTests.swift index 91d9c13..61ce9bc 100644 --- a/ios/CodeMatchTests/HistoryExporterTests.swift +++ b/ios/CodeMatchTests/HistoryExporterTests.swift @@ -16,6 +16,10 @@ final class HistoryExporterTests: XCTestCase { private let moltenQRD10E = "AK6805D10E50N10B U543820000MB S600700000020908 " private let moltenTagD10E = "D10E-50-N10B@0UBL00" + // デンソーのかんばんQR(JAMA自己記述形式)。項目間の空白もレコードの一部。 + private let densoQRKanban0140 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + private let densoTag0140 = "860150-7722@1DZ50O" + private let exportedAt = Date(timeIntervalSince1970: 1_757_207_025) // 2025-09-07T01:03:45Z private let moltenStartedAt = Date(timeIntervalSince1970: 1_700_000_000) private let sawaiStartedAt = Date(timeIntervalSince1970: 1_600_000_000) @@ -85,6 +89,39 @@ final class HistoryExporterTests: XCTestCase { XCTAssertEqual(sessions[0]["destination"] as? String, "sawai") } + /// デンソーのセッションも永続化idの `denso` で出し、かんばんQRは1文字も変えずに往復する。 + func testDensoSessionExportsDestinationId() throws { + XCTAssertEqual(densoQRKanban0140.count, 221) + + let session = MatchSession( + id: UUID(uuidString: "88888888-8888-8888-8888-888888888888")!, + startedAt: sawaiStartedAt, + endedAt: sawaiStartedAt.addingTimeInterval(600), + entries: [ + MatchHistoryEntry( + id: UUID(uuidString: "99999999-9999-9999-9999-999999999999")!, + code: "860150-7722", + matchedAt: sawaiStartedAt.addingTimeInterval(60), + qrPayload: densoQRKanban0140, + barcodePayload: densoTag0140 + ) + ], + destination: .denso + ) + + let root = try exportRoot(sessions: [session]) + let sessions = try XCTUnwrap(root["sessions"] as? [[String: Any]]) + XCTAssertEqual(sessions[0]["destination"] as? String, "denso") + + let entries = try XCTUnwrap(sessions[0]["entries"] as? [[String: Any]]) + let qrPayload = try XCTUnwrap(entries[0]["qrPayload"] as? String) + XCTAssertEqual(qrPayload.count, 221) + XCTAssertEqual(qrPayload, densoQRKanban0140) + XCTAssertEqual(Array(qrPayload.unicodeScalars), Array(densoQRKanban0140.unicodeScalars)) + XCTAssertEqual(entries[0]["barcodePayload"] as? String, densoTag0140) + XCTAssertEqual(entries[0]["code"] as? String, "860150-7722") + } + func testSessionWithoutAnyDestinationEmitsNull() throws { let session = MatchSession(startedAt: sawaiStartedAt, entries: [], destination: nil) let root = try exportRoot(sessions: [session]) diff --git a/ios/CodeMatchTests/HistoryStoreTests.swift b/ios/CodeMatchTests/HistoryStoreTests.swift index 2c7b55a..c5f0721 100644 --- a/ios/CodeMatchTests/HistoryStoreTests.swift +++ b/ios/CodeMatchTests/HistoryStoreTests.swift @@ -342,6 +342,39 @@ final class HistoryStoreTests: XCTestCase { ) } + /// 履歴の記録からデンソーのかんばん項目を解析できる(履歴詳細とPDFが使う経路)。 + /// `KanbanQRRecord.parse` は寛容でデンソーのQRも受理してしまうため、 + /// 仕向地ガード付きの `kanbanRecord` / `densoRecord` の出方まで確かめる。 + func testDensoRecordParsesFromEntry() throws { + let entry = MatchHistoryEntry( + code: "860150-7722", + qrPayload: densoQRKanban0140, + barcodePayload: densoTag0140 + ) + + let record = try XCTUnwrap(entry.densoRecord) + XCTAssertEqual(record.formType, "20") + XCTAssertEqual(record.partNumber, "8601507722") + XCTAssertEqual(record.packagingCode, "00") + XCTAssertEqual(record.packQuantity, 24) + XCTAssertEqual(record.nextProcess, "D850") + XCTAssertEqual(record.instructionCode, "C01008-45") + XCTAssertEqual(record.kanbanSerial, "0140") + XCTAssertEqual(record.managementNumber, "SWS") + XCTAssertEqual(record.formattedDeliveryDate, "2026/09/08") + XCTAssertEqual(record.deliveryRun, "S001") + XCTAssertEqual(record.instructedQuantity, 72) + XCTAssertEqual(record.itemNumber, "9924543330") + XCTAssertEqual(record.receivingCode, "M6") + + // 澤井製作所・モルテンとしては解析しない + XCTAssertNil(entry.kanbanRecord) + XCTAssertNil(entry.moltenRecord) + // QR全文を持たない旧履歴はどの仕向地としても解析しない + let legacy = MatchHistoryEntry(code: "860150-7722") + XCTAssertNil(legacy.densoRecord) + } + /// デンソーは納品番号ではなく品番ごとに箱を数える(澤井製作所と同じ規則)。 func testDensoMatchCountPerPartNumber() { let storageURL = temporaryStorageURL() diff --git a/ios/CodeMatchTests/SessionPDFExporterTests.swift b/ios/CodeMatchTests/SessionPDFExporterTests.swift index 8b1f320..afdfc7a 100644 --- a/ios/CodeMatchTests/SessionPDFExporterTests.swift +++ b/ios/CodeMatchTests/SessionPDFExporterTests.swift @@ -19,6 +19,11 @@ final class SessionPDFExporterTests: XCTestCase { // 澤井製作所の納品書兼現品票QR(66桁)。 private let sawaiQR = "DCLP675300BCJH5281GG020000120000001200L000000000000BLBDILLU92 0*" private let sawaiTag = "BCJH-52-81GG@1N5X0C" + // デンソーのかんばんQR(JAMA自己記述形式)。同じ品番の2箱はかんばん連番だけが違う。 + private let densoQRKanban0140 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + private let densoQRKanban0141 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0141SWS 20260908S0010000720000009924543330454333M6" + private let densoTag0140 = "860150-7722@1DZ50O" + private let densoTag0141 = "860150-7722@1DZB0O" private let startedAt = Date(timeIntervalSince1970: 1_700_000_000) @@ -26,6 +31,8 @@ final class SessionPDFExporterTests: XCTestCase { XCTAssertEqual(moltenQRD10E.count, 61) XCTAssertEqual(moltenQRPAF1.count, 61) XCTAssertEqual(sawaiQR.count, 66) + XCTAssertEqual(densoQRKanban0140.count, 221) + XCTAssertEqual(densoQRKanban0141.count, 221) } func testMoltenInstructionDateAndTimeAreFormattedForDisplay() throws { @@ -83,6 +90,33 @@ final class SessionPDFExporterTests: XCTestCase { // モルテン向けの見出しと集計は出さない XCTAssertFalse(text.contains("納品番号")) XCTAssertFalse(text.contains("TYロケーション")) + // デンソー向けのかんばん項目も出さない + XCTAssertFalse(text.contains("かんばん連番")) + } + + /// デンソーはかんばんの共通欄を3行にまとめ、箱ごとの記録にかんばん連番を添える。 + /// 澤井製作所の解析は寛容でデンソーのQRも受理できてしまうため、 + /// カード番号などの澤井向けの欄が出ないことまで確認する。 + func testDensoSessionPrintsKanbanBlockAndBoxes() throws { + let text = pdfText(for: densoSession()) + + assertContains("仕向地: デンソー", in: text) + assertContains("検査箱数: 2箱(品番数: 1)", in: text) + + // かんばんの共通欄3行(PDFKitは全角スペースを半角へ畳む) + assertContains("部品番号: 860150-7722 収容数: 24 指示数: 72", in: text) + assertContains("次区: D850 指示: C01008-45 納入日: 2026/09/08 便: S001", in: text) + assertContains("管理番号: SWS アイテムNo: 9924543330 受入: M6", in: text) + + // 箱ごとの記録はかんばん連番で見分ける + assertContains("かんばん連番: 0140", in: text) + assertContains("かんばん連番: 0141", in: text) + XCTAssertTrue(try boxLine(managementCode: "1DZ50O", in: text).hasPrefix("1箱目 ")) + XCTAssertTrue(try boxLine(managementCode: "1DZB0O", in: text).hasPrefix("2箱目 ")) + + // モルテン向けの集計と、澤井製作所向けの納品書欄は出さない + XCTAssertFalse(text.contains("納品番号数")) + XCTAssertFalse(text.contains("カード番号")) } func testSessionWithoutParsableQRHasNoDestinationLine() { @@ -148,6 +182,29 @@ final class SessionPDFExporterTests: XCTestCase { ) } + /// 同じ品番(860150-7722)の2箱を持つデンソーのセッション。かんばん連番は0140と0141。 + private func densoSession() -> MatchSession { + MatchSession( + startedAt: startedAt, + endedAt: startedAt.addingTimeInterval(600), + entries: [ + MatchHistoryEntry( + code: "860150-7722", + matchedAt: startedAt.addingTimeInterval(30), + qrPayload: densoQRKanban0140, + barcodePayload: densoTag0140 + ), + MatchHistoryEntry( + code: "860150-7722", + matchedAt: startedAt.addingTimeInterval(90), + qrPayload: densoQRKanban0141, + barcodePayload: densoTag0141 + ) + ], + destination: .denso + ) + } + /// PDFから全ページの本文を取り出す。 private func pdfText( for session: MatchSession, diff --git a/ios/CodeMatchUITests/CodeMatchUITests.swift b/ios/CodeMatchUITests/CodeMatchUITests.swift index 5ff9b7f..53dba43 100644 --- a/ios/CodeMatchUITests/CodeMatchUITests.swift +++ b/ios/CodeMatchUITests/CodeMatchUITests.swift @@ -236,6 +236,41 @@ final class CodeMatchUITests: XCTestCase { ) XCTAssertEqual(scannerTitle.label, "QRコードを読み取る") XCTAssertEqual(app.staticTexts["sessionMatchCount"].label, "1件照合済み") + + // セッションを終了すると、履歴にも仕向地とデンソーのかんばん項目が残る。 + app.buttons["endSessionButton"].tap() + app.alerts.buttons["終了する"].tap() + XCTAssertTrue(app.buttons["startSessionButton"].waitForExistence(timeout: 5)) + + app.tabBars.buttons["履歴"].tap() + XCTAssertTrue(app.navigationBars["照合履歴"].waitForExistence(timeout: 3)) + let sessionRow = app.buttons["historySessionRow"] + XCTAssertTrue(sessionRow.waitForExistence(timeout: 3)) + sessionRow.tap() + + // LabeledContentは見出しと値を1つの読み上げ要素にまとめる。 + let sessionDestination = app.staticTexts["historySessionDestination"] + XCTAssertTrue(sessionDestination.waitForExistence(timeout: 3)) + XCTAssertEqual(sessionDestination.label, "仕向地、デンソー") + + let matchEntryRow = app.buttons["matchEntryRow"] + XCTAssertTrue(matchEntryRow.waitForExistence(timeout: 3)) + matchEntryRow.tap() + + let boxEntryRow = app.buttons["boxEntryRow"] + XCTAssertTrue(boxEntryRow.waitForExistence(timeout: 3)) + boxEntryRow.tap() + + // デンソーのかんばん項目(QR解析)にかんばん連番が並ぶ。 + let kanbanSerial = app.staticTexts["かんばん連番"] + var detailScrollAttempts = 0 + while !kanbanSerial.exists, detailScrollAttempts < 5 { + app.swipeUp() + detailScrollAttempts += 1 + } + XCTAssertTrue(kanbanSerial.waitForExistence(timeout: 3)) + // 澤井製作所として解析した欄は出さない。 + XCTAssertFalse(app.staticTexts["カード番号"].exists) } func testSettingsDiscoversAndConnectsMockScanner() {