This commit is contained in:
Leonard-ThindPad-P50
2026-09-08 10:26:21 +07:00
commit c81f249920
169 changed files with 38726 additions and 0 deletions

View File

@@ -0,0 +1,460 @@
export const meta = {
name: 'generate-bid',
description: 'Lập hồ sơ dự thầu từ SAD theo từng stage có người duyệt: intake (HSMT, ma trận đáp ứng, checklist) → technical (Phần B) → estimate (WBS + UCP; MM/chi phí/timeline tính bằng code) → plan (B7/B8) → financial (Phần C) → assemble (HO-SO-THAU.md + HTML) → deck (slide 16:9) → export (PDF bằng Edge/Chrome headless) → review. Một stage mỗi lần gọi.',
whenToUse: 'Gọi từ skill sad-bid. Bắt buộc args.stage và args.date; stage estimate cần args.pricing (từ bid/bid-config.md).',
phases: [
{ title: 'Intake', detail: 'bid-analyst: bid-brief, compliance matrix, document checklist' },
{ title: 'Technical', detail: 'bid-technical-writer: Phần B (B1–B6, B9, B10)' },
{ title: 'Estimate', detail: 'bid-estimator → code tính MD/MM/UCP/chi phí/timeline → persist computed.json' },
{ title: 'Plan', detail: 'bid-planner: B7 kế hoạch, B8 nhân sự' },
{ title: 'Financial', detail: 'bid-financial-writer: Phần C' },
{ title: 'Assemble', detail: 'bid-builder: HO-SO-THAU.md + index.html + artifact.html' },
{ title: 'Deck', detail: 'bid-deck-builder: deck.html 15–25 slide 16:9 tự chứa' },
{ title: 'Export', detail: 'bid-exporter: Edge/Chrome headless → HO-SO-THAU.pdf, deck.pdf' },
{ title: 'Review', detail: 'bid-reviewer: đủ mục, đáp ứng, số liệu, rò rỉ, khả thi, PDF/deck' },
],
}
// ---------------------------------------------------------------------------
// Tham số
// ---------------------------------------------------------------------------
const a = args && typeof args === 'object' ? args : {}
const STAGES = ['intake', 'technical', 'estimate', 'plan', 'financial', 'assemble', 'deck', 'export', 'review']
if (!STAGES.includes(a.stage)) throw new Error(`args.stage không hợp lệ: "${a.stage}". Hợp lệ: ${STAGES.join(' | ')}`)
if (!a.date || !/^\d{4}-\d{2}-\d{2}$/.test(a.date)) throw new Error('Thiếu args.date (YYYY-MM-DD)')
if (a.stage === 'estimate' && (!a.pricing || typeof a.pricing !== 'object')) {
throw new Error('Stage estimate cần args.pricing (object lấy từ frontmatter bid/bid-config.md: roles, mdPerMM, rateCard, vatPct, contingencyPct, …)')
}
const stage = a.stage
const BID = a.bidDir || 'bid'
const SAD = a.sadFile || 'docs/SAD.md'
const S = a.sectionsDir || 'docs/sections'
const BRIEF = a.briefFile || 'docs/00-project-brief.md'
const CFG = `${BID}/bid-config.md`
const REF = '.claude/skills/sad-bid/references/dossier-structure.md'
const F = {
brief: `${BID}/00-bid-brief.md`, compliance: `${BID}/01-compliance-matrix.md`, checklist: `${BID}/02-document-checklist.md`,
technical: `${BID}/10-technical-proposal.md`, estimate: `${BID}/estimate.json`, estimationMd: `${BID}/20-estimation.md`,
computed: `${BID}/estimate.computed.json`, plan: `${BID}/30-implementation-plan.md`, financial: `${BID}/40-financial-proposal.md`,
dossier: `${BID}/HO-SO-THAU.md`, html: `${BID}/index.html`, artifact: `${BID}/artifact.html`,
deck: `${BID}/deck.html`, deckArtifact: `${BID}/deck-artifact.html`,
pdf: `${BID}/HO-SO-THAU.pdf`, deckPdf: `${BID}/deck.pdf`,
}
const notes = a.notes ? `\n\n## Ghi chú từ người duyệt (bắt buộc xử lý trước khi làm gì khác)\n${a.notes}\n` : ''
const sources = `Nguồn: ${REF} (cấu trúc chuẩn), ${CFG} (thông tin thương mại), ${SAD} (nếu chưa có thì ${BRIEF} + ${S}/01–09), HSMT/RFP trong ${BID}/inputs/ nếu có. Ngày hôm nay: ${a.date}.`
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
const SA = { type: 'array', items: { type: 'string' } }
const obj = (p, r) => ({ type: 'object', properties: p, required: r })
const arr = (i) => ({ type: 'array', items: i })
const T = { type: 'string' }, Bo = { type: 'boolean' }, Nu = { type: 'number' }
const NuN = { type: ['number', 'null'] }
const CONF = { type: 'string', enum: ['high', 'medium', 'low'] }
const INTAKE_SCHEMA = obj({
filesWritten: SA, hasRfp: Bo,
evaluationCriteria: arr(obj({ criterion: T, weight: T, source: T }, ['criterion'])),
mandatoryRequirements: arr(obj({ id: T, text: T, met: { type: 'string', enum: ['yes', 'partial', 'no', 'unclear'] } }, ['id', 'text', 'met'])),
complianceSummary: obj({ total: Nu, met: Nu, partial: Nu, no: Nu, unclear: Nu }, []),
gaps: arr(obj({ reqId: T, issue: T, suggestion: T }, ['reqId', 'issue'])),
documentChecklist: arr(obj({ id: T, name: T, mandatory: Bo, status: T }, ['id', 'name', 'status'])),
keyDates: arr(obj({ event: T, date: T }, ['event'])),
dossierStructureOverride: T, risks: SA, confidence: CONF, summary: T,
}, ['filesWritten', 'hasRfp', 'mandatoryRequirements', 'gaps', 'documentChecklist', 'confidence', 'summary'])
const TECH_SCHEMA = obj({
filesWritten: SA,
sections: arr(obj({ id: T, title: T, sadSources: SA, mandatoryReqsCovered: SA }, ['id', 'title'])),
diagrams: arr(obj({ title: T, type: T, sadSource: T }, ['title', 'type'])),
techStack: arr(obj({ layer: T, technology: T, license: T }, ['layer', 'technology'])),
placeholders: arr(obj({ description: T, section: T }, ['description'])),
uncoveredMandatory: SA, keyFacts: arr(obj({ fact: T, source: T }, ['fact', 'source'])), confidence: CONF, summary: T,
}, ['filesWritten', 'sections', 'placeholders', 'uncoveredMandatory', 'confidence', 'summary'])
const ITEM = obj({
id: T, name: T, group: T, sources: SA,
complexity: { type: 'string', enum: ['S', 'M', 'L', 'XL'] },
risk: { type: 'string', enum: ['low', 'medium', 'high'] },
effortMD: { type: 'object', additionalProperties: Nu },
rationale: T, assumptions: SA,
}, ['id', 'name', 'complexity', 'risk', 'effortMD'])
const FACTOR = obj({ id: T, name: T, score: Nu, why: T }, ['id', 'score'])
const ESTIMATE_SCHEMA = obj({
filesWritten: SA,
items: arr(ITEM),
ucp: obj({
actors: obj({ simple: Nu, average: Nu, complex: Nu }, ['simple', 'average', 'complex']),
useCases: obj({ simple: Nu, average: Nu, complex: Nu }, ['simple', 'average', 'complex']),
tcf: arr(FACTOR), ef: arr(FACTOR), notes: T,
}, ['actors', 'useCases', 'tcf', 'ef']),
nonLabor: arr(obj({ id: T, name: T, basis: T, amount: NuN, currency: T, recurring: T, note: T }, ['id', 'name'])),
configEcho: { type: 'object' },
uncovered: SA, assumptions: SA, openQuestions: SA, nonLaborMissingAmounts: SA, confidence: CONF, summary: T,
}, ['filesWritten', 'items', 'ucp', 'nonLabor', 'uncovered', 'confidence', 'summary'])
const PERSIST_SCHEMA = obj({ filesWritten: SA, summary: T }, ['filesWritten', 'summary'])
const PLAN_SCHEMA = obj({
filesWritten: SA, durationMonths: Nu,
phases: arr(obj({ name: T, start: T, end: T, deliverables: SA }, ['name'])),
milestones: arr(obj({ name: T, date: T, paymentLinked: Bo }, ['name'])),
peakHeadcount: Nu, deadlineFits: { type: ['boolean', 'null'] }, accelerationOptions: SA,
placeholders: arr(obj({ description: T, section: T }, ['description'])), numbersUsed: SA, confidence: CONF, summary: T,
}, ['filesWritten', 'phases', 'milestones', 'placeholders', 'confidence', 'summary'])
const FIN_SCHEMA = obj({
filesWritten: SA, grandMM: Nu, totalBeforeVat: NuN, totalAfterVat: NuN, currency: T,
missingRates: SA, missingAmounts: SA, paymentMilestones: arr(obj({ milestone: T, pct: NuN }, ['milestone'])),
placeholders: arr(obj({ description: T, section: T }, ['description'])), numbersUsed: SA, confidence: CONF, summary: T,
}, ['filesWritten', 'placeholders', 'confidence', 'summary'])
const BUILD_SCHEMA = obj({
filesWritten: SA, sectionsPresent: SA, sectionsMissing: SA, structureOverrideUsed: Bo,
placeholdersCount: Nu, mermaidBlocks: Nu, approxSizeKB: Nu,
checks: obj({ standaloneDoc: Bo, artifactVariant: Bo, toc: Bo, themeTokens: Bo, printCss: Bo, responsiveTables: Bo, priceConsistentA1C5: Bo }, []),
confidence: CONF, summary: T,
}, ['filesWritten', 'sectionsMissing', 'checks', 'confidence', 'summary'])
const DECK_SCHEMA = obj({
filesWritten: SA, slideCount: Nu,
slides: arr(obj({ id: T, title: T, source: T }, ['id', 'title'])),
numbersUsed: SA, placeholdersCount: Nu, mermaidBlocks: Nu,
checks: obj({ selfContained: Bo, printOnePerSlide: Bo, navigation: Bo, mermaidGuarded: Bo, artifactVariant: Bo, themeTokens: Bo }, []),
confidence: CONF, summary: T,
}, ['filesWritten', 'slideCount', 'slides', 'checks', 'confidence', 'summary'])
const EXPORT_SCHEMA = obj({
blocked: Bo, browser: T,
pdfs: arr(obj({ source: T, output: T, pages: Nu, sizeKB: Nu, mermaidExpected: Nu, mermaidRendered: Nu, outline: Bo, ok: Bo }, ['source', 'output', 'ok'])),
commands: SA, warnings: SA,
findings: arr(obj({ target: { type: 'string', enum: ['assemble', 'deck'] }, issue: T, suggestion: T, severity: { type: 'string', enum: ['high', 'medium', 'low'] } }, ['target', 'issue', 'severity'])),
manualInstructions: T, confidence: CONF, summary: T,
}, ['blocked', 'pdfs', 'warnings', 'confidence', 'summary'])
const REVIEW_SCHEMA = obj({
verdict: { type: 'string', enum: ['pass', 'revise'] }, canSubmit: Bo,
missingSections: SA,
uncoveredMandatory: arr(obj({ reqId: T, text: T }, ['reqId'])),
numberMismatches: arr(obj({ where: T, found: T, expected: T }, ['where', 'found', 'expected'])),
leaks: arr(obj({ file: T, snippet: T, why: T }, ['file', 'snippet'])),
placeholders: SA,
exportChecks: obj({ dossierPdf: Bo, deckPdf: Bo, deckNumbersMatch: Bo }, []),
findings: arr(obj({ target: { type: 'string', enum: ['intake', 'technical', 'estimate', 'plan', 'financial', 'assemble', 'deck', 'export'] }, issue: T, suggestion: T, severity: { type: 'string', enum: ['high', 'medium', 'low'] } }, ['target', 'issue', 'severity'])),
factChecks: arr(obj({ claim: T, evidence: T, ok: Bo }, ['claim', 'ok'])),
summary: T,
}, ['verdict', 'canSubmit', 'missingSections', 'uncoveredMandatory', 'numberMismatches', 'leaks', 'placeholders', 'findings', 'summary'])
// ---------------------------------------------------------------------------
// Tính toán ước lượng — thuần code, không LLM
// ---------------------------------------------------------------------------
const r2 = (x) => Math.round((Number(x) || 0) * 100) / 100
const r0 = (x) => Math.round(Number(x) || 0)
const TCF_W = { T1: 2, T2: 1, T3: 1, T4: 1, T5: 1, T6: 0.5, T7: 0.5, T8: 2, T9: 1, T10: 1, T11: 1, T12: 1, T13: 1 }
const EF_W = { E1: 1.5, E2: 0.5, E3: 1, E4: 0.5, E5: 1, E6: 2, E7: -1, E8: -1 }
const DEFAULT_PHASES = [
{ name: 'Khởi động & chuẩn bị', pct: 5 },
{ name: 'Phân tích & thiết kế chi tiết', pct: 15 },
{ name: 'Phát triển', pct: 45 },
{ name: 'Kiểm thử hệ thống, hiệu năng, bảo mật', pct: 15 },
{ name: 'UAT & đào tạo', pct: 12 },
{ name: 'Go-live & hỗ trợ ổn định', pct: 8 },
]
// phân bổ % MM của từng vai trò theo 6 giai đoạn (tổng 100)
const ROLE_PHASE = {
PM: [10, 15, 40, 15, 12, 8], BA: [15, 45, 20, 5, 15, 0], SA: [20, 45, 25, 10, 0, 0], UIUX: [10, 60, 30, 0, 0, 0],
BE: [0, 10, 75, 10, 3, 2], FE: [0, 10, 75, 10, 3, 2], MOBILE: [0, 10, 75, 10, 3, 2],
QA: [0, 10, 35, 40, 15, 0], DEVOPS: [30, 10, 25, 15, 5, 15], DEFAULT: [5, 15, 45, 15, 12, 8],
}
function addMonths(iso, months) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || '')
if (!m) return null
const y = Number(m[1]), mo = Number(m[2]) - 1, d = Number(m[3])
const whole = Math.floor(months), frac = months - whole
const dt = new Date(Date.UTC(y, mo + whole, d))
if (frac > 0) dt.setUTCDate(dt.getUTCDate() + Math.round(frac * 30))
return dt.toISOString().slice(0, 10)
}
function monthsBetween(startIso, endIso) {
const s = /^(\d{4})-(\d{2})-(\d{2})$/.exec(startIso || ''), e = /^(\d{4})-(\d{2})-(\d{2})$/.exec(endIso || '')
if (!s || !e) return null
const ms = Date.UTC(Number(e[1]), Number(e[2]) - 1, Number(e[3])) - Date.UTC(Number(s[1]), Number(s[2]) - 1, Number(s[3]))
return r2(ms / (1000 * 60 * 60 * 24 * 30.4375))
}
function computeEstimate(est, cfg) {
const warnings = []
const mdPerMM = Number(cfg.mdPerMM) > 0 ? Number(cfg.mdPerMM) : (warnings.push('mdPerMM không có trong config — dùng 21'), 21)
const hoursPerDay = Number(cfg.hoursPerDay) > 0 ? Number(cfg.hoursPerDay) : 8
const hoursPerUCP = Number(cfg.hoursPerUCP) > 0 ? Number(cfg.hoursPerUCP) : (warnings.push('hoursPerUCP không có — dùng 20'), 20)
const cont = Object.assign({ low: 10, medium: 20, high: 35 }, cfg.contingencyPct || {})
const overheadMode = cfg.overheadMode || 'itemized'
const overheadPct = overheadMode === 'percent' ? Number(cfg.overheadPct) || 0 : 0
const overheadRole = cfg.overheadRole || 'PM'
const rateCard = cfg.rateCard || {}
const vatPct = cfg.vatPct === undefined || cfg.vatPct === null ? (warnings.push('vatPct không có — dùng 10'), 10) : Number(cfg.vatPct)
const currency = cfg.currency || 'VND'
const roleSet = new Set(Array.isArray(cfg.roles) ? cfg.roles : [])
// --- WBS bottom-up ---
const items = []
const baseByRole = {}, contByRole = {}
let baseMD = 0, contMD = 0
for (const it of est.items || []) {
const eff = it.effortMD || {}
let itemMD = 0
for (const role of Object.keys(eff)) {
const v = Number(eff[role]) || 0
if (v < 0) warnings.push(`${it.id}: effort âm cho ${role} — bỏ qua`)
if (v <= 0) continue
roleSet.add(role)
itemMD += v
baseByRole[role] = r2((baseByRole[role] || 0) + v)
}
const pct = cont[it.risk] !== undefined ? Number(cont[it.risk]) : cont.medium
const itemCont = r2(itemMD * pct / 100)
for (const role of Object.keys(eff)) {
const v = Number(eff[role]) || 0
if (v <= 0 || itemMD === 0) continue
contByRole[role] = r2((contByRole[role] || 0) + itemCont * (v / itemMD))
}
baseMD += itemMD; contMD += itemCont
items.push({ id: it.id, name: it.name, group: it.group || '', sources: it.sources || [], complexity: it.complexity, risk: it.risk, effortMD: eff, itemMD: r2(itemMD), contingencyPct: pct, contingencyMD: itemCont })
}
baseMD = r2(baseMD); contMD = r2(contMD)
const overheadMD = r2(baseMD * overheadPct / 100)
if (overheadMD > 0) { roleSet.add(overheadRole) }
const roles = Array.from(roleSet)
const totalByRole = {}, mmByRole = {}
let grandMD = 0
for (const role of roles) {
const t = r2((baseByRole[role] || 0) + (contByRole[role] || 0) + (role === overheadRole ? overheadMD : 0))
totalByRole[role] = t; mmByRole[role] = r2(t / mdPerMM); grandMD += t
}
grandMD = r2(grandMD)
const grandMM = r2(grandMD / mdPerMM)
const baseMM = r2(baseMD / mdPerMM)
if (!items.length) warnings.push('Không có hạng mục WBS — ước lượng rỗng')
const qa = (baseByRole.QA || 0), dev = (baseByRole.BE || 0) + (baseByRole.FE || 0) + (baseByRole.MOBILE || 0)
if (dev > 0 && qa / dev < 0.15) warnings.push(`QA chỉ bằng ${r0(qa / dev * 100)}% effort dev — thấp bất thường (thường 25–40%)`)
if (!(baseByRole.PM > 0) && overheadMD === 0) warnings.push('Không có effort PM và không có overhead quản lý')
// --- Chi phí ---
const laborByRole = {}, missingRates = []
let laborTotal = 0
for (const role of roles) {
const rate = Number(rateCard[role])
if (!(rate > 0)) { missingRates.push(role); laborByRole[role] = null; continue }
const c = r0(mmByRole[role] * rate)
laborByRole[role] = c; laborTotal += c
}
const nonLaborItems = [], missingAmounts = []
let nonLaborTotal = 0
const cfgNL = Array.isArray(cfg.nonLabor) ? cfg.nonLabor : []
const merged = [...(est.nonLabor || [])]
for (const c of cfgNL) if (!merged.some((x) => x.id === c.id || x.name === c.name)) merged.push(c)
for (const nl of merged) {
const cfgMatch = cfgNL.find((x) => x.id === nl.id || x.name === nl.name)
const amount = cfgMatch && Number(cfgMatch.amount) > 0 ? Number(cfgMatch.amount) : (Number(nl.amount) > 0 ? Number(nl.amount) : null)
if (amount === null) missingAmounts.push(nl.name || nl.id)
else nonLaborTotal += amount
nonLaborItems.push({ id: nl.id, name: nl.name, basis: nl.basis || (cfgMatch && cfgMatch.basis) || '', recurring: nl.recurring || (cfgMatch && cfgMatch.recurring) || 'one-off', amount, currency })
}
const subtotal = r0(laborTotal + nonLaborTotal)
const vat = r0(subtotal * vatPct / 100)
const total = r0(subtotal + vat)
const priceComplete = missingRates.length === 0 && missingAmounts.length === 0
if (!priceComplete) warnings.push(`Giá tạm tính: thiếu đơn giá [${missingRates.join(', ')}]${missingAmounts.length ? `, thiếu số tiền [${missingAmounts.join(', ')}]` : ''}`)
// --- UCP đối chiếu ---
const u = est.ucp || {}
const ac = u.actors || {}, uc = u.useCases || {}
const UAW = (Number(ac.simple) || 0) * 1 + (Number(ac.average) || 0) * 2 + (Number(ac.complex) || 0) * 3
const UUCW = (Number(uc.simple) || 0) * 5 + (Number(uc.average) || 0) * 10 + (Number(uc.complex) || 0) * 15
let tSum = 0
for (const f of u.tcf || []) tSum += (TCF_W[f.id] || 0) * Math.min(5, Math.max(0, Number(f.score) || 0))
let eSum = 0
for (const f of u.ef || []) eSum += (EF_W[f.id] || 0) * Math.min(5, Math.max(0, Number(f.score) || 0))
const TCF = r2(0.6 + 0.01 * tSum), EF = r2(1.4 - 0.03 * eSum)
const UCP = r2((UAW + UUCW) * TCF * EF)
const ucpHours = r2(UCP * hoursPerUCP)
const ucpMD = r2(ucpHours / hoursPerDay)
const ucpMM = r2(ucpMD / mdPerMM)
const variancePct = ucpMM > 0 ? r2((baseMM - ucpMM) / ucpMM * 100) : null
const threshold = Number(cfg.ucpVarianceThresholdPct) > 0 ? Number(cfg.ucpVarianceThresholdPct) : 25
const varianceFlag = variancePct !== null && Math.abs(variancePct) > threshold
if (varianceFlag) warnings.push(`WBS (${baseMM} MM) lệch ${variancePct}% so với UCP (${ucpMM} MM) — vượt ngưỡng ${threshold}%, cần giải thích ở C1`)
if ((u.tcf || []).length !== 13 || (u.ef || []).length !== 8) warnings.push('UCP: thiếu yếu tố TCF (13) hoặc EF (8) — kết quả đối chiếu kém tin cậy')
// --- Timeline & staffing ---
const eff = Number(cfg.parallelEfficiency) > 0 && Number(cfg.parallelEfficiency) <= 1 ? Number(cfg.parallelEfficiency) : 0.85
let teamSize = Number(cfg.teamSize) > 0 ? Number(cfg.teamSize) : 0
let teamDerived = false
if (!teamSize && Number(cfg.targetMonths) > 0) { teamSize = Math.max(1, Math.ceil(grandMM / (Number(cfg.targetMonths) * eff))); teamDerived = true }
if (!teamSize) { teamSize = Math.min(12, Math.max(3, Math.ceil(grandMM / 6))); teamDerived = true }
const durationMonths = grandMM > 0 ? Math.max(1, Math.ceil(grandMM / (teamSize * eff))) : 0
const startDate = /^\d{4}-\d{2}-\d{2}$/.test(cfg.projectStartDate || '') ? cfg.projectStartDate : null
if (!startDate) warnings.push('projectStartDate chưa có — Gantt sẽ dùng tháng tương đối (M1, M2…)')
const phaseDefs = Array.isArray(cfg.phases) && cfg.phases.length ? cfg.phases : DEFAULT_PHASES
const phases = []
let cursor = 0
for (const p of phaseDefs) {
const len = durationMonths > 0 ? Math.max(0.5, Math.round(durationMonths * p.pct / 100 * 2) / 2) : 0
phases.push({ name: p.name, pct: p.pct, startMonth: r2(cursor), endMonth: r2(cursor + len), startDate: startDate ? addMonths(startDate, cursor) : null, endDate: startDate ? addMonths(startDate, cursor + len) : null, effortMM: r2(grandMM * p.pct / 100) })
cursor += len
}
const scheduleMonths = r2(cursor)
const endDate = startDate ? addMonths(startDate, scheduleMonths) : null
let deadlineFit = { deadline: cfg.projectDeadline || null, fits: null, availableMonths: null, suggestedTeamSize: null }
if (startDate && /^\d{4}-\d{2}-\d{2}$/.test(cfg.projectDeadline || '')) {
const avail = monthsBetween(startDate, cfg.projectDeadline)
const fits = avail !== null && scheduleMonths <= avail
deadlineFit = { deadline: cfg.projectDeadline, fits, availableMonths: avail, suggestedTeamSize: fits ? teamSize : Math.ceil(grandMM / (Math.max(0.5, avail) * eff)) }
if (!fits) warnings.push(`Deadline ${cfg.projectDeadline}: cần ${scheduleMonths} tháng nhưng chỉ có ${avail} — cân nhắc team ${deadlineFit.suggestedTeamSize} người hoặc cắt phạm vi`)
}
const nMonths = Math.max(1, Math.ceil(scheduleMonths))
const byMonth = []
for (let m = 1; m <= nMonths; m++) byMonth.push({ month: m, label: startDate ? addMonths(startDate, m - 1).slice(0, 7) : `M${m}`, fte: {}, total: 0 })
for (const role of roles) {
const dist = ROLE_PHASE[role] || ROLE_PHASE.DEFAULT
phases.forEach((p, i) => {
const mm = mmByRole[role] * (dist[i] || 0) / 100
const len = p.endMonth - p.startMonth
if (mm <= 0 || len <= 0) return
for (let m = 1; m <= nMonths; m++) {
const ms = m - 1, me = m
const overlap = Math.max(0, Math.min(me, p.endMonth) - Math.max(ms, p.startMonth))
if (overlap <= 0) continue
byMonth[m - 1].fte[role] = r2((byMonth[m - 1].fte[role] || 0) + mm * (overlap / len))
}
})
}
let peak = 0
for (const row of byMonth) { row.total = r2(Object.values(row.fte).reduce((s, v) => s + v, 0)); if (row.total > peak) peak = row.total }
return {
generatedAt: cfg.date || null, unit: 'MD', mdPerMM, hoursPerDay, roles,
items,
totals: { baseMDByRole: baseByRole, contingencyMDByRole: contByRole, overheadMD, overheadRole: overheadMD > 0 ? overheadRole : null, totalMDByRole: totalByRole, baseMD, contingencyMD: contMD, grandMD, baseMM, mmByRole, grandMM },
cost: { currency, rateCard, laborByRole, laborTotal: r0(laborTotal), nonLaborItems, nonLaborTotal: r0(nonLaborTotal), subtotal, vatPct, vat, total, priceComplete, missingRates, missingAmounts },
ucp: { UAW, UUCW, TCF, EF, UCP, hoursPerUCP, hours: ucpHours, md: ucpMD, mm: ucpMM, tcfSum: r2(tSum), efSum: r2(eSum) },
crosscheck: { wbsBaseMM: baseMM, ucpMM, variancePct, thresholdPct: threshold, flag: varianceFlag },
timeline: { teamSize, teamDerived, parallelEfficiency: eff, durationMonths, scheduleMonths, startDate, endDate, phases, deadlineFit },
staffing: { unit: 'FTE (MM/tháng)', byMonth, peak: r2(peak), peakHeadcount: Math.ceil(peak) },
warnings,
}
}
// ---------------------------------------------------------------------------
// Stages
// ---------------------------------------------------------------------------
const wrap = (kind, res, extra) => Object.assign({ stage, kind, ok: !!res, date: a.date, files: F, result: res || {} }, extra || {})
if (stage === 'intake') {
phase('Intake')
const r = await agent(
`${sources}\nNhiệm vụ: lập ${F.brief}, ${F.compliance}, ${F.checklist} theo đúng system prompt của bạn. ` +
`Nếu ${BID}/inputs/ không có HSMT/RFP ⇒ dùng cấu trúc mặc định và ma trận tự đối chiếu theo FR của SAD.${notes}`,
{ agentType: 'bid-analyst', label: 'bid-analyst', schema: INTAKE_SCHEMA },
)
return wrap('intake', r)
}
if (stage === 'technical') {
phase('Technical')
const r = await agent(
`${sources}\nĐọc ${F.brief} và ${F.compliance}. Viết ${F.technical} (Phần B: B1–B6, B9, B10; B2.1 từ ma trận đáp ứng). ` +
`Không có con số MM/chi phí/số tháng trong Phần B. Không rò rỉ nội bộ.${notes}`,
{ agentType: 'bid-technical-writer', label: 'bid-technical-writer', schema: TECH_SCHEMA },
)
return wrap('technical', r)
}
if (stage === 'estimate') {
phase('Estimate')
const est = await agent(
`${sources}\nĐọc ${F.compliance} và ${F.technical} (nếu có). Lập ước lượng theo system prompt: ghi ${F.estimate} và ${F.estimationMd}. ` +
`TRẢ VỀ đầy đủ items[], ucp{actors,useCases,tcf(13),ef(8)}, nonLabor[] trong structured output (workflow sẽ tính toán bằng code từ dữ liệu này). ` +
`Vai trò được phép: ${Array.isArray(a.pricing.roles) ? a.pricing.roles.join(', ') : 'đọc từ bid-config'}. Không cộng tổng, không quy đổi MM, không ghi tiền.${notes}`,
{ agentType: 'bid-estimator', label: 'bid-estimator', schema: ESTIMATE_SCHEMA },
)
if (!est) return wrap('estimate', null, { computed: null })
const cfg = Object.assign({}, est.configEcho || {}, a.pricing, { date: a.date })
const computed = computeEstimate(est, cfg)
log(`Ước lượng: ${computed.items.length} hạng mục · ${computed.totals.grandMM} MM (WBS cơ sở ${computed.totals.baseMM} MM, UCP ${computed.ucp.mm} MM) · ${computed.timeline.durationMonths} tháng với team ${computed.timeline.teamSize}${computed.timeline.teamDerived ? ' (suy ra)' : ''} · ${computed.cost.priceComplete ? 'giá đầy đủ' : 'giá tạm tính'}`)
for (const w of computed.warnings) log(`Cảnh báo: ${w}`)
const persisted = await agent(
`Nhiệm vụ "persist": ghi NGUYÊN VĂN JSON dưới đây vào ${F.computed} (không sửa số, không thêm trường). Trả filesWritten.\n\n` +
'```json\n' + JSON.stringify(computed, null, 2) + '\n```',
{ agentType: 'bid-estimator', label: 'persist-computed', schema: PERSIST_SCHEMA, effort: 'low' },
)
return wrap('estimate', est, { computed, persisted: !!persisted && (persisted.filesWritten || []).length > 0, uncovered: est.uncovered || [], warnings: computed.warnings })
}
if (stage === 'plan') {
phase('Plan')
const r = await agent(
`${sources}\nĐọc ${F.computed} (timeline, staffing, totals — KHÔNG đổi số), ${F.estimate}, ${F.brief}, ${F.technical}. ` +
`Viết ${F.plan} (B7 kế hoạch triển khai với Gantt ngày thật, B8 tổ chức nhân sự & staffing plan). ` +
`deadlineFit.fits=false ⇒ trình bày kế hoạch cơ sở + phương án tăng tốc, không ép số.${notes}`,
{ agentType: 'bid-planner', label: 'bid-planner', schema: PLAN_SCHEMA },
)
return wrap('plan', r)
}
if (stage === 'financial') {
phase('Financial')
const r = await agent(
`${sources}\nĐọc ${F.computed} (nguồn số liệu duy nhất), ${F.estimate}, ${F.estimationMd}, ${F.plan} (mốc thanh toán), ${F.brief} (mẫu biểu giá). ` +
`Viết ${F.financial} (Phần C: C1–C7). Mọi con số phải trùng computed; thiếu đơn giá/số tiền ⇒ placeholder và ghi "giá tạm tính".${notes}`,
{ agentType: 'bid-financial-writer', label: 'bid-financial-writer', schema: FIN_SCHEMA },
)
return wrap('financial', r)
}
if (stage === 'assemble') {
phase('Assemble')
const r = await agent(
`${sources}\nRáp ${F.dossier} từ ${F.checklist}, ${F.compliance}, ${F.technical}, ${F.plan}, ${F.financial}, ${F.estimate}, ${F.computed} theo cấu trúc chuẩn ` +
`(hoặc cấu trúc HSMT ghi trong ${F.brief}); dựng ${F.html} (độc lập, in được) và ${F.artifact} (biến thể Artifact). Không viết nội dung mới, không đổi số; giá A1 = C5.${notes}`,
{ agentType: 'bid-builder', label: 'bid-builder', schema: BUILD_SCHEMA },
)
return wrap('assemble', r)
}
if (stage === 'deck') {
phase('Deck')
const r = await agent(
`${sources}\nĐọc ${F.dossier}, ${F.computed}, ${F.brief}. Dựng ${F.deck} (bộ slide tự chứa 16:9, 15–25 slide, mỗi slide in đúng 1 trang) ` +
`và ${F.deckArtifact} theo storyboard & design spec trong system prompt. Không viết nội dung mới; mọi số liệu trùng computed; không rò rỉ nội bộ.${notes}`,
{ agentType: 'bid-deck-builder', label: 'bid-deck-builder', schema: DECK_SCHEMA },
)
return wrap('deck', r)
}
if (stage === 'export') {
phase('Export')
const targets = Array.isArray(a.targets) && a.targets.length ? a.targets : [
{ html: F.html, pdf: F.pdf, kind: 'dossier', optional: false },
{ html: F.deck, pdf: F.deckPdf, kind: 'deck', optional: true },
]
const r = await agent(
`${sources}\nNhiệm vụ "export": với từng target dưới đây, kiểm Mermaid đã render (--dump-dom), in PDF bằng Edge/Chrome headless theo đúng quy trình ` +
`trong system prompt, rồi kiểm file/số trang/kích thước/outline. Target optional mà HTML chưa tồn tại ⇒ bỏ qua và ghi warning. Không sửa HTML.\n` +
targets.map((t, i) => `${i + 1}. [${t.kind}] ${t.html} → ${t.pdf}${t.optional ? ' (optional)' : ''}`).join('\n') + notes,
{ agentType: 'bid-exporter', label: 'bid-exporter', schema: EXPORT_SCHEMA },
)
return wrap('export', r, { blocked: !!(r && r.blocked), pdfs: r ? r.pdfs || [] : [] })
}
// review
phase('Review')
const r = await agent(
`${sources}\nRà soát toàn bộ ${BID}/ (00–02, 10, 20, 30, 40, estimate.json, estimate.computed.json, HO-SO-THAU.md, index.html, artifact.html, ` +
`deck.html, deck-artifact.html, HO-SO-THAU.pdf, deck.pdf nếu có) theo 6 lăng kính trong system prompt. ` +
`Nguồn sự thật số liệu: ${F.computed}. Không sửa file — chỉ trả findings.${notes}`,
{ agentType: 'bid-reviewer', label: 'bid-reviewer', schema: REVIEW_SCHEMA },
)
return wrap('review', r, { canSubmit: !!(r && r.canSubmit) })