Commit 503cc251 by tdgiang

update code

parent 4bdc896a
...@@ -7,22 +7,26 @@ var tokenCache = { token: null, expiresAt: 0 }; ...@@ -7,22 +7,26 @@ 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(
if (err) { url,
return callback(err); { login: config.sobanle.username, password: config.sobanle.password },
} function (err, body) {
if (!body || !body.access_token) { if (err) {
return callback(new Error("SOBANLE_LOGIN_FAILED")); return callback(err);
} }
tokenCache.token = body.access_token; if (!body || !body.access_token) {
var expiresInMs = (body.expires_in || 0) * 1000; return callback(new Error("SOBANLE_LOGIN_FAILED"));
// Refresh a bit early to avoid racing a near-expiry token. Headroom scales }
// with the token's own TTL (capped at 60s) rather than a flat 60s, so a tokenCache.token = body.access_token;
// short-TTL token isn't already "expired" the instant it's cached. var expiresInMs = (body.expires_in || 0) * 1000;
var earlyRefreshMs = Math.min(60000, expiresInMs * 0.1); // Refresh a bit early to avoid racing a near-expiry token. Headroom scales
tokenCache.expiresAt = Date.now() + expiresInMs - earlyRefreshMs; // with the token's own TTL (capped at 60s) rather than a flat 60s, so a
callback(null, body.access_token); // short-TTL token isn't already "expired" the instant it's cached.
}); var earlyRefreshMs = Math.min(60000, expiresInMs * 0.1);
tokenCache.expiresAt = Date.now() + expiresInMs - earlyRefreshMs;
callback(null, body.access_token);
},
);
} }
exports.login = login; exports.login = login;
...@@ -78,67 +82,98 @@ function withAuthRetry(makeRequest, callback) { ...@@ -78,67 +82,98 @@ 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 +
if (err) { "/products?page=1&is_active=true&per_page=1000&";
return callback(err); ApiRequest.getOtherUrlWithHeader(
} url,
if (!body || !Array.isArray(body.data)) { {},
return callback(new Error("SOBANLE_PRODUCTS_INVALID")); { Authorization: "Bearer " + token },
} cb,
callback(null, body.data); );
}); },
function (err, body) {
if (err) {
return callback(err);
}
if (!body || !Array.isArray(body.data)) {
return callback(new Error("SOBANLE_PRODUCTS_INVALID"));
}
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(
var url = config.sobanle.base_url + "/sales"; function (token, cb) {
var payload = { var url = config.sobanle.base_url + "/sales";
warehouse_id: config.sobanle.warehouse_id, var payload = {
biller_id: config.sobanle.biller_id, warehouse_id: config.sobanle.warehouse_id,
account_id: config.sobanle.account_id, biller_id: config.sobanle.biller_id,
currency_id: config.sobanle.currency_id, account_id: config.sobanle.account_id,
exchange_rate: "1", currency_id: config.sobanle.currency_id,
reference_no: null, exchange_rate: "1",
is_internal_api: true, reference_no: null,
sale_status: 2, is_internal_api: true,
list_product: lines.map(function (line) { sale_status: 2,
return { product_id: String(line.product_id), qty: String(line.qty) }; list_product: lines.map(function (line) {
}), return { product_id: String(line.product_id), qty: String(line.qty) };
customer_data: customerData, }),
payment_receiver: "", customer_data: customerData,
payment_note: "", payment_receiver: "",
sale_note: "Tạo tự động từ payment-gate", payment_note: "",
staff_note: "", staff_note: "",
}; };
ApiRequest.postOtherUrlWithHeader(url, payload, { Authorization: "Bearer " + token }, cb); ApiRequest.postOtherUrlWithHeader(
}, function (err, body) { url,
if (err) { payload,
return callback(err); { Authorization: "Bearer " + token },
} cb,
if (!body || !body.data || typeof body.data.id === "undefined" || typeof body.data.grand_total === "undefined") { );
return callback(new Error("SOBANLE_CREATE_SALE_INVALID")); },
} function (err, body) {
var total = Math.round(Number(body.data.grand_total)); if (err) {
if (!isFinite(total) || total <= 0) { return callback(err);
return callback(new Error("SOBANLE_CREATE_SALE_INVALID")); }
} if (
callback(null, { orderId: body.data.id, orderTotal: total }); !body ||
}); !body.data ||
typeof body.data.id === "undefined" ||
typeof body.data.grand_total === "undefined"
) {
return callback(new Error("SOBANLE_CREATE_SALE_INVALID"));
}
var total = Math.round(Number(body.data.grand_total));
if (!isFinite(total) || total <= 0) {
return callback(new Error("SOBANLE_CREATE_SALE_INVALID"));
}
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;
callback(err || null); ApiRequest.patchOtherUrlWithHeader(
}); url,
{ sale_status: saleStatus },
{ Authorization: "Bearer " + token },
cb,
);
},
function (err) {
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,82 +114,82 @@ Add at the end of the file (before the final blank line), and update `postOtherU ...@@ -112,82 +114,82 @@ 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
request(options, function (err, httpResponse, body) { request(options, function (err, httpResponse, body) {
var statusCode = httpResponse ? httpResponse.statusCode : undefined; var statusCode = httpResponse ? httpResponse.statusCode : undefined;
if (err) { if (err) {
callback(err, body, statusCode); callback(err, body, statusCode);
} else if (statusCode >= 200 && statusCode < 300) { } else if (statusCode >= 200 && statusCode < 300) {
callback(err, body, statusCode); callback(err, body, statusCode);
} else { } else {
callback(body, undefined, statusCode); callback(body, undefined, statusCode);
} }
}); });
}; };
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;
if (err) { if (err) {
callback(err, body, statusCode); callback(err, body, statusCode);
} else if (statusCode >= 200 && statusCode < 300) { } else if (statusCode >= 200 && statusCode < 300) {
callback(err, body, statusCode); callback(err, body, statusCode);
} else { } else {
callback(body, undefined, statusCode); callback(body, undefined, statusCode);
} }
}); });
}; };
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;
if (err) { if (err) {
callback(err, body, statusCode); callback(err, body, statusCode);
} else if (statusCode >= 200 && statusCode < 300) { } else if (statusCode >= 200 && statusCode < 300) {
callback(err, body, statusCode); callback(err, body, statusCode);
} else { } else {
callback(body, undefined, statusCode); callback(body, undefined, statusCode);
} }
}); });
}; };
``` ```
...@@ -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`).
...@@ -306,69 +310,73 @@ var DEFAULT_MAX_ATTEMPTS = 20; ...@@ -306,69 +310,73 @@ var DEFAULT_MAX_ATTEMPTS = 20;
var DEFAULT_MAX_QTY_PER_LINE = 3; var DEFAULT_MAX_QTY_PER_LINE = 3;
function shuffle(list, random) { function shuffle(list, random) {
var arr = list.slice(); var arr = list.slice();
for (var i = arr.length - 1; i > 0; i--) { for (var i = arr.length - 1; i > 0; i--) {
var j = Math.floor(random() * (i + 1)); var j = Math.floor(random() * (i + 1));
var tmp = arr[i]; var tmp = arr[i];
arr[i] = arr[j]; arr[i] = arr[j];
arr[j] = tmp; arr[j] = tmp;
} }
return arr; return arr;
} }
function computeLineTotal(product, qty) { function computeLineTotal(product, qty) {
var rate = product.tax && typeof product.tax.rate === "number" ? product.tax.rate : 0; var rate =
return Math.round(qty * product.price * (1 + rate / 100)); product.tax && typeof product.tax.rate === "number" ? product.tax.rate : 0;
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 =
var maxAttempts = options.maxAttempts || DEFAULT_MAX_ATTEMPTS; typeof options.tolerance === "number"
var random = options.random || Math.random; ? options.tolerance
var maxQtyPerLine = options.maxQtyPerLine || DEFAULT_MAX_QTY_PER_LINE; : DEFAULT_TOLERANCE;
var maxAttempts = options.maxAttempts || DEFAULT_MAX_ATTEMPTS;
var candidates = products.filter(function (p) { var random = options.random || Math.random;
return p.qty > 0 && p.price > 0; var maxQtyPerLine = options.maxQtyPerLine || DEFAULT_MAX_QTY_PER_LINE;
});
if (candidates.length === 0) { var candidates = products.filter(function (p) {
return null; return p.qty > 0 && p.price > 0;
} });
if (candidates.length === 0) {
var minAcceptable = Math.max(1, targetAmount - tolerance); return null;
}
for (var attempt = 0; attempt < maxAttempts; attempt++) {
var shuffled = shuffle(candidates, random); var minAcceptable = Math.max(1, targetAmount - tolerance);
var total = 0;
var lines = []; for (var attempt = 0; attempt < maxAttempts; attempt++) {
var shuffled = shuffle(candidates, random);
for (var i = 0; i < shuffled.length; i++) { var total = 0;
var product = shuffled[i]; var lines = [];
var maxQty = Math.min(maxQtyPerLine, product.qty);
var qty = 1 + Math.floor(random() * maxQty); for (var i = 0; i < shuffled.length; i++) {
var lineTotal = computeLineTotal(product, qty); var product = shuffled[i];
var maxQty = Math.min(maxQtyPerLine, product.qty);
if (total + lineTotal > targetAmount) { var qty = 1 + Math.floor(random() * maxQty);
continue; var lineTotal = computeLineTotal(product, qty);
}
total += lineTotal; if (total + lineTotal > targetAmount) {
lines.push({ product_id: product.id, qty: qty }); continue;
if (total >= minAcceptable) { }
break; total += lineTotal;
} lines.push({ product_id: product.id, qty: qty });
} if (total >= minAcceptable) {
break;
if (total >= minAcceptable && total <= targetAmount) { }
return { lines: lines, total: total }; }
}
} if (total >= minAcceptable && total <= targetAmount) {
return { lines: lines, total: total };
return null; }
}
return null;
} }
module.exports = { module.exports = {
pickProductCombo: pickProductCombo, pickProductCombo: pickProductCombo,
computeLineTotal: computeLineTotal, computeLineTotal: computeLineTotal,
}; };
``` ```
...@@ -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.
...@@ -545,37 +557,41 @@ var ApiRequest = require("./ApiRequest"); ...@@ -545,37 +557,41 @@ var ApiRequest = require("./ApiRequest");
var tokenCache = { token: null, expiresAt: 0 }; 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(
if (err) { url,
return callback(err); { login: config.sobanle.username, password: config.sobanle.password },
} function (err, body) {
if (!body || !body.access_token) { if (err) {
return callback(new Error("SOBANLE_LOGIN_FAILED")); return callback(err);
} }
tokenCache.token = body.access_token; if (!body || !body.access_token) {
var expiresInMs = (body.expires_in || 0) * 1000; return callback(new Error("SOBANLE_LOGIN_FAILED"));
// Refresh a bit early to avoid racing a near-expiry token. Headroom scales }
// with the token's own TTL (capped at 60s) rather than a flat 60s, so a tokenCache.token = body.access_token;
// short-TTL token isn't already "expired" the instant it's cached. var expiresInMs = (body.expires_in || 0) * 1000;
var earlyRefreshMs = Math.min(60000, expiresInMs * 0.1); // Refresh a bit early to avoid racing a near-expiry token. Headroom scales
tokenCache.expiresAt = Date.now() + expiresInMs - earlyRefreshMs; // with the token's own TTL (capped at 60s) rather than a flat 60s, so a
callback(null, body.access_token); // short-TTL token isn't already "expired" the instant it's cached.
}); var earlyRefreshMs = Math.min(60000, expiresInMs * 0.1);
tokenCache.expiresAt = Date.now() + expiresInMs - earlyRefreshMs;
callback(null, body.access_token);
},
);
} }
exports.login = login; exports.login = login;
function getToken(callback) { function getToken(callback) {
if (tokenCache.token && Date.now() < tokenCache.expiresAt) { if (tokenCache.token && Date.now() < tokenCache.expiresAt) {
return callback(null, tokenCache.token); return callback(null, tokenCache.token);
} }
exports.login(callback); exports.login(callback);
} }
exports.getToken = getToken; exports.getToken = getToken;
exports._resetTokenCacheForTest = function () { exports._resetTokenCacheForTest = function () {
tokenCache.token = null; tokenCache.token = null;
tokenCache.expiresAt = 0; tokenCache.expiresAt = 0;
}; };
``` ```
...@@ -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).
...@@ -717,90 +735,124 @@ Append to the file (after the Task 4 code, before nothing else — this is the f ...@@ -717,90 +735,124 @@ Append to the file (after the Task 4 code, before nothing else — this is the f
```js ```js
function withAuthRetry(makeRequest, callback) { function withAuthRetry(makeRequest, callback) {
exports.getToken(function (err, token) { exports.getToken(function (err, token) {
if (err) { if (err) {
return callback(err); return callback(err);
} }
makeRequest(token, function (err2, body, statusCode) { makeRequest(token, function (err2, body, statusCode) {
if (statusCode === 401) { if (statusCode === 401) {
tokenCache.token = null; tokenCache.token = null;
return exports.getToken(function (loginErr, freshToken) { return exports.getToken(function (loginErr, freshToken) {
if (loginErr) { if (loginErr) {
return callback(loginErr); return callback(loginErr);
} }
makeRequest(freshToken, function (err3, body3) { makeRequest(freshToken, function (err3, body3) {
if (err3) { if (err3) {
return callback(err3); return callback(err3);
} }
callback(null, body3); callback(null, body3);
}); });
}); });
} }
if (err2) { if (err2) {
return callback(err2); return callback(err2);
} }
callback(null, body); callback(null, body);
}); });
}); });
} }
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 +
if (err) { "/products?page=1&is_active=true&per_page=1000&";
return callback(err); ApiRequest.getOtherUrlWithHeader(
} url,
if (!body || !Array.isArray(body.data)) { {},
return callback(new Error("SOBANLE_PRODUCTS_INVALID")); { Authorization: "Bearer " + token },
} cb,
callback(null, body.data); );
}); },
function (err, body) {
if (err) {
return callback(err);
}
if (!body || !Array.isArray(body.data)) {
return callback(new Error("SOBANLE_PRODUCTS_INVALID"));
}
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(
var url = config.sobanle.base_url + "/sales"; function (token, cb) {
var payload = { var url = config.sobanle.base_url + "/sales";
warehouse_id: config.sobanle.warehouse_id, var payload = {
biller_id: config.sobanle.biller_id, warehouse_id: config.sobanle.warehouse_id,
account_id: config.sobanle.account_id, biller_id: config.sobanle.biller_id,
currency_id: config.sobanle.currency_id, account_id: config.sobanle.account_id,
exchange_rate: "1", currency_id: config.sobanle.currency_id,
reference_no: null, exchange_rate: "1",
is_internal_api: true, reference_no: null,
sale_status: 2, is_internal_api: true,
list_product: lines.map(function (line) { sale_status: 2,
return { product_id: String(line.product_id), qty: String(line.qty) }; list_product: lines.map(function (line) {
}), return { product_id: String(line.product_id), qty: String(line.qty) };
customer_data: customerData, }),
payment_receiver: "", customer_data: customerData,
payment_note: "", payment_receiver: "",
sale_note: "Tạo tự động từ payment-gate", payment_note: "",
staff_note: "", staff_note: "",
}; };
ApiRequest.postOtherUrlWithHeader(url, payload, { Authorization: "Bearer " + token }, cb); ApiRequest.postOtherUrlWithHeader(
}, function (err, body) { url,
if (err) { payload,
return callback(err); { Authorization: "Bearer " + token },
} cb,
if (!body || !body.data || typeof body.data.id === "undefined" || typeof body.data.grand_total === "undefined") { );
return callback(new Error("SOBANLE_CREATE_SALE_INVALID")); },
} function (err, body) {
callback(null, { orderId: body.data.id, orderTotal: body.data.grand_total }); if (err) {
}); return callback(err);
}
if (
!body ||
!body.data ||
typeof body.data.id === "undefined" ||
typeof body.data.grand_total === "undefined"
) {
return callback(new Error("SOBANLE_CREATE_SALE_INVALID"));
}
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;
callback(err || null); ApiRequest.patchOtherUrlWithHeader(
}); url,
{ sale_status: 4 },
{ Authorization: "Bearer " + token },
cb,
);
},
function (err) {
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`).
...@@ -913,16 +967,16 @@ var pickProductCombo = require("./productComboPicker").pickProductCombo; ...@@ -913,16 +967,16 @@ var pickProductCombo = require("./productComboPicker").pickProductCombo;
```js ```js
function createAutoOrder(customerData, targetAmount, callback) { function createAutoOrder(customerData, targetAmount, callback) {
exports.getProducts(function (err, products) { exports.getProducts(function (err, products) {
if (err) { if (err) {
return callback(err); return callback(err);
} }
var combo = pickProductCombo(products, targetAmount, {}); var combo = pickProductCombo(products, targetAmount, {});
if (!combo) { if (!combo) {
return callback(new Error("NO_PRODUCT_COMBO_MATCH")); return callback(new Error("NO_PRODUCT_COMBO_MATCH"));
} }
exports.createSale(combo.lines, customerData, callback); exports.createSale(combo.lines, customerData, callback);
}); });
} }
exports.createAutoOrder = createAutoOrder; exports.createAutoOrder = createAutoOrder;
``` ```
...@@ -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}}`).
...@@ -1138,77 +1196,90 @@ Replace the full `exports.createTransaction` function (currently lines 16-67) wi ...@@ -1138,77 +1196,90 @@ Replace the full `exports.createTransaction` function (currently lines 16-67) wi
```js ```js
exports.createTransaction = function (req, res) { exports.createTransaction = function (req, res) {
var customerName = req.body.customerName; var customerName = req.body.customerName;
var customerPhone = req.body.customerPhone; var customerPhone = req.body.customerPhone;
var customerAddress = req.body.customerAddress; var customerAddress = req.body.customerAddress;
var rawAmount = req.body.amount; var rawAmount = req.body.amount;
if (!customerName || !customerPhone || !customerAddress || !rawAmount) { if (!customerName || !customerPhone || !customerAddress || !rawAmount) {
return res.status(400).json({ code: "99", data: "MISSING_FIELDS" }); return res.status(400).json({ code: "99", data: "MISSING_FIELDS" });
} }
var targetAmount = parseInt(rawAmount, 10); var targetAmount = parseInt(rawAmount, 10);
if (!isFinite(targetAmount) || targetAmount <= 0 || String(targetAmount) !== String(rawAmount).trim()) { if (
return res.status(400).json({ code: "99", data: "INVALID_AMOUNT" }); !isFinite(targetAmount) ||
} targetAmount <= 0 ||
String(targetAmount) !== String(rawAmount).trim()
var customerData = { ) {
customer_group_id: 1, return res.status(400).json({ code: "99", data: "INVALID_AMOUNT" });
customer_type: 2, }
phone_number: customerPhone,
tax_no: null, var customerData = {
name: customerName, customer_group_id: 1,
address: customerAddress, customer_type: 2,
id_card_number: null, phone_number: customerPhone,
passport_number: null, tax_no: null,
email: null, name: customerName,
}; address: customerAddress,
id_card_number: null,
SobanleClient.createAutoOrder(customerData, targetAmount, function (sobanleErr, order) { passport_number: null,
if (sobanleErr) { email: null,
console.error("createTransaction: SobanleClient error:", sobanleErr.message); };
return res.status(502).json({ code: "99", data: "SOBANLE_ORDER_FAILED" });
} SobanleClient.createAutoOrder(
customerData,
var amount = order.orderTotal; targetAmount,
var timeStamp = moment().format("YYYYMMDDHHmmss"); function (sobanleErr, order) {
var uniqueSuffix = uuidv4().split("-")[0]; if (sobanleErr) {
var merTrxId = "HY_" + timeStamp + "_" + uniqueSuffix; console.error(
var transCode = "HY_RT_" + timeStamp + "_" + uniqueSuffix; "createTransaction: SobanleClient error:",
var merchantToken = epaySign.signRequest( sobanleErr.message,
timeStamp, );
merTrxId, return res
config.epay.merchant_id, .status(502)
amount, .json({ code: "99", data: "SOBANLE_ORDER_FAILED" });
config.epay.encode_key }
);
var amount = order.orderTotal;
AdminTransaction.create( var timeStamp = moment().format("YYYYMMDDHHmmss");
{ var uniqueSuffix = uuidv4().split("-")[0];
merTrxId: merTrxId, var merTrxId = "HY_" + timeStamp + "_" + uniqueSuffix;
transCode: transCode, var transCode = "HY_RT_" + timeStamp + "_" + uniqueSuffix;
customerName: customerName, var merchantToken = epaySign.signRequest(
customerPhone: customerPhone, timeStamp,
customerAddress: customerAddress, merTrxId,
amount: amount, config.epay.merchant_id,
sobanleOrderId: String(order.orderId), amount,
merchantToken: merchantToken, config.epay.encode_key,
timeStamp: timeStamp, );
createdByUsername: req.session.username,
}, AdminTransaction.create(
function (err, tx) { {
if (err) { merTrxId: merTrxId,
console.error("createTransaction: DB error:", err.message); transCode: transCode,
return res.status(500).json({ code: "99", data: "DB_ERROR" }); customerName: customerName,
} customerPhone: customerPhone,
var paymentUrl = config.epay.req_domain + "/admin/pay/" + tx.merTrxId; customerAddress: customerAddress,
return res.status(200).json({ amount: amount,
code: "00", sobanleOrderId: String(order.orderId),
data: { merTrxId: tx.merTrxId, paymentUrl: paymentUrl }, merchantToken: merchantToken,
}); timeStamp: timeStamp,
} createdByUsername: req.session.username,
); },
}); function (err, tx) {
if (err) {
console.error("createTransaction: DB error:", err.message);
return res.status(500).json({ code: "99", data: "DB_ERROR" });
}
var paymentUrl = config.epay.req_domain + "/admin/pay/" + tx.merTrxId;
return res.status(200).json({
code: "00",
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