K11 — Decision Log: NocoDB Schema + n8n WF09 Technical Design

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


1. BỐI CẢNH & LÝ DO

Vấn đềThực trạngRủi ro
Vụ 8tr Option CTreo 65 ngày, không ai trackThương hiệu (Brand) + tài chính 200-300tr
NCC vận tải7 tuần chưa ký HĐPháp lý, vận hành
BHXH 7 thợChưa rõ trạng tháiThuế + lao động
BH cô Thúy>1 tháng chưa giải quyếtKH phàn nàn, reputation
Ring-fence cashChốt miệng, không có ai nhắc4-4.5 tỷ VND tồn đọng

Giải pháp: NocoDB bảng GK_Decision_Log + n8n WF09 tự động nhắc.


2. SCHEMA BẢNG 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)

2.1 Field Definitions

FieldNocoDB TypeRequiredDefaultGhi chú
IdID (AutoNumber)YautoNocoDB tự tạo, format DL-2026-001
decision_textLongTextYNội dung quyết định, 1-3 câu rõ ràng
contextLongTextNTại sao quyết định này, dữ liệu dẫn đến
ownerSingleLineTextYTên người thực thi (Minh, Trung Anh, Thép, Doan...)
deadlineDateYDeadline cứng (YYYY-MM-DD)
statusSingleSelectYOpenOpen / In Progress / Done / Overdue / Cancelled
meeting_sourceSingleSelectNWeekly T6 / BGĐ T2 / Monthly / Quarterly / Ad-hoc / Standup
impact_levelSingleSelectYMediumCritical / High / Medium / Low
financial_valueNumberN0Giá trị tài chính (triệu VND, để trống = 0)
stakeholdersMultiSelectNBGĐ / MKT / KD / Doan / Cường / Minh Anh / Thép / Kế toán
notesLongTextNCập nhật tiến độ, ghi chú thêm
completed_atDateTimeNTự điền khi status → Done
linked_docURLNLink biên bản họp docx / file Google Drive
owner_confirmedCheckboxNfalseOwner đã reply "NHẬN" qua Telegram bot
date_loggedDateTimeYNOW()Thời điểm tạo record

2.2 Status Flow

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).

2.3 Ví dụ record JSON đầy đủ

{
  "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/..."
}

3. NocoDB API — Endpoints & Patterns

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)

3.1 Tạo record (CREATE)

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"
}

3.2 Update status (PATCH)

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.

3.3 Query OVERDUE

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)

3.4 Query deadline 1-3 ngày tới

GET /api/v2/tables/{TABLE_ID}/records
  ?where=(deadline,gte,{TODAY})~and(deadline,lte,{TODAY+3})~and(status,neq,Done)~and(status,neq,Cancelled)

3.5 Query của 1 owner cụ thể

GET /api/v2/tables/{TABLE_ID}/records
  ?where=(owner,eq,Trung Anh)~and(status,neq,Done)~and(status,neq,Cancelled)
  &sort=deadline

3.6 Code node pattern (n8n — từ WF08)

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();
  });
}

4. n8n WF09 — Decision Log Automation

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)

4.1 Sơ đồ tổng quan WF09

[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

4.2 Trigger 1 — OVERDUE Push (Cron CN 20:00)

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 } }];

4.3 Trigger 2 — Deadline Warning (Cron Daily 8:00)

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])) } }];

4.4 Trigger 3 — New Decision Notify (Webhook từ NocoDB)

Setup NocoDB webhook:

Node: 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 } }];

4.5 Trigger 4 — Status Update Handler (Webhook Telegram callback)

**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 } }];

4.6 Node Graph (n8n JSON skeleton)

{
  "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"
  }
}

5. TELEGRAM BOT COMMANDS

Bot: @hotrobhgk_bot — thêm vào BotFather command list

CommandSyntaxMô tả
/decisions/decisionsList tất cả decisions đang mở của tôi
/decisions_overdue/decisions_overdueList 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:

ButtonCallback dataHành động
✅ NHẬNconfirm_{id}Đánh dấu owner_confirmed = true
✅ Donedone_{id}PATCH status = Done
⏳ Delaydelay_{id}PATCH status = In Progress + notify Minh
❌ Cancelcancel_{id}PATCH status = Cancelled + notify Minh
❓ Clarifyclarify_{id}Notify Minh owner cần thêm thông tin

6. 3 RETROACTIVE DECISIONS — GHI NGAY

Ghi vào NocoDB ngay khi bảng tạo xong (dùng UI hoặc API):

