[FEAT] 實作遠端找零與遠端出貨功能
1. 新增 CashPayoutManager.java:封裝 MDB 退幣/退鈔硬體控制邏輯與金額拆解算法。 2. 新增 ProductDispenseManager.java:封裝背景出貨流程,包含貨道檢查、出貨執行及狀態輪詢,並實作成功後自動更新本地 SQLite 庫存與 MyApp 狀態。 3. 修改 MqttService.java:接入 'change' 與 'dispense' MQTT 指令,解析 payload 並調用對應管理器,執行完畢後回傳 ACK 狀態至雲端。
This commit is contained in:
parent
df8dae9a56
commit
7a990ffc12
@ -136,6 +136,89 @@ public class MqttService extends Service {
|
||||
Log.e(TAG, "NFC checkout failed: " + e.getMessage());
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "NFC checkout failed: " + e.getMessage());
|
||||
}
|
||||
} else if ("change".equals(cmd)) {
|
||||
Log.i(TAG, "Executing remote change command...");
|
||||
try {
|
||||
int amount = 0;
|
||||
if (command.getPayload() != null && command.getPayload().isJsonObject()) {
|
||||
com.google.gson.JsonObject payloadObj = command.getPayload().getAsJsonObject();
|
||||
if (payloadObj.has("amount")) {
|
||||
amount = payloadObj.get("amount").getAsInt();
|
||||
}
|
||||
}
|
||||
|
||||
if (amount <= 0) {
|
||||
throw new Exception("Invalid or missing amount: " + amount);
|
||||
}
|
||||
|
||||
int finalAmount = amount;
|
||||
com.unibuy.smartdevice.tools.CashPayoutManager payoutManager = new com.unibuy.smartdevice.tools.CashPayoutManager(getApplicationContext(), new com.unibuy.smartdevice.tools.CashPayoutManager.PayoutListener() {
|
||||
@Override
|
||||
public void onProgress(String message) {
|
||||
Log.d(TAG, "Payout Progress: " + message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSuccess() {
|
||||
Log.i(TAG, "Remote payout success for amount: " + finalAmount);
|
||||
mqttManager.publishCommandAck(cmdId, "success", "Payout completed: " + finalAmount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(String error) {
|
||||
Log.e(TAG, "Remote payout failed: " + error);
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "Payout failed: " + error);
|
||||
}
|
||||
});
|
||||
|
||||
payoutManager.startPayout(amount);
|
||||
// 注意:ACK 會在 PayoutListener 的回調中發送,不在此處發送。
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to parse or start change command: " + e.getMessage());
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "Error: " + e.getMessage());
|
||||
}
|
||||
} else if ("dispense".equals(cmd)) {
|
||||
Log.i(TAG, "Executing remote dispense command...");
|
||||
try {
|
||||
int slot = -1;
|
||||
if (command.getPayload() != null && command.getPayload().isJsonObject()) {
|
||||
com.google.gson.JsonObject payloadObj = command.getPayload().getAsJsonObject();
|
||||
if (payloadObj.has("slot_no")) {
|
||||
slot = payloadObj.get("slot_no").getAsInt();
|
||||
} else if (payloadObj.has("slot")) {
|
||||
slot = payloadObj.get("slot").getAsInt();
|
||||
}
|
||||
}
|
||||
|
||||
if (slot <= 0) {
|
||||
throw new Exception("Invalid or missing slot_no: " + slot);
|
||||
}
|
||||
|
||||
int finalSlot = slot;
|
||||
com.unibuy.smartdevice.tools.ProductDispenseManager dispenseManager = new com.unibuy.smartdevice.tools.ProductDispenseManager(getApplicationContext(), new com.unibuy.smartdevice.tools.ProductDispenseManager.DispenseListener() {
|
||||
@Override
|
||||
public void onProgress(String message) {
|
||||
Log.d(TAG, "Dispense Progress: " + message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSuccess() {
|
||||
Log.i(TAG, "Remote dispense success for slot: " + finalSlot);
|
||||
mqttManager.publishCommandAck(cmdId, "success", "Product dispensed from slot: " + finalSlot);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(String error) {
|
||||
Log.e(TAG, "Remote dispense failed: " + error);
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "Dispense failed: " + error);
|
||||
}
|
||||
});
|
||||
|
||||
dispenseManager.startDispense(slot);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to parse or start dispense command: " + e.getMessage());
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "Error: " + e.getMessage());
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Unknown MQTT command: " + cmd);
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "Unknown command");
|
||||
|
||||
@ -0,0 +1,235 @@
|
||||
package com.unibuy.smartdevice.tools;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.unibuy.smartdevice.exception.ErrorCode;
|
||||
import com.unibuy.smartdevice.tools.SimpleCashAcceptorHandler;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 遠端找零管理器
|
||||
* 負責處理 MDB 設備的連線、初始化、庫存查詢及退幣/退鈔指令發送。
|
||||
*/
|
||||
public class CashPayoutManager {
|
||||
private static final String TAG = "CashPayoutManager";
|
||||
|
||||
public interface PayoutListener {
|
||||
void onProgress(String message);
|
||||
void onSuccess();
|
||||
void onFailure(String error);
|
||||
}
|
||||
|
||||
private final Context context;
|
||||
private final PayoutListener listener;
|
||||
private SimpleCashAcceptorHandler handler;
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
// 庫存變數
|
||||
private int coin50_count = 0;
|
||||
private int coin10_count = 0;
|
||||
private int coin5_count = 0;
|
||||
private int coin1_count = 0;
|
||||
private int note100_count = 0;
|
||||
|
||||
public CashPayoutManager(Context context, PayoutListener listener) {
|
||||
this.context = context;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void startPayout(int amount) {
|
||||
Log.i(TAG, "Starting remote payout for amount: " + amount);
|
||||
listener.onProgress("正在初始化退幣系統...");
|
||||
|
||||
handler = new SimpleCashAcceptorHandler(context, event -> {
|
||||
Log.d(TAG, "MDB Event: " + event);
|
||||
handleMdbEvent(event);
|
||||
});
|
||||
|
||||
new Thread(() -> {
|
||||
String connectResult = handler.connect();
|
||||
if ("成功連接到 USB 轉接器!".equals(connectResult)) {
|
||||
mainHandler.post(() -> {
|
||||
handler.startReading();
|
||||
performInitializationSequence(amount);
|
||||
});
|
||||
} else {
|
||||
mainHandler.post(() -> listener.onFailure("MDB 連線失敗: " + connectResult));
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void performInitializationSequence(int amount) {
|
||||
// 依序發送初始化與查詢指令
|
||||
// 指令序列參考 CashMachineSettings 與 CashPayDialog
|
||||
sendMdbCommand("0C00000000", 0, () -> { // 先禁止硬幣接收
|
||||
sendMdbCommand("3400000000", 500, () -> { // 禁止紙鈔接收
|
||||
sendMdbCommand("370100000002", 500, () -> { // 設定紙鈔類型
|
||||
sendMdbCommand("3703", 500, () -> { // 查詢設置
|
||||
sendMdbCommand("3705", 500, () -> { // 查詢紙鈔庫存
|
||||
sendMdbCommand("0A", 1000, () -> { // 查詢硬幣庫存
|
||||
calculateAndExecutePayout(amount);
|
||||
});});});});});});
|
||||
}
|
||||
|
||||
private void calculateAndExecutePayout(int amount) {
|
||||
listener.onProgress("正在計算退幣組合...");
|
||||
List<ChangeCommand> commands = new ArrayList<>();
|
||||
int remaining = amount;
|
||||
|
||||
// 1. 100元紙鈔
|
||||
int hundredsToGive = Math.min(remaining / 100, note100_count);
|
||||
if (hundredsToGive > 0) {
|
||||
String cmd = "370600000" + String.format("%01X", hundredsToGive);
|
||||
commands.add(new ChangeCommand(cmd, 100, hundredsToGive));
|
||||
remaining -= hundredsToGive * 100;
|
||||
}
|
||||
|
||||
// 2. 50元硬幣
|
||||
int fiftiesToGive = Math.min(remaining / 50, coin50_count);
|
||||
if (fiftiesToGive > 0) {
|
||||
String cmd = "0D" + String.format("%01X", fiftiesToGive) + "4";
|
||||
commands.add(new ChangeCommand(cmd, 50, fiftiesToGive));
|
||||
remaining -= fiftiesToGive * 50;
|
||||
}
|
||||
|
||||
// 3. 10元硬幣
|
||||
int tensToGive = Math.min(remaining / 10, coin10_count);
|
||||
if (tensToGive > 0) {
|
||||
String cmd = "0D" + String.format("%01X", tensToGive) + "2";
|
||||
commands.add(new ChangeCommand(cmd, 10, tensToGive));
|
||||
remaining -= tensToGive * 10;
|
||||
}
|
||||
|
||||
// 4. 5元硬幣
|
||||
int fivesToGive = Math.min(remaining / 5, coin5_count);
|
||||
if (fivesToGive > 0) {
|
||||
String cmd = "0D" + String.format("%01X", fivesToGive) + "1";
|
||||
commands.add(new ChangeCommand(cmd, 5, fivesToGive));
|
||||
remaining -= fivesToGive * 5;
|
||||
}
|
||||
|
||||
// 5. 1元硬幣
|
||||
int onesToGive = Math.min(remaining, coin1_count);
|
||||
if (onesToGive > 0) {
|
||||
String cmd = "0D" + String.format("%01X", onesToGive) + "0";
|
||||
commands.add(new ChangeCommand(cmd, 1, onesToGive));
|
||||
remaining -= onesToGive;
|
||||
}
|
||||
|
||||
if (remaining > 0) {
|
||||
Log.w(TAG, "Insufficient inventory to payout full amount. Missing: " + remaining);
|
||||
// 依然執行現有的,但回報錯誤訊息
|
||||
}
|
||||
|
||||
if (commands.isEmpty()) {
|
||||
listener.onFailure("無需找零或庫存不足以找零");
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
executePayoutCommands(commands, 0);
|
||||
}
|
||||
|
||||
private void executePayoutCommands(List<ChangeCommand> commands, int index) {
|
||||
if (index >= commands.size()) {
|
||||
listener.onProgress("退幣完成");
|
||||
// 延遲關閉,確保指令處理完畢
|
||||
mainHandler.postDelayed(() -> {
|
||||
close();
|
||||
listener.onSuccess();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
ChangeCommand current = commands.get(index);
|
||||
listener.onProgress("正在執行: " + current.denomination + "元 x " + current.count);
|
||||
|
||||
sendMdbCommand(current.command, 0, () -> {
|
||||
// 退幣指令後需要較長延遲
|
||||
executePayoutCommands(commands, index + 1);
|
||||
});
|
||||
}
|
||||
|
||||
private void sendMdbCommand(String hexCommand, int delay, Runnable onComplete) {
|
||||
mainHandler.postDelayed(() -> {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
byte[] command = hexStringToByteArray(hexCommand);
|
||||
handler.writeData(command);
|
||||
if (onComplete != null) {
|
||||
mainHandler.post(onComplete);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error sending MDB command: " + hexCommand, e);
|
||||
}
|
||||
}).start();
|
||||
}, Math.max(delay, 1000)); // 每個指令至少間隔 1 秒,退幣可能需要更長
|
||||
}
|
||||
|
||||
private void handleMdbEvent(String event) {
|
||||
if (event == null || event.trim().isEmpty()) return;
|
||||
String[] parts = event.trim().split("\\s+");
|
||||
|
||||
// 解析硬幣庫存 (0A 回傳)
|
||||
if (parts.length == 19) {
|
||||
try {
|
||||
coin1_count = Integer.parseInt(parts[2], 16);
|
||||
coin5_count = Integer.parseInt(parts[3], 16);
|
||||
coin10_count = Integer.parseInt(parts[4], 16);
|
||||
coin50_count = Integer.parseInt(parts[6], 16);
|
||||
Log.d(TAG, String.format("Inventory Update: 1=%d, 5=%d, 10=%d, 50=%d",
|
||||
coin1_count, coin5_count, coin10_count, coin50_count));
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Parse coin inventory error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 解析百鈔庫存 (3705 回傳)
|
||||
if (parts.length >= 30) {
|
||||
try {
|
||||
int noteCount = Integer.parseInt(parts[3], 16);
|
||||
if (noteCount >= 0 && noteCount <= 100) {
|
||||
note100_count = noteCount;
|
||||
Log.d(TAG, "Inventory Update: 100=" + note100_count);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Parse bill inventory error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void close() {
|
||||
if (handler != null) {
|
||||
handler.stopReading();
|
||||
handler.close();
|
||||
handler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] hexStringToByteArray(String s) {
|
||||
int len = s.length();
|
||||
byte[] data = new byte[len / 2];
|
||||
for (int i = 0; i < len; i += 2) {
|
||||
data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
|
||||
+ Character.digit(s.charAt(i + 1), 16));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private static class ChangeCommand {
|
||||
final String command;
|
||||
final int denomination;
|
||||
final int count;
|
||||
|
||||
ChangeCommand(String command, int denomination, int count) {
|
||||
this.command = command;
|
||||
this.denomination = denomination;
|
||||
this.count = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,175 @@
|
||||
package com.unibuy.smartdevice.tools;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.controller.DevController;
|
||||
import com.unibuy.smartdevice.controller.DevXinYuanController;
|
||||
import com.unibuy.smartdevice.devices.PortTools;
|
||||
|
||||
/**
|
||||
* 遠端出貨管理器
|
||||
* 負責在背景執行商品出貨流程,不依賴於 UI (DispatchDialog)。
|
||||
*/
|
||||
public class ProductDispenseManager {
|
||||
private static final String TAG = "ProductDispenseManager";
|
||||
|
||||
public interface DispenseListener {
|
||||
void onProgress(String message);
|
||||
void onSuccess();
|
||||
void onFailure(String error);
|
||||
}
|
||||
|
||||
private final Context context;
|
||||
private final DispenseListener listener;
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private int checkRetryCount = 0;
|
||||
|
||||
public ProductDispenseManager(Context context, DispenseListener listener) {
|
||||
this.context = context;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void startDispense(int slot) {
|
||||
Log.i(TAG, "Starting background dispense for slot: " + slot);
|
||||
listener.onProgress("正在檢查貨道狀態...");
|
||||
|
||||
DevXinYuanController controller = MyApp.getInstance().getDevXinYuanController();
|
||||
if (controller == null || !controller.isConnected()) {
|
||||
listener.onFailure("下位機控制器未連線");
|
||||
return;
|
||||
}
|
||||
|
||||
// 步驟 1: 檢查貨道狀態 (CMD 01 -> VMC 02)
|
||||
controller.getReadBufferByNow(new DevController.ReadBufferOnScheduler(new HandlerMain(context, null) {
|
||||
@Override
|
||||
protected void execute(Context context, int commandCode, String message) {}
|
||||
}) {
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() { return getSrcHandlerMain(); }
|
||||
@Override
|
||||
protected Class<?> setCls() { return getClass(); }
|
||||
|
||||
@Override
|
||||
public void readBufferOnScheduler(DevController devController, HandlerMain handlerMain) {
|
||||
boolean isCMD = false;
|
||||
boolean isStatusOk = false;
|
||||
|
||||
for (byte[] buffer : devController.getReadBufferByMax()) {
|
||||
if (buffer[0] == 0x02) { // VMC_02_CHECK_SLOT_STATUS
|
||||
isCMD = true;
|
||||
Log.d(TAG, "Check Slot Result: " + PortTools.showHex(buffer));
|
||||
// 01: OK, 02: PDT_EMPTY (有些機型空也會允許出貨指令), 04: TMP_USE
|
||||
if (buffer[1] == 0x01 || buffer[1] == 0x02 || buffer[1] == 0x04) {
|
||||
isStatusOk = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCMD) {
|
||||
if (isStatusOk) {
|
||||
mainHandler.post(() -> executeActualDispense(slot));
|
||||
} else {
|
||||
mainHandler.post(() -> listener.onFailure("貨道狀態異常,無法出貨"));
|
||||
}
|
||||
} else {
|
||||
if (checkRetryCount < 3) {
|
||||
checkRetryCount++;
|
||||
controller.addSendBuffer(controller.getDevXinYuan().checkSlot(slot));
|
||||
controller.getReadBufferByNow(this, 1000);
|
||||
} else {
|
||||
mainHandler.post(() -> listener.onFailure("下位機檢查貨道無回應"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
controller.addSendBuffer(controller.getDevXinYuan().checkSlot(slot));
|
||||
}
|
||||
|
||||
private void executeActualDispense(int slot) {
|
||||
listener.onProgress("貨道正常,正在發送出貨指令...");
|
||||
DevXinYuanController controller = MyApp.getInstance().getDevXinYuanController();
|
||||
|
||||
// 步驟 2: 發送出貨指令並監聽狀態 (CMD 06 -> VMC 04)
|
||||
controller.getReadBufferByNow(new DevController.ReadBufferOnScheduler(new HandlerMain(context, null) {
|
||||
@Override
|
||||
protected void execute(Context context, int commandCode, String message) {}
|
||||
}) {
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() { return getSrcHandlerMain(); }
|
||||
@Override
|
||||
protected Class<?> setCls() { return getClass(); }
|
||||
|
||||
@Override
|
||||
public void readBufferOnScheduler(DevController devController, HandlerMain handlerMain) {
|
||||
boolean isCMD = false;
|
||||
boolean isSuccess = false;
|
||||
boolean isFailed = false;
|
||||
String errorDetail = "";
|
||||
|
||||
for (byte[] buffer : devController.getReadBufferByMax()) {
|
||||
if (buffer[0] == 0x04) { // VMC_04_BUY_STATUS
|
||||
isCMD = true;
|
||||
Log.d(TAG, "Dispense Status: " + PortTools.showHex(buffer));
|
||||
|
||||
// 02: OK, 24: PLIS_BOX (可能是某些特定機型的成功代碼)
|
||||
if (buffer[1] == 0x02 || buffer[1] == 0x24) {
|
||||
isSuccess = true;
|
||||
break;
|
||||
}
|
||||
// 01: NOW (處理中), 10: PLATFORM_UP, 11: PLATFORM_DOWN
|
||||
else if (buffer[1] == 0x01 || buffer[1] == 0x10 || buffer[1] == 0x11) {
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
isFailed = true;
|
||||
errorDetail = String.format("%02X", buffer[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isSuccess) {
|
||||
mainHandler.post(() -> {
|
||||
updateLocalStock(slot);
|
||||
listener.onSuccess();
|
||||
});
|
||||
} else if (isFailed) {
|
||||
String finalErrorDetail = errorDetail;
|
||||
mainHandler.post(() -> listener.onFailure("出貨失敗,錯誤代碼: " + finalErrorDetail));
|
||||
} else {
|
||||
// 繼續輪詢直到成功或失敗
|
||||
controller.getReadBufferByNow(this, 1000);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
controller.addSendBuffer(controller.getDevXinYuan().getProduct(slot));
|
||||
}
|
||||
|
||||
private void updateLocalStock(int slot) {
|
||||
try {
|
||||
// 預設為 VMC 貨道 (Field 1)
|
||||
int field = com.unibuy.smartdevice.devices.SlotField.VMC.getField();
|
||||
com.unibuy.smartdevice.structure.SlotStructure slotData = MyApp.getInstance().getSlotData(field, slot);
|
||||
int currentCount = slotData.getCount();
|
||||
if (currentCount > 0) {
|
||||
int newCount = currentCount - 1;
|
||||
slotData.setCount(newCount);
|
||||
Log.i(TAG, "Updated slot " + slot + " count: " + currentCount + " -> " + newCount);
|
||||
|
||||
// 儲存至資料庫
|
||||
com.unibuy.smartdevice.database.SlotsDao slotsDao = new com.unibuy.smartdevice.database.SlotsDao(context);
|
||||
slotsDao.deleteAll();
|
||||
slotsDao.insertAll(MyApp.getInstance().getSlotMap());
|
||||
slotsDao.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to update local stock: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user