[FIX] 鎖貨道/效期只鎖該貨道不再鎖整商品,同商品多貨道改逐道出貨

1. VmcFragment.updateFlowerBuyList():銷售卡合併同商品多貨道改為「任一可賣貨道(count>0且未鎖未過期)即可售」,庫存只計可賣貨道加總;全部不可賣才依 鎖定>過期>售完 顯示對應遮罩。修正鎖一個貨道或單一貨道過期就把整個商品擋掉的問題。
2. MyApp 新增 allocateBuyStructures():把購買數量依可賣貨道 greedy 拆成多筆 count=1 的 BuyStructure,規避 VMC 出貨狀態機不支援單筆 quantity>1(出完第一個會卡 40 秒看門狗);庫存不足才溢位回代表道,交既有「出貨失敗→後台依 dispense 退款」保護網。
3. CartViewModel.convertToBuyStructure():多品項購物車改用 allocateBuyStructures 拆道,避免同商品買多個時重複出同一貨道而卡頓/出貨失敗。
4. DialogProductDetail 直購:改走 allocateBuyStructures 拆道。
5. PickupSheetSaleFlow:中國醫領藥 buildMaterialSlotMap 與 isAvailableCmuhStock 排除鎖定/過期藥道,避免藥單驗證通過卻在出貨時失敗。
6. build.gradle:verPatch 18→19(versionName 依硬體規格產生)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sky121113 2026-06-24 10:22:03 +08:00
parent 8db3fab1c0
commit 90f86cf3c9
6 changed files with 122 additions and 31 deletions

View File

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

View File

@ -885,6 +885,78 @@ public class MyApp extends Application {
throw new LogsEmptyException(logs, "Slot data not found");
}
/**
* 某商品要買 quantity 依可賣貨道(count>0 && !lock && !expired)拆成多筆 count=1 BuyStructure
*
* 用途VMC 出貨狀態機不支援單筆 DispatchStructure quantity>1出完第 1 個後 count<quantity
* 狀態機不會主動再觸發下一次出貨只能等 40 秒看門狗逾時被動補出形同卡死因此多數量必須在
* 進狀態機之前就拆成一筆一個與中國醫領藥既有做法一致
*
* greedy 分配優先用代表貨道(representative)再依貨道號遞增溢位到下一個可賣道盡量集中以維持
* 貨道補貨整齊單道不足才換道可賣總庫存若仍不足 quantity例如加入購物車後庫存又被消耗
* 剩餘數量掛回代表貨道交由既有出貨失敗後台依 dispense 退款保護網處理不在此處吞掉訂單金額
*
* 注意representative 可能是銷售畫面合併後的代表卡(getCount() 為加總值)所以這裡一律以
* getSlotList(field) 的真實逐道庫存為準不信任 representative.getCount()
*/
public List<BuyStructure> allocateBuyStructures(SlotStructure representative, int quantity) {
List<BuyStructure> result = new ArrayList<>();
if (representative == null || quantity <= 0) {
return result;
}
ProductStructure repProduct = representative.getProduct();
if (repProduct == null || repProduct.getProductID() == null) {
// 退化情形仍維持一筆一個不變式避免漏單或重新塞回 quantity>1
for (int i = 0; i < quantity; i++) {
result.add(new BuyStructure(representative.getField(), representative.getSlot(), repProduct, 1));
}
return result;
}
int field = representative.getField();
String pid = repProduct.getProductID();
int preferredSlot = representative.getSlot();
// 收集同商品可賣貨道代表道優先其餘依貨道號遞增
SlotStructure preferred = null;
List<SlotStructure> others = new ArrayList<>();
List<SlotStructure> slotList = getSlotList(field);
if (slotList != null) {
for (SlotStructure s : slotList) {
if (s == null || s.getProduct() == null) continue;
if (!pid.equals(s.getProduct().getProductID())) continue;
if (s.getCount() <= 0 || s.isLock() || s.isExpired()) continue;
if (s.getSlot() == preferredSlot) {
preferred = s;
} else {
others.add(s);
}
}
}
others.sort((a, b) -> Integer.compare(a.getSlot(), b.getSlot()));
List<SlotStructure> candidates = new ArrayList<>();
if (preferred != null) candidates.add(preferred);
candidates.addAll(others);
// greedy 配貨每個候選道按其真實庫存填逐一拆成 count=1
int need = quantity;
for (SlotStructure s : candidates) {
if (need <= 0) break;
int take = Math.min(need, s.getCount());
for (int i = 0; i < take; i++) {
result.add(new BuyStructure(s.getField(), s.getSlot(), s.getProduct(), 1));
}
need -= take;
}
// 可賣庫存不足 quantity 的溢位掛回代表貨道交既有失敗退款保護網
for (int i = 0; i < need; i++) {
result.add(new BuyStructure(field, preferredSlot, repProduct, 1));
}
return result;
}
public List<MarketingPlanStructure> getMarketingPlanList() {
return marketingPlanList;
}