D-001 — Vụ 8tr Option C

{
  "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."
}

D-002 — Ring-fence Cash 4-4.5 tỷ

{
  "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."
}

D-003 — Phân bổ 419 VIP Tranthimo (3,572 KH)

{
  "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ỷ."
}

7. QUICK START — SETUP STEPS

Bước 1: Tạo bảng NocoDB (30 phút — Minh làm)

  1. Vào https://ql.minhdigital.com → Base B.Gia Khánh
  2. New Table → tên: GK_Decision_Log
  3. Thêm 14 fields theo Section 2.1 (type phải khớp — SingleSelect quan trọng)
  4. Lấy Table ID:
  1. Copy TABLE_ID → điền vào tất cả code node (thay {TABLE_ID})

Bước 2: Setup NocoDB Webhook (15 phút)

  1. Vào bảng GK_Decision_Log → Settings → Webhooks
  2. New Webhook:
  1. Save + Test

Bước 3: Build WF09 trong n8n (2 giờ)

  1. Vào n8n.noithatgiakhanh.com → New Quy trình tự động (Workflow) → đặt tên 09 — Decision Log Automation
  2. Tạo 4 trigger nodes + 4 code nodes theo node graph Section 4.6
  3. Copy code từ Section 4.2-4.5, thay {TABLE_ID} bằng ID thật
  4. Activate quy trình tự động (workflow)
  5. Test Trigger 1 bằng cách chạy manual

Bước 4: Ghi 3 retroactive decisions (30 phút)

  1. Dùng NocoDB UI hoặc script POST API
  2. Thứ tự: D-003 → D-001 → D-002 (ưu tiên deadline gần nhất)
  3. Verify NocoDB webhook kích hoạt → Telegram nhận notification

Bước 5: Brief team (30 phút — Minh + Trung Anh)

  1. Brief Trung Anh: cách ghi decision sau mỗi họp
  2. Brief 4 KD: cách respond Telegram reminder
  3. Brief Thép: commands /decisions, /decision_done

Timeline target:


8. QUY TẮC VẬN HÀNH (Cứng)

Quy tắcChi tiếtOwner
Ghi NGAYMỗi cuộc họp phải có 1+ record vào log trước khi tan họpNgười chủ trì
Không miệngQuyết định ảnh hưởng >1 người hoặc có giá trị tài chính → phải vào logMọi người
Confirm 48hOwner nhận notification phải reply trong 48hOwner
Overdue 7dQuá 7 ngày chưa Done/update → tự động escalate MinhWF09 tự động
Review T2WF09 CN 20:00 push list → Minh review đầu tuần T2Minh
Done = real doneChỉ click Done khi thực sự hoàn thành, không pre-clickOwner

9. REPORTING INTEGRATION

Weekly (T6 — tham mưu BGĐ)

📊 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
• ...

Monthly (Monthly Review)

Quarterly mục tiêu & kết quả then chốt (OKR) Metrics

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

10. RISK MANAGEMENT

Rủi roGiảm thiểu
NocoDB downWF09 log lỗi vào Telegram Minh; team tạm dùng Google Sheet backup
Bot token resetGhi 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ó TGMinh tự nhắc trực tiếp; update OWNER_TG map khi có TG ID
Webhook NocoDB missCheck NocoDB webhook log; có thể kích thủ công từ NocoDB UI
staticData lossn8n restart reset staticData → pending confirm bị mất → cron Daily 8h vẫn nhắc qua deadline query

11. quy trình chuẩn (SOP) TÓM TẮT

quy trình chuẩn (SOP) Owner — Khi nhận reminder

  1. Đọc notification trên Telegram
  2. Click ✅ NHẬN trong 48h
  3. Cập nhật notes trong NocoDB khi có tiến độ
  4. Click ✅ Done (inline button) hoặc reply /decision_done <id> khi xong

quy trình chuẩn (SOP) Minh — Weekly review (T2 sáng)

  1. Đọc Telegram digest WF09 từ CN tối
  2. Mở NocoDB → filter status = Overdue
  3. Xem quyết định nào cần escalate BGĐ (T6)
  4. Update notes cho item nào cần context thêm

quy trình chuẩn (SOP) Trung Anh — Sau mỗi họp KD

  1. Mở NocoDB GK_Decision_Log trên điện thoại
  2. Tạo record mới ngay trong buổi họp
  3. Fields bắt buộc: decision_text, owner, deadline, impact_level
  4. Verify Telegram notification gửi về owner

K11 — 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

read.minhdigital.com  ·  27/05/2026 21:15