أغلب تطبيقات الموبايل بتفترض إنه في عندك اتصال إنترنت شغال دائماً. بس في لوجستيات المستودعات — وتحديداً داخل ثلاجات وغرف التبريد الضخمة المحاطة بألواح حديد وعزل حراري سميك — هذا الافتراض بنهار بلحظة واحدة.
أول مرة نزلت فيها أجرب نسخة أولية من تطبيق المسح بالموقع، شبكة الواي فاي ماتت تماماً أول ما خطينا خطوة وحدة وراء أبواب غرفة التجميد. كان العامل ماسك كرتونة وزنها 25 كيلو على الميزان، ضغط على زر ماسح الليزر البلوتوث، وشاشته علقت على لودينج ومؤشر انتظار عم يحاول يبعث طلب للـ API عشان يتأكد من صحة الباركود!
لما يكون عند العامل 400 صندوق لازم يسجلهم قبل ما تطلع الشاحنة الساعة 6:00 الصبح، تأخير 5 ثواني لكل صندوق مش بس بعصب فيه، بخليه ببساطة يرمي التلفون ويرجع يكتب بالقلم والدفتر.
عشان التطبيق ينجح، كان لازم يشتغل بشكل مستقل تماماً بدون أي حاجة للإنترنت: صفر اتصالات شبكة أثناء عملية المسح الحساسة، فك شيفرة الباركود ومطابقته فورياً على الجهاز، التقاط إشارات ماسح الليزر مباشرة، وحفظ كل حركة مسح في قاعدة بيانات SQLite محلية بوقت أقل من 10 مللي ثانية.
أجهزة المسح وفوضى شيفرات الباركود
الأجهزة اللي كنا بنتعامل معها كانت أجهزة أندرويد صناعية مصفحة (Rugged terminals) وماسحات ليزرية بتنلبس بالأصابع (Zebra وNewland). وكان عنا نوعين مختلفين تماماً من الباركود:
- لصاقات الموردين الخارجية (GS1-128): شحنات بتوصل بباركودات GS1-128 المعيارية اللي بتحتوي على معرّفات تطبيقات (Application Identifiers - AIs). الوزن الصافي بكون مشفر تحت المعرّف
(310x)للكيلوجرام أو(320x)للباوند، بجانب رقم الطبخة(10)والرقم التسلسلي(21). - طابعات الموازين الداخلية القديمة: موازين قديمة موجودة بالمستودع بتطبع أرقام داخلية خاصة من 13 خانة. اللصقة ممكن تبدأ برقم مثل
21، وبعده 5 خانات للوزن لازم تنقسم على1000.
لو حاولنا نبعث هالنصوص الخام لسيرفر عشان يفكها، كل كرتونة رح ترتبط بحظ الواي فاي التعبان بالمستودع. كان لازم كود التفكيك والمعالجة كله يعيش بالكامل على الموبايل نفسه.
[ ماسح ليزري صناعي / فرد باركود ]
│ (إشارات كيبورد HID بسرعة 5ms لكل حرف)
▼
[ حقل إدخال TextInput مخفي تماماً خارج الشاشة ]
├── مجمّع نصوص سريع (فارق أقل من 50ms بين الحروف)
└── نافذة منع الارتداد (Deduplication لمنع التكرار)
│
▼
[ محرك فك ومعالجة الباركود على الجهاز ]
├── خطوة 1: فحص صيغ الموازين الداخلية (البادئة + القص + المعامل)
├── خطوة 2: فك معرّفات GS1-128 (وتنظيف رموز AIM وFNC1)
└── خطوة 3: مطابقة حدود الوزن المسموح (الحد الأدنى والأقصى)
│
┌───────┴───────┐
▼ ▼
[ صوت واهتزاز فوري ] [ قاعدة بيانات SQLite (وضع WAL) ]
- صفارة صوتية بلحظتها - حفظ ذري بأقل من 4ms
- اهتزاز بقفازات العمل - فهارس سريعة لتقارير الجرد
1. فك شيفرة الباركود فورياً وبشكل متزامن (Synchronous)
كتبت نظام معالجة مزدوج الأولويات بلغة TypeScript: بفحص أولاً إذا الباركود بطابق صيغة الموازين الداخلية، وإذا لأ، بحوله لفك شيفرات GS1-128 القياسية:
export interface BarcodeLayout {
id: string;
prefix: string;
description: string;
weightStartIndex: number;
weightLength: number;
decimalDivisor: number;
}
export interface WeightResult {
weight: number | null;
labelType: "supplier" | "internal";
error?: "no_weight_ai" | "invalid_weight" | "out_of_range";
errorMessage?: string;
parsedAIs: Record<string, string>;
}
export function parseWeightFromBarcode(raw: string, product: Product): WeightResult {
const cleaned = raw.trim();
// 1. محاولة مطابقة صيغ موازين المستودع الداخلية أولاً
const layoutWeight = findWeightByLayout(cleaned, product.layoutId);
if (layoutWeight !== null) {
if (layoutWeight < product.minWeight || layoutWeight > product.maxWeight) {
return {
weight: null,
labelType: "internal",
error: "out_of_range",
errorMessage: `الوزن (${layoutWeight.toFixed(product.decimalPlaces)}) خارج الحدود المسموحة (${product.minWeight} - ${product.maxWeight} كغم)`,
parsedAIs: {},
};
}
return {
weight: layoutWeight,
labelType: "internal",
parsedAIs: {},
};
}
// 2. التحويل لمعيار GS1-128 الدولي
const parsedAIs = parseGs1Ais(cleaned);
const { weight, inKg } = extractWeightFromAis(parsedAIs);
if (weight === null) {
return {
weight: null,
labelType: "supplier",
error: "invalid_weight",
errorMessage: "لم يتم العثور على معرّف وزن صحيح في الباركود",
parsedAIs: parsedAIs.reduce((acc, p) => ({ ...acc, [p.ai]: p.value }), {}),
};
}
// تحويل الباوند لكيلوجرام إذا لزم الأمر
const kgWeight = inKg ? weight : weight / 2.20462262185;
if (kgWeight < product.minWeight || kgWeight > product.maxWeight) {
return {
weight: null,
labelType: "supplier",
error: "out_of_range",
errorMessage: `الوزن (${kgWeight.toFixed(product.decimalPlaces)}) خارج الحدود المسموحة`,
parsedAIs: {},
};
}
return {
weight: kgWeight,
labelType: "supplier",
parsedAIs: {},
};
}
خطأ في معيار GS1 كان رح يضيع الإنتاج
خلال التجارب الميدانية الأولى، واجهت مشكلة دقيقة جداً في معيار GS1 فهمتني قديش قراءة المواصفات بدقة بتفرق.
كنت مفترض إنه المعرّف 3102 (الوزن الصافي بالكيلو) دائماً بيعني تقسيم الرقم المستخرج على 100. بعدين وصلتنا شحنة لحوم دقيقة جداً عليها باركود بيبدأ بـ 3103. في معيار GS1، الرقم الرابع من 310x بحدد موقع الفاصلة العشرية ($10^x$). ولأني كنت مثبت القسمة على 100 بالكود، رقم مثل 025280 مع 3103 انقرأ على إنه 252.8 كغم بدل ما يكون 25.28 كغم!
عدلت الكود فوراً عشان يحسب المعامل ديناميكياً من الخانة الرابعة:
// حساب معامل الفاصلة العشرية ديناميكياً: '3102' -> 10^2 = 100، '3103' -> 10^3 = 1000
const decimals = parseInt(aiString.charAt(3), 10);
const divisor = Math.pow(10, decimals);
const parsedWeight = rawValue / divisor;
وشغلة ثانية مهمة: ماسحات الليزر الفيزيائية بتضيف أحياناً بادئة AIM مثل ]C1 أو بتفصل المقاطع برمز ASCII غير مرئي \x1D (Group Separator). إذا ما مسحتهم قبل ما تبدأ الفحص، الـ Regex رح يفشل بصمت.
2. التقاط إشارات ماسح الليزر بدون تخريب واجهة المستخدم
ماسحات الباركود الصناعية بتتعامل مع الموبايل كأنها كيبورد خارجي (HID). لما العامل يضغط الزناد، الجهاز بطبع حروف الباركود بسرعة فائقة (حوالي 5ms للحرف)، وبعدين بضغط Enter.
مشكلة كيبورد الشاشة في React Native
في React Native، عشان تستقبل نصوص لازم تحط <TextInput>. بس على أجهزة الأندرويد، مجرد ما تعمل Focus على حقل نصي، كيبورد الشاشة بطلع فوراً بوجهك.
الكيبورد كان ياخذ نص الشاشة، يغطي عدّاد الكراتين ومجموع الوزن، وينزل زر التأكيد لتحت الشاشة. وإعداد showSoftInputOnFocus={false} كان يشتغل على نسخ أندرويد الحديثة، بس على أجهزة المستودع القديمة (Android 10) كان الكيبورد يومض فجأة ويضيع الـ Focus.
الحل كان نقل حقل الـ <TextInput> بالكامل خارج حدود الشاشة:
// حقل إدخال مخفي خارج إطار الشاشة بالكامل لالتقاط إشارات الليزر بدون فتح الكيبورد
<TextInput
ref={inputRef}
onChangeText={handleBufferedInput}
style={{
position: 'absolute',
top: -9999,
left: -9999,
width: 1,
height: 1,
opacity: 0,
}}
autoFocus={true}
showSoftInputOnFocus={false}
autoComplete="off"
autoCorrect={false}
spellCheck={false}
keyboardType="default"
/>
معالجة ارتداد الليزر (Laser Bounce)
عمال المستودعات سريعين جداً، وغالباً بضغط زر الماسح مرتين متتاليات وراء بعض بسرعة. بدون فلترة، الكرتونة الوحدة كانت تتسجل مرتين خلال 40 ملي ثانية!
حطيت نافذة زمنية لمنع التكرار مدتها 150ms: إذا الباركود الجاي بطابق آخر باركود تسجل خلال هالمدة بتجاهله تماماً. أما لو مسح باركود ثاني مختلف فوراً (مسح سريع لكرتونتين وراء بعض)، بتسجل بلحظتها وبدون أي تأخير.
ولما المسح ينجح، التطبيق بطلع صوت صفارة فوري عبر expo-audio مع هزة اهتزاز قوية عبر expo-haptics. في غرفة تبريد مليانة صوت مراوح ضخمة والعمال لابسين قفازات حرارية سميكة، الصوت والاهتزاز بخلو العامل يتأكد إنه المسح تسجل بدون ما يضطر يرفع التلفون ويطلع على الشاشة بكل كرتونة.
3. حفظ البيانات محلياً: اختيار SQLite بدل AsyncStorage
لما تسجل مئات الصناديق بجلسة وحدة، استخدام AsyncStorage مخاطرة كبيرة. التطبيق رح يضطر يضغط البيانات كـ JSON عبر الـ Bridge، ولو الجهاز فضيت بطاريته فجأة بنص الوردية، البيانات ممكن تنضرب وتضيع.
استخدمنا expo-sqlite مع تفعيل وضع Write-Ahead Logging (WAL):
export async function createTables(db: SQLiteDatabase): Promise<void> {
// تفعيل وضع الكتابة المتزامنة فائق السرعة
await db.runAsync('PRAGMA journal_mode = WAL');
await db.runAsync('PRAGMA foreign_keys = ON');
await db.runAsync(`
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
minWeight REAL NOT NULL DEFAULT 0,
maxWeight REAL NOT NULL DEFAULT 0,
decimalPlaces INTEGER NOT NULL DEFAULT 0,
layoutId TEXT
)
`);
await db.runAsync(`
CREATE TABLE IF NOT EXISTS readings (
id TEXT PRIMARY KEY,
productId TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
weight REAL NOT NULL,
createdAt TEXT NOT NULL
)
`);
await db.runAsync('CREATE INDEX IF NOT EXISTS idx_readings_productId ON readings(productId)');
await db.runAsync('CREATE INDEX IF NOT EXISTS idx_readings_createdAt ON readings(createdAt)');
}
في وضع WAL، عمليات الحفظ في SQLite بتاخذ أقل من 4 ملي ثانية وما بتعلق الواجهة نهائياً. وحتى مع وجود 1,500 كرتونة مسجلة بنفس الجلسة، حساب مجموع الأوزان والعدد عبر فهارس SQL بأخذ أقل من 2ms.
4. تصدير البيانات ومشاركتها بدون سحابة وبدون إنترنت
لأنه التطبيق معزول تماماً عن الإنترنت، ما كان بقدر نستنى Background Sync على السحابة. العامل لما يخلص الوردية لازم يسلم كشف الميزان لمسؤول الشحن فوراً عند باب المستودع.
بنينا مسارين لتصدير البيانات مباشرة من التلفون:
- توليد ملفات PDF محلية (
expo-print): التطبيق برتب قراءات الجلسة في جدول HTML أنيق بوضح عدد الكراتين، الأوزان الفردية، والوزن الإجمالي. وبأمر واحد عبرexpo-printبطلع ملف PDF احترافي على الجهاز خلال نص ثانية. - تصدير ملفات CSV (
expo-file-system): للإدارة اللي بتحب تنزل الأوزان على إكسل وتدققها بأنظمة الـ ERP الخاصة فيها.
وبعدها العامل بقدر يشارك الملف عبر البلوتوث، أو Wi-Fi Direct، أو فلاشة USB-C مباشرة من قائمة المشاركة العادية بالنظام.
دروس مستفادة من هذا المشروع
- لا تحط اتصالات شبكة في المسار الحرج للشغل اليدوي: إذا في حدا حامل كرتونة 25 كيلو على الميزان، تطبيقك ما بصير يخليه يستنى طلب HTTP. افحص كل إشي واحفظه محلياً أولاً.
- الأجهزة الفيزيائية رح تفاجئك دائماً: ماسحات الليزر بتضيف حروف غريبة، الأزرار بترتد، وعناصر الموبايل العادية مش مصممة بطبيعتها لماسحات الباركود. لازم تجرب على الأجهزة الحقيقية بالمستودع مش بس على المحاكي.
- SQLite مع وضع WAL جبار على الموبايل: لأي تطبيق فيه كتابة بيانات متكررة وسجلات تدقيق، SQLite الأصلي أسرع، أضمن، ومستحيل يخرب مقارنة بحفظ JSON عشوائي في Key-Value Storage.
النقاش والتفاعلات
شارك بأفكارك، اطرح أسئلتك، أو اترك تفاعلاً بالأسفل عبر حسابك على GitHub.