Commit 503cc251 by tdgiang

update code

parent 4bdc896a
...@@ -7,7 +7,10 @@ var tokenCache = { token: null, expiresAt: 0 }; ...@@ -7,7 +7,10 @@ var tokenCache = { token: null, expiresAt: 0 };
function login(callback) { function login(callback) {
var url = config.sobanle.base_url + "/login"; var url = config.sobanle.base_url + "/login";
ApiRequest.postOtherUrl(url, { login: config.sobanle.username, password: config.sobanle.password }, function (err, body) { ApiRequest.postOtherUrl(
url,
{ login: config.sobanle.username, password: config.sobanle.password },
function (err, body) {
if (err) { if (err) {
return callback(err); return callback(err);
} }
...@@ -22,7 +25,8 @@ function login(callback) { ...@@ -22,7 +25,8 @@ function login(callback) {
var earlyRefreshMs = Math.min(60000, expiresInMs * 0.1); var earlyRefreshMs = Math.min(60000, expiresInMs * 0.1);
tokenCache.expiresAt = Date.now() + expiresInMs - earlyRefreshMs; tokenCache.expiresAt = Date.now() + expiresInMs - earlyRefreshMs;
callback(null, body.access_token); callback(null, body.access_token);
}); },
);
} }
exports.login = login; exports.login = login;
...@@ -78,10 +82,19 @@ function withAuthRetry(makeRequest, callback) { ...@@ -78,10 +82,19 @@ function withAuthRetry(makeRequest, callback) {
} }
function getProducts(callback) { function getProducts(callback) {
withAuthRetry(function (token, cb) { withAuthRetry(
var url = config.sobanle.base_url + "/products?page=1&is_active=true&per_page=1000&"; function (token, cb) {
ApiRequest.getOtherUrlWithHeader(url, {}, { Authorization: "Bearer " + token }, cb); var url =
}, function (err, body) { config.sobanle.base_url +
"/products?page=1&is_active=true&per_page=1000&";
ApiRequest.getOtherUrlWithHeader(
url,
{},
{ Authorization: "Bearer " + token },
cb,
);
},
function (err, body) {
if (err) { if (err) {
return callback(err); return callback(err);
} }
...@@ -89,12 +102,14 @@ function getProducts(callback) { ...@@ -89,12 +102,14 @@ function getProducts(callback) {
return callback(new Error("SOBANLE_PRODUCTS_INVALID")); return callback(new Error("SOBANLE_PRODUCTS_INVALID"));
} }
callback(null, body.data); callback(null, body.data);
}); },
);
} }
exports.getProducts = getProducts; exports.getProducts = getProducts;
function createSale(lines, customerData, callback) { function createSale(lines, customerData, callback) {
withAuthRetry(function (token, cb) { withAuthRetry(
function (token, cb) {
var url = config.sobanle.base_url + "/sales"; var url = config.sobanle.base_url + "/sales";
var payload = { var payload = {
warehouse_id: config.sobanle.warehouse_id, warehouse_id: config.sobanle.warehouse_id,
...@@ -111,15 +126,25 @@ function createSale(lines, customerData, callback) { ...@@ -111,15 +126,25 @@ function createSale(lines, customerData, callback) {
customer_data: customerData, customer_data: customerData,
payment_receiver: "", payment_receiver: "",
payment_note: "", payment_note: "",
sale_note: "Tạo tự động từ payment-gate",
staff_note: "", staff_note: "",
}; };
ApiRequest.postOtherUrlWithHeader(url, payload, { Authorization: "Bearer " + token }, cb); ApiRequest.postOtherUrlWithHeader(
}, function (err, body) { url,
payload,
{ Authorization: "Bearer " + token },
cb,
);
},
function (err, body) {
if (err) { if (err) {
return callback(err); return callback(err);
} }
if (!body || !body.data || typeof body.data.id === "undefined" || typeof body.data.grand_total === "undefined") { if (
!body ||
!body.data ||
typeof body.data.id === "undefined" ||
typeof body.data.grand_total === "undefined"
) {
return callback(new Error("SOBANLE_CREATE_SALE_INVALID")); return callback(new Error("SOBANLE_CREATE_SALE_INVALID"));
} }
var total = Math.round(Number(body.data.grand_total)); var total = Math.round(Number(body.data.grand_total));
...@@ -127,18 +152,28 @@ function createSale(lines, customerData, callback) { ...@@ -127,18 +152,28 @@ function createSale(lines, customerData, callback) {
return callback(new Error("SOBANLE_CREATE_SALE_INVALID")); return callback(new Error("SOBANLE_CREATE_SALE_INVALID"));
} }
callback(null, { orderId: body.data.id, orderTotal: total }); callback(null, { orderId: body.data.id, orderTotal: total });
}); },
);
} }
exports.createSale = createSale; exports.createSale = createSale;
// saleStatus: 1 = giao dịch thành công, 4 = giao dịch thất bại (theo Sổ Bán Lẻ). // saleStatus: 1 = giao dịch thành công, 4 = giao dịch thất bại (theo Sổ Bán Lẻ).
function changeSaleStatus(orderId, saleStatus, callback) { function changeSaleStatus(orderId, saleStatus, callback) {
withAuthRetry(function (token, cb) { withAuthRetry(
var url = config.sobanle.base_url + "/sales/change-sale-status/" + orderId; function (token, cb) {
ApiRequest.patchOtherUrlWithHeader(url, { sale_status: saleStatus }, { Authorization: "Bearer " + token }, cb); var url =
}, function (err) { config.sobanle.base_url + "/sales/change-sale-status/" + orderId;
ApiRequest.patchOtherUrlWithHeader(
url,
{ sale_status: saleStatus },
{ Authorization: "Bearer " + token },
cb,
);
},
function (err) {
callback(err || null); callback(err || null);
}); },
);
} }
exports.changeSaleStatus = changeSaleStatus; exports.changeSaleStatus = changeSaleStatus;
...@@ -158,7 +193,12 @@ function createAutoOrder(customerData, targetAmount, callback) { ...@@ -158,7 +193,12 @@ function createAutoOrder(customerData, targetAmount, callback) {
if (order.orderTotal > targetAmount) { if (order.orderTotal > targetAmount) {
console.error( console.error(
"createAutoOrder: Sổ Bán Lẻ order total exceeds target (orphan order sobanleOrderId=" + "createAutoOrder: Sổ Bán Lẻ order total exceeds target (orphan order sobanleOrderId=" +
order.orderId + " orderTotal=" + order.orderTotal + " targetAmount=" + targetAmount + ")" order.orderId +
" orderTotal=" +
order.orderTotal +
" targetAmount=" +
targetAmount +
")",
); );
return callback(new Error("SOBANLE_TOTAL_EXCEEDS_TARGET")); return callback(new Error("SOBANLE_TOTAL_EXCEEDS_TARGET"));
} }
......
...@@ -31,10 +31,12 @@ ...@@ -31,10 +31,12 @@
### Task 1: `ApiRequest.js` — header-aware GET/PATCH + status code passthrough ### Task 1: `ApiRequest.js` — header-aware GET/PATCH + status code passthrough
**Files:** **Files:**
- Modify: `app/libs/ApiRequest.js` - Modify: `app/libs/ApiRequest.js`
- Test: `scratch/test-sobanle-apirequest-headers.js` - Test: `scratch/test-sobanle-apirequest-headers.js`
**Interfaces:** **Interfaces:**
- Produces: `ApiRequest.getOtherUrlWithHeader(apiName, param, headers, callback)` and `ApiRequest.patchOtherUrlWithHeader(apiName, param, headers, callback)` — both call `callback(err, body, statusCode)` (3rd arg is the HTTP status code, `undefined` on network-level `err`). `ApiRequest.postOtherUrlWithHeader` (existing function) is extended to also pass `statusCode` as a 3rd callback arg — existing callers (`core.server.controller.js:2041`) are unaffected since they only read the first 2 args. - Produces: `ApiRequest.getOtherUrlWithHeader(apiName, param, headers, callback)` and `ApiRequest.patchOtherUrlWithHeader(apiName, param, headers, callback)` — both call `callback(err, body, statusCode)` (3rd arg is the HTTP status code, `undefined` on network-level `err`). `ApiRequest.postOtherUrlWithHeader` (existing function) is extended to also pass `statusCode` as a 3rd callback arg — existing callers (`core.server.controller.js:2041`) are unaffected since they only read the first 2 args.
- Consumed by: Task 5 (`SobanleClient.js``getProducts`, `createSale`, `changeSaleStatus`). - Consumed by: Task 5 (`SobanleClient.js``getProducts`, `createSale`, `changeSaleStatus`).
...@@ -112,19 +114,19 @@ Add at the end of the file (before the final blank line), and update `postOtherU ...@@ -112,19 +114,19 @@ Add at the end of the file (before the final blank line), and update `postOtherU
```js ```js
exports.postOtherUrlWithHeader = function (apiName, param, headers, callback) { exports.postOtherUrlWithHeader = function (apiName, param, headers, callback) {
if (typeof (param) === 'function') { if (typeof param === "function") {
callback = param; callback = param;
param = {}; param = {};
} }
var options = { var options = {
method: 'POST', method: "POST",
uri: apiName, uri: apiName,
headers: headers, headers: headers,
json: param json: param,
}; };
if (param && param.token) { if (param && param.token) {
options['auth'] = { options["auth"] = {
'bearer': param.token bearer: param.token,
}; };
} }
// request ato API Host // request ato API Host
...@@ -141,19 +143,19 @@ exports.postOtherUrlWithHeader = function (apiName, param, headers, callback) { ...@@ -141,19 +143,19 @@ exports.postOtherUrlWithHeader = function (apiName, param, headers, callback) {
}; };
exports.getOtherUrlWithHeader = function (apiName, param, headers, callback) { exports.getOtherUrlWithHeader = function (apiName, param, headers, callback) {
if (typeof (param) === 'function') { if (typeof param === "function") {
callback = param; callback = param;
param = {}; param = {};
} }
var url = apiName; var url = apiName;
for (var k in param) { for (var k in param) {
url += k + '=' + param[k] + '&'; url += k + "=" + param[k] + "&";
} }
var options = { var options = {
method: 'GET', method: "GET",
uri: url, uri: url,
headers: headers, headers: headers,
json: true json: true,
}; };
request(options, function (err, httpResponse, body) { request(options, function (err, httpResponse, body) {
var statusCode = httpResponse ? httpResponse.statusCode : undefined; var statusCode = httpResponse ? httpResponse.statusCode : undefined;
...@@ -168,15 +170,15 @@ exports.getOtherUrlWithHeader = function (apiName, param, headers, callback) { ...@@ -168,15 +170,15 @@ exports.getOtherUrlWithHeader = function (apiName, param, headers, callback) {
}; };
exports.patchOtherUrlWithHeader = function (apiName, param, headers, callback) { exports.patchOtherUrlWithHeader = function (apiName, param, headers, callback) {
if (typeof (param) === 'function') { if (typeof param === "function") {
callback = param; callback = param;
param = {}; param = {};
} }
var options = { var options = {
method: 'PATCH', method: "PATCH",
uri: apiName, uri: apiName,
headers: headers, headers: headers,
json: param json: param,
}; };
request(options, function (err, httpResponse, body) { request(options, function (err, httpResponse, body) {
var statusCode = httpResponse ? httpResponse.statusCode : undefined; var statusCode = httpResponse ? httpResponse.statusCode : undefined;
...@@ -213,10 +215,12 @@ git commit -m "Add header-aware GET/PATCH helpers with status code passthrough t ...@@ -213,10 +215,12 @@ git commit -m "Add header-aware GET/PATCH helpers with status code passthrough t
### Task 2: `productComboPicker.js` — tax-aware product combo algorithm ### Task 2: `productComboPicker.js` — tax-aware product combo algorithm
**Files:** **Files:**
- Create: `app/libs/productComboPicker.js` - Create: `app/libs/productComboPicker.js`
- Test: `scratch/test-product-combo-picker.js` - Test: `scratch/test-product-combo-picker.js`
**Interfaces:** **Interfaces:**
- Produces: `pickProductCombo(products, targetAmount, options)``{ lines: [{product_id, qty}], total: Number }` or `null`. `options` (all optional): `{ tolerance = 50000, maxAttempts = 20, maxQtyPerLine = 3, random = Math.random }`. Also exports `computeLineTotal(product, qty)``Math.round(qty * product.price * (1 + rate/100))`. - Produces: `pickProductCombo(products, targetAmount, options)``{ lines: [{product_id, qty}], total: Number }` or `null`. `options` (all optional): `{ tolerance = 50000, maxAttempts = 20, maxQtyPerLine = 3, random = Math.random }`. Also exports `computeLineTotal(product, qty)``Math.round(qty * product.price * (1 + rate/100))`.
- Consumed by: Task 6 (`SobanleClient.createAutoOrder`). - Consumed by: Task 6 (`SobanleClient.createAutoOrder`).
...@@ -317,13 +321,17 @@ function shuffle(list, random) { ...@@ -317,13 +321,17 @@ function shuffle(list, random) {
} }
function computeLineTotal(product, qty) { function computeLineTotal(product, qty) {
var rate = product.tax && typeof product.tax.rate === "number" ? product.tax.rate : 0; var rate =
product.tax && typeof product.tax.rate === "number" ? product.tax.rate : 0;
return Math.round(qty * product.price * (1 + rate / 100)); return Math.round(qty * product.price * (1 + rate / 100));
} }
function pickProductCombo(products, targetAmount, options) { function pickProductCombo(products, targetAmount, options) {
options = options || {}; options = options || {};
var tolerance = typeof options.tolerance === "number" ? options.tolerance : DEFAULT_TOLERANCE; var tolerance =
typeof options.tolerance === "number"
? options.tolerance
: DEFAULT_TOLERANCE;
var maxAttempts = options.maxAttempts || DEFAULT_MAX_ATTEMPTS; var maxAttempts = options.maxAttempts || DEFAULT_MAX_ATTEMPTS;
var random = options.random || Math.random; var random = options.random || Math.random;
var maxQtyPerLine = options.maxQtyPerLine || DEFAULT_MAX_QTY_PER_LINE; var maxQtyPerLine = options.maxQtyPerLine || DEFAULT_MAX_QTY_PER_LINE;
...@@ -392,11 +400,13 @@ git commit -m "Add tax-aware random product combo picker for Sổ Bán Lẻ auto ...@@ -392,11 +400,13 @@ git commit -m "Add tax-aware random product combo picker for Sổ Bán Lẻ auto
### Task 3: `config/env/all.js` + `.env` — Sổ Bán Lẻ config block ### Task 3: `config/env/all.js` + `.env` — Sổ Bán Lẻ config block
**Files:** **Files:**
- Modify: `config/env/all.js` - Modify: `config/env/all.js`
- Modify: `.env` - Modify: `.env`
- Test: `scratch/test-sobanle-config.js` - Test: `scratch/test-sobanle-config.js`
**Interfaces:** **Interfaces:**
- Produces: `config.sobanle = { base_url, username, password, warehouse_id, biller_id, account_id, currency_id }`. - Produces: `config.sobanle = { base_url, username, password, warehouse_id, biller_id, account_id, currency_id }`.
- Consumed by: Task 4, 5 (`SobanleClient.js`). - Consumed by: Task 4, 5 (`SobanleClient.js`).
...@@ -471,10 +481,12 @@ Note: confirm `.env` is already git-ignored or already tracked-with-secrets per ...@@ -471,10 +481,12 @@ Note: confirm `.env` is already git-ignored or already tracked-with-secrets per
### Task 4: `SobanleClient.js` — token cache, login, `getToken` ### Task 4: `SobanleClient.js` — token cache, login, `getToken`
**Files:** **Files:**
- Create: `app/libs/SobanleClient.js` - Create: `app/libs/SobanleClient.js`
- Test: `scratch/test-sobanle-client-token.js` - Test: `scratch/test-sobanle-client-token.js`
**Interfaces:** **Interfaces:**
- Produces: `SobanleClient.login(callback)``(err, token)`, always performs a real login call and refreshes the cache. `SobanleClient.getToken(callback)``(err, token)`, returns the cached token if not expired, otherwise calls `exports.login`. `SobanleClient._resetTokenCacheForTest()` — test-only helper that clears the module-level cache between scratch-script scenarios. - Produces: `SobanleClient.login(callback)``(err, token)`, always performs a real login call and refreshes the cache. `SobanleClient.getToken(callback)``(err, token)`, returns the cached token if not expired, otherwise calls `exports.login`. `SobanleClient._resetTokenCacheForTest()` — test-only helper that clears the module-level cache between scratch-script scenarios.
- Consumed by: Task 5, 6, and (indirectly) Task 8, 9. - Consumed by: Task 5, 6, and (indirectly) Task 8, 9.
- Internal calls to `login`/`getToken` from other functions in this file (later tasks) MUST go through `exports.login(...)` / `exports.getToken(...)`, not a local reference, so tests can monkey-patch them. - Internal calls to `login`/`getToken` from other functions in this file (later tasks) MUST go through `exports.login(...)` / `exports.getToken(...)`, not a local reference, so tests can monkey-patch them.
...@@ -546,7 +558,10 @@ var tokenCache = { token: null, expiresAt: 0 }; ...@@ -546,7 +558,10 @@ var tokenCache = { token: null, expiresAt: 0 };
function login(callback) { function login(callback) {
var url = config.sobanle.base_url + "/login"; var url = config.sobanle.base_url + "/login";
ApiRequest.postOtherUrl(url, { login: config.sobanle.username, password: config.sobanle.password }, function (err, body) { ApiRequest.postOtherUrl(
url,
{ login: config.sobanle.username, password: config.sobanle.password },
function (err, body) {
if (err) { if (err) {
return callback(err); return callback(err);
} }
...@@ -561,7 +576,8 @@ function login(callback) { ...@@ -561,7 +576,8 @@ function login(callback) {
var earlyRefreshMs = Math.min(60000, expiresInMs * 0.1); var earlyRefreshMs = Math.min(60000, expiresInMs * 0.1);
tokenCache.expiresAt = Date.now() + expiresInMs - earlyRefreshMs; tokenCache.expiresAt = Date.now() + expiresInMs - earlyRefreshMs;
callback(null, body.access_token); callback(null, body.access_token);
}); },
);
} }
exports.login = login; exports.login = login;
...@@ -599,10 +615,12 @@ git commit -m "Add SobanleClient token cache and login" ...@@ -599,10 +615,12 @@ git commit -m "Add SobanleClient token cache and login"
### Task 5: `SobanleClient.js` — `getProducts`, `createSale`, `changeSaleStatus` with 401 retry ### Task 5: `SobanleClient.js` — `getProducts`, `createSale`, `changeSaleStatus` with 401 retry
**Files:** **Files:**
- Modify: `app/libs/SobanleClient.js` - Modify: `app/libs/SobanleClient.js`
- Test: `scratch/test-sobanle-client-api.js` - Test: `scratch/test-sobanle-client-api.js`
**Interfaces:** **Interfaces:**
- Produces: `SobanleClient.getProducts(callback)``(err, products)`. `SobanleClient.createSale(lines, customerData, callback)``(err, { orderId, orderTotal })`. `SobanleClient.changeSaleStatus(orderId, callback)``(err)`. All three transparently retry once (re-login, then retry the original call) on a `401` response. - Produces: `SobanleClient.getProducts(callback)``(err, products)`. `SobanleClient.createSale(lines, customerData, callback)``(err, { orderId, orderTotal })`. `SobanleClient.changeSaleStatus(orderId, callback)``(err)`. All three transparently retry once (re-login, then retry the original call) on a `401` response.
- Consumes: `exports.getToken` from Task 4. - Consumes: `exports.getToken` from Task 4.
- Consumed by: Task 6 (`createAutoOrder`), Task 8, 9 (controller). - Consumed by: Task 6 (`createAutoOrder`), Task 8, 9 (controller).
...@@ -745,10 +763,19 @@ function withAuthRetry(makeRequest, callback) { ...@@ -745,10 +763,19 @@ function withAuthRetry(makeRequest, callback) {
} }
function getProducts(callback) { function getProducts(callback) {
withAuthRetry(function (token, cb) { withAuthRetry(
var url = config.sobanle.base_url + "/products?page=1&is_active=true&per_page=1000&"; function (token, cb) {
ApiRequest.getOtherUrlWithHeader(url, {}, { Authorization: "Bearer " + token }, cb); var url =
}, function (err, body) { config.sobanle.base_url +
"/products?page=1&is_active=true&per_page=1000&";
ApiRequest.getOtherUrlWithHeader(
url,
{},
{ Authorization: "Bearer " + token },
cb,
);
},
function (err, body) {
if (err) { if (err) {
return callback(err); return callback(err);
} }
...@@ -756,12 +783,14 @@ function getProducts(callback) { ...@@ -756,12 +783,14 @@ function getProducts(callback) {
return callback(new Error("SOBANLE_PRODUCTS_INVALID")); return callback(new Error("SOBANLE_PRODUCTS_INVALID"));
} }
callback(null, body.data); callback(null, body.data);
}); },
);
} }
exports.getProducts = getProducts; exports.getProducts = getProducts;
function createSale(lines, customerData, callback) { function createSale(lines, customerData, callback) {
withAuthRetry(function (token, cb) { withAuthRetry(
function (token, cb) {
var url = config.sobanle.base_url + "/sales"; var url = config.sobanle.base_url + "/sales";
var payload = { var payload = {
warehouse_id: config.sobanle.warehouse_id, warehouse_id: config.sobanle.warehouse_id,
...@@ -778,29 +807,52 @@ function createSale(lines, customerData, callback) { ...@@ -778,29 +807,52 @@ function createSale(lines, customerData, callback) {
customer_data: customerData, customer_data: customerData,
payment_receiver: "", payment_receiver: "",
payment_note: "", payment_note: "",
sale_note: "Tạo tự động từ payment-gate",
staff_note: "", staff_note: "",
}; };
ApiRequest.postOtherUrlWithHeader(url, payload, { Authorization: "Bearer " + token }, cb); ApiRequest.postOtherUrlWithHeader(
}, function (err, body) { url,
payload,
{ Authorization: "Bearer " + token },
cb,
);
},
function (err, body) {
if (err) { if (err) {
return callback(err); return callback(err);
} }
if (!body || !body.data || typeof body.data.id === "undefined" || typeof body.data.grand_total === "undefined") { if (
!body ||
!body.data ||
typeof body.data.id === "undefined" ||
typeof body.data.grand_total === "undefined"
) {
return callback(new Error("SOBANLE_CREATE_SALE_INVALID")); return callback(new Error("SOBANLE_CREATE_SALE_INVALID"));
} }
callback(null, { orderId: body.data.id, orderTotal: body.data.grand_total }); callback(null, {
orderId: body.data.id,
orderTotal: body.data.grand_total,
}); });
},
);
} }
exports.createSale = createSale; exports.createSale = createSale;
function changeSaleStatus(orderId, callback) { function changeSaleStatus(orderId, callback) {
withAuthRetry(function (token, cb) { withAuthRetry(
var url = config.sobanle.base_url + "/sales/change-sale-status/" + orderId; function (token, cb) {
ApiRequest.patchOtherUrlWithHeader(url, { sale_status: 4 }, { Authorization: "Bearer " + token }, cb); var url =
}, function (err) { config.sobanle.base_url + "/sales/change-sale-status/" + orderId;
ApiRequest.patchOtherUrlWithHeader(
url,
{ sale_status: 4 },
{ Authorization: "Bearer " + token },
cb,
);
},
function (err) {
callback(err || null); callback(err || null);
}); },
);
} }
exports.changeSaleStatus = changeSaleStatus; exports.changeSaleStatus = changeSaleStatus;
``` ```
...@@ -825,10 +877,12 @@ git commit -m "Add SobanleClient getProducts/createSale/changeSaleStatus with 40 ...@@ -825,10 +877,12 @@ git commit -m "Add SobanleClient getProducts/createSale/changeSaleStatus with 40
### Task 6: `SobanleClient.createAutoOrder` — orchestration ### Task 6: `SobanleClient.createAutoOrder` — orchestration
**Files:** **Files:**
- Modify: `app/libs/SobanleClient.js` - Modify: `app/libs/SobanleClient.js`
- Test: `scratch/test-sobanle-client-auto-order.js` - Test: `scratch/test-sobanle-client-auto-order.js`
**Interfaces:** **Interfaces:**
- Produces: `SobanleClient.createAutoOrder(customerData, targetAmount, callback)``(err, { orderId, orderTotal })`. Fetches products, picks a combo via `productComboPicker.pickProductCombo`, creates the sale. Fails with `NO_PRODUCT_COMBO_MATCH` without ever calling `createSale` if no combo fits. - Produces: `SobanleClient.createAutoOrder(customerData, targetAmount, callback)``(err, { orderId, orderTotal })`. Fetches products, picks a combo via `productComboPicker.pickProductCombo`, creates the sale. Fails with `NO_PRODUCT_COMBO_MATCH` without ever calling `createSale` if no combo fits.
- Consumes: `exports.getProducts`, `exports.createSale` from Task 5; `pickProductCombo` from Task 2. - Consumes: `exports.getProducts`, `exports.createSale` from Task 5; `pickProductCombo` from Task 2.
- Consumed by: Task 8 (`admin.server.controller.js#createTransaction`). - Consumed by: Task 8 (`admin.server.controller.js#createTransaction`).
...@@ -947,10 +1001,12 @@ git commit -m "Add SobanleClient.createAutoOrder orchestration" ...@@ -947,10 +1001,12 @@ git commit -m "Add SobanleClient.createAutoOrder orchestration"
### Task 7: `AdminTransaction` model — `sobanleOrderId` field ### Task 7: `AdminTransaction` model — `sobanleOrderId` field
**Files:** **Files:**
- Modify: `app/models/AdminTransaction.js` - Modify: `app/models/AdminTransaction.js`
- Test: `scratch/test-admin-transaction-sobanle-field.js` - Test: `scratch/test-admin-transaction-sobanle-field.js`
**Interfaces:** **Interfaces:**
- Produces: `AdminTransaction` schema gains `sobanleOrderId: { type: String, default: null }`. - Produces: `AdminTransaction` schema gains `sobanleOrderId: { type: String, default: null }`.
- Consumed by: Task 8, 9. - Consumed by: Task 8, 9.
...@@ -1035,10 +1091,12 @@ git commit -m "Add sobanleOrderId field to AdminTransaction" ...@@ -1035,10 +1091,12 @@ git commit -m "Add sobanleOrderId field to AdminTransaction"
### Task 8: Wire `createTransaction` to `SobanleClient.createAutoOrder` ### Task 8: Wire `createTransaction` to `SobanleClient.createAutoOrder`
**Files:** **Files:**
- Modify: `app/controllers/admin.server.controller.js` - Modify: `app/controllers/admin.server.controller.js`
- Test: `scratch/test-admin-create-transaction-sobanle.js` - Test: `scratch/test-admin-create-transaction-sobanle.js`
**Interfaces:** **Interfaces:**
- Consumes: `SobanleClient.createAutoOrder(customerData, targetAmount, callback)` (Task 6), `AdminTransaction` (Task 7). - Consumes: `SobanleClient.createAutoOrder(customerData, targetAmount, callback)` (Task 6), `AdminTransaction` (Task 7).
- Produces: `createTransaction` now calls Sổ Bán Lẻ first; `AdminTransaction.amount` = `order.orderTotal`; `AdminTransaction.sobanleOrderId` = `String(order.orderId)`. On Sổ Bán Lẻ failure: `502 {code:"99", data:"SOBANLE_ORDER_FAILED"}`, no DB row written. Response contract for the success path is unchanged (`{code:"00", data:{merTrxId, paymentUrl}}`). - Produces: `createTransaction` now calls Sổ Bán Lẻ first; `AdminTransaction.amount` = `order.orderTotal`; `AdminTransaction.sobanleOrderId` = `String(order.orderId)`. On Sổ Bán Lẻ failure: `502 {code:"99", data:"SOBANLE_ORDER_FAILED"}`, no DB row written. Response contract for the success path is unchanged (`{code:"00", data:{merTrxId, paymentUrl}}`).
...@@ -1148,7 +1206,11 @@ exports.createTransaction = function (req, res) { ...@@ -1148,7 +1206,11 @@ exports.createTransaction = function (req, res) {
} }
var targetAmount = parseInt(rawAmount, 10); var targetAmount = parseInt(rawAmount, 10);
if (!isFinite(targetAmount) || targetAmount <= 0 || String(targetAmount) !== String(rawAmount).trim()) { if (
!isFinite(targetAmount) ||
targetAmount <= 0 ||
String(targetAmount) !== String(rawAmount).trim()
) {
return res.status(400).json({ code: "99", data: "INVALID_AMOUNT" }); return res.status(400).json({ code: "99", data: "INVALID_AMOUNT" });
} }
...@@ -1164,10 +1226,18 @@ exports.createTransaction = function (req, res) { ...@@ -1164,10 +1226,18 @@ exports.createTransaction = function (req, res) {
email: null, email: null,
}; };
SobanleClient.createAutoOrder(customerData, targetAmount, function (sobanleErr, order) { SobanleClient.createAutoOrder(
customerData,
targetAmount,
function (sobanleErr, order) {
if (sobanleErr) { if (sobanleErr) {
console.error("createTransaction: SobanleClient error:", sobanleErr.message); console.error(
return res.status(502).json({ code: "99", data: "SOBANLE_ORDER_FAILED" }); "createTransaction: SobanleClient error:",
sobanleErr.message,
);
return res
.status(502)
.json({ code: "99", data: "SOBANLE_ORDER_FAILED" });
} }
var amount = order.orderTotal; var amount = order.orderTotal;
...@@ -1180,7 +1250,7 @@ exports.createTransaction = function (req, res) { ...@@ -1180,7 +1250,7 @@ exports.createTransaction = function (req, res) {
merTrxId, merTrxId,
config.epay.merchant_id, config.epay.merchant_id,
amount, amount,
config.epay.encode_key config.epay.encode_key,
); );
AdminTransaction.create( AdminTransaction.create(
...@@ -1206,9 +1276,10 @@ exports.createTransaction = function (req, res) { ...@@ -1206,9 +1276,10 @@ exports.createTransaction = function (req, res) {
code: "00", code: "00",
data: { merTrxId: tx.merTrxId, paymentUrl: paymentUrl }, data: { merTrxId: tx.merTrxId, paymentUrl: paymentUrl },
}); });
} },
);
},
); );
});
}; };
``` ```
...@@ -1233,10 +1304,12 @@ git commit -m "Create Sổ Bán Lẻ order before payment transaction in createT ...@@ -1233,10 +1304,12 @@ git commit -m "Create Sổ Bán Lẻ order before payment transaction in createT
### Task 9: Wire `epayIPN` to `SobanleClient.changeSaleStatus` ### Task 9: Wire `epayIPN` to `SobanleClient.changeSaleStatus`
**Files:** **Files:**
- Modify: `app/controllers/admin.server.controller.js` - Modify: `app/controllers/admin.server.controller.js`
- Test: `scratch/test-admin-epay-ipn-sobanle.js` - Test: `scratch/test-admin-epay-ipn-sobanle.js`
**Interfaces:** **Interfaces:**
- Consumes: `SobanleClient.changeSaleStatus(orderId, callback)` (Task 5). - Consumes: `SobanleClient.changeSaleStatus(orderId, callback)` (Task 5).
- Produces: after `epayIPN` marks a transaction `success`, it fire-and-forgets a call to `SobanleClient.changeSaleStatus(tx.sobanleOrderId, ...)`. A failure there is logged only — the IPN still responds `200 {code:"00", data:"Success"}` immediately, unaffected by the Sổ Bán Lẻ call's outcome or latency. - Produces: after `epayIPN` marks a transaction `success`, it fire-and-forgets a call to `SobanleClient.changeSaleStatus(tx.sobanleOrderId, ...)`. A failure there is logged only — the IPN still responds `200 {code:"00", data:"Success"}` immediately, unaffected by the Sổ Bán Lẻ call's outcome or latency.
...@@ -1333,16 +1406,18 @@ Expected: fails — `changeSaleStatus called with correct orderId: false` (`chan ...@@ -1333,16 +1406,18 @@ Expected: fails — `changeSaleStatus called with correct orderId: false` (`chan
Inside the `AdminTransaction.updateOne(...)` callback (after the `updateErr` check, before `return res.status(200)...`), add: Inside the `AdminTransaction.updateOne(...)` callback (after the `updateErr` check, before `return res.status(200)...`), add:
```js ```js
if (newStatus === "success" && tx.sobanleOrderId) { if (newStatus === "success" && tx.sobanleOrderId) {
SobanleClient.changeSaleStatus(tx.sobanleOrderId, function (sobanleErr) { SobanleClient.changeSaleStatus(tx.sobanleOrderId, function (sobanleErr) {
if (sobanleErr) { if (sobanleErr) {
console.error( console.error(
"epayIPN: SobanleClient.changeSaleStatus failed for orderId=" + tx.sobanleOrderId + ":", "epayIPN: SobanleClient.changeSaleStatus failed for orderId=" +
sobanleErr.message tx.sobanleOrderId +
":",
sobanleErr.message,
); );
} }
}); });
} }
``` ```
(`SobanleClient` is already required at the top of the file from Task 8.) (`SobanleClient` is already required at the top of the file from Task 8.)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment