Реализация инвентаризации через RFID-сканер в мобильном приложении
RFID-инвентаризация в отличие от штрихкода — массовое одновременное считывание. Склад прогнал ридером вдоль стеллажа и за 3 секунды считал 200 тегов. Задача мобильного приложения: принять этот поток EPC-кодов без потерь, дедуплицировать (один тег может попасть в несколько reads), сверить с ожидаемым списком и показать расхождения. Кажется просто — пока не столкнёшься с дубликатами, тегами вне сессии и необходимостью работать офлайн.
Архитектура RFID-инвентаризации
Сессия инвентаризации — конечный автомат с чёткими переходами:
IDLE → SCANNING → PROCESSING → COMPLETED
↓
PAUSED → SCANNING
На каждый прочитанный тег — update в MutableStateFlow с дедупликацией по EPC:
class InventorySession(private val expectedItems: List<InventoryItem>) {
private val _scannedEpcs = MutableStateFlow<Set<String>>(emptySet())
val scannedEpcs: StateFlow<Set<String>> = _scannedEpcs.asStateFlow()
// Производные состояния
val matchedItems = scannedEpcs.map { epcs ->
expectedItems.filter { it.epc in epcs }
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
val missingItems = scannedEpcs.map { epcs ->
expectedItems.filter { it.epc !in epcs }
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
val unexpectedEpcs = scannedEpcs.map { epcs ->
val knownEpcs = expectedItems.map { it.epc }.toSet()
epcs.filter { it !in knownEpcs }
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
fun onTagRead(epc: String) {
_scannedEpcs.update { current -> current + epc }
}
fun reset() {
_scannedEpcs.value = emptySet()
}
}
Set<String> — автоматическая дедупликация. Один EPC может прийти 50+ раз за одну инвентаризацию (ридер сканирует на высокой скорости), но в Set попадёт один раз.
Отображение результатов в реальном времени
LazyColumn с key(item.epc) — анимированное добавление найденных позиций:
@Composable
fun InventoryResultsScreen(session: InventorySession) {
val matched by session.matchedItems.collectAsState()
val missing by session.missingItems.collectAsState()
val scanned by session.scannedEpcs.collectAsState()
Column {
// Прогресс: X из Y найдено
LinearProgressIndicator(
progress = { if (session.expectedItems.isEmpty()) 0f
else matched.size.toFloat() / session.expectedItems.size }
)
Text("Найдено: ${matched.size}/${session.expectedItems.size}")
LazyColumn {
items(matched, key = { it.epc }) { item ->
InventoryItemRow(item = item, status = ItemStatus.FOUND)
}
items(missing, key = { it.epc }) { item ->
InventoryItemRow(item = item, status = ItemStatus.MISSING)
}
}
}
}
Офлайн-режим и синхронизация
Склад часто без Wi-Fi. Локальная БД через Room — хранит ожидаемый список и результаты:
@Entity(tableName = "inventory_sessions")
data class InventorySessionEntity(
@PrimaryKey val sessionId: String,
val locationId: String,
val startedAt: Long,
val completedAt: Long?,
val status: String // "in_progress", "completed", "synced"
)
@Entity(tableName = "scanned_tags")
data class ScannedTagEntity(
@PrimaryKey val epc: String,
val sessionId: String,
val firstSeenAt: Long,
val readCount: Int
)
readCount — количество reads одного тега за сессию. Аномально низкий (1–2) при том что соседние теги читались 20+ раз — признак плохого физического расположения тега или повреждения. Полезная метрика для QA.
После завершения сессии — синхронизация через WorkManager при появлении сети:
val syncRequest = OneTimeWorkRequestBuilder<InventorySyncWorker>()
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setInputData(workDataOf("session_id" to sessionId))
.build()
workManager.enqueueUniqueWork("sync_$sessionId", ExistingWorkPolicy.KEEP, syncRequest)
GS1 EPC декодирование
EPC — это не просто hex-строка. Это структурированный код: urn:epc:id:sgtin:0614141.107346.2017 (SGTIN — Serialized GTIN, глобальный торговый номер с серийным номером). Декодирование через GS1 EPC Information Services:
// SGTIN-96 декодирование (наиболее распространённый формат)
fun decodeSgtin96(epc: String): Sgtin96? {
val bytes = epc.chunked(2).map { it.toInt(16) }.toByteArray()
val bits = BigInteger(1, bytes)
val header = bits.shiftRight(88).and(BigInteger.valueOf(0xFF)).toInt()
if (header != 0x30) return null // Не SGTIN-96
val filter = bits.shiftRight(85).and(BigInteger.valueOf(0x07)).toInt()
val partition = bits.shiftRight(82).and(BigInteger.valueOf(0x07)).toInt()
// ... далее по partition table: company prefix + item reference + serial
}
Готовая библиотека: com.gs4tr.epcis:epcis-rest-client или org.fosstrak.epcis:epcis-repository-client.
Сроки
Мобильное приложение инвентаризации с Zebra/кастомным BLE-ридером, офлайн Room, GS1 декодированием и синхронизацией: 5 дней (простой склад, один ридер, один тип тегов) до 2–3 недель (multi-location, несколько типов тегов, кастомная EPC-схема, REST-интеграция с WMS).







