Files
sys-analysis-design/.claude/workflows/generate-bid.js
Leonard-ThindPad-P50 c81f249920 init git
2026-09-08 10:26:21 +07:00

461 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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