Người lập: Claude Code (Systems Engineer) Ngày: 22/05/2026 Nguồn: K7-cadence-hop-mkt-kd, WF04/WF08 pattern, NocoDB v2 API Mục đích: Thiết kế kỹ thuật chi tiết để tránh tái diễn vụ 8tr treo 65 ngày
| Vấn đề | Thực trạng | Rủi ro |
|---|---|---|
| Vụ 8tr Option C | Treo 65 ngày, không ai track | Thương hiệu (Brand) + tài chính 200-300tr |
| NCC vận tải | 7 tuần chưa ký HĐ | Pháp lý, vận hành |
| BHXH 7 thợ | Chưa rõ trạng thái | Thuế + lao động |
| BH cô Thúy | >1 tháng chưa giải quyết | KH phàn nàn, reputation |
| Ring-fence cash | Chốt miệng, không có ai nhắc | 4-4.5 tỷ VND tồn đọng |
Giải pháp: NocoDB bảng GK_Decision_Log + n8n WF09 tự động nhắc.
GK_Decision_Log (NocoDB)Base: B.Gia Khánh — ql.minhdigital.com Token: 2rIAS5EsiDrJMiZXRQk7JALJD52avJ9Mm8TQU8gm Table ID: (lấy sau khi tạo bảng — xem Bước 2)
| Field | NocoDB Type | Required | Default | Ghi chú |
|---|---|---|---|---|
Id | ID (AutoNumber) | Y | auto | NocoDB tự tạo, format DL-2026-001 |
decision_text | LongText | Y | — | Nội dung quyết định, 1-3 câu rõ ràng |
context | LongText | N | — | Tại sao quyết định này, dữ liệu dẫn đến |
owner | SingleLineText | Y | — | Tên người thực thi (Minh, Trung Anh, Thép, Doan...) |
deadline | Date | Y | — | Deadline cứng (YYYY-MM-DD) |
status | SingleSelect | Y | Open | Open / In Progress / Done / Overdue / Cancelled |
meeting_source | SingleSelect | N | — | Weekly T6 / BGĐ T2 / Monthly / Quarterly / Ad-hoc / Standup |
impact_level | SingleSelect | Y | Medium | Critical / High / Medium / Low |
financial_value | Number | N | 0 | Giá trị tài chính (triệu VND, để trống = 0) |
stakeholders | MultiSelect | N | — | BGĐ / MKT / KD / Doan / Cường / Minh Anh / Thép / Kế toán |
notes | LongText | N | — | Cập nhật tiến độ, ghi chú thêm |
completed_at | DateTime | N | — | Tự điền khi status → Done |
linked_doc | URL | N | — | Link biên bản họp docx / file Google Drive |
owner_confirmed | Checkbox | N | false | Owner đã reply "NHẬN" qua Telegram bot |
date_logged | DateTime | Y | NOW() | Thời điểm tạo record |
Open ──────────────────────────────────────────→ Done
└──→ In Progress ──→ Done
└──→ Overdue (auto khi deadline < today, status = Open/In Progress)
└──→ Cancelled (BGĐ quyết dừng)
Auto Overdue rule: WF09 Trigger 1 tự PATCH status=Overdue khi deadline < today AND status IN (Open, In Progress).
{
"decision_text": "Vụ 8tr — Áp dụng Option C: Trừ dần từ hoa hồng KD tháng T5-T6 BN",
"context": "KH phản ánh dịch vụ lắp đặt chậm 65 ngày, thiệt hại ước 8tr. BGĐ họp 22/05 chọn Option C thay vì hoàn tiền trực tiếp để bảo vệ cashflow.",
"owner": "Trung Anh",
"deadline": "2026-05-28",
"status": "Open",
"meeting_source": "BGĐ T2",
"impact_level": "High",
"financial_value": 250,
"stakeholders": ["BGĐ", "KD"],
"notes": "4 KD đã được thông báo. Cần Trung Anh xác nhận phân bổ từng người.",
"owner_confirmed": false,
"linked_doc": "https://docs.google.com/..."
}
Base URL: https://ql.minhdigital.com Auth header: xc-token: 2rIAS5EsiDrJMiZXRQk7JALJD52avJ9Mm8TQU8gm Table ID placeholder: {TABLE_ID} — thay bằng ID thật sau khi tạo bảng (xem Bước 2)
POST /api/v2/tables/{TABLE_ID}/records
Content-Type: application/json
xc-token: 2rIAS5EsiDrJMiZXRQk7JALJD52avJ9Mm8TQU8gm
{
"decision_text": "...",
"owner": "Trung Anh",
"deadline": "2026-05-28",
"status": "Open",
"impact_level": "High"
}
PATCH /api/v2/tables/{TABLE_ID}/records
Content-Type: application/json
xc-token: ...
[{"Id": 1, "status": "Done", "completed_at": "2026-05-28T15:00:00.000Z"}]
Lưu ý: NocoDB v2 PATCH nhận array, KHÔNG phải single object.
GET /api/v2/tables/{TABLE_ID}/records
?where=(deadline,lt,{TODAY})~and(status,neq,Done)~and(status,neq,Cancelled)
&sort=-deadline
&limit=100
Ví dụ ngày thực tế:
?where=(deadline,lt,2026-05-22)~and(status,neq,Done)~and(status,neq,Cancelled)
GET /api/v2/tables/{TABLE_ID}/records
?where=(deadline,gte,{TODAY})~and(deadline,lte,{TODAY+3})~and(status,neq,Done)~and(status,neq,Cancelled)
GET /api/v2/tables/{TABLE_ID}/records
?where=(owner,eq,Trung Anh)~and(status,neq,Done)~and(status,neq,Cancelled)
&sort=deadline
const https = require('https');
const NC_TOKEN = '2rIAS5EsiDrJMiZXRQk7JALJD52avJ9Mm8TQU8gm';
const TABLE_ID = '{TABLE_ID}'; // Điền sau khi tạo bảng
function ncGet(path) {
return new Promise((resolve, reject) => {
https.get({
hostname: 'ql.minhdigital.com',
path,
headers: { 'xc-token': NC_TOKEN }
}, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => {
try { resolve(JSON.parse(d)); }
catch(e) { reject(new Error('Parse error: ' + d.substring(0, 200))); }
});
}).on('error', reject);
});
}
function ncPatch(path, body) {
const bodyStr = JSON.stringify(body);
return new Promise((resolve, reject) => {
const req = https.request({
hostname: 'ql.minhdigital.com',
path,
method: 'PATCH',
headers: {
'xc-token': NC_TOKEN,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr)
}
}, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => resolve(JSON.parse(d)));
});
req.on('error', reject);
req.write(bodyStr);
req.end();
});
}
Quy trình tự động (Workflow) ID: wf09 (đặt khi import vào n8n) Bot: @hotrobhgk_bot — token: 8696719099:AAFk9INPJUjTwh3gBdlTYcZpalNB74-CVM0 Admin chat (Minh): 481197292 (Telegram @vuquangminh) Minh PLH chat: 5139874383 (dùng cho override nội bộ nếu cần)
[Trigger 1: Cron CN 20:00]──────────────────────────────────────────→ Code node: OVERDUE Push
↓
Telegram: Minh + Owner
[Trigger 2: Cron Daily 8:00]────────────────────────────────────────→ Code node: Deadline 1-3d
↓
Telegram: từng Owner
[Trigger 3: Webhook NocoDB INSERT]──────────────────────────────────→ Code node: New Decision Push
↓
Telegram: Owner với inline buttons
↓
Wait 48h (staticData timer)
↓
Nếu chưa confirm → Telegram Minh
[Trigger 4: Webhook Telegram callback]──────────────────────────────→ Code node: Process Button Click
↓
PATCH NocoDB + Telegram confirm
Node: Schedule Trigger
{
"rule": {
"interval": [{
"field": "cronExpression",
"expression": "0 20 * * 0"
}]
}
}
**Code node — Decision OVERDUE Push:**
const https = require('https');
const NC_TOKEN = '2rIAS5EsiDrJMiZXRQk7JALJD52avJ9Mm8TQU8gm';
const TABLE_ID = '{TABLE_ID}';
const BOT_TOKEN = '8696719099:AAFk9INPJUjTwh3gBdlTYcZpalNB74-CVM0';
const ADMIN_CHAT = '481197292';
// Map owner name → Telegram chat_id
const OWNER_TG = {
'Minh': '481197292',
'Trung Anh': '6847968276',
'Thép': '8763475240',
'Doan': null, // Chưa có TG — chỉ nhắc Minh
'Minh Anh': null,
'Anh Khánh': null, // BGĐ không nhắc bot — Minh tự nhắc trực tiếp
};
function ncGet(path) {
return new Promise((resolve, reject) => {
https.get({
hostname: 'ql.minhdigital.com',
path,
headers: { 'xc-token': NC_TOKEN }
}, res => {
let d = ''; res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d)); } catch(e) { reject(e); } });
}).on('error', reject);
});
}
function sendTG(chat_id, text, reply_markup) {
const body = JSON.stringify({ chat_id, text, parse_mode: 'Markdown', reply_markup });
return new Promise((resolve, reject) => {
const req = https.request({
hostname: 'api.telegram.org',
path: `/bot${BOT_TOKEN}/sendMessage`,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
}, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); });
req.on('error', reject);
req.write(body); req.end();
});
}
// === VN time ===
const nowVN = new Date(Date.now() + 7 * 3600000);
const todayStr = nowVN.toISOString().substring(0, 10);
const todayDisplay = nowVN.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' });
// === Query OVERDUE ===
const where = encodeURIComponent(`(deadline,lt,${todayStr})~and(status,neq,Done)~and(status,neq,Cancelled)`);
const result = await ncGet(`/api/v2/tables/${TABLE_ID}/records?where=${where}&sort=-deadline&limit=100`);
const overdue = result.list || [];
if (overdue.length === 0) {
await sendTG(ADMIN_CHAT, `✅ *Decision Log — CN ${todayDisplay}*\n\nKhông có quyết định nào overdue. Tuần tốt!`);
return [{ json: { overdue_count: 0 } }];
}
// === Group by owner ===
const byOwner = {};
for (const d of overdue) {
const owner = d.owner || 'Không rõ';
if (!byOwner[owner]) byOwner[owner] = [];
byOwner[owner].push(d);
}
// === PATCH status = Overdue cho item chưa có ===
const ncPatch = (path, body) => {
const bodyStr = JSON.stringify(body);
return new Promise((resolve, reject) => {
const req = https.request({
hostname: 'ql.minhdigital.com',
path, method: 'PATCH',
headers: { 'xc-token': NC_TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) }
}, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(JSON.parse(d))); });
req.on('error', reject);
req.write(bodyStr); req.end();
});
};
const toMark = overdue.filter(d => d.status !== 'Overdue').map(d => ({ Id: d.Id, status: 'Overdue' }));
if (toMark.length > 0) {
await ncPatch(`/api/v2/tables/${TABLE_ID}/records`, toMark);
}
// === Gửi cho từng owner ===
const sent = [];
for (const [owner, items] of Object.entries(byOwner)) {
const tgId = OWNER_TG[owner];
const daysCalc = (d) => {
const diff = new Date(todayStr) - new Date(d.deadline);
return Math.ceil(diff / 86400000);
};
let msg = `⚠️ *OVERDUE DECISIONS — ${owner}*\n_Tuần tới phải xử lý:_\n\n`;
for (const d of items.slice(0, 8)) {
const days = daysCalc(d);
const dl = new Date(d.deadline).toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit' });
msg += `🔴 *#${d.Id}* — Quá ${days} ngày (${dl})\n`;
msg += ` ${(d.decision_text || '').substring(0, 80)}\n\n`;
}
if (items.length > 8) msg += `_...và ${items.length - 8} quyết định khác_\n\n`;
msg += `Reply /decision_done <id> để đánh dấu xong`;
if (tgId) {
await sendTG(tgId, msg);
sent.push(owner);
}
}
// === Báo Minh tổng overview ===
let adminMsg = `📋 *DECISION LOG — OVERDUE REPORT*\n_${todayDisplay}_\n\n`;
adminMsg += `Tổng overdue: *${overdue.length} quyết định*\n\n`;
for (const [owner, items] of Object.entries(byOwner)) {
adminMsg += `• ${owner}: ${items.length} item\n`;
}
adminMsg += `\n_Xem đầy đủ: https://ql.minhdigital.com_`;
await sendTG(ADMIN_CHAT, adminMsg);
return [{ json: { overdue_count: overdue.length, by_owner: Object.fromEntries(Object.entries(byOwner).map(([k,v]) => [k, v.length])), sent } }];
Node: Schedule Trigger
{
"rule": {
"interval": [{
"field": "cronExpression",
"expression": "0 8 * * *"
}]
}
}
**Code node — Decision Deadline Warning:**
const https = require('https');
const NC_TOKEN = '2rIAS5EsiDrJMiZXRQk7JALJD52avJ9Mm8TQU8gm';
const TABLE_ID = '{TABLE_ID}';
const BOT_TOKEN = '8696719099:AAFk9INPJUjTwh3gBdlTYcZpalNB74-CVM0';
const ADMIN_CHAT = '481197292';
const OWNER_TG = {
'Minh': '481197292',
'Trung Anh': '6847968276',
'Thép': '8763475240',
};
function ncGet(path) {
return new Promise((resolve, reject) => {
https.get({ hostname: 'ql.minhdigital.com', path, headers: { 'xc-token': NC_TOKEN } }, res => {
let d = ''; res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d)); } catch(e) { reject(e); } });
}).on('error', reject);
});
}
function sendTG(chat_id, text) {
const body = JSON.stringify({ chat_id, text, parse_mode: 'Markdown' });
return new Promise((resolve, reject) => {
const req = https.request({
hostname: 'api.telegram.org',
path: `/bot${BOT_TOKEN}/sendMessage`,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
}, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); });
req.on('error', reject);
req.write(body); req.end();
});
}
const nowVN = new Date(Date.now() + 7 * 3600000);
const todayStr = nowVN.toISOString().substring(0, 10);
const plus3 = new Date(nowVN.getTime() + 3 * 86400000).toISOString().substring(0, 10);
// Lấy tất cả deadline trong 0-3 ngày tới, chưa Done/Cancelled
const where = encodeURIComponent(
`(deadline,gte,${todayStr})~and(deadline,lte,${plus3})~and(status,neq,Done)~and(status,neq,Cancelled)`
);
const result = await ncGet(`/api/v2/tables/${TABLE_ID}/records?where=${where}&sort=deadline&limit=50`);
const items = result.list || [];
if (items.length === 0) {
return [{ json: { warned: 0 } }];
}
// Group by owner
const byOwner = {};
for (const item of items) {
const owner = item.owner || 'Không rõ';
if (!byOwner[owner]) byOwner[owner] = [];
byOwner[owner].push(item);
}
// Gửi cho từng owner
for (const [owner, ownerItems] of Object.entries(byOwner)) {
const tgId = OWNER_TG[owner];
if (!tgId) continue;
let msg = `⏰ *Nhắc deadline hôm nay / 3 ngày tới — ${owner}*\n\n`;
for (const d of ownerItems) {
const daysLeft = Math.ceil((new Date(d.deadline) - new Date(todayStr)) / 86400000);
const dl = new Date(d.deadline).toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit' });
const urgency = daysLeft === 0 ? '🔴 HÔM NAY' : daysLeft === 1 ? '🟡 Ngày mai' : `🟢 Còn ${daysLeft} ngày`;
msg += `${urgency} (${dl}) — *#${d.Id}*\n`;
msg += `${(d.decision_text || '').substring(0, 70)}\n\n`;
}
msg += `Reply /decision_done <id> để đánh dấu hoàn thành`;
await sendTG(tgId, msg);
}
return [{ json: { warned: items.length, by_owner: Object.fromEntries(Object.entries(byOwner).map(([k,v]) => [k, v.length])) } }];
Setup NocoDB webhook:
GK_Decision_Log → Settings → WebhooksAfter Inserthttps://n8n.noithatgiakhanh.com/webhook/decision-newNode: Webhook Trigger (path: decision-new)
**Code node — Decision New Push:**
const https = require('https');
const BOT_TOKEN = '8696719099:AAFk9INPJUjTwh3gBdlTYcZpalNB74-CVM0';
const ADMIN_CHAT = '481197292';
const staticData = $getWorkflowStaticData('global');
if (!staticData.pendingConfirm) staticData.pendingConfirm = {};
const OWNER_TG = {
'Minh': '481197292',
'Trung Anh': '6847968276',
'Thép': '8763475240',
};
function sendTG(chat_id, text, reply_markup) {
const body = JSON.stringify({ chat_id, text, parse_mode: 'Markdown', reply_markup, disable_web_page_preview: true });
return new Promise((resolve, reject) => {
const req = https.request({
hostname: 'api.telegram.org',
path: `/bot${BOT_TOKEN}/sendMessage`,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
}, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(JSON.parse(d))); });
req.on('error', reject);
req.write(body); req.end();
});
}
// Parse dữ liệu từ NocoDB webhook
const body = $input.first().json.body || $input.first().json;
const data = body.data || body;
const record = data.rows ? data.rows[0] : data;
const id = record.Id;
const text = record.decision_text || '';
const owner = record.owner || '';
const deadline = record.deadline || '';
const impact = record.impact_level || 'Medium';
const source = record.meeting_source || 'Ad-hoc';
const financial = record.financial_value || 0;
// Format deadline
const dlFormatted = deadline
? new Date(deadline).toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
: 'Chưa xác định';
const impactIcon = { Critical: '🚨', High: '🔴', Medium: '🟡', Low: '🟢' }[impact] || '🟡';
// Gửi cho owner
const ownerTgId = OWNER_TG[owner];
if (ownerTgId) {
const ownerMsg = `📌 *Quyết định mới — #${id}*\n\n` +
`${impactIcon} ${impact} | ${source}\n` +
`*Nội dung:* ${text.substring(0, 120)}\n` +
`*Deadline:* ${dlFormatted}\n` +
(financial > 0 ? `*Giá trị:* ${financial}tr VND\n` : '') +
`\nBạn là owner. Vui lòng xác nhận nhận nhiệm vụ:`;
const reply_markup = {
inline_keyboard: [[
{ text: '✅ NHẬN', callback_data: `confirm_${id}` },
{ text: '❓ Cần clarify', callback_data: `clarify_${id}` },
]]
};
await sendTG(ownerTgId, ownerMsg, reply_markup);
// Lưu vào staticData để theo dõi 48h
staticData.pendingConfirm[id] = {
owner,
tgId: ownerTgId,
created: Date.now(),
deadline,
text: text.substring(0, 60)
};
}
// Notify Minh (nếu không phải Minh là owner)
if (owner !== 'Minh') {
const adminMsg = `📋 *Decision Log — Mới*\n\n` +
`#${id} giao cho *${owner}*\n` +
`${impactIcon} ${text.substring(0, 80)}\n` +
`Deadline: ${dlFormatted}\n` +
`_Đang chờ owner confirm 48h_`;
await sendTG(ADMIN_CHAT, adminMsg);
}
return [{ json: { id, owner, deadline, notified: !!ownerTgId } }];
**Pattern kế thừa từ WF04 — node Xử lý Callback/Message**
Node: Webhook Trigger (path: bot-wf09) — dùng chung với bot @hotrobhgk_bot
**Code node — Process Decision Callback:**
const https = require('https');
const NC_TOKEN = '2rIAS5EsiDrJMiZXRQk7JALJD52avJ9Mm8TQU8gm';
const TABLE_ID = '{TABLE_ID}';
const BOT_TOKEN = '8696719099:AAFk9INPJUjTwh3gBdlTYcZpalNB74-CVM0';
const ADMIN_CHAT = '481197292';
const staticData = $getWorkflowStaticData('global');
if (!staticData.pendingConfirm) staticData.pendingConfirm = {};
const OWNER_TG = {
'Minh': '481197292',
'Trung Anh': '6847968276',
'Thép': '8763475240',
};
function ncPatch(body) {
const bodyStr = JSON.stringify(body);
return new Promise((resolve, reject) => {
const req = https.request({
hostname: 'ql.minhdigital.com',
path: `/api/v2/tables/${TABLE_ID}/records`,
method: 'PATCH',
headers: { 'xc-token': NC_TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) }
}, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(JSON.parse(d))); });
req.on('error', reject);
req.write(bodyStr); req.end();
});
}
function sendTG(chat_id, text) {
const body = JSON.stringify({ chat_id, text, parse_mode: 'Markdown' });
return new Promise((resolve, reject) => {
const req = https.request({
hostname: 'api.telegram.org',
path: `/bot${BOT_TOKEN}/sendMessage`,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
}, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); });
req.on('error', reject);
req.write(body); req.end();
});
}
function answerCallback(callback_query_id, text) {
const body = JSON.stringify({ callback_query_id, text });
return new Promise((resolve, reject) => {
const req = https.request({
hostname: 'api.telegram.org',
path: `/bot${BOT_TOKEN}/answerCallbackQuery`,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
}, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); });
req.on('error', reject);
req.write(body); req.end();
});
}
const rawInput = $input.first().json;
const input = rawInput.body || rawInput;
const chatId = String(input.message?.chat?.id || input.callback_query?.message?.chat?.id || '');
const text = input.message?.text || '';
const callbackData = input.callback_query?.data || '';
const callbackQueryId = input.callback_query?.id || '';
let action = null;
// === Xử lý inline button callbacks ===
if (callbackData) {
const match = callbackData.match(/^(confirm|clarify|done|delay|cancel)_(\d+)$/);
if (match) {
const [, actionType, recordId] = match;
const id = parseInt(recordId);
await answerCallback(callbackQueryId, 'Đang xử lý...');
if (actionType === 'confirm') {
await ncPatch([{ Id: id, owner_confirmed: true }]);
delete staticData.pendingConfirm[id];
action = `confirm_${id}`;
await sendTG(chatId, `✅ Đã xác nhận nhận nhiệm vụ #${id}. Cập nhật tiến độ qua /decision_done ${id} khi hoàn thành.`);
await sendTG(ADMIN_CHAT, `✅ Owner đã xác nhận Decision #${id}`);
} else if (actionType === 'done') {
const nowVN = new Date(Date.now() + 7 * 3600000);
await ncPatch([{ Id: id, status: 'Done', completed_at: nowVN.toISOString() }]);
action = `done_${id}`;
await sendTG(chatId, `🎉 Decision #${id} đã đánh dấu *Done*!`);
await sendTG(ADMIN_CHAT, `🎉 Decision #${id} — *Done* bởi ${chatId}`);
} else if (actionType === 'delay') {
await ncPatch([{ Id: id, status: 'In Progress', notes: `Delay xin gia hạn — ${new Date().toLocaleDateString('vi-VN')}` }]);
action = `delay_${id}`;
await sendTG(chatId, `⏳ Ghi nhận gia hạn #${id}. Minh sẽ liên hệ để confirm deadline mới.`);
await sendTG(ADMIN_CHAT, `⚠️ Decision #${id} bị *delay* bởi ${chatId} — cần confirm deadline mới`);
} else if (actionType === 'cancel') {
await ncPatch([{ Id: id, status: 'Cancelled' }]);
action = `cancel_${id}`;
await sendTG(chatId, `❌ Decision #${id} đã Cancelled. Vui lòng ghi lý do vào NocoDB.`);
await sendTG(ADMIN_CHAT, `❌ Decision #${id} bị *Cancelled* bởi ${chatId}`);
}
}
}
// === Xử lý text commands ===
if (text.startsWith('/decision_done ')) {
const id = parseInt(text.split(' ')[1]);
if (!isNaN(id)) {
const nowVN = new Date(Date.now() + 7 * 3600000);
await ncPatch([{ Id: id, status: 'Done', completed_at: nowVN.toISOString() }]);
await sendTG(chatId, `🎉 Decision #${id} marked *Done*!`);
action = `text_done_${id}`;
}
}
if (text === '/decisions' || text === '/decisions_overdue') {
// Redirect đến Trigger 1 logic inline (simplified)
const isOverdue = text === '/decisions_overdue';
const ncGet = (path) => new Promise((resolve, reject) => {
https.get({ hostname: 'ql.minhdigital.com', path, headers: { 'xc-token': NC_TOKEN } }, res => {
let d = ''; res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d)); } catch(e) { reject(e); } });
}).on('error', reject);
});
const todayStr = new Date(Date.now() + 7 * 3600000).toISOString().substring(0, 10);
const ownerName = Object.entries(OWNER_TG).find(([, v]) => v === chatId)?.[0] || '';
let where;
if (isOverdue) {
where = encodeURIComponent(`(deadline,lt,${todayStr})~and(owner,eq,${ownerName})~and(status,neq,Done)~and(status,neq,Cancelled)`);
} else {
where = encodeURIComponent(`(owner,eq,${ownerName})~and(status,neq,Done)~and(status,neq,Cancelled)`);
}
const result = await ncGet(`/api/v2/tables/${TABLE_ID}/records?where=${where}&sort=deadline&limit=20`);
const items = result.list || [];
if (items.length === 0) {
await sendTG(chatId, isOverdue ? '✅ Không có overdue nào!' : '✅ Không có decision đang mở nào.');
} else {
let msg = isOverdue ? `⚠️ *OVERDUE — ${ownerName}:*\n\n` : `📋 *Decisions của ${ownerName}:*\n\n`;
for (const d of items.slice(0, 10)) {
const dl = d.deadline ? new Date(d.deadline).toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit' }) : '?';
msg += `• *#${d.Id}* (${dl}) — ${(d.decision_text || '').substring(0, 60)}\n`;
}
if (items.length > 10) msg += `_...và ${items.length - 10} item khác_\n`;
await sendTG(chatId, msg);
}
action = 'list_command';
}
return [{ json: { chatId, action, callbackData, command: text } }];
{
"id": "wf09",
"name": "09 — Decision Log Automation",
"nodes": [
{ "id": "t1-cron-cn", "name": "Cron CN 20:00", "type": "n8n-nodes-base.scheduleTrigger",
"parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 20 * * 0" }] } } },
{ "id": "t2-cron-daily", "name": "Cron Daily 8:00", "type": "n8n-nodes-base.scheduleTrigger",
"parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 8 * * *" }] } } },
{ "id": "t3-webhook-new", "name": "Webhook NocoDB Insert", "type": "n8n-nodes-base.webhook",
"parameters": { "path": "decision-new", "responseMode": "onReceived" } },
{ "id": "t4-webhook-tg", "name": "Webhook Telegram Callback", "type": "n8n-nodes-base.webhook",
"parameters": { "path": "bot-wf09", "responseMode": "onReceived" } },
{ "id": "c1-overdue", "name": "Decision OVERDUE Push", "type": "n8n-nodes-base.code" },
{ "id": "c2-deadline", "name": "Decision Deadline Warning", "type": "n8n-nodes-base.code" },
{ "id": "c3-new", "name": "Decision New Push", "type": "n8n-nodes-base.code" },
{ "id": "c4-callback", "name": "Process Decision Callback", "type": "n8n-nodes-base.code" }
],
"connections": {
"Cron CN 20:00": { "main": [[{ "node": "Decision OVERDUE Push", "type": "main", "index": 0 }]] },
"Cron Daily 8:00": { "main": [[{ "node": "Decision Deadline Warning", "type": "main", "index": 0 }]] },
"Webhook NocoDB Insert": { "main": [[{ "node": "Decision New Push", "type": "main", "index": 0 }]] },
"Webhook Telegram Callback": { "main": [[{ "node": "Process Decision Callback", "type": "main", "index": 0 }]] }
},
"settings": {
"executionOrder": "v1",
"timezone": "Asia/Ho_Chi_Minh"
}
}
Bot: @hotrobhgk_bot — thêm vào BotFather command list
| Command | Syntax | Mô tả |
|---|---|---|
/decisions | /decisions | List tất cả decisions đang mở của tôi |
/decisions_overdue | /decisions_overdue | List overdue của tôi |
/decision_done | /decision_done 5 | Đánh dấu Done Decision #5 |
/decision_new | (manual vào NocoDB) | Tạo nhanh — dùng NocoDB UI |
Inline buttons trên reminder message:
| Button | Callback data | Hành động |
|---|---|---|
| ✅ NHẬN | confirm_{id} | Đánh dấu owner_confirmed = true |
| ✅ Done | done_{id} | PATCH status = Done |
| ⏳ Delay | delay_{id} | PATCH status = In Progress + notify Minh |
| ❌ Cancel | cancel_{id} | PATCH status = Cancelled + notify Minh |
| ❓ Clarify | clarify_{id} | Notify Minh owner cần thêm thông tin |
Ghi vào NocoDB ngay khi bảng tạo xong (dùng UI hoặc API):
{
"decision_text": "Vụ khách hàng 8tr: Áp dụng Option C — trừ dần từ hoa hồng KD trong T5-T6 BN. Không hoàn tiền trực tiếp.",
"context": "KH phàn nàn dịch vụ lắp đặt chậm 65 ngày gây thiệt hại 8tr. BGĐ họp tháng 3/2026 chọn Option C để bảo vệ cashflow. Cần thực thi NGAY — đã treo quá lâu.",
"owner": "Trung Anh",
"deadline": "2026-05-28",
"status": "Open",
"meeting_source": "BGĐ T2",
"impact_level": "High",
"financial_value": 250,
"stakeholders": ["BGĐ", "KD"],
"notes": "Retroactive — quyết định từ tháng 3/2026, ghi log 22/05/2026. D-001."
}
{
"decision_text": "Ring-fence cash 4-4.5 tỷ VND từ tài khoản vận hành: không dùng cho chi phí MKT hoặc CAPEX, giữ cho quỹ dự phòng và thanh toán NCC.",
"context": "BGĐ lo ngại dòng tiền T7-T9 BN (mùa thấp). Quyết định tách riêng 4-4.5 tỷ vào tài khoản riêng. Kế toán phụ trách thực thi.",
"owner": "Anh Khánh",
"deadline": "2026-07-10",
"status": "Open",
"meeting_source": "Ad-hoc",
"impact_level": "Critical",
"financial_value": 4250,
"stakeholders": ["BGĐ", "Kế toán"],
"notes": "Retroactive — quyết định ~T3-T4 BN, ghi log 22/05/2026. D-002. BGĐ chưa confirm bằng văn bản — cần clarify với Anh Khánh."
}
{
"decision_text": "Phân bổ 3,572 KH từ bracket sofa >50tr trong 2017-now cho 4 KD telesale: ưu tiên 419 KH VIP Tranthimo trước. Chia theo account_manager hiện tại.",
"context": "ERPNext đã sync 8,730 SO + classify 5,008 items. Bracket sofa >=50tr/đơn = 1,033 KH. 419 KH Tranthimo là VIP cao nhất. Tiềm năng upsell ước ~185 tỷ nếu 5% convert.",
"owner": "Trung Anh",
"deadline": "2026-05-28",
"status": "Open",
"meeting_source": "BGĐ T2",
"impact_level": "Critical",
"financial_value": 9250,
"stakeholders": ["BGĐ", "KD", "Minh Anh"],
"notes": "Retroactive — phân tích 21/05/2026, quyết định cần chốt với BGĐ tuần 25-29/05. D-003. financial_value = 5% × 185 tỷ tiềm năng = 9.25 tỷ."
}
https://ql.minhdigital.com → Base B.Gia KhánhGK_Decision_Log/nc/xxx/table/TABLE_ID/...curl https://ql.minhdigital.com/api/v2/meta/tables -H "xc-token: 2rIAS5EsiDrJMiZXRQk7JALJD52avJ9Mm8TQU8gm" → tìm GK_Decision_Log{TABLE_ID})GK_Decision_Log → Settings → WebhooksNew Decision → WF09After Inserthttps://n8n.noithatgiakhanh.com/webhook/decision-newn8n.noithatgiakhanh.com → New Quy trình tự động (Workflow) → đặt tên 09 — Decision Log Automation{TABLE_ID} bằng ID thật/decisions, /decision_doneTimeline target:
| Quy tắc | Chi tiết | Owner |
|---|---|---|
| Ghi NGAY | Mỗi cuộc họp phải có 1+ record vào log trước khi tan họp | Người chủ trì |
| Không miệng | Quyết định ảnh hưởng >1 người hoặc có giá trị tài chính → phải vào log | Mọi người |
| Confirm 48h | Owner nhận notification phải reply trong 48h | Owner |
| Overdue 7d | Quá 7 ngày chưa Done/update → tự động escalate Minh | WF09 tự động |
| Review T2 | WF09 CN 20:00 push list → Minh review đầu tuần T2 | Minh |
| Done = real done | Chỉ click Done khi thực sự hoàn thành, không pre-click | Owner |
📊 DECISION LOG TUẦN XX
Tạo mới: X item | Done: X item | Overdue: X item
Done rate: X%
OVERDUE cần BGĐ chú ý:
• #ID — Owner — Quá X ngày
• ...
OKR Decision Quality:
- Decision capture rate: X% cuộc họp có ít nhất 1 decision logged
- On-time completion rate: X%
- Average days to resolve: X ngày
| Rủi ro | Giảm thiểu |
|---|---|
| NocoDB down | WF09 log lỗi vào Telegram Minh; team tạm dùng Google Sheet backup |
| Bot token reset | Ghi token vào .env hoặc n8n credentials; backup webhook URL khác |
| Cron skip (VPS restart) | n8n tự retry; nếu miss CN → T2 sáng check manual |
| Owner không có TG | Minh tự nhắc trực tiếp; update OWNER_TG map khi có TG ID |
| Webhook NocoDB miss | Check NocoDB webhook log; có thể kích thủ công từ NocoDB UI |
| staticData loss | n8n restart reset staticData → pending confirm bị mất → cron Daily 8h vẫn nhắc qua deadline query |
notes trong NocoDB khi có tiến độ/decision_done <id> khi xongstatus = Overduenotes cho item nào cần context thêmGK_Decision_Log trên điện thoạidecision_text, owner, deadline, impact_levelK11 — Decision Log WF09 Technical Design | Tạo: 22/05/2026 | Systems Engineer: Claude Code Nguồn: K7-cadence-hop-mkt-kd, WF04 callback pattern, WF08 cron+NocoDB pattern Cập nhật tiếp: Sau khi TABLE_ID được lấy → điền vào tất cả code nodes