[FEAT] 貨道效期+鎖定 App↔後台雙向同步(LWW)+ 現場效期輸入
1. SlotStructure 新增 lock_rev/lock_synced_rev/expiry_rev/expiry_synced_rev(per-field LWW 序號)與 getExpiryDate/setExpiryDate/getBatchNo helper;clearExpiry 明定不可遞增 rev。 2. DatabaseHelper:slot 表新增 4 個 rev 欄位;onUpgrade 由「DROP 全表重建」改為「ALTER ADD COLUMN」增量升級保留既有資料;並把現場已鎖貨道 lock_rev 設 1 以遷移上雲。 3. SlotsDao:新增單行 updateSlot 與單一交易 replaceAll;insert/read 帶 4 個 rev,取代 deleteAll+insertAll 全表重寫避免 race。 4. StarCloudAPI B009:每筆 data[] 加 is_locked/lock_rev/expiry_date/batch_no/expiry_rev;被鎖空貨道(有 lock/rev>0)也上報;上報成功以快照 rev 回填 synced_rev。 5. MqttService update_inventory 下行:解析 is_locked/lock_rev/expiry_rev,依 synced_rev 拒絕覆蓋未上報的現場編輯,只動「下行確實帶的鍵」,改用單行 updateSlot 寫入。 6. VmcSlotListActivity:進頁拍 lock/效期 baseline,存檔時實際變更才遞增對應 rev;saveData 改用 replaceAll 交易。 7. RecyclerVmcSlotListAdpter:效期欄位改為永遠可點,開 DatePicker 供補貨員現場輸入效期(含「清除效期」)。 8. build.gradle:verPatch 19→20。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
78f497d3f8
commit
a24bdfdc0e
@ -10,7 +10,7 @@ plugins {
|
||||
// 避免不同尺寸機台間版本號產生衝突。
|
||||
// =========================================================================
|
||||
def verMinor = 2
|
||||
def verPatch = 19
|
||||
def verPatch = 20
|
||||
|
||||
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import android.util.Log;
|
||||
|
||||
public class DatabaseHelper extends SQLiteOpenHelper {
|
||||
private static final String DATABASE_NAME = "mydata.db";
|
||||
private static final int DATABASE_VERSION = 1;
|
||||
private static final int DATABASE_VERSION = 2;
|
||||
|
||||
public enum column {
|
||||
COLUMN_ID("INTEGER PRIMARY KEY");
|
||||
@ -164,6 +164,11 @@ public class DatabaseHelper extends SQLiteOpenHelper {
|
||||
COUNT("INTEGER NOT NULL"),
|
||||
LOCK("TINYINT NOT NULL"),
|
||||
OTHER_DATA("TEXT"),
|
||||
// 雙向 LWW 同步序號(per-field)
|
||||
LOCK_REV("INTEGER NOT NULL DEFAULT 0"),
|
||||
LOCK_SYNCED_REV("INTEGER NOT NULL DEFAULT 0"),
|
||||
EXPIRY_REV("INTEGER NOT NULL DEFAULT 0"),
|
||||
EXPIRY_SYNCED_REV("INTEGER NOT NULL DEFAULT 0"),
|
||||
;
|
||||
|
||||
private String type;
|
||||
@ -267,7 +272,11 @@ public class DatabaseHelper extends SQLiteOpenHelper {
|
||||
slot.PRODUCT_ID.getCreateSQL(", ") +
|
||||
slot.COUNT.getCreateSQL(", ") +
|
||||
slot.LOCK.getCreateSQL(", ") +
|
||||
slot.OTHER_DATA.getCreateSQL(");")
|
||||
slot.OTHER_DATA.getCreateSQL(", ") +
|
||||
slot.LOCK_REV.getCreateSQL(", ") +
|
||||
slot.LOCK_SYNCED_REV.getCreateSQL(", ") +
|
||||
slot.EXPIRY_REV.getCreateSQL(", ") +
|
||||
slot.EXPIRY_SYNCED_REV.getCreateSQL(");")
|
||||
;
|
||||
|
||||
private static final String CREATE_TABLE_MEMO =
|
||||
@ -299,12 +308,31 @@ public class DatabaseHelper extends SQLiteOpenHelper {
|
||||
|
||||
@Override
|
||||
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
|
||||
exec(db, "DROP TABLE IF EXISTS " + settinglogs.TABLE.getName());
|
||||
exec(db, "DROP TABLE IF EXISTS " + settings.TABLE.getName());
|
||||
exec(db, "DROP TABLE IF EXISTS " + products.TABLE.getName());
|
||||
exec(db, "DROP TABLE IF EXISTS " + slot.TABLE.getName());
|
||||
exec(db, "DROP TABLE IF EXISTS " + memo.TABLE.getName());
|
||||
onCreate(db);
|
||||
// v1→v2:雙向 LWW 同步序號。採「ALTER ADD COLUMN」增量升級,保留既有 slot/lock/效期/庫存資料,
|
||||
// 不可 DROP 全表(否則本機補貨設定、現場鎖定狀態會全部遺失)。
|
||||
if (oldVersion < 2) {
|
||||
tryAddColumn(db, slot.TABLE.getName(), slot.LOCK_REV.getName(), "INTEGER NOT NULL DEFAULT 0");
|
||||
tryAddColumn(db, slot.TABLE.getName(), slot.LOCK_SYNCED_REV.getName(), "INTEGER NOT NULL DEFAULT 0");
|
||||
tryAddColumn(db, slot.TABLE.getName(), slot.EXPIRY_REV.getName(), "INTEGER NOT NULL DEFAULT 0");
|
||||
tryAddColumn(db, slot.TABLE.getName(), slot.EXPIRY_SYNCED_REV.getName(), "INTEGER NOT NULL DEFAULT 0");
|
||||
// 既有「現場已鎖定」貨道:lock_rev 設為 1(synced 仍為 0 = 未上報),
|
||||
// 升級後首次 B009 即以 1>0 透過常態 LWW 把現場鎖定遷移上雲,無需 app_uuid/重灌機制。
|
||||
try {
|
||||
exec(db, "UPDATE " + slot.TABLE.getName() + " SET " + slot.LOCK_REV.getName()
|
||||
+ "=1 WHERE " + slot.LOCK.getName() + "=1");
|
||||
} catch (Exception e) {
|
||||
Log.i("DatabaseHelper", "seed lock_rev on upgrade skipped: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 安全新增欄位:欄位已存在時忽略例外,避免升級重入時報錯。 */
|
||||
private void tryAddColumn(SQLiteDatabase db, String table, String column, String type) {
|
||||
try {
|
||||
exec(db, "ALTER TABLE " + table + " ADD COLUMN " + column + " " + type);
|
||||
} catch (Exception e) {
|
||||
Log.i("DatabaseHelper", "add column skipped (" + table + "." + column + "): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -38,10 +38,34 @@ public class SlotsDao {
|
||||
values.put(DatabaseHelper.slot.COUNT.getName(), data.getCount());
|
||||
values.put(DatabaseHelper.slot.LOCK.getName(), data.isLock());
|
||||
values.put(DatabaseHelper.slot.OTHER_DATA.getName(), data.getOtherData().toString());
|
||||
values.put(DatabaseHelper.slot.LOCK_REV.getName(), data.getLockRev());
|
||||
values.put(DatabaseHelper.slot.LOCK_SYNCED_REV.getName(), data.getLockSyncedRev());
|
||||
values.put(DatabaseHelper.slot.EXPIRY_REV.getName(), data.getExpiryRev());
|
||||
values.put(DatabaseHelper.slot.EXPIRY_SYNCED_REV.getName(), data.getExpirySyncedRev());
|
||||
|
||||
database.insert(DatabaseHelper.slot.TABLE.getName(), null, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 單行更新一個貨道(count/lock/otherData/4 個 rev),依 field+slot 定位。
|
||||
* 取代 MQTT 下行/局部更新時 deleteAll()+insertAll() 全表重寫,避免與 UI 存檔互相 race。
|
||||
*/
|
||||
public void updateSlot(SlotStructure data) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(DatabaseHelper.slot.PRODUCT_ID.getName(), data.getProduct().getProductID());
|
||||
values.put(DatabaseHelper.slot.COUNT.getName(), data.getCount());
|
||||
values.put(DatabaseHelper.slot.LOCK.getName(), data.isLock());
|
||||
values.put(DatabaseHelper.slot.OTHER_DATA.getName(), data.getOtherData() != null ? data.getOtherData().toString() : "{}");
|
||||
values.put(DatabaseHelper.slot.LOCK_REV.getName(), data.getLockRev());
|
||||
values.put(DatabaseHelper.slot.LOCK_SYNCED_REV.getName(), data.getLockSyncedRev());
|
||||
values.put(DatabaseHelper.slot.EXPIRY_REV.getName(), data.getExpiryRev());
|
||||
values.put(DatabaseHelper.slot.EXPIRY_SYNCED_REV.getName(), data.getExpirySyncedRev());
|
||||
|
||||
String whereClause = DatabaseHelper.slot.FIELD.getName() + "=? AND " + DatabaseHelper.slot.SLOT.getName() + "=?";
|
||||
String[] whereArgs = { String.valueOf(data.getField()), String.valueOf(data.getSlot()) };
|
||||
database.update(DatabaseHelper.slot.TABLE.getName(), values, whereClause, whereArgs);
|
||||
}
|
||||
|
||||
// 更新指定 field 和 slot 的數量(count)
|
||||
public void updateCount(int field, int slot, int newCount) {
|
||||
ContentValues values = new ContentValues();
|
||||
@ -77,6 +101,29 @@ public class SlotsDao {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 以單一交易「清空後重建」整個 slot 表,避免 deleteAll() 與 insertAll() 之間的空窗被其他連線讀到空表,
|
||||
* 或與背景下行寫入互相覆蓋。供 UI 存檔使用。
|
||||
*/
|
||||
public void replaceAll(Map<Integer, List<SlotStructure>> slotMap) {
|
||||
database.beginTransaction();
|
||||
try {
|
||||
database.delete(DatabaseHelper.slot.TABLE.getName(), null, null);
|
||||
for (SlotField slotField : SlotField.values()) {
|
||||
if (slotMap.containsKey(slotField.getField())) {
|
||||
List<SlotStructure> slotList = slotMap.get(slotField.getField());
|
||||
if (slotList == null) continue;
|
||||
for (SlotStructure data : slotList) {
|
||||
insertOne(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
database.setTransactionSuccessful();
|
||||
} finally {
|
||||
database.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
// 獲取所有 Products
|
||||
public Map<Integer, List<SlotStructure>> getAll() {
|
||||
Map<Integer, List<SlotStructure>> slotMap = new HashMap<>();
|
||||
@ -99,6 +146,10 @@ public class SlotsDao {
|
||||
int count = cursor.getInt(cursor.getColumnIndexOrThrow(DatabaseHelper.slot.COUNT.getName()));
|
||||
boolean lock = cursor.getInt(cursor.getColumnIndexOrThrow(DatabaseHelper.slot.LOCK.getName())) == 1;
|
||||
String otherData = cursor.getString(cursor.getColumnIndexOrThrow(DatabaseHelper.slot.OTHER_DATA.getName()));
|
||||
int lockRev = cursor.getInt(cursor.getColumnIndexOrThrow(DatabaseHelper.slot.LOCK_REV.getName()));
|
||||
int lockSyncedRev = cursor.getInt(cursor.getColumnIndexOrThrow(DatabaseHelper.slot.LOCK_SYNCED_REV.getName()));
|
||||
int expiryRev = cursor.getInt(cursor.getColumnIndexOrThrow(DatabaseHelper.slot.EXPIRY_REV.getName()));
|
||||
int expirySyncedRev = cursor.getInt(cursor.getColumnIndexOrThrow(DatabaseHelper.slot.EXPIRY_SYNCED_REV.getName()));
|
||||
|
||||
JSONObject jsonObject = new JSONObject();
|
||||
try {
|
||||
@ -117,7 +168,12 @@ public class SlotsDao {
|
||||
slotMap.put(field, new ArrayList<>());
|
||||
}
|
||||
|
||||
slotMap.get(field).add(new SlotStructure(field, slot, product, count, lock, jsonObject));
|
||||
SlotStructure slotData = new SlotStructure(field, slot, product, count, lock, jsonObject);
|
||||
slotData.setLockRev(lockRev);
|
||||
slotData.setLockSyncedRev(lockSyncedRev);
|
||||
slotData.setExpiryRev(expiryRev);
|
||||
slotData.setExpirySyncedRev(expirySyncedRev);
|
||||
slotMap.get(field).add(slotData);
|
||||
}
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
@ -411,17 +411,33 @@ public class StarCloudAPI {
|
||||
String receive = "";
|
||||
try {
|
||||
JSONArray jsonArray = new JSONArray();
|
||||
// 記錄本次快照上報的 rev,成功後回填 synced_rev(用快照值而非當下值,避免吃掉上報期間的新編輯)
|
||||
final java.util.List<int[]> reportedRevs = new java.util.ArrayList<>();
|
||||
for (SlotField slotField : SlotField.values()) {
|
||||
for (SlotStructure slotData : MyApp.getInstance().getSlotList(slotField.getField())) {
|
||||
// 判斷是否有商品(若無商品 ID 為 "?",則不傳送,觸發後台全量同步移除邏輯)
|
||||
if (slotData.getProduct() != null && !slotData.getProduct().getProductID().equals("?")) {
|
||||
JSONObject item = new JSONObject();
|
||||
item.put("tid", String.valueOf(slotData.getSlot())); // 貨道編號轉字串
|
||||
item.put("t060v00", slotData.getProduct().getProductID()); // 商品 ID
|
||||
item.put("num", String.valueOf(slotData.getCount())); // 實體庫存轉字串
|
||||
// 不送 status 參數(依據新文件,只送 tid, t060v00, num)
|
||||
jsonArray.put(item);
|
||||
boolean hasProduct = slotData.getProduct() != null
|
||||
&& !slotData.getProduct().getProductID().equals("?");
|
||||
// 即使無商品,只要有鎖定或 rev>0(現場改過)也要上報,否則被鎖的空貨道狀態無法同步
|
||||
boolean hasState = slotData.isLock()
|
||||
|| slotData.getLockRev() > 0 || slotData.getExpiryRev() > 0;
|
||||
if (!hasProduct && !hasState) {
|
||||
continue; // 無商品又無狀態 → 不送,維持後台全量同步移除語意
|
||||
}
|
||||
JSONObject item = new JSONObject();
|
||||
item.put("tid", String.valueOf(slotData.getSlot())); // 貨道編號轉字串
|
||||
item.put("t060v00", hasProduct ? slotData.getProduct().getProductID() : ""); // 商品 ID
|
||||
item.put("num", String.valueOf(slotData.getCount())); // 實體庫存轉字串
|
||||
// 雙向 LWW:鎖定 + 效期/批號 + 各自的編輯序號
|
||||
item.put("is_locked", slotData.isLock());
|
||||
item.put("lock_rev", slotData.getLockRev());
|
||||
String exp = slotData.getExpiryDate();
|
||||
item.put("expiry_date", (exp == null || exp.isEmpty()) ? JSONObject.NULL : exp);
|
||||
String batch = slotData.getBatchNo();
|
||||
item.put("batch_no", (batch == null || batch.isEmpty()) ? JSONObject.NULL : batch);
|
||||
item.put("expiry_rev", slotData.getExpiryRev());
|
||||
jsonArray.put(item);
|
||||
reportedRevs.add(new int[]{ slotField.getField(), slotData.getSlot(),
|
||||
slotData.getLockRev(), slotData.getExpiryRev() });
|
||||
}
|
||||
}
|
||||
|
||||
@ -469,6 +485,23 @@ public class StarCloudAPI {
|
||||
|
||||
if (responseCode >= 200 && responseCode < 300) {
|
||||
Log.i(TAG, "B009 Success: " + receive);
|
||||
// 回填 synced_rev = 本次快照上報的 rev(用快照值,期間若有新編輯則 rev>synced 仍視為未同步)
|
||||
try {
|
||||
com.unibuy.smartdevice.database.SlotsDao revDao =
|
||||
new com.unibuy.smartdevice.database.SlotsDao(handlerMain.getContext());
|
||||
for (int[] r : reportedRevs) {
|
||||
try {
|
||||
SlotStructure s = MyApp.getInstance().getSlotData(r[0], r[1]);
|
||||
if (s == null) continue;
|
||||
if (s.getLockRev() == r[2]) s.setLockSyncedRev(r[2]);
|
||||
if (s.getExpiryRev() == r[3]) s.setExpirySyncedRev(r[3]);
|
||||
revDao.updateSlot(s);
|
||||
} catch (Exception ignore) {}
|
||||
}
|
||||
revDao.close();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "B009 synced_rev backfill error: " + e.getMessage());
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "B009 Failed (" + responseCode + "): " + receive);
|
||||
}
|
||||
|
||||
@ -356,7 +356,15 @@ public class MqttService extends Service {
|
||||
int slotNo = -1;
|
||||
int stock = -1;
|
||||
String expiryDate = null;
|
||||
boolean hasExpiryKey = false;
|
||||
String batchNo = null;
|
||||
boolean hasBatchKey = false;
|
||||
boolean isLocked = false;
|
||||
boolean hasLock = false;
|
||||
int lockRev = 0;
|
||||
boolean hasLockRev = false;
|
||||
int expiryRev = 0;
|
||||
boolean hasExpiryRev = false;
|
||||
|
||||
if (command.getPayload() != null && command.getPayload().isJsonObject()) {
|
||||
com.google.gson.JsonObject payloadObj = command.getPayload().getAsJsonObject();
|
||||
@ -364,10 +372,28 @@ public class MqttService extends Service {
|
||||
slotNo = payloadObj.get("slot_no").getAsInt();
|
||||
if (payloadObj.has("stock"))
|
||||
stock = payloadObj.get("stock").getAsInt();
|
||||
if (payloadObj.has("expiry_date") && !payloadObj.get("expiry_date").isJsonNull())
|
||||
expiryDate = payloadObj.get("expiry_date").getAsString();
|
||||
if (payloadObj.has("batch_no") && !payloadObj.get("batch_no").isJsonNull())
|
||||
batchNo = payloadObj.get("batch_no").getAsString();
|
||||
if (payloadObj.has("expiry_date")) {
|
||||
hasExpiryKey = true;
|
||||
if (!payloadObj.get("expiry_date").isJsonNull())
|
||||
expiryDate = payloadObj.get("expiry_date").getAsString();
|
||||
}
|
||||
if (payloadObj.has("batch_no")) {
|
||||
hasBatchKey = true;
|
||||
if (!payloadObj.get("batch_no").isJsonNull())
|
||||
batchNo = payloadObj.get("batch_no").getAsString();
|
||||
}
|
||||
if (payloadObj.has("is_locked") && !payloadObj.get("is_locked").isJsonNull()) {
|
||||
hasLock = true;
|
||||
isLocked = payloadObj.get("is_locked").getAsBoolean();
|
||||
}
|
||||
if (payloadObj.has("lock_rev") && !payloadObj.get("lock_rev").isJsonNull()) {
|
||||
hasLockRev = true;
|
||||
lockRev = payloadObj.get("lock_rev").getAsInt();
|
||||
}
|
||||
if (payloadObj.has("expiry_rev") && !payloadObj.get("expiry_rev").isJsonNull()) {
|
||||
hasExpiryRev = true;
|
||||
expiryRev = payloadObj.get("expiry_rev").getAsInt();
|
||||
}
|
||||
}
|
||||
|
||||
if (slotNo <= 0 || stock < 0) {
|
||||
@ -381,24 +407,57 @@ public class MqttService extends Service {
|
||||
if (slotData != null) {
|
||||
slotData.setCount(stock);
|
||||
|
||||
// 更新選填欄位至 otherData
|
||||
org.json.JSONObject otherData = slotData.getOtherData();
|
||||
if (otherData == null)
|
||||
otherData = new org.json.JSONObject();
|
||||
try {
|
||||
if (expiryDate != null)
|
||||
otherData.put("expiry_date", expiryDate);
|
||||
if (batchNo != null)
|
||||
otherData.put("batch_no", batchNo);
|
||||
} catch (org.json.JSONException e) {
|
||||
logs.info("Failed to update JSON otherData: " + e.getMessage());
|
||||
|
||||
// 鎖定 LWW:本地 rev>synced_rev 表示有未上報的現場編輯 → 拒絕被下行覆蓋
|
||||
if (hasLock) {
|
||||
if (slotData.getLockRev() <= slotData.getLockSyncedRev()) {
|
||||
slotData.setLock(isLocked);
|
||||
int r = hasLockRev ? lockRev : slotData.getLockSyncedRev();
|
||||
slotData.setLockRev(r);
|
||||
slotData.setLockSyncedRev(r);
|
||||
} else {
|
||||
logs.info("update_inventory: 本地有未上報鎖定編輯,略過下行 is_locked slot=" + slotNo);
|
||||
}
|
||||
}
|
||||
|
||||
// 效期/批號 LWW(只動「下行確實帶了該鍵」的欄位,避免缺鍵時誤清本地效期)
|
||||
if (hasExpiryRev) {
|
||||
if (slotData.getExpiryRev() <= slotData.getExpirySyncedRev()) {
|
||||
try {
|
||||
if (hasExpiryKey) {
|
||||
if (expiryDate != null) otherData.put("expiry_date", expiryDate);
|
||||
else otherData.remove("expiry_date");
|
||||
}
|
||||
if (hasBatchKey) {
|
||||
if (batchNo != null) otherData.put("batch_no", batchNo);
|
||||
else otherData.remove("batch_no");
|
||||
}
|
||||
} catch (org.json.JSONException e) {
|
||||
logs.info("Failed to update JSON otherData: " + e.getMessage());
|
||||
}
|
||||
slotData.setExpiryRev(expiryRev);
|
||||
slotData.setExpirySyncedRev(expiryRev);
|
||||
} else {
|
||||
logs.info("update_inventory: 本地有未上報效期編輯,略過下行 expiry slot=" + slotNo);
|
||||
}
|
||||
} else {
|
||||
// 舊版相容:無 expiry_rev,沿用原本「有給就寫」行為
|
||||
try {
|
||||
if (expiryDate != null) otherData.put("expiry_date", expiryDate);
|
||||
if (batchNo != null) otherData.put("batch_no", batchNo);
|
||||
} catch (org.json.JSONException e) {
|
||||
logs.info("Failed to update JSON otherData: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
slotData.setOtherData(otherData);
|
||||
|
||||
// 單行更新,避免 deleteAll()+insertAll() 全表重寫與 UI 存檔 race
|
||||
com.unibuy.smartdevice.database.SlotsDao slotsDao = new com.unibuy.smartdevice.database.SlotsDao(
|
||||
getApplicationContext());
|
||||
slotsDao.deleteAll();
|
||||
slotsDao.insertAll(MyApp.getInstance().getSlotMap());
|
||||
slotsDao.updateSlot(slotData);
|
||||
slotsDao.close();
|
||||
|
||||
logs.info("Inventory updated: slot=" + slotNo + ", stock=" + stock);
|
||||
|
||||
@ -12,6 +12,15 @@ public class SlotStructure {
|
||||
private JSONObject otherData;
|
||||
public int cartSelectedCount;// 本地欄位:購物車已選數量(General flavor 多品項購物車使用)
|
||||
|
||||
// ── 雙向 LWW 同步序號(per-field)──
|
||||
// rev:店員在貨道管理「存檔且實際變更」時才遞增;synced_rev:已被後台確認(B009 成功/下行採用)的 rev。
|
||||
// rev > synced_rev = 有未上報的現場編輯 → 收到下行時該欄位拒絕被覆蓋。
|
||||
// 注意:出貨扣到 0 的 clearExpiry / 自動扣庫存 / 一鍵補貨「不可」遞增 rev。
|
||||
private int lockRev;
|
||||
private int lockSyncedRev;
|
||||
private int expiryRev;
|
||||
private int expirySyncedRev;
|
||||
|
||||
/*
|
||||
JSONObject jsonObject = new JSONObject();
|
||||
jsonObject.put("key", apiKey);
|
||||
@ -146,6 +155,7 @@ public class SlotStructure {
|
||||
/**
|
||||
* 清除效期與批號。通常在貨道庫存歸 0(賣完)時呼叫,避免舊批貨效期殘留,
|
||||
* 導致下次本機補新貨時被舊效期誤鎖成「暫不販售」。
|
||||
* 注意:這是「自動」清除,呼叫端不可遞增 expiryRev(否則賣完會把後台預設的下批效期洗掉)。
|
||||
*/
|
||||
public void clearExpiry() {
|
||||
if (otherData == null)
|
||||
@ -153,4 +163,42 @@ public class SlotStructure {
|
||||
otherData.remove("expiry_date");
|
||||
otherData.remove("batch_no");
|
||||
}
|
||||
|
||||
/** 批號(otherData.batch_no),無則回空字串。 */
|
||||
public String getBatchNo() {
|
||||
if (otherData == null || !otherData.has("batch_no"))
|
||||
return "";
|
||||
try {
|
||||
String b = otherData.getString("batch_no");
|
||||
return (b == null || "null".equalsIgnoreCase(b)) ? "" : b;
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定效期字串(yyyy-MM-dd)。供貨道管理 UI 編輯用;空字串/null 代表清除(無效期管控)。
|
||||
* 不在此遞增 rev —— rev 由 saveData 與 baseline 比對後統一遞增。
|
||||
*/
|
||||
public void setExpiryDate(String expiry) {
|
||||
if (otherData == null)
|
||||
otherData = new JSONObject();
|
||||
try {
|
||||
if (expiry == null || expiry.trim().isEmpty()) {
|
||||
otherData.remove("expiry_date");
|
||||
} else {
|
||||
otherData.put("expiry_date", expiry.trim());
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public int getLockRev() { return lockRev; }
|
||||
public void setLockRev(int lockRev) { this.lockRev = lockRev; }
|
||||
public int getLockSyncedRev() { return lockSyncedRev; }
|
||||
public void setLockSyncedRev(int lockSyncedRev) { this.lockSyncedRev = lockSyncedRev; }
|
||||
public int getExpiryRev() { return expiryRev; }
|
||||
public void setExpiryRev(int expiryRev) { this.expiryRev = expiryRev; }
|
||||
public int getExpirySyncedRev() { return expirySyncedRev; }
|
||||
public void setExpirySyncedRev(int expirySyncedRev) { this.expirySyncedRev = expirySyncedRev; }
|
||||
}
|
||||
|
||||
@ -76,20 +76,20 @@ public class RecyclerVmcSlotListAdpter extends RecyclerView.Adapter<RecyclerVmcS
|
||||
// numberPassword 預設會遮罩輸入,關閉遮罩以顯示明碼數字
|
||||
holder.binding.editCount.setTransformationMethod(null);
|
||||
|
||||
// 效期顯示:有設定效期才顯示;已過期以紅字標示,方便維護人員辨識
|
||||
// 效期顯示+編輯:永遠可點擊,補貨員現場輸入;空白顯示「設定效期」,已過期紅字標示
|
||||
String expiry = slot.getExpiryDate();
|
||||
holder.binding.textExpiry.setVisibility(View.VISIBLE);
|
||||
if (expiry.isEmpty()) {
|
||||
holder.binding.textExpiry.setVisibility(View.GONE);
|
||||
holder.binding.textExpiry.setText("設定效期");
|
||||
holder.binding.textExpiry.setTextColor(androidx.core.content.ContextCompat.getColor(context, R.color.black));
|
||||
} else if (slot.isExpired()) {
|
||||
holder.binding.textExpiry.setText("效期 " + expiry + "(已過期)");
|
||||
holder.binding.textExpiry.setTextColor(androidx.core.content.ContextCompat.getColor(context, R.color.warning));
|
||||
} else {
|
||||
holder.binding.textExpiry.setVisibility(View.VISIBLE);
|
||||
if (slot.isExpired()) {
|
||||
holder.binding.textExpiry.setText("效期 " + expiry + "(已過期)");
|
||||
holder.binding.textExpiry.setTextColor(androidx.core.content.ContextCompat.getColor(context, R.color.warning));
|
||||
} else {
|
||||
holder.binding.textExpiry.setText("效期 " + expiry);
|
||||
holder.binding.textExpiry.setTextColor(androidx.core.content.ContextCompat.getColor(context, R.color.black));
|
||||
}
|
||||
holder.binding.textExpiry.setText("效期 " + expiry);
|
||||
holder.binding.textExpiry.setTextColor(androidx.core.content.ContextCompat.getColor(context, R.color.black));
|
||||
}
|
||||
holder.binding.textExpiry.setOnClickListener(v -> showExpiryPicker(position));
|
||||
|
||||
if (slot.isLock()) {
|
||||
holder.binding.textLock.setChecked(false);
|
||||
@ -296,6 +296,39 @@ public class RecyclerVmcSlotListAdpter extends RecyclerView.Adapter<RecyclerVmcS
|
||||
notifyItemChanged(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* 開效期日期選擇器供補貨員現場輸入;提供「清除效期」表示無效期管控。
|
||||
* 僅改記憶體值(rev 由 VmcSlotListActivity 存檔時與 baseline 比對後統一遞增)。
|
||||
*/
|
||||
private void showExpiryPicker(int position) {
|
||||
final SlotStructure slot = slotList.get(position);
|
||||
java.util.Calendar cal = java.util.Calendar.getInstance();
|
||||
String cur = slot.getExpiryDate();
|
||||
try {
|
||||
if (cur != null && cur.length() >= 10) {
|
||||
cal.set(Integer.parseInt(cur.substring(0, 4)),
|
||||
Integer.parseInt(cur.substring(5, 7)) - 1,
|
||||
Integer.parseInt(cur.substring(8, 10)));
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
android.app.DatePickerDialog dialog = new android.app.DatePickerDialog(
|
||||
context,
|
||||
(view, year, month, day) -> {
|
||||
String date = String.format(java.util.Locale.US, "%04d-%02d-%02d", year, month + 1, day);
|
||||
slot.setExpiryDate(date);
|
||||
notifyItemChanged(position);
|
||||
},
|
||||
cal.get(java.util.Calendar.YEAR),
|
||||
cal.get(java.util.Calendar.MONTH),
|
||||
cal.get(java.util.Calendar.DAY_OF_MONTH));
|
||||
dialog.setButton(android.app.DatePickerDialog.BUTTON_NEUTRAL, "清除效期", (d, w) -> {
|
||||
slot.setExpiryDate("");
|
||||
notifyItemChanged(position);
|
||||
});
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
public void delProductData(int position) {
|
||||
slotList.get(position).setProduct(new ProductStructure("?", "", "", 0, 0, 0, 0, "", "","",""));
|
||||
slotList.get(position).setCount(0);
|
||||
|
||||
@ -38,6 +38,9 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
|
||||
private RecyclerVmcSlotListAdpter recyclerVmcSlotListAdpter;
|
||||
private HttpAPI httpAPI;
|
||||
private StarCloudAPI starCloudAPI;
|
||||
// rev 遞增 baseline:進畫面時快照各貨道 lock/效期,存檔時與此比對,「實際變更」才遞增對應 rev。
|
||||
private final Map<Integer, Boolean> baselineLock = new HashMap<>();
|
||||
private final Map<Integer, String> baselineExpiry = new HashMap<>();
|
||||
|
||||
@Override
|
||||
protected Context setCtx() {
|
||||
@ -128,6 +131,7 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
|
||||
recyclerVmcSlotListAdpter = new RecyclerVmcSlotListAdpter(this, resultLauncher, MyApp.getInstance().getSlotList(SlotField.VMC.getField()));
|
||||
binding.recyclerSlotList.setAdapter(recyclerVmcSlotListAdpter);
|
||||
binding.recyclerSlotList.setLayoutManager(new GridLayoutManager(this, 3));
|
||||
captureRevBaseline();
|
||||
|
||||
// dataShow();
|
||||
|
||||
@ -216,11 +220,37 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
|
||||
finish();
|
||||
}
|
||||
|
||||
/** 進畫面/存檔後快照各貨道 lock 與效期,作為下次存檔判斷「是否實際變更」的 baseline。 */
|
||||
private void captureRevBaseline() {
|
||||
baselineLock.clear();
|
||||
baselineExpiry.clear();
|
||||
for (SlotStructure s : MyApp.getInstance().getSlotList(SlotField.VMC.getField())) {
|
||||
baselineLock.put(s.getSlot(), s.isLock());
|
||||
baselineExpiry.put(s.getSlot(), s.getExpiryDate());
|
||||
}
|
||||
}
|
||||
|
||||
/** 存檔時:與 baseline 比對,鎖定或效期「實際變更」才遞增對應 rev(每次存檔至多 +1,避免重複切換虛增)。 */
|
||||
private void applyRevBumps() {
|
||||
for (SlotStructure s : MyApp.getInstance().getSlotList(SlotField.VMC.getField())) {
|
||||
Boolean bLock = baselineLock.get(s.getSlot());
|
||||
if (bLock != null && bLock != s.isLock()) {
|
||||
s.setLockRev(Math.max(s.getLockRev(), s.getLockSyncedRev()) + 1);
|
||||
}
|
||||
String bExp = baselineExpiry.get(s.getSlot());
|
||||
String curExp = s.getExpiryDate();
|
||||
if (bExp != null && !bExp.equals(curExp)) {
|
||||
s.setExpiryRev(Math.max(s.getExpiryRev(), s.getExpirySyncedRev()) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void saveData() {
|
||||
applyRevBumps();
|
||||
SlotsDao slotsDao = new SlotsDao(this);
|
||||
slotsDao.deleteAll();
|
||||
slotsDao.insertAll(MyApp.getInstance().getSlotMap());
|
||||
slotsDao.replaceAll(MyApp.getInstance().getSlotMap()); // 單一交易清空重建,避免與背景下行寫入 race / 讀到空表
|
||||
slotsDao.close();
|
||||
captureRevBaseline(); // 存檔後重設 baseline,避免同次連續存檔重複遞增 rev
|
||||
getLogs().info("save count:"+ MyApp.getInstance().getSlotList(SlotField.VMC.getField()).size());
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user