docs(sdlc): content-sized columns in Word, trace record with test cases, PSRs presented at minuted meetings
This commit is contained in:
@@ -26,22 +26,23 @@ node "$SCRIPT_DIR/sdlc-seed/build.mjs"
|
||||
|
||||
rm -rf "$STAGING_DIR"
|
||||
mkdir -p "$STAGING_DIR"
|
||||
# Scratch space for the CI bands and the table column widths handed to the converter.
|
||||
BAND_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$BAND_DIR"' EXIT
|
||||
|
||||
while IFS= read -r -d '' source; do
|
||||
relative="${source#"$ROOT_DIR/sdlc/"}"
|
||||
destination="$STAGING_DIR/${relative%.md}.pdf"
|
||||
echo "Rendering ${relative%.md}.pdf"
|
||||
node "$TOOL_DIR/render-sdlc.mjs" "$source" "$destination"
|
||||
SDLC_WIDTHS_FILE="$BAND_DIR/widths/${relative%.md}.json" node "$TOOL_DIR/render-sdlc.mjs" "$source" "$destination"
|
||||
done < <(find "$SOURCE_DIR" -mindepth 2 -type f -name '*.md' -print0 | sort -z)
|
||||
|
||||
# Every .pdf gets a Word copy beside it, reconstructed from the rendered page.
|
||||
# The CI header bands need Playwright, so they are rendered here with node and
|
||||
# handed to the converter, which then only needs Python with pdf2docx: the local
|
||||
# interpreter ($PYTHON) when it has it, otherwise a throwaway python container.
|
||||
BAND_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$BAND_DIR"' EXIT
|
||||
echo "Rendering CI header bands..."
|
||||
node "$TOOL_DIR/render-ci-band.mjs" "$SOURCE_DIR" "$BAND_DIR"
|
||||
node "$TOOL_DIR/render-ci-band.mjs" "$SOURCE_DIR" "$BAND_DIR" "$STAGING_DIR"
|
||||
|
||||
echo "Converting PDFs to Word..."
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
|
||||
@@ -74,7 +74,7 @@ table:not(.document-control) {
|
||||
border-collapse: collapse;
|
||||
border: .5pt solid var(--brn-line);
|
||||
break-inside: auto;
|
||||
font-size: 9pt;
|
||||
font-size: 8.5pt;
|
||||
margin: 0 0 5mm;
|
||||
table-layout: auto;
|
||||
width: 100%;
|
||||
|
||||
@@ -41,7 +41,6 @@ HERE = Path(__file__).resolve().parent
|
||||
# Page box measured from the audited reference package, as in sdlc-delivery.css.
|
||||
BODY_TOP_MM = 32
|
||||
FOOTER_FROM_BOTTOM_MM = 24.2 # footer rule at 272.8mm on a 297mm page
|
||||
BAND_WIDTH_MM = 210
|
||||
STANDARD = 'ISO/IEC 29110-4-1:2018'
|
||||
RULE_SIZE = '4' # .5pt table rule, as printed
|
||||
RULE_COLOUR = '595959' # --brn-line from sdlc-delivery.css
|
||||
@@ -702,6 +701,68 @@ def build_table(document, rows):
|
||||
return table
|
||||
|
||||
|
||||
SIGNATURE_HEADER = tuple(letters(cell) for cell in ('ชื่อ', 'ตำแหน่ง', 'ลายเซ็น', 'วันที่'))
|
||||
|
||||
|
||||
def apply_column_widths(document, sized):
|
||||
"""Give each table the column widths the PDF was printed with.
|
||||
|
||||
pdf2docx writes its own grid, usually equal columns, so a table that reads
|
||||
well in the PDF comes out cramped in Word. Tables are matched to the source
|
||||
by their header row; pdf2docx repeats the header on each page's piece of a
|
||||
long table, so every piece is matched. Returns the headers left unmatched.
|
||||
"""
|
||||
section = document.sections[0]
|
||||
landscape = section.page_width > section.page_height
|
||||
width = round(Mm(PRINTED_TEXT_WIDTH_MM[landscape]).twips)
|
||||
by_header = {tuple(letters(cell) for cell in table['header']): table['widths'] for table in sized}
|
||||
unmatched = []
|
||||
for table in document.element.body.iter(qn('w:tbl')):
|
||||
rows = table.findall(qn('w:tr'))
|
||||
if not rows:
|
||||
continue
|
||||
header = tuple(letters(text_of(cell)) for cell in rows[0].findall(qn('w:tc')))
|
||||
if len(header) < 2:
|
||||
continue
|
||||
percents = by_header.get(header)
|
||||
if percents is None:
|
||||
# The document-control block and the signature tables keep their own layout.
|
||||
if header[0] != letters('Document No') and header != SIGNATURE_HEADER:
|
||||
unmatched.append(' | '.join(header))
|
||||
continue
|
||||
total = sum(percents)
|
||||
twips = [round(width * p / total) for p in percents]
|
||||
properties = table.find(qn('w:tblPr'))
|
||||
for tag, attributes in (('w:tblW', {'w:w': width, 'w:type': 'dxa'}), ('w:tblLayout', {'w:type': 'fixed'})):
|
||||
node = properties.find(qn(tag))
|
||||
if node is None:
|
||||
node = OxmlElement(tag)
|
||||
properties.append(node)
|
||||
for name, value in attributes.items():
|
||||
node.set(qn(name), str(value))
|
||||
grid = table.find(qn('w:tblGrid'))
|
||||
for column in list(grid):
|
||||
grid.remove(column)
|
||||
for value in twips:
|
||||
column = OxmlElement('w:gridCol')
|
||||
column.set(qn('w:w'), str(value))
|
||||
grid.append(column)
|
||||
for row in rows:
|
||||
index = 0
|
||||
for cell in row.findall(qn('w:tc')):
|
||||
cell_properties = cell.get_or_add_tcPr()
|
||||
span = cell_properties.find(qn('w:gridSpan'))
|
||||
count = int(span.get(qn('w:val'))) if span is not None else 1
|
||||
cell_width = cell_properties.find(qn('w:tcW'))
|
||||
if cell_width is None:
|
||||
cell_width = OxmlElement('w:tcW')
|
||||
cell_properties.insert(0, cell_width)
|
||||
cell_width.set(qn('w:w'), str(sum(twips[index:index + count])))
|
||||
cell_width.set(qn('w:type'), 'dxa')
|
||||
index += count
|
||||
return unmatched
|
||||
|
||||
|
||||
def restore_dropped_tables(document, markdown):
|
||||
"""Put back tables the converter left out, where the source places them.
|
||||
|
||||
@@ -774,7 +835,7 @@ def install_letterhead(document, band, footer_tag):
|
||||
header.paragraph_format.left_indent = -section.left_margin
|
||||
header.paragraph_format.space_before = Pt(0)
|
||||
header.paragraph_format.space_after = Pt(0)
|
||||
header.add_run().add_picture(str(band), width=Mm(BAND_WIDTH_MM))
|
||||
header.add_run().add_picture(str(band), width=section.page_width)
|
||||
|
||||
footer = section.footer.paragraphs[0]
|
||||
drop_style(footer)
|
||||
@@ -789,7 +850,7 @@ def install_letterhead(document, band, footer_tag):
|
||||
style_run(footer.add_run(f'\t{footer_tag}'), 9)
|
||||
|
||||
|
||||
def convert(pdf, source, band, footer_tag):
|
||||
def convert(pdf, source, band, footer_tag, sized=()):
|
||||
docx = pdf.with_suffix('.docx')
|
||||
converter = Converter(str(pdf))
|
||||
try:
|
||||
@@ -806,6 +867,8 @@ def convert(pdf, source, band, footer_tag):
|
||||
markdown = source.read_text(encoding='utf8')
|
||||
restore_text(document, markdown, bullets)
|
||||
restore_dropped_tables(document, markdown)
|
||||
for header in apply_column_widths(document, sized):
|
||||
print(f' WARNING {pdf.name}: no source widths for table "{header[:80]}"', file=sys.stderr)
|
||||
for paragraph in bullets:
|
||||
if paragraph.getparent() is not None:
|
||||
make_bullet(document, paragraph)
|
||||
@@ -816,9 +879,9 @@ def convert(pdf, source, band, footer_tag):
|
||||
return docx
|
||||
|
||||
|
||||
def render_bands(source_root, band_root):
|
||||
def render_bands(source_root, band_root, pdf_root):
|
||||
subprocess.run(
|
||||
['node', str(HERE / 'render-ci-band.mjs'), str(source_root), str(band_root)],
|
||||
['node', str(HERE / 'render-ci-band.mjs'), str(source_root), str(band_root), str(pdf_root)],
|
||||
check=True
|
||||
)
|
||||
return json.loads((Path(band_root) / 'bands.json').read_text(encoding='utf8'))
|
||||
@@ -831,16 +894,20 @@ def main(argv):
|
||||
source_root, delivery_root = Path(argv[0]), Path(argv[1])
|
||||
temporary = None
|
||||
if len(argv) > 2:
|
||||
bands = json.loads((Path(argv[2]) / 'bands.json').read_text(encoding='utf8'))
|
||||
band_root = Path(argv[2])
|
||||
bands = json.loads((band_root / 'bands.json').read_text(encoding='utf8'))
|
||||
else:
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
bands = render_bands(source_root, temporary.name)
|
||||
band_root = Path(temporary.name)
|
||||
bands = render_bands(source_root, temporary.name, delivery_root)
|
||||
|
||||
failures = []
|
||||
for index, (relative, band) in enumerate(sorted(bands.items()), start=1):
|
||||
pdf = delivery_root / Path(relative).with_suffix('.pdf')
|
||||
try:
|
||||
docx = convert(pdf, source_root / relative, Path(band['band']), band['footer'])
|
||||
widths = band_root / 'widths' / Path(relative).with_suffix('.json')
|
||||
sized = json.loads(widths.read_text(encoding='utf8')) if widths.exists() else []
|
||||
docx = convert(pdf, source_root / relative, Path(band['band']), band['footer'], sized)
|
||||
print(f'[{index}/{len(bands)}] {docx.relative_to(delivery_root)}')
|
||||
except Exception as error: # noqa: BLE001 - reported, not swallowed
|
||||
failures.append((pdf, error))
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Page geometry shared by the PDF header (render-sdlc.mjs) and the Word header
|
||||
// band (render-ci-band.mjs), so both draw the same letterhead for a page.
|
||||
//
|
||||
// Portrait is the audited reference layout. Landscape pages are 87mm wider: the
|
||||
// grey ribbon stretches and the blue banner keeps its distance from the right
|
||||
// edge, while the logo and company block stay where they are.
|
||||
export const PAGE_MM = { portrait: 210, landscape: 297 };
|
||||
const EXTRA_MM = PAGE_MM.landscape - PAGE_MM.portrait;
|
||||
|
||||
export function headerValues(title, landscape) {
|
||||
const extra = landscape ? EXTRA_MM : 0;
|
||||
return {
|
||||
documentTitle: title,
|
||||
// The banner holds about 22 characters at 13.5pt; longer titles shrink to fit.
|
||||
titleSize: `${Math.min(13.5, (13.5 * 22) / title.length).toFixed(1)}pt`,
|
||||
pageWidth: `${landscape ? PAGE_MM.landscape : PAGE_MM.portrait}mm`,
|
||||
ribbonWidth: `${64.7 + extra}mm`,
|
||||
bannerLeft: `${127.2 + extra}mm`
|
||||
};
|
||||
}
|
||||
|
||||
/** Orientation of a rendered PDF, read from its first /MediaBox. */
|
||||
export function isLandscapePdf(bytes) {
|
||||
const box = bytes.toString('latin1').match(/\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]/);
|
||||
if (!box) throw new Error('PDF has no /MediaBox');
|
||||
const [x0, y0, x1, y1] = box.slice(1).map(Number);
|
||||
return x1 - x0 > y1 - y0;
|
||||
}
|
||||
@@ -7,12 +7,13 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { chromium } from 'playwright';
|
||||
import { PAGE_MM, headerValues, isLandscapePdf } from './letterhead.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '../..');
|
||||
const assets = path.join(here, 'assets');
|
||||
|
||||
export const BAND_MM = { width: 210, height: 27 };
|
||||
export const BAND_MM = { height: 27 };
|
||||
const SCALE = 3; // the band is drawn at 3x so it stays crisp in print
|
||||
|
||||
const applyTemplate = (template, values) => template.replace(/{{(\w+)}}/g, (_, key) => values[key] ?? '');
|
||||
@@ -30,8 +31,9 @@ async function markdownFiles(directory, depth = 0) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [sourceRoot, outputRoot] = process.argv.slice(2);
|
||||
if (!sourceRoot || !outputRoot) throw new Error('Usage: render-ci-band.mjs SOURCE_DIRECTORY OUTPUT_DIRECTORY');
|
||||
// PDF_DIRECTORY holds the rendered PDFs: each band matches its PDF's orientation.
|
||||
const [sourceRoot, outputRoot, pdfRoot] = process.argv.slice(2);
|
||||
if (!sourceRoot || !outputRoot || !pdfRoot) throw new Error('Usage: render-ci-band.mjs SOURCE_DIRECTORY OUTPUT_DIRECTORY PDF_DIRECTORY');
|
||||
|
||||
const [template, logo] = await Promise.all([
|
||||
fs.readFile(path.join(assets, 'header.html'), 'utf8'),
|
||||
@@ -56,19 +58,25 @@ async function main() {
|
||||
const title = markdown.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? 'BRN WMS';
|
||||
const footer = markdown.match(/^<!--\s*footer:\s*(.+?)\s*-->\s*$/m)?.[1] ?? '';
|
||||
|
||||
if (!rendered.has(title)) {
|
||||
const file = path.join(outputRoot, `${slug(title)}.png`);
|
||||
const relative = path.relative(sourceRoot, source);
|
||||
const pdf = path.join(pdfRoot, relative.replace(/\.md$/, '.pdf'));
|
||||
const landscape = isLandscapePdf(await fs.readFile(pdf));
|
||||
const key = `${title}|${landscape ? 'landscape' : 'portrait'}`;
|
||||
const widthMm = landscape ? PAGE_MM.landscape : PAGE_MM.portrait;
|
||||
|
||||
if (!rendered.has(key)) {
|
||||
const file = path.join(outputRoot, `${slug(title)}-${landscape ? 'landscape' : 'portrait'}.png`);
|
||||
await page.setContent(
|
||||
'<!doctype html><meta charset="utf-8"><body style="margin:0;background:#fff">' +
|
||||
`<div id="band" style="width:${BAND_MM.width}mm;height:${BAND_MM.height}mm;overflow:hidden;position:relative">` +
|
||||
`${applyTemplate(template, { ...values, documentTitle: title })}</div></body>`,
|
||||
`<div id="band" style="width:${widthMm}mm;height:${BAND_MM.height}mm;overflow:hidden;position:relative">` +
|
||||
`${applyTemplate(template, { ...values, ...headerValues(title, landscape) })}</div></body>`,
|
||||
{ waitUntil: 'networkidle' }
|
||||
);
|
||||
await page.locator('#band').screenshot({ type: 'png', path: file });
|
||||
rendered.set(title, file);
|
||||
rendered.set(key, file);
|
||||
}
|
||||
|
||||
bands[path.relative(sourceRoot, source)] = { title, footer, band: rendered.get(title) };
|
||||
bands[relative] = { title, footer, band: rendered.get(key), widthMm };
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { chromium } from 'playwright';
|
||||
import { headerValues } from './letterhead.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '../..');
|
||||
@@ -101,7 +102,7 @@ function renderTable(lines, className = '') {
|
||||
const attr = classFor(column);
|
||||
return `<${tag}${attr ? ` class="${attr}"` : ''}>${renderInline(cell)}</${tag}>`;
|
||||
}).join('');
|
||||
const classes = [className, header.length >= 8 ? 'dense' : ''].filter(Boolean).join(' ');
|
||||
const classes = [className, header.length >= 7 ? 'dense' : ''].filter(Boolean).join(' ');
|
||||
return `<table${classes ? ` class="${classes}"` : ''}><thead><tr>${cells('th', header)}</tr></thead><tbody>${data.map((row) => isGroup(row)
|
||||
? `<tr class="group"><td colspan="${header.length}">${renderInline(row[0])}</td></tr>`
|
||||
: `<tr>${cells('td', row)}</tr>`).join('')}</tbody></table>`;
|
||||
@@ -233,9 +234,6 @@ async function main() {
|
||||
const LANDSCAPE_TITLES = new Set(['List of Evidence', 'Traceability Record Table']);
|
||||
const values = {
|
||||
logoPath: `data:image/svg+xml;base64,${logo.toString('base64')}`,
|
||||
documentTitle: title,
|
||||
// The banner holds about 22 characters at 13.5pt; longer titles shrink to fit.
|
||||
titleSize: `${Math.min(13.5, (13.5 * 22) / title.length).toFixed(1)}pt`,
|
||||
documentType: metadata['Document No'] ?? title,
|
||||
ciBlue: '#0797d5',
|
||||
ciGrey: '#8e8e8e', // ribbon grey measured from the audited reference documents
|
||||
@@ -247,13 +245,7 @@ async function main() {
|
||||
pageNumber: '<span class="pageNumber"></span>',
|
||||
totalPages: '<span class="totalPages"></span>'
|
||||
};
|
||||
// Landscape pages are 87mm wider: the ribbon stretches and the banner keeps its distance from the right edge.
|
||||
const headerFor = (landscape) => applyTemplate(headerTemplate, {
|
||||
...values,
|
||||
pageWidth: landscape ? '297mm' : '210mm',
|
||||
ribbonWidth: `${64.7 + (landscape ? 87 : 0)}mm`,
|
||||
bannerLeft: `${127.2 + (landscape ? 87 : 0)}mm`
|
||||
});
|
||||
const headerFor = (landscape) => applyTemplate(headerTemplate, { ...values, ...headerValues(title, landscape) });
|
||||
const footerFor = (landscape) => applyTemplate(footerTemplate, {
|
||||
...values,
|
||||
pageWidth: landscape ? '297mm' : '210mm',
|
||||
@@ -280,6 +272,18 @@ async function main() {
|
||||
if (landscape) await page.setContent(htmlFor(true), { waitUntil: 'networkidle' });
|
||||
const textMm = landscape ? 267 : PORTRAIT_TEXT_MM;
|
||||
const clipped = await applyProportionalWidths(page, textMm);
|
||||
// The Word copy is rebuilt from the PDF by pdf2docx, which loses these widths;
|
||||
// the converter reads them back from this file (see convert-to-docx.py).
|
||||
if (process.env.SDLC_WIDTHS_FILE) {
|
||||
const sized = await page.evaluate(() => [...document.querySelectorAll('main table')]
|
||||
.filter((table) => table.querySelector('colgroup'))
|
||||
.map((table) => ({
|
||||
header: [...table.tHead.rows[0].cells].map((cell) => cell.textContent.trim()),
|
||||
widths: [...table.querySelectorAll('colgroup col')].map((col) => parseFloat(col.style.width))
|
||||
})));
|
||||
await fs.mkdir(path.dirname(process.env.SDLC_WIDTHS_FILE), { recursive: true });
|
||||
await fs.writeFile(process.env.SDLC_WIDTHS_FILE, `${JSON.stringify(sized)}\n`);
|
||||
}
|
||||
for (const what of clipped) console.error(`WARNING ${path.basename(source)}: ${what} does not fit its column`);
|
||||
// Unbreakable IDs can make a table wider than the page; say so rather than print it clipped.
|
||||
const overflowMm = await page.evaluate((mm) => {
|
||||
@@ -360,7 +364,17 @@ async function applyProportionalWidths(page, textMm) {
|
||||
+ '.width-probe.narrowest table { width: 1px !important; } '
|
||||
+ '.width-probe.narrowest th, .width-probe.narrowest td { overflow-wrap: normal !important; }';
|
||||
document.head.append(probe);
|
||||
// Per column: every single-column cell's rendered width in one probe layout.
|
||||
// Per column: the width of every single-column cell's own content (plus its
|
||||
// padding) in one probe layout. A cell's box is as wide as its whole column,
|
||||
// so the content is measured with a range rather than the cell's rectangle.
|
||||
const contentWidth = (cell) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(cell);
|
||||
const rects = [...range.getClientRects()];
|
||||
const text = rects.length ? Math.max(...rects.map((r) => r.right)) - Math.min(...rects.map((r) => r.left)) : 0;
|
||||
const style = getComputedStyle(cell);
|
||||
return text + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + 1;
|
||||
};
|
||||
const measure = (table, mode) => {
|
||||
const holder = document.createElement('div');
|
||||
holder.className = `delivery-document width-probe ${mode}`;
|
||||
@@ -370,7 +384,7 @@ async function applyProportionalWidths(page, textMm) {
|
||||
for (const row of holder.querySelector('table').rows) {
|
||||
let index = 0;
|
||||
for (const cell of row.cells) {
|
||||
if (cell.colSpan === 1) (columns[index] ??= []).push({ width: cell.getBoundingClientRect().width / mm, head: cell.tagName === 'TH' });
|
||||
if (cell.colSpan === 1) (columns[index] ??= []).push({ width: contentWidth(cell) / mm, head: cell.tagName === 'TH' });
|
||||
index += cell.colSpan;
|
||||
}
|
||||
}
|
||||
@@ -424,6 +438,13 @@ async function applyProportionalWidths(page, textMm) {
|
||||
widths = widths.map((w, i) => w + ((availMm - used) * weight[i]) / total);
|
||||
}
|
||||
}
|
||||
// Topic columns (CR Topic, SRS Topic, Unit Topic) share their combined width equally.
|
||||
const heads = [...(table.tHead?.rows[0]?.cells ?? [])].map((cell) => cell.textContent.trim());
|
||||
const topics = heads.map((h, i) => (/Topic$/i.test(h) ? i : -1)).filter((i) => i >= 0);
|
||||
if (topics.length > 1 && heads.length === n) {
|
||||
const even = topics.reduce((sum, i) => sum + widths[i], 0) / topics.length;
|
||||
if (topics.every((i) => even >= floor[i])) for (const i of topics) widths[i] = even;
|
||||
}
|
||||
const colgroup = document.createElement('colgroup');
|
||||
for (const w of widths) {
|
||||
const col = document.createElement('col');
|
||||
|
||||
@@ -187,6 +187,17 @@ export function changeReports() {
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Each Progress Status Record is presented at the meeting its "Next Meeting" names;
|
||||
* the last one, issued at closure, at the closing meeting. The minutes attach them.
|
||||
*/
|
||||
const presentedAt = (period) => period[7]?.date ?? MEETINGS.at(-1).date;
|
||||
const attachments = (m) => {
|
||||
const records = PERIODS.filter((p) => presentedAt(p) === m.date).map((p) => thShort(p[0]));
|
||||
const listed = records.length ? [`Progress Status Record ${records.join(', ')}`] : [];
|
||||
return [...listed, ...(m.attachment === '-' ? [] : [m.attachment])].join(', ') || '-';
|
||||
};
|
||||
|
||||
/** 7. Meeting Record — Minutes of Meeting per checkpoint. */
|
||||
export function meetingRecords() {
|
||||
return MEETINGS.map((m) => {
|
||||
@@ -202,7 +213,7 @@ export function meetingRecords() {
|
||||
['Task ID / Task Name', `${m.taskId} ${m.taskName}`],
|
||||
['Work Period of Task', thRangeLong(t[5], t[6])],
|
||||
['Meeting Date', thLong(m.date), 'Time', m.time],
|
||||
['สิ่งแนบ', m.attachment],
|
||||
['สิ่งแนบ', attachments(m)],
|
||||
['Location', `ห้องประชุม ${COMPANY_TH}`],
|
||||
['Organizer', ORGANIZER],
|
||||
['Recorder', RECORDER],
|
||||
|
||||
@@ -155,20 +155,21 @@ export function softwareDesign() {
|
||||
};
|
||||
}
|
||||
|
||||
/** 13. Traceability Record. */
|
||||
/** 13. Traceability Record — completed once the test cases exist, so it is issued with them. */
|
||||
export function traceabilityRecord() {
|
||||
const date = '2026-03-06';
|
||||
const date = '2026-07-31';
|
||||
const rows = [];
|
||||
let group = '';
|
||||
for (const [id, text] of CRS) {
|
||||
const g = id.slice(0, 4);
|
||||
if (g !== group) { group = g; const name = SCOPEGROUP(g); rows.push([`${g}: ${name}`, '', '', '', '', '', '']); }
|
||||
const t = traceOf(id);
|
||||
rows.push([id, text, t.sr.join(', '), t.sr.map(srTopic).join(' / '), t.un.join(', ') || '-', t.un.map(unitTopic).join(' / ') || '-', t.ev || '-']);
|
||||
rows.push([id, text, t.sr.join(', '), t.sr.map(srTopic).join(' / '), t.un.join(', ') || '-', t.un.map(unitTopic).join(' / ') || '-', t.tc]);
|
||||
}
|
||||
const traced = CRS.map(([id]) => traceOf(id));
|
||||
const withUnits = traced.filter((t) => t.un.length).length;
|
||||
const byDocument = traced.filter((t) => !t.un.length).length;
|
||||
const withTests = traced.filter((t) => t.tcs.length).length;
|
||||
return {
|
||||
dir: '2-SI Process (12 Work Product)/13.Traceability record',
|
||||
name: `${CODE} Traceability Record ${bud(date)} V1.0 NoC`,
|
||||
@@ -182,20 +183,21 @@ export function traceabilityRecord() {
|
||||
]),
|
||||
body: [
|
||||
'## วัตถุประสงค์ (Objective)',
|
||||
'เอกสารที่เชื่อมโยงความต้องการของลูกค้า → ความต้องการซอฟต์แวร์ → การออกแบบ (Software Unit) ณ วันที่ออกแบบระบบ เพื่อยืนยันความครบถ้วนของ Customer Requirements ก่อนเริ่มพัฒนา และลดความเสี่ยงของความต้องการที่ตกหล่น การสอบกลับไปยัง Test Case และผลการทดสอบจะเพิ่มใน Traceability Record ฉบับส่งมอบ หลังจัดทำ Test Case and Test Procedures',
|
||||
'เอกสารที่เชื่อมโยงความต้องการของลูกค้า → ความต้องการซอฟต์แวร์ → การออกแบบ (Software Unit) → Test Case จัดทำพร้อม Test Case and Test Procedures เพื่อยืนยันว่าความต้องการทุกรายการมีการออกแบบและการทดสอบรองรับก่อนเริ่มทดสอบระบบ ผลการทดสอบสรุปใน Traceability Record Table ฉบับส่งมอบ',
|
||||
'## Traceability Matrix',
|
||||
table(['CR ID', 'CR Topic', 'SRS ID', 'SRS Topic', 'Unit ID', 'Unit Topic', 'หลักฐานอื่น (Document)'], rows),
|
||||
'หมายเหตุ: ความต้องการที่เป็นเอกสาร การอบรม หรือหลักฐานด้านคุณภาพ สอบกลับไปยังเอกสารส่งมอบที่เป็นหลักฐานในคอลัมน์ "หลักฐานอื่น" แทน Software Unit',
|
||||
table(['CR ID', 'CR Topic', 'SRS ID', 'SRS Topic', 'Unit ID', 'Unit Topic', 'Test Case ID'], rows),
|
||||
'หมายเหตุ: ความต้องการที่เป็นเอกสาร การอบรม หรือหลักฐานด้านคุณภาพไม่มี Software Unit และ Test Case จึงยืนยันด้วยเอกสารส่งมอบตามแผน ซึ่งสรุปไว้ใน Traceability Record Table ฉบับส่งมอบ',
|
||||
'## สรุปความครอบคลุม',
|
||||
table(['รายการ', 'จำนวน'], [
|
||||
['ความต้องการของลูกค้าทั้งหมด (CR01–CR14)', CRS.length],
|
||||
['เชื่อมโยงกับความต้องการซอฟต์แวร์ (SRS)', CRS.length],
|
||||
['เชื่อมโยงกับ Software Unit', withUnits],
|
||||
['ยืนยันด้วยเอกสารส่งมอบ (ไม่มี Software Unit)', byDocument],
|
||||
['เชื่อมโยงกับ Test Case', withTests],
|
||||
['ยืนยันด้วยเอกสารส่งมอบตามแผน (ไม่มี Software Unit)', byDocument],
|
||||
['ความต้องการซอฟต์แวร์ทั้งหมด (SR01–SR09)', SRS.length],
|
||||
['Software Unit ทั้งหมด', UNITS.length],
|
||||
], ['', 'r']),
|
||||
'ความต้องการทุกรายการเชื่อมโยงกับความต้องการซอฟต์แวร์ และเชื่อมโยงกับ Software Unit หรือเอกสารส่งมอบที่เป็นหลักฐาน Test Case ของแต่ละ Software Unit จะกำหนดใน Test Case and Test Procedures (WP 6.0) ก่อนรอบทดสอบระบบ',
|
||||
'ความต้องการทุกรายการเชื่อมโยงกับความต้องการซอฟต์แวร์ ความต้องการที่มี Software Unit เชื่อมโยงกับ Test Case ใน Test Case and Test Procedures (WP 6.0) ครบทุกรายการ ส่วนที่เหลือยืนยันด้วยเอกสารส่งมอบตามแผน',
|
||||
].join('\n\n'),
|
||||
secretary: P.NoC, reviewer: P.YaB, approver: P.SeV,
|
||||
}),
|
||||
|
||||
@@ -134,20 +134,20 @@ export const LESSONS = [
|
||||
* [date, from, to, weekNo, taskRows[[taskId, status, pct, remark]], issues[ISS ids], changes[CH ids], next{date,topic}]
|
||||
*/
|
||||
export const PERIODS = [
|
||||
['2026-01-09', '2026-01-05', '2026-01-09', 1, [['1.1', 'Completed', 100, 'เปิดโครงการอย่างเป็นทางการ ชี้แจงขอบเขตและทีมงานครบทุกบทบาท'], ['1.2', 'In Progress', 40, 'อยู่ระหว่างรวบรวมรายชื่อผู้มีส่วนได้ส่วนเสียและระดับการมีส่วนร่วม']], [], [], { date: '2026-01-16', topic: 'ทบทวนรายชื่อผู้มีส่วนได้ส่วนเสียและเริ่มกำหนดวัตถุประสงค์โครงการ' }],
|
||||
['2026-01-23', '2026-01-12', '2026-01-23', 2, [['1.2', 'Completed', 100, 'จัดทำ Stakeholder Register ครบ 6 บทบาทหลักและผู้ใช้งานที่เกี่ยวข้อง'], ['1.3', 'Completed', 100, 'กำหนดวัตถุประสงค์ ขอบเขตระบบ 11 ระบบงาน และเกณฑ์ความสำเร็จ'], ['1.4', 'Completed', 100, 'Project Charter ได้รับอนุมัติจาก Project Sponsor'], ['2.1', 'In Progress', 30, 'เริ่มเก็บความต้องการลูกค้าจากฝ่ายคลังสินค้า ขาย และบัญชี']], [], [], { date: '2026-02-06', topic: 'สรุปความต้องการลูกค้าและทบทวนร่างแผนโครงการ' }],
|
||||
['2026-02-06', '2026-01-26', '2026-02-06', 3, [['2.1', 'Completed', 100, `สรุปความต้องการลูกค้า ${CRS.length} รายการ จัดหมวด CR01–CR14 และได้ผลสรุป A ทุกรายการ`], ['2.2', 'In Progress', 60, 'อยู่ระหว่างจัดทำ Software Project Plan และ Work Schedule']], [], [], { date: '2026-02-13', topic: 'ทบทวน Software Project Plan, Work Schedule และแผนความเสี่ยง' }],
|
||||
['2026-02-18', '2026-02-09', '2026-02-18', 4, [['2.2', 'Completed', 100, 'จัดทำแผนโครงการ ทรัพยากร ความเสี่ยง R1–R8 และแผนสำรองงานที่ไม่แล้วเสร็จ'], ['2.3', 'Completed', 100, 'ตั้ง Baseline ความต้องการและแผนงาน อนุมัติเริ่มพัฒนา 19 ก.พ. 69']], [], [], { date: '2026-03-02', topic: 'ติดตามการจัดทำ SRS, เอกสารออกแบบระบบ และการเริ่มพัฒนา' }],
|
||||
['2026-02-25', '2026-02-19', '2026-02-25', 5, [['3.1', 'Completed', 100, 'จัดทำ SRS (SR01–SR09) และเอกสารออกแบบระบบ เริ่มพัฒนาโครงสร้างแอปพลิเคชัน'], ['4.2', 'In Progress', 20, 'เริ่มควบคุม Source Code บน Git และกำหนด main เป็น Baseline']], [], [], { date: '2026-03-02', topic: 'สรุปผล SRS เอกสารออกแบบ และการเริ่มพัฒนาระบบ' }],
|
||||
['2026-01-09', '2026-01-05', '2026-01-09', 1, [['1.1', 'Completed', 100, 'เปิดโครงการอย่างเป็นทางการ ชี้แจงขอบเขตและทีมงานครบทุกบทบาท'], ['1.2', 'In Progress', 40, 'อยู่ระหว่างรวบรวมรายชื่อผู้มีส่วนได้ส่วนเสียและระดับการมีส่วนร่วม']], [], [], { date: '2026-01-23', topic: 'พิจารณาอนุมัติกฎบัตรโครงการและทบทวนผู้มีส่วนได้ส่วนเสีย' }],
|
||||
['2026-01-23', '2026-01-12', '2026-01-23', 2, [['1.2', 'Completed', 100, 'จัดทำ Stakeholder Register ครบ 6 บทบาทหลักและผู้ใช้งานที่เกี่ยวข้อง'], ['1.3', 'Completed', 100, 'กำหนดวัตถุประสงค์ ขอบเขตระบบ 11 ระบบงาน และเกณฑ์ความสำเร็จ'], ['1.4', 'Completed', 100, 'Project Charter ได้รับอนุมัติจาก Project Sponsor'], ['2.1', 'In Progress', 30, 'เริ่มเก็บความต้องการลูกค้าจากฝ่ายคลังสินค้า ขาย และบัญชี']], [], [], { date: '2026-02-18', topic: 'ทบทวนความต้องการลูกค้า แผนโครงการ และตั้ง Baseline' }],
|
||||
['2026-02-06', '2026-01-26', '2026-02-06', 3, [['2.1', 'Completed', 100, `สรุปความต้องการลูกค้า ${CRS.length} รายการ จัดหมวด CR01–CR14 และได้ผลสรุป A ทุกรายการ`], ['2.2', 'In Progress', 60, 'อยู่ระหว่างจัดทำ Software Project Plan และ Work Schedule']], [], [], { date: '2026-02-18', topic: 'ทบทวนความต้องการลูกค้า แผนโครงการ และตั้ง Baseline' }],
|
||||
['2026-02-18', '2026-02-09', '2026-02-18', 4, [['2.2', 'Completed', 100, 'จัดทำแผนโครงการ ทรัพยากร ความเสี่ยง R1–R8 และแผนสำรองงานที่ไม่แล้วเสร็จ'], ['2.3', 'Completed', 100, 'ตั้ง Baseline ความต้องการและแผนงาน อนุมัติเริ่มพัฒนา 19 ก.พ. 69']], [], [], { date: '2026-03-23', topic: 'สรุปผลการแก้ไขด้านความปลอดภัยและการออกแบบฐานข้อมูลสต๊อก' }],
|
||||
['2026-02-25', '2026-02-19', '2026-02-25', 5, [['3.1', 'Completed', 100, 'จัดทำ SRS (SR01–SR09) และเอกสารออกแบบระบบ เริ่มพัฒนาโครงสร้างแอปพลิเคชัน'], ['4.2', 'In Progress', 20, 'เริ่มควบคุม Source Code บน Git และกำหนด main เป็น Baseline']], [], [], { date: '2026-03-23', topic: 'สรุปผลการแก้ไขด้านความปลอดภัยและการออกแบบฐานข้อมูลสต๊อก' }],
|
||||
['2026-03-17', '2026-03-09', '2026-03-17', 6, [['3.2', 'Completed', 100, 'ป้องกันไฟล์ตั้งค่า แก้ผลตรวจความปลอดภัย ออกแบบฐานข้อมูลสต๊อกและโมดูล ICS'], ['4.3', 'In Progress', 10, 'เริ่มตรวจสอบเอกสารความต้องการและออกแบบรอบที่ 1']], ['ISS-001'], [], { date: '2026-03-23', topic: 'สรุปผลการแก้ไขด้านความปลอดภัยและการออกแบบฐานข้อมูลสต๊อก' }],
|
||||
['2026-04-29', '2026-04-09', '2026-04-29', 7, [['3.3', 'Completed', 100, 'พัฒนาแดชบอร์ดสต๊อก สินค้า ความจุคลัง Lot/Serial/Expiry และรายงานครบตามแผน'], ['3.4', 'In Progress', 50, 'อยู่ระหว่างพัฒนาเข้าสู่ระบบ ลงทะเบียน และ Onboarding']], ['ISS-002'], [], { date: '2026-05-04', topic: 'สรุปผลการพัฒนาโมดูลคลังสินค้า สินค้า และรายงาน' }],
|
||||
['2026-05-08', '2026-04-30', '2026-05-08', 8, [['3.4', 'In Progress', 80, 'พัฒนาการเข้าสู่ระบบและสิทธิ์ตามบทบาทเกือบครบ เหลือขั้นตอนกู้คืนรหัสผ่าน'], ['3.5', 'Completed', 100, 'พัฒนาใบสั่งขาย ใบรับคืน ใบแจ้งหนี้ คลังหลายชั้น และบาร์โค้ดครบตามแผน'], ['3.9', 'In Progress', 25, 'เริ่มทบทวน Role Guard และการรวมศูนย์การตรวจสิทธิ์']], ['ISS-003', 'ISS-004', 'ISS-005'], [], { date: '2026-05-13', topic: 'ติดตามการพัฒนาระบบสิทธิ์ ใบสั่งขาย และบาร์โค้ด' }],
|
||||
['2026-05-08', '2026-04-30', '2026-05-08', 8, [['3.4', 'In Progress', 80, 'พัฒนาการเข้าสู่ระบบและสิทธิ์ตามบทบาทเกือบครบ เหลือขั้นตอนกู้คืนรหัสผ่าน'], ['3.5', 'Completed', 100, 'พัฒนาใบสั่งขาย ใบรับคืน ใบแจ้งหนี้ คลังหลายชั้น และบาร์โค้ดครบตามแผน'], ['3.9', 'In Progress', 25, 'เริ่มทบทวน Role Guard และการรวมศูนย์การตรวจสิทธิ์']], ['ISS-003', 'ISS-004', 'ISS-005'], [], { date: '2026-05-18', topic: 'ติดตามการเตรียมใช้งานจริง สคริปต์ติดตั้ง และการเริ่มโมดูลบัญชี' }],
|
||||
['2026-05-13', '2026-05-11', '2026-05-13', 9, [['3.4', 'Completed', 100, 'พัฒนาการเข้าสู่ระบบ Onboarding และกู้คืนรหัสผ่านครบถ้วน'], ['3.6', 'Completed', 100, 'จัดทำ Base URL อัตโนมัติ สคริปต์ติดตั้งฐานข้อมูล และข้อมูลทดสอบ'], ['3.7', 'In Progress', 20, 'เริ่มพัฒนาโมดูลบัญชีและผังบัญชี']], ['ISS-006', 'ISS-007', 'ISS-008', 'ISS-009'], [], { date: '2026-05-18', topic: 'ติดตามการเตรียมใช้งานจริง สคริปต์ติดตั้ง และการเริ่มโมดูลบัญชี' }],
|
||||
['2026-05-25', '2026-05-20', '2026-05-23', 10, [['3.7', 'Completed', 100, 'พัฒนาผังบัญชี GL สมุดรายวัน รายงานบัญชี ใบวางบิลและใบเสร็จครบตามแผน'], ['3.8', 'In Progress', 45, 'อยู่ระหว่างพัฒนา Socket.IO และตารางสรุปยอด'], ['3.9', 'In Progress', 60, 'เพิ่มการเชิญผู้ใช้ การควบคุมสิทธิ์แอปพลิเคชัน และโควตารายการ']], ['ISS-010', 'ISS-011', 'ISS-012', 'ISS-013', 'ISS-014', 'ISS-015'], [], { date: '2026-05-28', topic: 'ติดตามการพัฒนาบัญชี การเงิน และการควบคุมสิทธิ์ผู้ใช้' }],
|
||||
['2026-05-29', '2026-05-24', '2026-05-29', 11, [['3.8', 'Completed', 100, 'พัฒนาบริการแจ้งเตือน Real-time ตารางสรุปยอด และงานตามกำหนดเวลาครบถ้วน'], ['3.9', 'Completed', 100, 'ทบทวนความปลอดภัย ขอบเขตบริษัท วงจรเอกสาร และปิดข้อบกพร่องที่พบ'], ['3.10', 'Completed', 100, 'ลดความซ้ำซ้อนของคลาสและตั้ง Baseline การพัฒนาเมื่อ 29 พ.ค. 69']], ['ISS-016', 'ISS-017', 'ISS-018', 'ISS-019', 'ISS-020', 'ISS-021', 'ISS-022', 'ISS-023', 'ISS-024', 'ISS-025', 'ISS-026', 'ISS-027'], [], { date: '2026-06-04', topic: 'สรุปผลการพัฒนาและตั้ง Baseline การพัฒนา' }],
|
||||
['2026-08-03', '2026-06-01', '2026-08-03', 12, [['4.3', 'In Progress', 70, 'ตรวจสอบเอกสารความต้องการ ออกแบบ และการสอบกลับ รอบที่ 1–3'], ['4.5', 'In Progress', 80, 'จัดทำคู่มือผู้ใช้ คู่มือผู้ดูแลระบบ และคู่มือบำรุงรักษา'], ['4.6', 'Completed', 100, 'แก้ไขการเข้าสู่ระบบและค่าตั้งค่าสภาพแวดล้อมหลัง Baseline']], ['ISS-028'], [], { date: '2026-08-10', topic: 'ติดตามการแก้ไขหลัง Baseline การตรวจสอบเอกสาร และแผนการทดสอบ' }],
|
||||
['2026-08-14', '2026-08-04', '2026-08-14', 13, [['4.4', 'Completed', 100, `ทดสอบระบบ 45 Test Case และ UAT ครบ 14 หมวดความต้องการ (${CRS.length} รายการ) ผ่านทั้งหมดระหว่าง 10–14 ส.ค. 69`], ['4.7', 'In Progress', 60, 'จัดทำข้อมูลสาธิตเสร็จ อยู่ระหว่างปรับแบรนด์และชุดติดตั้ง Docker Compose'], ['5.1', 'In Progress', 70, 'ตรวจสอบความครบถ้วนของ Work Products']], [], [], { date: '2026-08-19', topic: 'สรุปผลการทดสอบระบบ UAT และข้อมูลสาธิต' }],
|
||||
['2026-08-17', '2026-08-15', '2026-08-17', 14, [['4.3', 'Completed', 100, 'ตรวจสอบ Work Products รอบที่ 4 ครบทุกรายการ ผลผ่านทั้งหมด'], ['4.5', 'Completed', 100, 'คู่มือผู้ใช้ คู่มือผู้ดูแลระบบ และคู่มือบำรุงรักษาเสร็จสมบูรณ์'], ['4.7', 'Completed', 100, 'ปรับแบรนด์และชุดติดตั้ง Docker Compose เสร็จ Baseline 6c39700'], ['5.1', 'Completed', 100, 'จัดทำ List of Evidence ครบทุก Work Product'], ['5.3', 'In Progress', 20, 'ส่งมอบสิ่งส่งมอบครบ นัดตรวจรับและปิดโครงการ 24 ส.ค. 69']], [], [], { date: '2026-08-19', topic: 'สรุปผลการทดสอบระบบ UAT และข้อมูลสาธิต' }],
|
||||
['2026-08-17', '2026-08-15', '2026-08-17', 14, [['4.3', 'Completed', 100, 'ตรวจสอบ Work Products รอบที่ 4 ครบทุกรายการ ผลผ่านทั้งหมด'], ['4.5', 'Completed', 100, 'คู่มือผู้ใช้ คู่มือผู้ดูแลระบบ และคู่มือบำรุงรักษาเสร็จสมบูรณ์'], ['4.7', 'Completed', 100, 'ปรับแบรนด์และชุดติดตั้ง Docker Compose เสร็จ Baseline 6c39700'], ['5.1', 'Completed', 100, 'จัดทำ List of Evidence ครบทุก Work Product'], ['5.3', 'In Progress', 20, 'ส่งมอบสิ่งส่งมอบครบ นัดตรวจรับและปิดโครงการ 24 ส.ค. 69']], [], [], { date: '2026-08-24', topic: 'ตรวจรับส่งมอบระบบและปิดโครงการอย่างเป็นทางการ' }],
|
||||
['2026-08-24', '2026-08-18', '2026-08-24', 15, [['5.2', 'Completed', 100, 'อบรมผู้ใช้งาน 6 คน เมื่อ 22 ส.ค. 69 ผ่านการทดลองใช้งานครบทุกขั้นตอน'], ['5.3', 'Completed', 100, 'ปิดงานควบคุมปฏิบัติการ 23 ส.ค. 69 ตรวจรับระบบและปิดโครงการอย่างเป็นทางการ 24 ส.ค. 69'], ['4.1', 'Completed', 100, 'บันทึกความก้าวหน้าและรายงานการประชุมครบตลอดโครงการ'], ['4.2', 'Completed', 100, 'ควบคุมเวอร์ชันและสำรองข้อมูลครบ']], [], [], null],
|
||||
];
|
||||
|
||||
@@ -163,7 +163,7 @@ export const MEETINGS = [
|
||||
agenda: [['แนะนำสมาชิกทีมโครงการ', 'สร้างความเข้าใจบทบาทและหน้าที่'], ['ทบทวนความจำเป็นและวัตถุประสงค์ของระบบคลังสินค้า', 'กำหนดเป้าหมายร่วมกัน'], ['ภาพรวมระบบงาน 11 ระบบที่จะพัฒนา', 'เห็นขอบเขตและแผนการพัฒนา'], ['สรุปแผนโครงการ 232 วัน', 'ยืนยัน Timeline 5 ม.ค. – 24 ส.ค. 69'], ['กำหนดรอบการประชุมและการรายงานความก้าวหน้า', 'ตั้งรอบประชุม ผู้รับผิดชอบ และเอกสารอ้างอิง']],
|
||||
discussion: [['วัตถุประสงค์โครงการ', 'พัฒนาระบบบริหารจัดการคลังสินค้าแบบรวมศูนย์ รองรับหลายบริษัทและหลายคลัง', ['SeV', 'ApS'], 'ยืนยันแล้ว'], ['ระยะเวลาโครงการ', 'ระยะเวลา 232 วัน ตั้งแต่ 5 ม.ค. – 24 ส.ค. 69', ['ApS'], 'อนุมัติ'], ['ทรัพยากรและทีมงาน', 'ทีมหลัก 6 คน ครบบทบาท PM, SA, Developer, QA, Document Control และ Project Sponsor', ['ApS', 'YaB'], 'ผ่านการอนุมัติ'], ['Project Repository', 'ใช้ Git Server เป็นศูนย์กลางเก็บ Source Code และเอกสารโครงการ', ['ThS', 'YaB'], 'ดำเนินการแล้ว']],
|
||||
actions: [['จัดทำ Stakeholder Register (Task 1.2)', ['ApS', 'YaB'], '2026-01-16', 'Scheduled'], ['เตรียม Repository และโครงสร้างโฟลเดอร์เอกสารโครงการ', ['ThS'], '2026-01-16', 'Scheduled']],
|
||||
next: { date: '2026-01-16', topic: 'ทบทวนรายชื่อผู้มีส่วนได้ส่วนเสียและกำหนดวัตถุประสงค์โครงการ' },
|
||||
next: { date: '2026-01-23', topic: 'พิจารณาอนุมัติกฎบัตรโครงการและทบทวนผู้มีส่วนได้ส่วนเสีย' },
|
||||
},
|
||||
{
|
||||
date: '2026-01-23', phase: 'Project Initiation', taskId: '1.4', taskName: 'Approve Project Charter', time: '09:00 – 11:00 น.',
|
||||
@@ -172,7 +172,7 @@ export const MEETINGS = [
|
||||
agenda: [['พิจารณาอนุมัติกฎบัตรโครงการ (Project Charter)', 'ยืนยันวัตถุประสงค์ ขอบเขต และงบประมาณ'], ['ทบทวน Stakeholder Register', 'ยืนยันบทบาทและระดับการมีส่วนร่วม'], ['แผนการเก็บความต้องการลูกค้า', 'กำหนดผู้ให้ข้อมูลและกรอบเวลา']],
|
||||
discussion: [['อนุมัติกฎบัตรโครงการ', 'Project Sponsor อนุมัติวัตถุประสงค์ ขอบเขต 11 ระบบงาน และเกณฑ์ความสำเร็จ', ['SeV'], 'อนุมัติ'], ['ผู้มีส่วนได้ส่วนเสีย', 'ยืนยันผู้รับผิดชอบตาม RACI ครบทุกบทบาท', ['ApS', 'YaB'], 'ยืนยันแล้ว'], ['การเก็บความต้องการ', 'มอบหมาย System Analyst สัมภาษณ์ฝ่ายคลังสินค้า ขาย จัดซื้อ และบัญชี', ['NoC'], 'ดำเนินการ']],
|
||||
actions: [['เก็บและสรุปความต้องการลูกค้า (Task 2.1)', ['NoC', 'ApS'], '2026-02-06', 'Scheduled'], ['จัดทำร่าง Software Project Plan และ Work Schedule (Task 2.2)', ['ApS'], '2026-02-13', 'Scheduled']],
|
||||
next: { date: '2026-02-06', topic: 'สรุปความต้องการลูกค้าและทบทวนร่างแผนโครงการ' },
|
||||
next: { date: '2026-02-18', topic: 'ทบทวนความต้องการลูกค้า แผนโครงการ และตั้ง Baseline' },
|
||||
},
|
||||
{
|
||||
date: '2026-02-18', phase: 'Project Planning', taskId: '2.3', taskName: 'Baseline Requirements & Schedule', time: '09:00 – 11:00 น.',
|
||||
@@ -181,11 +181,11 @@ export const MEETINGS = [
|
||||
agenda: [['ทบทวนความต้องการลูกค้า CR01–CR14', 'ยืนยันความครบถ้วนและความเป็นไปได้'], ['ทบทวน Software Project Plan และ Work Schedule', 'ยืนยันแผนงาน ทรัพยากร และความเสี่ยง'], ['อนุมัติเริ่มพัฒนาระบบ', 'ยืนยันวันเริ่มพัฒนาและการควบคุม Source Code']],
|
||||
discussion: [['ความต้องการลูกค้า', `ความต้องการ ${CRS.length} รายการ จัดหมวด CR01–CR14 ผลสรุปยอมรับ (A) ทุกรายการ`, ['NoC', 'SeV'], 'อนุมัติ'], ['แผนโครงการและความเสี่ยง', 'ยืนยันแผน 232 วัน ความเสี่ยง R1–R8 และแผนสำรองงานที่ไม่แล้วเสร็จ', ['ApS'], 'อนุมัติ'], ['การควบคุม Source Code', 'กำหนด main เป็น Baseline หลัก ไม่นำค่าตั้งค่าและความลับเข้า Git', ['ThS', 'YaB'], 'บังคับใช้ทันที']],
|
||||
actions: [['เริ่มพัฒนาระบบตาม Task 3.1 ในวันที่ 19 ก.พ. 69', ['ThS', 'NoC'], '2026-02-25', 'Scheduled'], ['จัดทำ SRS และเอกสารออกแบบระบบ', ['NoC', 'ThS'], '2026-02-25', 'Scheduled'], ['รายงานความก้าวหน้าตามรหัสงานใน Work Schedule', ['ApS'], '2026-08-24', 'Ongoing']],
|
||||
next: { date: '2026-02-25', topic: 'ติดตามการจัดทำ SRS เอกสารออกแบบ และการเริ่มพัฒนาระบบ' },
|
||||
next: { date: '2026-03-23', topic: 'สรุปผลการแก้ไขด้านความปลอดภัยและการออกแบบฐานข้อมูลสต๊อก' },
|
||||
},
|
||||
{
|
||||
date: '2026-03-23', phase: 'Project Execution', taskId: '3.2', taskName: 'Security & Database Foundation', time: '09:00 – 11:00 น.',
|
||||
attachment: 'Progress Status Record 17 มี.ค. 69',
|
||||
attachment: '-',
|
||||
progress: [['Phase 3: Project Execution', 'กำลังดำเนินการ', 25, 'พัฒนาฐานข้อมูลสต๊อกและโมดูล ICS เบื้องต้น'], ['รวมความคืบหน้าทั้งโครงการ', 'ตามแผน', 30, 'การพัฒนาเป็นไปตามแผน']],
|
||||
agenda: [['ผลการแก้ไขตามผลตรวจความปลอดภัย', 'ยืนยันการปิดข้อบกพร่อง ISS-001'], ['การออกแบบฐานข้อมูลสต๊อกและโมดูล ICS', 'ทบทวนโครงสร้างตารางและความสัมพันธ์'], ['แผนพัฒนาโมดูลคลังสินค้าและรายงาน', 'ยืนยันลำดับงานช่วงเดือนเมษายน']],
|
||||
discussion: [['ความปลอดภัยของระบบ', 'ถอดไฟล์ตั้งค่าออกจาก Git และแก้ไขตามผลตรวจความปลอดภัยครบถ้วน', ['ThS'], 'เสร็จสิ้น'], ['ฐานข้อมูลสต๊อก', 'ยืนยันโครงสร้างตาราง td_stock และตารางข้อมูลหลักที่เกี่ยวข้อง', ['NoC', 'ThS'], 'ยืนยันแล้ว'], ['แผนงานถัดไป', 'พัฒนาแดชบอร์ดสต๊อก สินค้า ความจุคลัง และรายงาน', ['ThS'], 'ดำเนินการ']],
|
||||
@@ -194,16 +194,16 @@ export const MEETINGS = [
|
||||
},
|
||||
{
|
||||
date: '2026-05-04', phase: 'Project Execution', taskId: '3.3', taskName: 'Inventory & Warehouse Modules', time: '09:00 – 11:00 น.',
|
||||
attachment: 'Progress Status Record 29 เม.ย. 69',
|
||||
attachment: '-',
|
||||
progress: [['Phase 3: Project Execution', 'กำลังดำเนินการ', 50, 'พัฒนาโมดูลคลังสินค้า สินค้า Lot/Serial/Expiry และรายงานเสร็จ'], ['รวมความคืบหน้าทั้งโครงการ', 'ตามแผน', 45, 'เข้าสู่การพัฒนาระบบสิทธิ์และเอกสารธุรกิจ']],
|
||||
agenda: [['ผลการพัฒนาโมดูลคลังสินค้าและรายงาน', 'ตรวจสอบความครบถ้วนตาม CR01:005–CR01:012'], ['ประเด็นความถูกต้องของข้อมูล', 'พิจารณาการแก้ไข ISS-002'], ['แผนพัฒนาระบบสิทธิ์ ใบสั่งขาย และบาร์โค้ด', 'ยืนยันลำดับงานช่วงต้นเดือนพฤษภาคม']],
|
||||
discussion: [['โมดูลคลังสินค้า', 'พัฒนาแดชบอร์ดสต๊อก ความจุคลัง Lot/Serial/Expiry และรายงานครบตามความต้องการ', ['ThS', 'NoC'], 'เสร็จสิ้น'], ['ความถูกต้องของข้อมูล', 'ถอดคำสั่งที่กระทบความถูกต้องและเพิ่มการตรวจสอบก่อนทำรายการ', ['ThS'], 'เสร็จสิ้น'], ['ระบบสิทธิ์ผู้ใช้', 'มอบหมายพัฒนา Role Guard และรวมศูนย์การตรวจสิทธิ์', ['ThS'], 'ดำเนินการ']],
|
||||
actions: [['บันทึกการแก้ไข ISS-002 ใน Correction Register', ['YaB'], '2026-05-06', 'Scheduled'], ['พัฒนาระบบเข้าสู่ระบบ สิทธิ์ และเอกสารขาย (Task 3.4, 3.5)', ['ThS'], '2026-05-12', 'Scheduled']],
|
||||
next: { date: '2026-05-13', topic: 'ติดตามการพัฒนาระบบสิทธิ์ ใบสั่งขาย และบาร์โค้ด' },
|
||||
next: { date: '2026-05-18', topic: 'ติดตามการพัฒนาระบบสิทธิ์ ใบสั่งขาย และบาร์โค้ด' },
|
||||
},
|
||||
{
|
||||
date: '2026-05-18', phase: 'Project Execution', taskId: '3.6', taskName: 'Production Preparation & Setup', time: '09:00 – 11:00 น.',
|
||||
attachment: 'Progress Status Record 13 พ.ค. 69',
|
||||
attachment: '-',
|
||||
progress: [['Phase 3: Project Execution', 'กำลังดำเนินการ', 70, 'ระบบสิทธิ์ เอกสารขาย และสคริปต์ติดตั้งเสร็จ'], ['รวมความคืบหน้าทั้งโครงการ', 'ตามแผน', 60, 'เริ่มพัฒนาโมดูลบัญชีและการเงิน']],
|
||||
agenda: [['ผลการพัฒนาระบบสิทธิ์และเอกสารขาย', 'ตรวจสอบความครบถ้วนตาม CR01:001–CR01:004 และ CR01:013'], ['ประเด็นความปลอดภัยและ Onboarding ที่พบ', 'พิจารณาการแก้ไข ISS-003 ถึง ISS-009'], ['คำเรียกตำแหน่งจัดเก็บ (Rack / Bin)', 'กำหนดคำเรียกที่ใช้ในหน้าจอให้ตรงกับการปฏิบัติงานจริง'], ['แผนพัฒนาบัญชีและการเงิน', 'ยืนยันลำดับงานช่วงกลางเดือนพฤษภาคม']],
|
||||
discussion: [['ระบบสิทธิ์และ Onboarding', 'แก้ไขข้อบกพร่องการลงทะเบียน Onboarding และการตรวจสิทธิ์ครบทุกรายการที่พบ', ['ThS'], 'เสร็จสิ้น'], ['การเตรียมใช้งานจริง', 'จัดทำ Base URL อัตโนมัติและสคริปต์ติดตั้งฐานข้อมูลในขั้นตอนเดียว', ['ThS'], 'เสร็จสิ้น'], ['คำเรียกตำแหน่งจัดเก็บ', 'ที่ประชุมมีมติให้ใช้คำว่า Bin แทน Rack ทั้งระบบให้ตรงกับคำที่พนักงานคลังใช้จริง เป็นการปรับคำเรียกบนหน้าจอ ไม่เพิ่มหรือลดฟังก์ชันและไม่กระทบ Man-day จึงไม่ถือเป็นคำขอเปลี่ยนแปลง', ['SeV', 'ApS', 'NoC'], 'มติที่ประชุม'], ['บัญชีและการเงิน', 'มอบหมายพัฒนาผังบัญชี GL สมุดรายวัน และเอกสารการเงิน', ['ThS', 'NoC'], 'ดำเนินการ']],
|
||||
@@ -212,7 +212,7 @@ export const MEETINGS = [
|
||||
},
|
||||
{
|
||||
date: '2026-05-28', phase: 'Project Execution', taskId: '3.7', taskName: 'Accounting & Finance Workflows', time: '09:00 – 11:00 น.',
|
||||
attachment: 'Progress Status Record 25 พ.ค. 69',
|
||||
attachment: '-',
|
||||
progress: [['Phase 3: Project Execution', 'กำลังดำเนินการ', 85, 'บัญชีและการเงินเสร็จ เหลือบริการแจ้งเตือนและการทบทวนความปลอดภัย'], ['รวมความคืบหน้าทั้งโครงการ', 'ตามแผน', 72, 'เตรียมเข้าสู่ Baseline การพัฒนา']],
|
||||
agenda: [['ผลการพัฒนาบัญชีและการเงิน', 'ตรวจสอบความครบถ้วนตาม CR01:015–CR01:017'], ['แผนงานก่อนตั้ง Baseline การพัฒนา', 'ยืนยันงานที่ต้องเสร็จภายใน 29 พ.ค. 69']],
|
||||
discussion: [['บัญชีและการเงิน', 'พัฒนาผังบัญชี GL สมุดรายวัน รายงานบัญชี ใบวางบิลและใบเสร็จครบตามความต้องการ', ['ThS', 'NoC'], 'เสร็จสิ้น'], ['ความปลอดภัยและโควตารายการ', 'เพิ่มการควบคุมสิทธิ์แอปพลิเคชันและโควตารายการต่อบริษัท', ['ThS'], 'ดำเนินการ']],
|
||||
@@ -221,7 +221,7 @@ export const MEETINGS = [
|
||||
},
|
||||
{
|
||||
date: '2026-06-04', phase: 'Project Execution', taskId: '3.10', taskName: 'Refactor & Development Baseline', time: '09:00 – 11:00 น.',
|
||||
attachment: 'Progress Status Record 29 พ.ค. 69',
|
||||
attachment: '-',
|
||||
progress: [['Phase 3: Project Execution', 'เสร็จสมบูรณ์', 100, 'พัฒนาครบทุกโมดูลและตั้ง Baseline การพัฒนา (Git a0677d6)'], ['Phase 4: Verification & Validation', 'กำลังดำเนินการ', 10, 'เริ่มตรวจสอบเอกสารและจัดทำคู่มือ'], ['รวมความคืบหน้าทั้งโครงการ', 'ตามแผน', 78, 'เข้าสู่ขั้นตอนตรวจสอบและจัดทำเอกสาร']],
|
||||
agenda: [['สรุปผลการพัฒนา Task 3.1–3.10', 'ยืนยันความครบถ้วนของการพัฒนา'], ['สรุปผลการทบทวนความปลอดภัยและวงจรเอกสาร', 'ยืนยันการปิดข้อบกพร่อง ISS-016 ถึง ISS-027'], ['แผนการตรวจสอบ ทดสอบ และจัดทำเอกสาร', 'กำหนดผู้รับผิดชอบและกรอบเวลา']],
|
||||
discussion: [['Baseline การพัฒนา', 'พัฒนาครบทุกโมดูลตามขอบเขต และลดความซ้ำซ้อนของคลาสเรียบร้อย ตั้ง Baseline เมื่อ 29 พ.ค. 69', ['ThS'], 'เสร็จสิ้น'], ['ความปลอดภัยและการแยกข้อมูล', 'ปิดข้อบกพร่องด้านการเข้าสู่ระบบ ขอบเขตบริษัท และวงจรเอกสารครบทุกรายการที่พบ', ['ThS', 'PaNg'], 'เสร็จสิ้น'], ['ขั้นตอนถัดไป', 'ตรวจสอบ Work Products จัดทำคู่มือ และเตรียมชุดทดสอบระบบ', ['PaNg', 'NoC', 'YaB'], 'ดำเนินการ']],
|
||||
@@ -230,7 +230,7 @@ export const MEETINGS = [
|
||||
},
|
||||
{
|
||||
date: '2026-08-10', phase: 'Stabilization', taskId: '4.6', taskName: 'Post-baseline Corrections', time: '09:00 – 11:00 น.',
|
||||
attachment: 'Progress Status Record 3 ส.ค. 69',
|
||||
attachment: '-',
|
||||
progress: [['Phase 4: Verification & Validation', 'กำลังดำเนินการ', 70, 'ตรวจสอบเอกสารรอบที่ 1–3 และจัดทำคู่มือใกล้เสร็จ'], ['รวมความคืบหน้าทั้งโครงการ', 'ตามแผน', 85, 'เตรียมทดสอบระบบและ UAT']],
|
||||
agenda: [['ผลการแก้ไขหลัง Baseline การพัฒนา', 'ยืนยันการปิดข้อบกพร่อง ISS-028'], ['ความคืบหน้าการตรวจสอบเอกสารและคู่มือ', 'ทบทวนผลการตรวจสอบรอบที่ 1–3'], ['แผนการทดสอบระบบและ UAT', 'ยืนยันสภาพแวดล้อม ผู้ทดสอบ และกรอบเวลา'], ['ข้อมูลสาธิต อัตลักษณ์องค์กร และชุดติดตั้ง (Task 4.7)', 'พิจารณาตามเกณฑ์การเปลี่ยนแปลงและกำหนดผู้รับผิดชอบ']],
|
||||
discussion: [['การแก้ไขหลัง Baseline', 'แก้ไขการเข้าสู่ระบบและค่าตั้งค่าสภาพแวดล้อมที่พบหลังตั้ง Baseline เรียบร้อย', ['ThS'], 'เสร็จสิ้น'], ['การตรวจสอบเอกสาร', 'ตรวจสอบความต้องการ ออกแบบ และการสอบกลับรอบที่ 1–3 พบประเด็นที่ต้องปรับปรุงและแก้ไขแล้ว', ['PaNg', 'NoC'], 'ดำเนินการ'], ['แผนการทดสอบ', 'กำหนดทดสอบระบบบน Internal Testing Server โดย QA และ UAT บนสภาพแวดล้อมจริงโดยตัวแทนลูกค้า ระหว่าง 10–14 ส.ค. 69', ['PaNg', 'SeV'], 'อนุมัติ'], ['ข้อมูลสาธิตและชุดส่งมอบ', 'Project Sponsor ขอให้เตรียมข้อมูลสาธิตสำหรับการตรวจรับ ปรับอัตลักษณ์องค์กร และจัดทำชุดติดตั้ง Docker Compose ตามความต้องการ CR10:002 ที่ประชุมพิจารณาตามเกณฑ์การเปลี่ยนแปลงแล้ว ไม่เพิ่มหรือลดสิ่งส่งมอบและไม่กระทบ Man-day จึงดำเนินการเป็นงานเตรียมการส่งมอบตาม Task 4.7', ['SeV', 'ApS'], 'มติที่ประชุม']],
|
||||
|
||||
@@ -90,7 +90,9 @@ export function hindsightErrors(docs, root) {
|
||||
const where = `Minutes Of Meeting ${m.date}`;
|
||||
const latest = lagged(m.date);
|
||||
if (weekend(m.date)) errors.push(`${where}: held on a weekend`);
|
||||
if (m.next && !(m.next.date > m.date && !weekend(m.next.date))) errors.push(`${where}: next meeting ${m.next.date} is not a later working day`);
|
||||
// The next meeting named in minutes is the next meeting that has minutes.
|
||||
const following = MEETINGS.find((x) => x.date > m.date)?.date ?? null;
|
||||
if ((m.next?.date ?? null) !== following) errors.push(`${where}: next meeting ${m.next?.date ?? 'none'} should be ${following ?? 'none'}`);
|
||||
const text = JSON.stringify(m);
|
||||
const hashes = new Set([...text.matchAll(/(?<![\w/.-])([0-9a-f]{7,40})(?![\w/.-])/g)].map((x) => x[1]));
|
||||
const range = taskById(m.taskId)[9].match(/\bGit (\w+)(?:–(\w+))?/);
|
||||
@@ -103,9 +105,20 @@ export function hindsightErrors(docs, root) {
|
||||
if (corrected.get(id) > latest) errors.push(`${where}: confirms ${id} corrected ${corrected.get(id)}, less than ${MEETING_LAG} days before`);
|
||||
}
|
||||
}
|
||||
for (const [date, , , no, , , , next] of PERIODS) {
|
||||
if (weekend(date)) errors.push(`Progress Status Record ${no} (${date}): dated on a weekend`);
|
||||
if (next && !(next.date > date && !weekend(next.date))) errors.push(`Progress Status Record ${no} (${date}): next meeting ${next.date} is not a later working day`);
|
||||
// Every Progress Status Record is presented at a meeting that has minutes: the one
|
||||
// its "Next Meeting" names, held MEETING_LAG days or more after the period's last commit.
|
||||
// Only the closing record has no next meeting; the closing meeting presents it.
|
||||
const meetingDates = new Set(MEETINGS.map((m) => m.date));
|
||||
PERIODS.forEach(([date, from, to, no, , , , next], i) => {
|
||||
const where = `Progress Status Record ${no} (${date})`;
|
||||
if (weekend(date)) errors.push(`${where}: dated on a weekend`);
|
||||
if (!next) {
|
||||
if (i !== PERIODS.length - 1) errors.push(`${where}: names no next meeting`);
|
||||
return;
|
||||
}
|
||||
if (!(next.date > date && meetingDates.has(next.date))) errors.push(`${where}: next meeting ${next.date} has no minutes`);
|
||||
const last = hasGit ? git(root, ['log', 'main', '-1', '--format=%ad', '--date=short', `--since=${from} 00:00`, `--until=${to} 23:59:59`]) : null;
|
||||
if (last && last > lagged(next.date)) errors.push(`${where}: next meeting ${next.date} is less than ${MEETING_LAG} days after commit on ${last}`);
|
||||
});
|
||||
return [...new Set(errors)];
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user