View File

@ -386,13 +386,19 @@ public class PickupSheetSaleFlow extends SaleFlowHandler {
MyApp.getInstance().getLogs().info("Skip invalid VMC slot for CMUH pickup: " + slotData.getSlot());
continue;
}
// 鎖貨道/效期過期的藥道不得列入可用清單否則庫存驗證與分配會把它算進去出貨時才失敗
if (slotData.isLock() || slotData.isExpired()) {
MyApp.getInstance().getLogs().info("Skip locked/expired VMC slot for CMUH pickup: slot=" + slotData.getSlot());
continue;
}
materialSlotMap.computeIfAbsent(materialCode.trim(), k -> new ArrayList<>()).add(slotData);
}
return materialSlotMap;
}
private boolean isAvailableCmuhStock(SlotStructure slotData) {
return slotData != null && slotData.getCount() > 0 && slotData.getCount() <= 20;
return slotData != null && slotData.getCount() > 0 && slotData.getCount() <= 20
&& !slotData.isLock() && !slotData.isExpired();
}
private int safeParseInt(String value, int defaultValue) {

View File

@ -267,36 +267,48 @@ public class VmcFragment extends FragmentAbstract {
new TypeToken<List<SlotStructure>>() {}.getType()
);
if (rawSlots == null || rawSlots.isEmpty()) return;
// 2. 处理数据确保不修改任何对象
Map<String, SlotStructure> uniqueProductMap = new LinkedHashMap<>();
// 2. ProductID 分組同一商品的多個貨道收成一組
LinkedHashMap<String, List<SlotStructure>> groups = new LinkedHashMap<>();
for (SlotStructure slot : rawSlots) {
ProductStructure product = slot.getProduct();
if (isInvalidProduct(product)) continue;
groups.computeIfAbsent(product.getProductID(), k -> new ArrayList<>()).add(slot);
}
String productId = product.getProductID();
SlotStructure existingSlot = uniqueProductMap.get(productId);
if (existingSlot == null) {
// 直接使用拷贝后的 slot避免修改
uniqueProductMap.put(productId, slot);
} else {
// 數量累加
int newCount = existingSlot.getCount() + slot.getCount();
// 判斷代表 slot如果 existingSlot 數量是 0而這個 slot 有貨就換掉
if (existingSlot.getCount() == 0 && slot.getCount() > 0) {
SlotStructure newSlot = CloneUtils.deepClone(slot, SlotStructure.class);
newSlot.setCount(newCount);
uniqueProductMap.put(productId, newSlot);
} else {
// 否則保留原本的貨道只更新數量
SlotStructure newSlot = CloneUtils.deepClone(existingSlot, SlotStructure.class);
newSlot.setCount(newCount);
uniqueProductMap.put(productId, newSlot);
// 3. 每個商品產生一張卡可售判定看同商品任一可賣貨道(count>0 && !lock && !expired)
// 只有全部貨道都不可賣時才鎖整張卡避免鎖一個貨道/單一貨道過期就把整個商品擋掉
Map<String, SlotStructure> uniqueProductMap = new LinkedHashMap<>();
for (Map.Entry<String, List<SlotStructure>> entry : groups.entrySet()) {
List<SlotStructure> group = entry.getValue();
int sellableCount = 0; // 可賣貨道庫存加總顯示與可加購上限排除鎖定/過期/0 庫存防買超
SlotStructure sellableRep = null; // 第一個可賣貨道
SlotStructure lockedRep = null; // 第一個被鎖貨道全不可賣時用來顯示暫停販售
SlotStructure expiredRep = null; // 第一個過期貨道全不可賣時用來顯示暫不販售
for (SlotStructure s : group) {
if (s.getCount() > 0 && !s.isLock() && !s.isExpired()) {
sellableCount += s.getCount();
if (sellableRep == null) sellableRep = s;
} else if (s.isLock()) {
if (lockedRep == null) lockedRep = s;
} else if (s.isExpired()) {
if (expiredRep == null) expiredRep = s;
}
}
SlotStructure card;
if (sellableRep != null) {
// 有可賣貨道用可賣道當代表不鎖不過期庫存只算可賣道加總
card = CloneUtils.deepClone(sellableRep, SlotStructure.class);
card.setCount(sellableCount);
} else {
// 全部不可賣才鎖整卡 鎖定 > 過期 > 售完 擇一代表道讓遮罩顯示正確原因
SlotStructure rep = lockedRep != null ? lockedRep
: (expiredRep != null ? expiredRep : group.get(0));
card = CloneUtils.deepClone(rep, SlotStructure.class);
}
uniqueProductMap.put(entry.getKey(), card);
}
// 3. 更新UI
// 4. 更新UI
if (!uniqueProductMap.isEmpty()) {
slotList.clear();
slotList.addAll(uniqueProductMap.values());

View File

@ -8,7 +8,6 @@ import com.unibuy.smartdevice.MyApp;
import com.unibuy.smartdevice.R;
import com.unibuy.smartdevice.databinding.DialogProductDetailBinding;
import com.unibuy.smartdevice.exception.LogsEmptyException;
import com.unibuy.smartdevice.structure.BuyStructure;
import com.unibuy.smartdevice.structure.SlotStructure;
import com.unibuy.smartdevice.tools.HandlerMain;
import com.unibuy.smartdevice.tools.ToastHandlerMain;
@ -181,8 +180,9 @@ public class DialogProductDetail extends DialogAbstract {
cancel();
} else {
MyApp.getInstance().getBuyList().clear();
MyApp.getInstance().getBuyList().add(
new BuyStructure(slot.getField(), slot.getSlot(), slot.getProduct(), quantity));
// 依可賣貨道把數量拆成多筆 count=1規避 VMC 出貨狀態機不支援單筆 quantity>1會卡 40 秒看門狗
MyApp.getInstance().getBuyList().addAll(
MyApp.getInstance().allocateBuyStructures(slot, quantity));
cancel();
requestBuy();
}

View File

@ -30,11 +30,12 @@ class CartViewModel : ViewModel() {
buyList.clear()
// 1. 免費商品來店禮General 不做時恆空)
buyList += freeGoods
// 2. 購物車商品
// 2. 購物車商品:依可賣貨道把數量拆成多筆 count=1規避 VMC 出貨狀態機不支援單筆 quantity>1
// (出完第 1 個後不會自動觸發下一次出貨,會卡 40 秒看門狗)。拆道邏輯集中在 MyApp.allocateBuyStructures。
buyList += UnibuyHelper.getCartViewModel()
.getCartItemsSnapshot()
.map { cartItem ->
BuyStructure(cartItem.field, cartItem.slot, cartItem.product, cartItem.cartSelectedCount)
.flatMap { cartItem ->
allocateBuyStructures(cartItem, cartItem.cartSelectedCount)
}
}
}