[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:
sky121113 2026-06-24 14:57:25 +08:00
parent 78f497d3f8
commit a24bdfdc0e
8 changed files with 331 additions and 44 deletions

View File

@ -10,7 +10,7 @@ plugins {
//
// =========================================================================
def verMinor = 2
def verPatch = 19
def verPatch = 20

View File

@ -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);
// v1v2雙向 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 設為 1synced 仍為 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());
}
}
}

View File

@ -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();
}

View File

@ -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);
}

View File

@ -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);

View File

@ -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; }
}

View File

@ -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);

View File

@ -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());
}