In der Chargenzeile beim Einlagern gibt es einen Knopf "MHD scannen". Er oeffnet die Texterkennung von VisionKit; erkannte Daten erscheinen als Vorschlag und werden erst uebernommen, wenn man sie antippt. Bewusst kein automatisches Uebernehmen: Eine Fehllesung beim MHD faellt sonst erst auf, wenn die Ware bereits falsch einsortiert ist. Der Parser deckt die Schreibweisen ab, die auf Verpackungen vorkommen - Tagesdaten (12.09.2026, 2026-09-12, 12/09/2026, 12.09.26) und Monatsangaben (09/2026, 09.2026, 2026-09, 09/26). Erkennt der Aufdruck nur Monat und Jahr, stellt sich die Eingabe sichtbar auf Monat/Jahr um. Damit aus Chargennummern und EAN-Codes keine Phantasiedaten werden, greifen mehrere Einschraenkungen: Muster mit Tag werden zuerst geprueft, ein bereits erkannter Textabschnitt wird nicht erneut ausgewertet, Monate ausserhalb 1-12 entfallen und das Jahr muss im Fenster von fuenf Jahren rueckwaerts bis dreissig vorwaerts liegen. Der Knopf erscheint nur, wenn das Geraet Texterkennung unterstuetzt (Neural Engine); sonst bleibt die Eingabe von Hand. Getestet: Der Parser wurde eigenstaendig uebersetzt und gegen 14 Faelle geprueft, alle korrekt - darunter, dass "12.09.2026" nicht als Monatsangabe gelesen wird, dass eine EAN keine Treffer erzeugt und dass unplausible Jahre und Monate verworfen werden. Der iOS-Geraetebuild laeuft fehlerfrei durch. Die Erkennung an einer echten Verpackung habe ich nicht selbst ausprobiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
262 lines
9.7 KiB
Swift
262 lines
9.7 KiB
Swift
import SwiftUI
|
||
|
||
/// Damit sich eine UUID direkt als sheet(item:) verwenden laesst.
|
||
extension UUID: Identifiable {
|
||
public var id: UUID { self }
|
||
}
|
||
|
||
extension Date {
|
||
/// Erster Tag des Monats - so wird eine Monatsangabe an den Server geschickt.
|
||
var startOfMonth: Date {
|
||
let calendar = Calendar.current
|
||
let parts = calendar.dateComponents([.year, .month], from: self)
|
||
return calendar.date(from: parts) ?? self
|
||
}
|
||
}
|
||
|
||
/// Monat und Jahr auswaehlen. SwiftUI kennt keinen DatePicker ohne Tag,
|
||
/// deshalb zwei schmale Auswahlfelder nebeneinander.
|
||
struct MonthYearPicker: View {
|
||
@Binding var date: Date
|
||
|
||
private let calendar = Calendar.current
|
||
private var monthNames: [String] { calendar.monthSymbols }
|
||
|
||
private var years: [Int] {
|
||
let current = calendar.component(.year, from: Date())
|
||
// Rueckwaerts fuer bereits abgelaufene Ware, vorwaerts fuer Konserven.
|
||
return Array((current - 2)...(current + 15))
|
||
}
|
||
|
||
private var month: Binding<Int> {
|
||
Binding(
|
||
get: { calendar.component(.month, from: date) },
|
||
set: { date = replacing(month: $0, year: calendar.component(.year, from: date)) }
|
||
)
|
||
}
|
||
|
||
private var year: Binding<Int> {
|
||
Binding(
|
||
get: { calendar.component(.year, from: date) },
|
||
set: { date = replacing(month: calendar.component(.month, from: date), year: $0) }
|
||
)
|
||
}
|
||
|
||
private func replacing(month: Int, year: Int) -> Date {
|
||
calendar.date(from: DateComponents(year: year, month: month, day: 1)) ?? date
|
||
}
|
||
|
||
var body: some View {
|
||
HStack {
|
||
Text("MHD")
|
||
Spacer()
|
||
Picker("Monat", selection: month) {
|
||
ForEach(1...12, id: \.self) { Text(monthNames[$0 - 1]).tag($0) }
|
||
}
|
||
.labelsHidden()
|
||
Picker("Jahr", selection: year) {
|
||
ForEach(years, id: \.self) { Text(String($0)).tag($0) }
|
||
}
|
||
.labelsHidden()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Mengen-Erfassung nach dem Scan: mehrere Chargen mit eigenem MHD.
|
||
struct CheckInFormView: View {
|
||
let product: Product
|
||
let units: [Unit]
|
||
let locations: [StorageLocation]
|
||
var onDone: (String) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
struct Line: Identifiable {
|
||
let id = UUID()
|
||
var quantity: String = "1"
|
||
var hasDate: Bool = false
|
||
var bestBefore: Date = Date()
|
||
}
|
||
|
||
@State private var lines: [Line] = [Line()]
|
||
/// Gilt fuer den ganzen Vorgang: Auf einer Packung steht entweder ein
|
||
/// Tagesdatum oder nur Monat/Jahr - nicht beides gemischt.
|
||
@State private var precision: String = "day"
|
||
/// Charge, fuer die gerade das MHD abgescannt wird.
|
||
@State private var scanLineId: UUID?
|
||
@State private var unit: String = ""
|
||
@State private var locationId: Int?
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
|
||
/// Einheiten der passenden Art plus das Gebinde des Artikels.
|
||
private var unitOptions: [String] {
|
||
var options = units.filter { $0.kind == product.kind }.map(\.name)
|
||
if let size = product.packageSize, size > 0 {
|
||
options.append(product.packageLabel ?? "Packung")
|
||
}
|
||
return options
|
||
}
|
||
|
||
private var packageOptionName: String? {
|
||
guard let size = product.packageSize, size > 0 else { return nil }
|
||
return product.packageLabel ?? "Packung"
|
||
}
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
HStack {
|
||
VStack(alignment: .leading) {
|
||
Text(product.name).font(.headline)
|
||
Text("Bestand: \(format(product.stockInArticleUnits)) \(product.articleUnitLabel)")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
Section("Einheit") {
|
||
Picker("Einheit", selection: $unit) {
|
||
ForEach(unitOptions, id: \.self) { Text($0).tag($0) }
|
||
}
|
||
if !locations.isEmpty {
|
||
Picker("Lagerort", selection: $locationId) {
|
||
Text("– keiner –").tag(Int?.none)
|
||
ForEach(locations) { location in
|
||
Text(location.name).tag(Int?.some(location.id))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Section("MHD") {
|
||
Picker("Angabe", selection: $precision) {
|
||
Text("Tagesdatum").tag("day")
|
||
Text("nur Monat/Jahr").tag("month")
|
||
}
|
||
}
|
||
|
||
Section("Chargen") {
|
||
ForEach($lines) { $line in
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack {
|
||
// Ohne Beschriftung sah die vorbelegte 1 wie eine
|
||
// Nummerierung der Charge aus.
|
||
Text("Menge")
|
||
Spacer(minLength: 12)
|
||
TextField("Menge", text: $line.quantity)
|
||
.keyboardType(.decimalPad)
|
||
.multilineTextAlignment(.trailing)
|
||
.frame(maxWidth: 110)
|
||
if lines.count > 1 {
|
||
Button(role: .destructive) {
|
||
lines.removeAll { $0.id == line.id }
|
||
} label: { Image(systemName: "trash") }
|
||
.buttonStyle(.borderless)
|
||
}
|
||
}
|
||
Toggle("MHD angeben", isOn: $line.hasDate)
|
||
if line.hasDate {
|
||
if precision == "month" {
|
||
MonthYearPicker(date: $line.bestBefore)
|
||
} else {
|
||
DatePicker("MHD", selection: $line.bestBefore, displayedComponents: .date)
|
||
}
|
||
if DateScanView.isSupported {
|
||
Button {
|
||
scanLineId = line.id
|
||
} label: {
|
||
Label("MHD scannen", systemImage: "text.viewfinder")
|
||
.font(.callout)
|
||
}
|
||
.buttonStyle(.borderless)
|
||
}
|
||
}
|
||
}
|
||
.padding(.vertical, 2)
|
||
}
|
||
Button {
|
||
lines.append(Line())
|
||
} label: {
|
||
Label("Weitere Charge (anderes MHD)", systemImage: "plus")
|
||
}
|
||
}
|
||
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
|
||
Section {
|
||
Button(busy ? "Speichern…" : "Einlagern") { Task { await submit() } }
|
||
.disabled(busy)
|
||
}
|
||
}
|
||
.navigationTitle("Einlagern")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||
}
|
||
.sheet(item: $scanLineId) { id in
|
||
NavigationStack {
|
||
DateScanView { datum, erkanntePraezision in
|
||
// Nennt der Aufdruck keinen Tag, stellt sich die Eingabe
|
||
// sichtbar auf Monat/Jahr um.
|
||
if erkanntePraezision == "month" { precision = "month" }
|
||
if let index = lines.firstIndex(where: { $0.id == id }) {
|
||
lines[index].bestBefore = datum
|
||
lines[index].hasDate = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.onAppear {
|
||
// Gibt es ein Gebinde, ist das die naheliegende Eingabe.
|
||
unit = packageOptionName ?? product.unitName
|
||
// Am Produkt hinterlegt, ob dort ueblicherweise nur Monat/Jahr steht.
|
||
precision = product.datePrecision == "month" ? "month" : "day"
|
||
}
|
||
}
|
||
|
||
private func format(_ value: Double) -> String {
|
||
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||
}
|
||
|
||
private func submit() async {
|
||
error = nil
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy-MM-dd"
|
||
|
||
let payloadLines: [CheckInLine] = lines.compactMap { line in
|
||
guard let quantity = Double(line.quantity.replacingOccurrences(of: ",", with: ".")),
|
||
quantity > 0 else { return nil }
|
||
// Bei Monatsangabe genuegt der Monatserste - der Server legt daraus
|
||
// den Monatsletzten und merkt sich die Genauigkeit.
|
||
let date = precision == "month" ? line.bestBefore.startOfMonth : line.bestBefore
|
||
return CheckInLine(
|
||
quantity: quantity,
|
||
bestBefore: line.hasDate ? formatter.string(from: date) : nil,
|
||
bestBeforePrecision: precision,
|
||
locationId: locationId
|
||
)
|
||
}
|
||
guard !payloadLines.isEmpty else {
|
||
error = "Bitte mindestens eine Menge angeben."
|
||
return
|
||
}
|
||
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
let response = try await APIClient.shared.checkInBatch(
|
||
BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines)
|
||
)
|
||
let total = response.productStock / product.articleUnitFactor
|
||
onDone("Eingelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
|
||
dismiss()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|