Реалізація RFID-інвентаризації у мобільних додатках
RFID-інвентаризація відрізняється від штрихкодів — це масове одночасне читання. Оператор складу проходить зчитувачем вздовж полиці і читає 200 тегів за 3 секунди. Задача мобільного додатка: отримати цей потік тегів без втрат, дедублювати (один тег може прочитатися багато разів), порівняти з очікуваним списком і показати розбіжності. Здається просто, поки не зіткнетеся з дублікатами, тегами поза сесією та офлайн-вимогами.
Архітектура RFID-інвентаризації
Сесія інвентаризації — кінцевий автомат з чіткими переходами:
IDLE → SCANNING → PROCESSING → COMPLETED
↓
PAUSED → SCANNING
На кожен прочитаний тег — оновлення у 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 як джерело істини, синхронізація через WorkManager при відновленні з'єднання. Конфлікти при злитті вирішуються стратегією «остання запис перемагає» з timestamp на сервері або через чергу операцій з ідемпотентними ключами.
Типова проблема — транзакції при масовому приймані товару. Якщо користувач сканував 200 позицій і додаток упав на 150-й, потрібно або відкотити все, або продовжити з точки зупину. Room підтримує транзакції через @Transaction, але межа «що вважати завершеною операцією» повинна бути явно визначена на рівні бізнес-логіки.
@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 — кількість читань одного тегу за сесію. Аномально низький (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 тижнів (багато локацій, кілька типів тегів, користувацька EPC-схема, REST WMS інтеграція).







