hanoman 0.9.13 → 0.9.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -8194,7 +8194,7 @@ var init_api = __esm({
8194
8194
  qaReportBacklog: (pid, rid) => `${API}/projects/${encodeURIComponent(pid)}/qa/reports/${encodeURIComponent(rid)}/backlog`,
8195
8195
  qaCasesImport: (pid, rid) => `${API}/projects/${encodeURIComponent(pid)}/qa/reports/${encodeURIComponent(rid)}/cases/import`,
8196
8196
  qaImport: (pid) => `${API}/projects/${encodeURIComponent(pid)}/qa/import`,
8197
- qaTemplate: `${API}/qa/template.md`,
8197
+ qaTemplate: `${API}/qa/template.xlsx`,
8198
8198
  // SPEC-945 · ADR-0150 · papan tim. `members` GLOBAL (bukan per project) — task boleh tanpa
8199
8199
  // project, jadi direktori orang tak bisa digantung pada project.
8200
8200
  members: `${API}/members`,
@@ -17638,9 +17638,9 @@ import {
17638
17638
  import { dirname as dirname6, isAbsolute as isAbsolute6, join as join11, normalize, relative as relative4, sep as sep5 } from "node:path";
17639
17639
  function safeJoin(dir2, rel) {
17640
17640
  if (!rel || isAbsolute6(rel)) throw new SkillError(400, "path harus relatif terhadap skill");
17641
- const norm4 = normalize(rel);
17642
- if (norm4 === ".." || norm4.startsWith(".." + sep5)) throw new SkillError(400, "path keluar dari skill");
17643
- const full = join11(dir2, norm4);
17641
+ const norm5 = normalize(rel);
17642
+ if (norm5 === ".." || norm5.startsWith(".." + sep5)) throw new SkillError(400, "path keluar dari skill");
17643
+ const full = join11(dir2, norm5);
17644
17644
  let probe = full;
17645
17645
  while (!existsSync7(probe) && probe !== dir2) probe = dirname6(probe);
17646
17646
  const realDir = realpathSync5(dir2);
@@ -54953,11 +54953,11 @@ async function listConflicts() {
54953
54953
  detectedAt: r.detectedAt.toISOString()
54954
54954
  }));
54955
54955
  }
54956
- async function resolveConflict(entity, recordId, choice, push) {
54956
+ async function resolveConflict(entity, recordId, choice2, push) {
54957
54957
  if (!isEntity(entity)) return { ok: false, reason: "not-found" };
54958
54958
  const c = await prisma.syncConflict.findUnique({ where: { entity_recordId: { entity, recordId } } });
54959
54959
  if (!c || c.resolvedAt) return { ok: false, reason: "not-found" };
54960
- if (choice === "server") {
54960
+ if (choice2 === "server") {
54961
54961
  await upsertLocal(entity, recordId, c.serverVersion, c.serverData);
54962
54962
  } else {
54963
54963
  let r = (await push([{ entity, id: recordId, baseVersion: c.serverVersion, data: c.localData }])).results?.[0];
@@ -55289,7 +55289,7 @@ function createRelayDispatcher(o) {
55289
55289
  send(i === 0 ? { t: "res", id: id2, status: status2, contentType, part, end: end2 } : { t: "res", id: id2, part, end: end2 });
55290
55290
  });
55291
55291
  };
55292
- const fail3 = (id2, status2, error) => reply(id2, status2, JSON_TYPE, JSON.stringify({ error }));
55292
+ const fail4 = (id2, status2, error) => reply(id2, status2, JSON_TYPE, JSON.stringify({ error }));
55293
55293
  async function handleReq(f) {
55294
55294
  const started2 = now();
55295
55295
  const record2 = (status2, extra = {}) => audit({
@@ -55300,19 +55300,19 @@ function createRelayDispatcher(o) {
55300
55300
  });
55301
55301
  if (inflight3.has(f.id)) return;
55302
55302
  if (inflight3.size >= RELAY_MAX_INFLIGHT) {
55303
- fail3(f.id, 429, "relay-busy");
55303
+ fail4(f.id, 429, "relay-busy");
55304
55304
  record2(429);
55305
55305
  return;
55306
55306
  }
55307
55307
  const payload = f.body === void 0 ? void 0 : JSON.stringify(f.body);
55308
55308
  if (payload !== void 0 && utf8Bytes(payload) > RELAY_REQUEST_BODY_MAX_BYTES) {
55309
- fail3(f.id, 413, "relay-body-too-large");
55309
+ fail4(f.id, 413, "relay-body-too-large");
55310
55310
  record2(413);
55311
55311
  return;
55312
55312
  }
55313
55313
  const url2 = f.query ? `${f.path}?${f.query.replace(/^\?/, "")}` : f.path;
55314
55314
  if (!relayRouteAllowed(f.method, url2) || !relayBodyAllowed(f.method, f.path, f.body)) {
55315
- fail3(f.id, 403, "relay route not allowed");
55315
+ fail4(f.id, 403, "relay route not allowed");
55316
55316
  record2(403);
55317
55317
  return;
55318
55318
  }
@@ -55360,13 +55360,13 @@ function createRelayDispatcher(o) {
55360
55360
  });
55361
55361
  if (entry2.cancelled) return;
55362
55362
  if (utf8Bytes(res.body) > RELAY_RESPONSE_MAX_BYTES) {
55363
- fail3(f.id, 502, "relay-response-too-large");
55363
+ fail4(f.id, 502, "relay-response-too-large");
55364
55364
  return;
55365
55365
  }
55366
55366
  reply(f.id, res.statusCode, String(res.headers["content-type"] ?? "application/octet-stream"), res.body);
55367
55367
  } catch {
55368
55368
  record2(502);
55369
- if (!entry2.cancelled) fail3(f.id, 502, "relay-dispatch-failed");
55369
+ if (!entry2.cancelled) fail4(f.id, 502, "relay-dispatch-failed");
55370
55370
  } finally {
55371
55371
  inflight3.delete(f.id);
55372
55372
  }
@@ -72960,20 +72960,20 @@ async function decide(req, deps = prodDecideDeps) {
72960
72960
  const resolved = resolveChoices(rawChoices, options2);
72961
72961
  const countProblem = options2.length && resolved.choices.length ? checkChoiceCount(resolved.choices.length, bounds) : null;
72962
72962
  const choices = countProblem ? [] : resolved.choices;
72963
- const choice = choices[0] ?? null;
72963
+ const choice2 = choices[0] ?? null;
72964
72964
  const choiceRejected = options2.length > 0 && rawChoices.length > 0 && (resolved.rejected.length > 0 || !!countProblem);
72965
72965
  const missing = verdict.missing.map((m) => m.trim()).filter(Boolean);
72966
72966
  let action = allowed ? verdict.action : "none";
72967
72967
  let actionNote = "";
72968
72968
  let conflict = false;
72969
- if (allowed && choices.length === 1 && choice) {
72970
- const hint = optionActionHint(choice.option);
72969
+ if (allowed && choices.length === 1 && choice2) {
72970
+ const hint = optionActionHint(choice2.option);
72971
72971
  if (hint && action === "none" && hint !== "none") {
72972
72972
  action = hint;
72973
72973
  actionNote = `Tindakan diturunkan dari opsi terpilih ("${hint}") karena lead tak menyebutnya sendiri (SPEC-480).`;
72974
72974
  } else if (hint && action !== "none" && action !== hint) {
72975
72975
  conflict = true;
72976
- actionNote = `KONFLIK: lead memilih opsi "${choice.option}" tetapi menyetel action "${action}" \u2014 tindakan dibatalkan (SPEC-480).`;
72976
+ actionNote = `KONFLIK: lead memilih opsi "${choice2.option}" tetapi menyetel action "${action}" \u2014 tindakan dibatalkan (SPEC-480).`;
72977
72977
  action = "none";
72978
72978
  }
72979
72979
  }
@@ -73006,8 +73006,8 @@ ${notes.join("\n\n")}` : "";
73006
73006
  confidence,
73007
73007
  action,
73008
73008
  weighty,
73009
- choice: choice?.option ?? null,
73010
- choiceIndex: choice?.index ?? null,
73009
+ choice: choice2?.option ?? null,
73010
+ choiceIndex: choice2?.index ?? null,
73011
73011
  choices,
73012
73012
  select: bounds,
73013
73013
  flowId: flow2.id,
@@ -73030,7 +73030,7 @@ ${notes.join("\n\n")}` : "";
73030
73030
  reason: `${clampProse(verdict.reason, LEAD_REASON_MAX)}${tail3}`,
73031
73031
  reply: verdict.reply,
73032
73032
  choices,
73033
- choice,
73033
+ choice: choice2,
73034
73034
  missing
73035
73035
  });
73036
73036
  return row;
@@ -79614,16 +79614,13 @@ async function qaAttachments(app3) {
79614
79614
  // src/routes/qa-transfer.ts
79615
79615
  init_src();
79616
79616
 
79617
- // src/services/qa-cases-import.ts
79617
+ // src/services/qa-workbook.ts
79618
79618
  init_src();
79619
- init_db();
79620
79619
 
79621
- // src/services/qa-transfer.ts
79622
- init_src();
79623
- init_db();
79624
- import { basename as basename10 } from "node:path";
79625
- init_qa_attachment_transfer();
79626
- init_sync_notify();
79620
+ // src/services/ooxml.ts
79621
+ var xmlEsc = (s2) => s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
79622
+ var xmlClean = (s2) => s2.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F￾￿]/g, "");
79623
+ var XML_HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
79627
79624
 
79628
79625
  // src/services/zip.ts
79629
79626
  import { deflateRawSync, inflateRawSync } from "node:zlib";
@@ -79739,7 +79736,319 @@ function readZip(buf, o = {}) {
79739
79736
  return out4;
79740
79737
  }
79741
79738
 
79739
+ // src/services/xlsx.ts
79740
+ var XlsxError = class extends Error {
79741
+ };
79742
+ var MAX_ROWS = 5e3;
79743
+ var MAX_COLS = 60;
79744
+ var MAX_CELL = 32767;
79745
+ var esc2 = xmlEsc;
79746
+ var clean2 = (s2) => xmlClean(s2).slice(0, MAX_CELL);
79747
+ var colLetter = (i) => {
79748
+ let s2 = "";
79749
+ for (let n2 = i + 1; n2 > 0; n2 = Math.floor((n2 - 1) / 26)) s2 = String.fromCharCode(65 + (n2 - 1) % 26) + s2;
79750
+ return s2;
79751
+ };
79752
+ var colIndex = (letters) => [...letters].reduce((n2, c) => n2 * 26 + (c.charCodeAt(0) - 64), 0) - 1;
79753
+ var sheetName = (n2, i) => n2.replace(/[[\]:*?/\\]/g, "_").trim().slice(0, 31) || `Sheet${i + 1}`;
79754
+ var NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
79755
+ var NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
79756
+ var HEAD = XML_HEAD;
79757
+ function writeXlsx(sheets) {
79758
+ const parts = [];
79759
+ const add = (name2, body) => parts.push({ name: name2, data: Buffer.from(HEAD + body, "utf8"), deflate: true });
79760
+ add(
79761
+ "[Content_Types].xml",
79762
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' + sheets.map((_, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join("") + "</Types>"
79763
+ );
79764
+ add(
79765
+ "_rels/.rels",
79766
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>'
79767
+ );
79768
+ add(
79769
+ "xl/workbook.xml",
79770
+ `<workbook xmlns="${NS}" xmlns:r="${NS_R}"><sheets>` + sheets.map((s2, i) => `<sheet name="${esc2(sheetName(s2.name, i))}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join("") + "</sheets></workbook>"
79771
+ );
79772
+ add(
79773
+ "xl/_rels/workbook.xml.rels",
79774
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' + sheets.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join("") + `<Relationship Id="rId${sheets.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`
79775
+ );
79776
+ add(
79777
+ "xl/styles.xml",
79778
+ `<styleSheet xmlns="${NS}"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFF1E6CC"/><bgColor indexed="64"/></patternFill></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="3"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`
79779
+ );
79780
+ sheets.forEach((s2, si) => {
79781
+ const cols = s2.widths?.length ? `<cols>${s2.widths.map((w, i) => `<col min="${i + 1}" max="${i + 1}" width="${w}" customWidth="1"/>`).join("")}</cols>` : "";
79782
+ const data = s2.rows.map((r, ri) => {
79783
+ const cells = r.map((v, ci) => {
79784
+ const t = clean2(v);
79785
+ return t === "" ? "" : `<c r="${colLetter(ci)}${ri + 1}" s="${ri === 0 ? 1 : 2}" t="inlineStr"><is><t xml:space="preserve">${esc2(t)}</t></is></c>`;
79786
+ }).join("");
79787
+ const lines2 = Math.max(1, ...r.map((v, ci) => v.split("\n").reduce((n2, line) => n2 + Math.max(1, Math.ceil(line.length / Math.max(8, (s2.widths?.[ci] ?? 24) - 2))), 0)));
79788
+ const height = Math.min(240, Math.max(24, lines2 * 15 + 8));
79789
+ return `<row r="${ri + 1}" ht="${height}" customHeight="1">${cells}</row>`;
79790
+ }).join("");
79791
+ const validations = s2.dropdowns?.length ? `<dataValidations count="${s2.dropdowns.length}">${s2.dropdowns.map(
79792
+ (d) => `<dataValidation type="list" allowBlank="1" showErrorMessage="1" errorTitle="Nilai tidak valid" error="Pilih nilai dari daftar" sqref="${esc2(d.range)}"><formula1>${esc2(`"${d.values.join(",")}"`)}</formula1></dataValidation>`
79793
+ ).join("")}</dataValidations>` : "";
79794
+ add(
79795
+ `xl/worksheets/sheet${si + 1}.xml`,
79796
+ `<worksheet xmlns="${NS}"><sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><sheetFormatPr defaultRowHeight="15"/>${cols}<sheetData>${data}</sheetData>${validations}</worksheet>`
79797
+ );
79798
+ });
79799
+ return writeZip(parts);
79800
+ }
79801
+ function unesc2(s2) {
79802
+ return s2.replace(/&(#x[0-9a-fA-F]+|#\d+|lt|gt|amp|quot|apos);/g, (_, e) => {
79803
+ if (e === "lt") return "<";
79804
+ if (e === "gt") return ">";
79805
+ if (e === "amp") return "&";
79806
+ if (e === "quot") return '"';
79807
+ if (e === "apos") return "'";
79808
+ const cp = e[1] === "x" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
79809
+ return Number.isFinite(cp) && cp > 0 && cp <= 1114111 ? String.fromCodePoint(cp) : "";
79810
+ });
79811
+ }
79812
+ var attrs = (s2) => {
79813
+ const out4 = {};
79814
+ for (const m of s2.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"/g)) out4[m[1]] = unesc2(m[2]);
79815
+ return out4;
79816
+ };
79817
+ var textOf = (xmlFragment) => [...xmlFragment.replace(/<rPh\b[\s\S]*?<\/rPh>/g, "").matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((m) => unesc2(m[1])).join("");
79818
+ function readXlsx(buf, opts2 = {}) {
79819
+ let files;
79820
+ try {
79821
+ files = readZip(buf, { maxEntries: 100, maxTotalBytes: 40 * 1024 * 1024 });
79822
+ } catch (e) {
79823
+ if (e instanceof ZipError) throw new XlsxError(`bukan berkas XLSX yang valid: ${e.message}`);
79824
+ throw e;
79825
+ }
79826
+ const text2 = (name2) => files.get(name2)?.toString("utf8");
79827
+ let target3 = "xl/worksheets/sheet1.xml";
79828
+ const wb = text2("xl/workbook.xml");
79829
+ const rels = text2("xl/_rels/workbook.xml.rels");
79830
+ if (wb && rels) {
79831
+ const all = [...wb.matchAll(/<sheet\b([^>]*)>/g)].map((m) => attrs(m[1]));
79832
+ const want = opts2.sheet?.trim().toLowerCase();
79833
+ if (want && opts2.required && !all.some((a) => (a.name ?? "").trim().toLowerCase() === want)) throw new XlsxError(`lembar "${opts2.sheet}" wajib ada \u2014 gunakan template QA Excel`);
79834
+ const chosen = (want ? all.find((a) => (a.name ?? "").trim().toLowerCase() === want) : void 0) ?? all[0];
79835
+ const rid = chosen?.["r:id"];
79836
+ for (const m of rels.matchAll(/<Relationship\b([^>]*)>/g)) {
79837
+ const a = attrs(m[1]);
79838
+ if (rid && a.Id === rid && a.Target) {
79839
+ target3 = a.Target.startsWith("/") ? a.Target.slice(1) : `xl/${a.Target}`;
79840
+ break;
79841
+ }
79842
+ }
79843
+ }
79844
+ const sheet = text2(target3);
79845
+ if (sheet === void 0) throw new XlsxError("lembar (worksheet) tak ditemukan di XLSX");
79846
+ const shared = [];
79847
+ const sst = text2("xl/sharedStrings.xml");
79848
+ if (sst) for (const m of sst.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)) shared.push(textOf(m[1]));
79849
+ const rows = [];
79850
+ let rowSeq = 0;
79851
+ for (const rm9 of sheet.matchAll(/<row\b([^>]*?)(?:\/>|>([\s\S]*?)<\/row>)/g)) {
79852
+ rowSeq++;
79853
+ const rn = Number(attrs(rm9[1]).r) || rowSeq;
79854
+ if (rn > MAX_ROWS) throw new XlsxError(`terlalu banyak baris (maks ${MAX_ROWS})`);
79855
+ const row = rows[rn - 1] ?? [];
79856
+ let colSeq = -1;
79857
+ for (const cm of (rm9[2] ?? "").matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
79858
+ const a = attrs(cm[1]);
79859
+ const ref = /^([A-Z]+)\d+$/.exec(a.r ?? "");
79860
+ const ci = ref ? colIndex(ref[1]) : colSeq + 1;
79861
+ colSeq = ci;
79862
+ if (ci >= MAX_COLS) throw new XlsxError(`terlalu banyak kolom (maks ${MAX_COLS})`);
79863
+ const inner = cm[2] ?? "";
79864
+ const v = /<v\b[^>]*>([\s\S]*?)<\/v>/.exec(inner)?.[1];
79865
+ let val = "";
79866
+ if (a.t === "s") val = shared[Number(v)] ?? "";
79867
+ else if (a.t === "inlineStr") val = textOf(inner);
79868
+ else if (a.t === "str") val = v === void 0 ? "" : unesc2(v);
79869
+ else if (a.t === "b") val = v === "1" ? "TRUE" : "FALSE";
79870
+ else if (a.t === "e") val = "";
79871
+ else val = v === void 0 ? "" : unesc2(v);
79872
+ while (row.length < ci) row.push("");
79873
+ row[ci] = val;
79874
+ }
79875
+ while (rows.length < rn - 1) rows.push([]);
79876
+ rows[rn - 1] = row;
79877
+ }
79878
+ return rows;
79879
+ }
79880
+
79881
+ // src/services/qa-workbook.ts
79882
+ var GUIDE2 = [
79883
+ ["Bagian", "Cara mengisi"],
79884
+ ["Mulai di sini", "Isi Ringkasan, lalu Test case dan Temuan. Hapus atau ganti baris contoh. Sheet Panduan tidak diimpor."],
79885
+ ["Ringkasan", "Judul wajib. Kolom lain boleh kosong. Status: draft, submitted, closed. Keputusan: go (siap), no-go (belum siap), conditional (siap dengan catatan); wajib untuk submitted/closed."],
79886
+ ["Test case", "Satu baris = satu pengujian. Status: belum, lulus, gagal, terblokir, dilewati (atau todo, pass, fail, blocked, skipped). Langkah: satu per baris di dalam sel (Alt+Enter)."],
79887
+ ["Temuan", "Satu baris = satu masalah. Severity: blocker (tidak bisa dipakai), critical (kritis), major (fitur terganggu), minor (gangguan kecil), trivial (tampilan). Prioritas: P0 segera, P1 tinggi, P2 normal, P3 rendah."],
79888
+ ["Lampiran", "Satu baris = satu berkas. Pemilik: Laporan, TC-01, atau F-01 sesuai Kode. Berkas: nama screenshot/log yang dipilih bersama Excel, atau attachments/nama.png dalam ZIP. Maks 10 MB/berkas, 30 berkas dan 100 MB/laporan."],
79889
+ ["Impor dengan lampiran", "Pilih Excel dan berkas lampiran bersama-sama di dashboard, atau ZIP berisi report.xlsx dan folder attachments/. Gambar yang hanya ditempel di sel Excel tidak diimpor; sertakan berkas gambarnya."],
79890
+ ["Ref", "Biarkan Ref laporan/test case/temuan hasil ekspor untuk memperbarui laporan asal. Kosongkan untuk entri baru. Kode harus unik; Test case pada Temuan merujuk Kode TC yang ada."],
79891
+ ["Lingkungan", "Satu per baris: os=Windows 11, browser=Chrome, device=Laptop, url=https://\u2026 ."]
79892
+ ];
79893
+ function writeQaWorkbook(d, paths2 = {}) {
79894
+ const owner = (type, id2) => type === "report" ? "Laporan" : type === "case" ? d.cases.find((c) => c.id === id2)?.code ?? "" : d.findings.find((f) => f.id === id2)?.code ?? "";
79895
+ return writeXlsx([
79896
+ { name: "Panduan", rows: GUIDE2, widths: [24, 100] },
79897
+ { name: "Ringkasan", widths: [24, 80], dropdowns: [{ range: "B7", values: QA_REPORT_STATUSES }, { range: "B8", values: QA_VERDICTS }], rows: [
79898
+ ["Kolom", "Nilai"],
79899
+ ["Judul", d.title],
79900
+ ["Build / versi", d.buildVersion],
79901
+ ["Penguji", d.tester],
79902
+ ["Cakupan", d.scope],
79903
+ ["Ringkasan", d.summary],
79904
+ ["Status", d.status],
79905
+ ["Keputusan", d.verdict ?? ""],
79906
+ ["Lingkungan", Object.entries(d.environment).map(([k, v]) => `${k}=${v}`).join("\n")],
79907
+ ["Ref", d.id]
79908
+ ] },
79909
+ { name: "Test case", dropdowns: [{ range: "F2:F5000", values: ["belum", "lulus", "gagal", "terblokir", "dilewati", "todo", "pass", "fail", "blocked", "skipped"] }], widths: [12, 34, 40, 32, 32, 14, 28], rows: [
79910
+ ["Kode", "Judul", "Langkah", "Diharapkan", "Aktual", "Status", "Ref"],
79911
+ ...d.cases.map((c) => [c.code, c.title, c.steps, c.expected, c.actual, c.status, c.id])
79912
+ ] },
79913
+ { name: "Temuan", dropdowns: [{ range: "C2:C5000", values: QA_SEVERITIES }, { range: "D2:D5000", values: QA_PRIORITIES }, { range: "E2:E5000", values: QA_FINDING_STATUSES }], widths: [12, 34, 14, 12, 14, 20, 14, 40, 32, 32, 14, 16, 28, 28], rows: [
79914
+ ["Kode", "Judul", "Severity", "Prioritas", "Status", "Area", "Test case", "Langkah", "Expected", "Actual", "Backlog", "Stage backlog", "Lampiran", "Ref"],
79915
+ ...d.findings.map((f) => [f.code, f.title, f.severity, f.priority, f.status, f.area, f.caseCode ?? "", f.steps.join("\n"), f.expected, f.actual, f.backlogId ?? "", f.spec?.stage ?? "", d.attachments.filter((a) => a.ownerType === "finding" && a.ownerId === f.id).map((a) => a.filename).join(", "), f.id])
79916
+ ] },
79917
+ { name: "Lampiran", widths: [20, 70], rows: [
79918
+ ["Pemilik", "Berkas"],
79919
+ ...d.attachments.map((a) => [owner(a.ownerType, a.ownerId), paths2[a.id] ?? a.filename])
79920
+ ] }
79921
+ ]);
79922
+ }
79923
+ function qaTemplateWorkbook() {
79924
+ const p3 = parseQaMarkdown(qaTemplateMarkdown());
79925
+ const at = (/* @__PURE__ */ new Date(0)).toISOString();
79926
+ return writeQaWorkbook({
79927
+ ...p3,
79928
+ id: "",
79929
+ projectId: "",
79930
+ code: "QA-001",
79931
+ createdAt: at,
79932
+ updatedAt: at,
79933
+ cases: p3.cases.map((c, i) => ({ ...c, id: "", reportId: "", order: i + 1, createdAt: at, updatedAt: at })),
79934
+ findings: p3.findings.map((f) => ({ ...f, id: "", reportId: "", backlogId: null, spec: null, createdAt: at, updatedAt: at })),
79935
+ attachments: [],
79936
+ stats: qaStats(p3.cases, p3.findings)
79937
+ });
79938
+ }
79939
+ var norm3 = (s2) => s2.trim().toLowerCase();
79940
+ function fail3(sheet, row, message) {
79941
+ throw new XlsxError(`${sheet}, baris ${row}: ${message}`);
79942
+ }
79943
+ var choice = (s2, allowed, sheet, row) => {
79944
+ const v = allowed.find((a) => norm3(a) === norm3(s2));
79945
+ return v ?? fail3(sheet, row, `nilai "${s2}" tidak dikenal; pilih ${allowed.join(", ")}`);
79946
+ };
79947
+ function parseQaWorkbook(buf) {
79948
+ const read2 = (sheet) => readXlsx(buf, { sheet, required: true });
79949
+ const summary = read2("Ringkasan");
79950
+ if (norm3(summary[0]?.[0] ?? "") !== "kolom" || norm3(summary[0]?.[1] ?? "") !== "nilai") fail3("Ringkasan", 1, "butuh kolom Kolom dan Nilai");
79951
+ const fields = new Map(summary.slice(1).map((r) => [norm3(r[0] ?? ""), r[1] ?? ""]));
79952
+ const val = (k) => fields.get(norm3(k)) ?? "";
79953
+ const rowOf = (k) => Math.max(1, summary.findIndex((r) => norm3(r[0] ?? "") === norm3(k)) + 1);
79954
+ if (!val("Judul").trim()) fail3("Ringkasan", rowOf("Judul"), "Judul wajib diisi");
79955
+ const environment = /* @__PURE__ */ Object.create(null);
79956
+ for (const line of val("Lingkungan").split("\n").filter((l) => l.trim())) {
79957
+ const match = /^\s*([^=:]+)[=:]\s*(.*)$/.exec(line);
79958
+ if (!match) fail3("Ringkasan", rowOf("Lingkungan"), "Lingkungan: gunakan kunci=nilai, satu per baris");
79959
+ environment[match[1].trim()] = match[2];
79960
+ }
79961
+ const base3 = zCreateQaReport.safeParse({
79962
+ title: val("Judul"),
79963
+ buildVersion: val("Build / versi"),
79964
+ tester: val("Penguji"),
79965
+ scope: val("Cakupan"),
79966
+ summary: val("Ringkasan"),
79967
+ environment,
79968
+ verdict: val("Keputusan") ? choice(val("Keputusan"), QA_VERDICTS, "Ringkasan", rowOf("Keputusan")) : null
79969
+ });
79970
+ if (!base3.success) fail3("Ringkasan", 1, base3.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`).join("; "));
79971
+ const parsed = {
79972
+ ...base3.data,
79973
+ reportId: val("Ref") || null,
79974
+ status: choice(val("Status") || "draft", QA_REPORT_STATUSES, "Ringkasan", rowOf("Status")),
79975
+ cases: [],
79976
+ findings: [],
79977
+ attachments: []
79978
+ };
79979
+ let cases;
79980
+ try {
79981
+ cases = parseCaseRows(read2("Test case"));
79982
+ } catch (e) {
79983
+ if (e instanceof QaTableError) throw new XlsxError(`Test case, ${e.message}`);
79984
+ throw e;
79985
+ }
79986
+ const codes = /* @__PURE__ */ new Set();
79987
+ const refs2 = /* @__PURE__ */ new Set();
79988
+ for (const [i, c] of cases.entries()) {
79989
+ const code = c.code || `TC-${String(i + 1).padStart(2, "0")}`;
79990
+ if (!/^TC-\d+$/.test(code) || codes.has(code)) fail3("Test case", c.row, "Kode harus unik dan berformat TC-01");
79991
+ if (c.ref && refs2.has(c.ref)) fail3("Test case", c.row, "Ref berulang");
79992
+ codes.add(code);
79993
+ if (c.ref) refs2.add(c.ref);
79994
+ const result = zCreateQaCase.safeParse({ title: c.title, steps: c.steps ?? "", expected: c.expected ?? "", actual: c.actual ?? "", status: c.status ?? "todo" });
79995
+ if (!result.success) fail3("Test case", c.row, result.error.issues.map((e) => `${e.path}: ${e.message}`).join("; "));
79996
+ parsed.cases.push({ ...result.data, id: c.ref, code, attachments: [] });
79997
+ }
79998
+ const rows = read2("Temuan");
79999
+ const headers = rows[0]?.map(norm3) ?? [];
80000
+ for (const h2 of ["judul", "severity", "prioritas", "langkah", "expected", "actual"]) if (!headers.includes(h2)) fail3("Temuan", 1, `kolom ${h2} wajib ada`);
80001
+ const findingCodes = /* @__PURE__ */ new Set();
80002
+ const findingRefs = /* @__PURE__ */ new Set();
80003
+ for (let i = 1; i < rows.length; i++) {
80004
+ const r = rows[i];
80005
+ if (r.every((v) => !v.trim())) continue;
80006
+ const get = (k) => r[headers.indexOf(norm3(k))] ?? "";
80007
+ const code = get("Kode") || `F-${String(parsed.findings.length + 1).padStart(2, "0")}`;
80008
+ if (!/^F-\d+$/.test(code) || findingCodes.has(code)) fail3("Temuan", i + 1, "Kode harus unik dan berformat F-01");
80009
+ findingCodes.add(code);
80010
+ const id2 = get("Ref") || null;
80011
+ if (id2 && findingRefs.has(id2)) fail3("Temuan", i + 1, "Ref berulang");
80012
+ if (id2) findingRefs.add(id2);
80013
+ const caseCode = get("Test case").trim() || null;
80014
+ if (caseCode && !codes.has(caseCode)) fail3("Temuan", i + 1, `Test case ${caseCode} tidak ditemukan`);
80015
+ const status2 = choice(get("Status") || "open", QA_FINDING_STATUSES, "Temuan", i + 1);
80016
+ const result = zCreateQaFinding.safeParse({
80017
+ title: get("Judul"),
80018
+ severity: choice(get("Severity") || "major", QA_SEVERITIES, "Temuan", i + 1),
80019
+ priority: choice(get("Prioritas") || "P2", QA_PRIORITIES, "Temuan", i + 1),
80020
+ area: get("Area"),
80021
+ steps: get("Langkah").split("\n").map((s2) => s2.trim().replace(/^\d+[.)]\s+/, "")).filter(Boolean),
80022
+ expected: get("Expected"),
80023
+ actual: get("Actual"),
80024
+ status: status2 === "sent" ? "open" : status2
80025
+ });
80026
+ if (!result.success) fail3("Temuan", i + 1, result.error.issues.map((e) => `${e.path}: ${e.message}`).join("; "));
80027
+ parsed.findings.push({ ...result.data, status: status2, id: id2, code, caseId: null, caseCode, attachments: [] });
80028
+ }
80029
+ const attachments = read2("Lampiran");
80030
+ if (norm3(attachments[0]?.[0] ?? "") !== "pemilik" || norm3(attachments[0]?.[1] ?? "") !== "berkas") fail3("Lampiran", 1, "butuh kolom Pemilik dan Berkas");
80031
+ for (let i = 1; i < attachments.length; i++) {
80032
+ const [owner = "", path = ""] = attachments[i];
80033
+ if (!owner.trim() && !path.trim()) continue;
80034
+ if (!path.trim() || path.includes("\\") || path.startsWith("/") || path.split("/").some((p3) => p3 === ".." || p3 === "." || !p3)) fail3("Lampiran", i + 1, "Berkas harus nama berkas atau path relatif yang aman");
80035
+ const target3 = norm3(owner) === "laporan" ? parsed.attachments : parsed.cases.find((c) => c.code === owner.trim())?.attachments ?? parsed.findings.find((f) => f.code === owner.trim())?.attachments;
80036
+ if (!target3) fail3("Lampiran", i + 1, `Pemilik "${owner}" tidak ditemukan; gunakan Laporan atau Kode TC/F yang ada`);
80037
+ target3.push(path.trim());
80038
+ }
80039
+ return parsed;
80040
+ }
80041
+
80042
+ // src/services/qa-cases-import.ts
80043
+ init_src();
80044
+ init_db();
80045
+
79742
80046
  // src/services/qa-transfer.ts
80047
+ init_src();
80048
+ init_db();
80049
+ import { basename as basename10 } from "node:path";
80050
+ init_qa_attachment_transfer();
80051
+ init_sync_notify();
79743
80052
  var QaImportError = class extends Error {
79744
80053
  constructor(status2, message) {
79745
80054
  super(message);
@@ -79779,23 +80088,31 @@ async function exportReport(projectId, reportId) {
79779
80088
  present.push(a);
79780
80089
  }
79781
80090
  const markdown = renderQaMarkdown({ ...full, attachments: present }, paths2);
79782
- const zip = writeZip([{ name: "report.md", data: Buffer.from(markdown, "utf8"), deflate: true }, ...files]);
80091
+ const zip = writeZip([{ name: "report.xlsx", data: writeQaWorkbook({ ...full, attachments: present }, paths2), deflate: true }, { name: "report.md", data: Buffer.from(markdown, "utf8"), deflate: true }, ...files]);
79783
80092
  return { code: full.code, markdown, zip };
79784
80093
  }
79785
- async function importReport(projectId, file) {
80094
+ async function importReport(projectId, file, companions = []) {
79786
80095
  if (!await prisma.project.findUnique({ where: { id: projectId }, select: { id: true } }))
79787
80096
  throw new QaImportError(404, "project tak ditemukan");
79788
- let markdown;
80097
+ let markdown = "";
80098
+ let workbook;
79789
80099
  let assets = /* @__PURE__ */ new Map();
79790
80100
  let dir2 = "";
79791
80101
  const isZip2 = file.buf.length >= 4 && file.buf.readUInt32LE(0) === 67324752;
79792
- if (isZip2) {
80102
+ if (/\.xlsx$/i.test(file.name)) {
80103
+ workbook = file.buf;
80104
+ } else if (isZip2) {
79793
80105
  try {
79794
80106
  const entries3 = readZip(file.buf);
80107
+ const xlsxNames = [...entries3.keys()].filter((k) => /\.xlsx$/i.test(k) && !/(^|\/)attachments\//.test(k));
80108
+ if (xlsxNames.length > 1) throw new QaImportError(400, "ZIP harus memuat satu laporan Excel saja");
80109
+ const xlsxName = xlsxNames[0];
79795
80110
  const mdName = [...entries3.keys()].find((k) => /(^|\/)report\.md$/i.test(k)) ?? [...entries3.keys()].find((k) => /\.md$/i.test(k) && !/(^|\/)attachments\//.test(k));
79796
- if (!mdName) throw new QaImportError(400, "ZIP tak memuat report.md");
79797
- markdown = entries3.get(mdName).toString("utf8");
79798
- dir2 = mdName.includes("/") ? mdName.slice(0, mdName.lastIndexOf("/") + 1) : "";
80111
+ if (!mdName && !xlsxName) throw new QaImportError(400, "ZIP tak memuat report.xlsx atau report.md");
80112
+ const reportName = xlsxName ?? mdName;
80113
+ if (xlsxName) workbook = entries3.get(xlsxName);
80114
+ else markdown = entries3.get(mdName).toString("utf8");
80115
+ dir2 = reportName.includes("/") ? reportName.slice(0, reportName.lastIndexOf("/") + 1) : "";
79799
80116
  assets = entries3;
79800
80117
  } catch (e) {
79801
80118
  if (e instanceof ZipError) throw new QaImportError(400, `ZIP tak valid: ${e.message}`);
@@ -79805,10 +80122,14 @@ async function importReport(projectId, file) {
79805
80122
  markdown = file.buf.toString("utf8");
79806
80123
  }
79807
80124
  let parsed;
80125
+ for (const c of companions) {
80126
+ if (assets.has(c.name)) throw new QaImportError(400, `nama lampiran berulang: ${c.name}`);
80127
+ assets.set(c.name, c.buf);
80128
+ }
79808
80129
  try {
79809
- parsed = parseQaMarkdown(markdown);
80130
+ parsed = workbook ? parseQaWorkbook(workbook) : parseQaMarkdown(markdown);
79810
80131
  } catch (e) {
79811
- if (e instanceof QaMarkdownError) throw new QaImportError(400, e.message);
80132
+ if (e instanceof QaMarkdownError || e instanceof XlsxError) throw new QaImportError(400, e.message);
79812
80133
  throw e;
79813
80134
  }
79814
80135
  if ((parsed.status === "submitted" || parsed.status === "closed") && !parsed.verdict)
@@ -79872,13 +80193,13 @@ async function importReport(projectId, file) {
79872
80193
  for (const o of owners) {
79873
80194
  const uploads = [];
79874
80195
  for (const path of o.paths) {
79875
- const buf = assets.get(dir2 + path);
79876
80196
  const name2 = originalName(path);
80197
+ if (have.some((h2) => h2.ownerType === o.ownerType && h2.ownerId === o.ownerId && h2.filename === name2)) continue;
80198
+ const buf = assets.get(dir2 + path) ?? assets.get(path);
79877
80199
  if (!buf) {
79878
80200
  rejected.push({ filename: name2, reason: "missing" });
79879
80201
  continue;
79880
80202
  }
79881
- if (have.some((h2) => h2.ownerType === o.ownerType && h2.ownerId === o.ownerId && h2.filename === name2)) continue;
79882
80203
  const mime = MIME_BY_EXT[name2.split(".").pop()?.toLowerCase() ?? ""];
79883
80204
  if (!mime) {
79884
80205
  rejected.push({ filename: name2, reason: "type" });
@@ -79891,6 +80212,8 @@ async function importReport(projectId, file) {
79891
80212
  saved += r.saved.length;
79892
80213
  rejected.push(...r.rejected);
79893
80214
  }
80215
+ const referenced = new Set(owners.flatMap((o) => o.paths));
80216
+ for (const c of companions) if (!referenced.has(c.name)) rejected.push({ filename: c.name, reason: "Tidak tercantum pada sheet Lampiran atau tautan lampiran laporan" });
79894
80217
  return {
79895
80218
  reportId: out4.id,
79896
80219
  created: !existing,
@@ -79902,149 +80225,6 @@ async function importReport(projectId, file) {
79902
80225
 
79903
80226
  // src/services/qa-cases-import.ts
79904
80227
  init_sync_notify();
79905
-
79906
- // src/services/ooxml.ts
79907
- var xmlEsc = (s2) => s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
79908
- var xmlClean = (s2) => s2.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F￾￿]/g, "");
79909
- var XML_HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
79910
-
79911
- // src/services/xlsx.ts
79912
- var XlsxError = class extends Error {
79913
- };
79914
- var MAX_ROWS = 5e3;
79915
- var MAX_COLS = 60;
79916
- var MAX_CELL = 32767;
79917
- var esc2 = xmlEsc;
79918
- var clean2 = (s2) => xmlClean(s2).slice(0, MAX_CELL);
79919
- var colLetter = (i) => {
79920
- let s2 = "";
79921
- for (let n2 = i + 1; n2 > 0; n2 = Math.floor((n2 - 1) / 26)) s2 = String.fromCharCode(65 + (n2 - 1) % 26) + s2;
79922
- return s2;
79923
- };
79924
- var colIndex = (letters) => [...letters].reduce((n2, c) => n2 * 26 + (c.charCodeAt(0) - 64), 0) - 1;
79925
- var sheetName = (n2, i) => n2.replace(/[[\]:*?/\\]/g, "_").trim().slice(0, 31) || `Sheet${i + 1}`;
79926
- var NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
79927
- var NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
79928
- var HEAD = XML_HEAD;
79929
- function writeXlsx(sheets) {
79930
- const parts = [];
79931
- const add = (name2, body) => parts.push({ name: name2, data: Buffer.from(HEAD + body, "utf8"), deflate: true });
79932
- add(
79933
- "[Content_Types].xml",
79934
- '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' + sheets.map((_, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join("") + "</Types>"
79935
- );
79936
- add(
79937
- "_rels/.rels",
79938
- '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>'
79939
- );
79940
- add(
79941
- "xl/workbook.xml",
79942
- `<workbook xmlns="${NS}" xmlns:r="${NS_R}"><sheets>` + sheets.map((s2, i) => `<sheet name="${esc2(sheetName(s2.name, i))}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join("") + "</sheets></workbook>"
79943
- );
79944
- add(
79945
- "xl/_rels/workbook.xml.rels",
79946
- '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' + sheets.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join("") + `<Relationship Id="rId${sheets.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`
79947
- );
79948
- add(
79949
- "xl/styles.xml",
79950
- `<styleSheet xmlns="${NS}"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFF1E6CC"/><bgColor indexed="64"/></patternFill></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="3"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`
79951
- );
79952
- sheets.forEach((s2, si) => {
79953
- const cols = s2.widths?.length ? `<cols>${s2.widths.map((w, i) => `<col min="${i + 1}" max="${i + 1}" width="${w}" customWidth="1"/>`).join("")}</cols>` : "";
79954
- const data = s2.rows.map((r, ri) => {
79955
- const cells = r.map((v, ci) => {
79956
- const t = clean2(v);
79957
- return t === "" ? "" : `<c r="${colLetter(ci)}${ri + 1}" s="${ri === 0 ? 1 : 2}" t="inlineStr"><is><t xml:space="preserve">${esc2(t)}</t></is></c>`;
79958
- }).join("");
79959
- return `<row r="${ri + 1}">${cells}</row>`;
79960
- }).join("");
79961
- add(
79962
- `xl/worksheets/sheet${si + 1}.xml`,
79963
- `<worksheet xmlns="${NS}"><sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><sheetFormatPr defaultRowHeight="15"/>${cols}<sheetData>${data}</sheetData></worksheet>`
79964
- );
79965
- });
79966
- return writeZip(parts);
79967
- }
79968
- function unesc2(s2) {
79969
- return s2.replace(/&(#x[0-9a-fA-F]+|#\d+|lt|gt|amp|quot|apos);/g, (_, e) => {
79970
- if (e === "lt") return "<";
79971
- if (e === "gt") return ">";
79972
- if (e === "amp") return "&";
79973
- if (e === "quot") return '"';
79974
- if (e === "apos") return "'";
79975
- const cp = e[1] === "x" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
79976
- return Number.isFinite(cp) && cp > 0 && cp <= 1114111 ? String.fromCodePoint(cp) : "";
79977
- });
79978
- }
79979
- var attrs = (s2) => {
79980
- const out4 = {};
79981
- for (const m of s2.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"/g)) out4[m[1]] = unesc2(m[2]);
79982
- return out4;
79983
- };
79984
- var textOf = (xmlFragment) => [...xmlFragment.replace(/<rPh\b[\s\S]*?<\/rPh>/g, "").matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((m) => unesc2(m[1])).join("");
79985
- function readXlsx(buf, opts2 = {}) {
79986
- let files;
79987
- try {
79988
- files = readZip(buf, { maxEntries: 100, maxTotalBytes: 40 * 1024 * 1024 });
79989
- } catch (e) {
79990
- if (e instanceof ZipError) throw new XlsxError(`bukan berkas XLSX yang valid: ${e.message}`);
79991
- throw e;
79992
- }
79993
- const text2 = (name2) => files.get(name2)?.toString("utf8");
79994
- let target3 = "xl/worksheets/sheet1.xml";
79995
- const wb = text2("xl/workbook.xml");
79996
- const rels = text2("xl/_rels/workbook.xml.rels");
79997
- if (wb && rels) {
79998
- const all = [...wb.matchAll(/<sheet\b([^>]*)>/g)].map((m) => attrs(m[1]));
79999
- const want = opts2.sheet?.trim().toLowerCase();
80000
- const chosen = (want ? all.find((a) => (a.name ?? "").trim().toLowerCase() === want) : void 0) ?? all[0];
80001
- const rid = chosen?.["r:id"];
80002
- for (const m of rels.matchAll(/<Relationship\b([^>]*)>/g)) {
80003
- const a = attrs(m[1]);
80004
- if (rid && a.Id === rid && a.Target) {
80005
- target3 = a.Target.startsWith("/") ? a.Target.slice(1) : `xl/${a.Target}`;
80006
- break;
80007
- }
80008
- }
80009
- }
80010
- const sheet = text2(target3);
80011
- if (sheet === void 0) throw new XlsxError("lembar (worksheet) tak ditemukan di XLSX");
80012
- const shared = [];
80013
- const sst = text2("xl/sharedStrings.xml");
80014
- if (sst) for (const m of sst.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)) shared.push(textOf(m[1]));
80015
- const rows = [];
80016
- let rowSeq = 0;
80017
- for (const rm9 of sheet.matchAll(/<row\b([^>]*?)(?:\/>|>([\s\S]*?)<\/row>)/g)) {
80018
- rowSeq++;
80019
- const rn = Number(attrs(rm9[1]).r) || rowSeq;
80020
- if (rn > MAX_ROWS) throw new XlsxError(`terlalu banyak baris (maks ${MAX_ROWS})`);
80021
- const row = rows[rn - 1] ?? [];
80022
- let colSeq = -1;
80023
- for (const cm of (rm9[2] ?? "").matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
80024
- const a = attrs(cm[1]);
80025
- const ref = /^([A-Z]+)\d+$/.exec(a.r ?? "");
80026
- const ci = ref ? colIndex(ref[1]) : colSeq + 1;
80027
- colSeq = ci;
80028
- if (ci >= MAX_COLS) throw new XlsxError(`terlalu banyak kolom (maks ${MAX_COLS})`);
80029
- const inner = cm[2] ?? "";
80030
- const v = /<v\b[^>]*>([\s\S]*?)<\/v>/.exec(inner)?.[1];
80031
- let val = "";
80032
- if (a.t === "s") val = shared[Number(v)] ?? "";
80033
- else if (a.t === "inlineStr") val = textOf(inner);
80034
- else if (a.t === "str") val = v === void 0 ? "" : unesc2(v);
80035
- else if (a.t === "b") val = v === "1" ? "TRUE" : "FALSE";
80036
- else if (a.t === "e") val = "";
80037
- else val = v === void 0 ? "" : unesc2(v);
80038
- while (row.length < ci) row.push("");
80039
- row[ci] = val;
80040
- }
80041
- while (rows.length < rn - 1) rows.push([]);
80042
- rows[rn - 1] = row;
80043
- }
80044
- return rows;
80045
- }
80046
-
80047
- // src/services/qa-cases-import.ts
80048
80228
  var isZip = (b) => b.length >= 4 && b.readUInt32LE(0) === 67324752;
80049
80229
  async function importCases(projectId, reportId, file) {
80050
80230
  const report = await prisma.qaReport.findFirst({ where: { id: reportId, projectId }, select: { id: true, status: true } });
@@ -80400,44 +80580,6 @@ var OFFICE_MIME = {
80400
80580
  docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
80401
80581
  xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
80402
80582
  };
80403
- var sheetSummary = (d) => {
80404
- const s2 = d.stats;
80405
- return [
80406
- ["Kolom", "Nilai"],
80407
- ["Kode", d.code],
80408
- ["Judul", d.title],
80409
- ["Build / versi", d.buildVersion],
80410
- ["Penguji", d.tester],
80411
- ["Status", d.status],
80412
- ["Keputusan", d.verdict ?? "belum diputuskan"],
80413
- ["Cakupan", d.scope],
80414
- ["Lingkungan", Object.entries(d.environment).map(([k, v]) => `${k}: ${v}`).join("\n")],
80415
- ["Ringkasan", d.summary],
80416
- ["Test case", `${s2.cases.total} \xB7 pass ${s2.cases.pass} \xB7 fail ${s2.cases.fail} \xB7 blocked ${s2.cases.blocked} \xB7 skipped ${s2.cases.skipped} \xB7 todo ${s2.cases.todo} \xB7 pass rate ${s2.passRate === null ? "-" : `${Math.round(s2.passRate * 100)}%`}`],
80417
- ["Temuan", `${s2.findings.total} \xB7 blocker ${s2.findings.blocker} \xB7 critical ${s2.findings.critical} \xB7 major ${s2.findings.major} \xB7 minor ${s2.findings.minor} \xB7 trivial ${s2.findings.trivial} \xB7 open ${s2.findings.open}`],
80418
- ["Dibuat", d.createdAt],
80419
- ["Diubah", d.updatedAt]
80420
- ];
80421
- };
80422
- var sheetFindings = (d) => [
80423
- ["Kode", "Judul", "Severity", "Prioritas", "Status", "Area", "Test case", "Langkah", "Expected", "Actual", "Backlog", "Stage backlog", "Lampiran", "Ref"],
80424
- ...d.findings.map((f) => [
80425
- f.code,
80426
- f.title,
80427
- f.severity,
80428
- f.priority,
80429
- f.status,
80430
- f.area,
80431
- f.caseCode ?? "",
80432
- f.steps.map((t, i) => `${i + 1}. ${t}`).join("\n"),
80433
- f.expected,
80434
- f.actual,
80435
- f.backlogId ?? "",
80436
- f.spec?.stage ?? "",
80437
- d.attachments.filter((a) => a.ownerType === "finding" && a.ownerId === f.id).map((a) => a.filename).join(", "),
80438
- f.id
80439
- ])
80440
- ];
80441
80583
  async function renderExport(projectId, reportId, format) {
80442
80584
  const d = await reportDetail(projectId, reportId);
80443
80585
  if (!d) return null;
@@ -80451,11 +80593,7 @@ async function renderExport(projectId, reportId, format) {
80451
80593
  return { filename: `${d.code}.pdf`, mime: "application/pdf", body: await writeQaPdf(d, images) };
80452
80594
  }
80453
80595
  case "xlsx":
80454
- return { filename: `${d.code}.xlsx`, mime: OFFICE_MIME.xlsx, body: writeXlsx([
80455
- { name: "Ringkasan", rows: sheetSummary(d), widths: [18, 80] },
80456
- { name: "Test case", rows: casesToRows(d.cases), widths: [10, 34, 40, 32, 32, 11, 28] },
80457
- { name: "Temuan", rows: sheetFindings(d), widths: [8, 34, 11, 10, 9, 14, 10, 40, 30, 30, 11, 14, 28, 28] }
80458
- ]) };
80596
+ return { filename: `${d.code}.xlsx`, mime: OFFICE_MIME.xlsx, body: writeQaWorkbook(d) };
80459
80597
  case "csv":
80460
80598
  return { filename: `${d.code}-testcase.csv`, mime: "text/csv; charset=utf-8", body: csvEncode(casesToRows(d.cases)) };
80461
80599
  }
@@ -80464,6 +80602,11 @@ async function renderExport(projectId, reportId, format) {
80464
80602
  // src/routes/qa-transfer.ts
80465
80603
  var IMPORT_MAX = QA_ATTACHMENT_LIMITS.reportBytes + 5 * 1024 * 1024;
80466
80604
  async function qaTransfer(app3) {
80605
+ app3.get("/qa/template.xlsx", async (_req, reply) => {
80606
+ reply.header("content-type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
80607
+ reply.header("content-disposition", 'attachment; filename="qa-template.xlsx"');
80608
+ return reply.send(qaTemplateWorkbook());
80609
+ });
80467
80610
  app3.get("/qa/template.md", async (_req, reply) => {
80468
80611
  reply.header("content-type", "text/markdown; charset=utf-8");
80469
80612
  reply.header("content-disposition", 'attachment; filename="qa-template.md"');
@@ -80496,19 +80639,26 @@ async function qaTransfer(app3) {
80496
80639
  const { pid } = req.params;
80497
80640
  if (!req.isMultipart?.()) return reply.code(400).send({ error: "butuh multipart/form-data" });
80498
80641
  let file = null;
80642
+ const companions = [];
80643
+ let totalBytes = 0;
80499
80644
  try {
80500
- for await (const part of req.parts({ limits: { fileSize: IMPORT_MAX, files: 1 } })) {
80501
- if (part.type !== "file" || file) continue;
80645
+ for await (const part of req.parts({ limits: { fileSize: IMPORT_MAX, files: 31 } })) {
80646
+ if (part.type !== "file") continue;
80502
80647
  const buf = await part.toBuffer();
80503
80648
  if (part.file?.truncated) return reply.code(413).send({ error: "berkas terlalu besar" });
80504
- file = { name: String(part.filename ?? "import"), buf };
80649
+ totalBytes += buf.length;
80650
+ if (totalBytes > IMPORT_MAX) return reply.code(413).send({ error: "total unggahan terlalu besar (maks 105 MB)" });
80651
+ const upload = { name: String(part.filename ?? "import"), buf };
80652
+ if (part.fieldname === "attachments") companions.push(upload);
80653
+ else if (file) return reply.code(400).send({ error: "pilih satu laporan Excel, ZIP, atau Markdown" });
80654
+ else file = upload;
80505
80655
  }
80506
80656
  } catch {
80507
80657
  return reply.code(400).send({ error: "unggahan tak valid" });
80508
80658
  }
80509
80659
  if (!file || file.buf.length === 0) return reply.code(400).send({ error: "tak ada berkas" });
80510
80660
  try {
80511
- const r = await importReport(pid, file);
80661
+ const r = await importReport(pid, file, companions);
80512
80662
  return reply.code(r.created ? 201 : 200).send(r);
80513
80663
  } catch (e) {
80514
80664
  if (e instanceof QaImportError) return reply.code(e.status).send({ error: e.message });
@@ -80748,7 +80898,7 @@ async function resolveGithubRepo(projectId) {
80748
80898
  init_config2();
80749
80899
  import { execFile as execFile18 } from "node:child_process";
80750
80900
  var labelNames = (l) => (l ?? []).map((x) => typeof x === "string" ? x : x.name ?? "").filter(Boolean);
80751
- var norm3 = (s2) => s2.toLowerCase() === "closed" ? "closed" : "open";
80901
+ var norm4 = (s2) => s2.toLowerCase() === "closed" ? "closed" : "open";
80752
80902
  function issueFromGh(raw) {
80753
80903
  return {
80754
80904
  number: raw.number,
@@ -80757,7 +80907,7 @@ function issueFromGh(raw) {
80757
80907
  authorLogin: raw.author?.login ?? "",
80758
80908
  labels: labelNames(raw.labels),
80759
80909
  url: raw.url,
80760
- issueState: norm3(raw.state),
80910
+ issueState: norm4(raw.state),
80761
80911
  issueCreatedAt: raw.createdAt,
80762
80912
  issueUpdatedAt: raw.updatedAt
80763
80913
  };
@@ -80777,7 +80927,7 @@ function issuesFromRest(raw) {
80777
80927
  authorLogin: r.user?.login ?? "",
80778
80928
  labels: labelNames(r.labels),
80779
80929
  url: r.html_url,
80780
- issueState: norm3(r.state),
80930
+ issueState: norm4(r.state),
80781
80931
  issueCreatedAt: r.created_at,
80782
80932
  issueUpdatedAt: r.updated_at
80783
80933
  });
@@ -81443,13 +81593,13 @@ init_ssrf();
81443
81593
  init_safe_outbound_request();
81444
81594
  async function sendOnce(o, deps = {}) {
81445
81595
  const started2 = Date.now();
81446
- const fail3 = (error, httpStatus = null, gone = false) => ({ ok: false, httpStatus, durationMs: Date.now() - started2, error, gone });
81596
+ const fail4 = (error, httpStatus = null, gone = false) => ({ ok: false, httpStatus, durationMs: Date.now() - started2, error, gone });
81447
81597
  const parsed = validateWebhookUrl(o.endpoint.url);
81448
- if (!parsed.ok) return fail3(`URL tak sah: ${parsed.error}`);
81598
+ if (!parsed.ok) return fail4(`URL tak sah: ${parsed.error}`);
81449
81599
  const guard = await checkDestination(parsed.url, o.endpoint.allowPrivate, deps.lookup);
81450
- if (!guard.ok) return fail3(guard.error);
81600
+ if (!guard.ok) return fail4(guard.error);
81451
81601
  const secret = secretOf(o.endpoint);
81452
- if (!secret) return fail3("secret tak bisa dibuka \u2014 rotasi secret endpoint ini");
81602
+ if (!secret) return fail4("secret tak bisa dibuka \u2014 rotasi secret endpoint ini");
81453
81603
  const headers = signedHeaders({
81454
81604
  secret,
81455
81605
  body: o.body,
@@ -81492,7 +81642,7 @@ async function sendOnce(o, deps = {}) {
81492
81642
  return { ok: false, httpStatus: res.status, durationMs, error: `HTTP ${res.status}`, gone: false };
81493
81643
  } catch (e) {
81494
81644
  const msg = e.name === "AbortError" ? `timeout ${WEBHOOK_TIMEOUT_MS} ms` : e.message;
81495
- return fail3(msg);
81645
+ return fail4(msg);
81496
81646
  } finally {
81497
81647
  clearTimeout(timer10);
81498
81648
  }