feat(docver): add document versioning API (docx+pdf in MinIO)
Stand-alone API group at /api/documents/{docId}/versions — one document
per docId (unique), each version stores .docx + optional .pdf as MinIO
objects with uploader userId/userName/createdAt (no auth, like
api/template; callers identify themselves per docs/PLAN-DOCVER.md).
- POST versions (multipart): validates fields/magic bytes/100MB cap,
uploads to MinIO first, cleans up objects if the DB insert fails;
returns {hasPdf, versionId, versionNumber} with server-authoritative
numbering (tx: upsert + SELECT FOR UPDATE, safe under parallel calls)
- GET versions: DocumentVersionDTO[] newest-first (404 for unknown docId)
- GET versions/{versionId}/download-link?format=docx|pdf: presigned URL
(docId+versionId matched together; pdf-missing = 404)
- tables documents/document_versions + migration drizzle/0005
- docker-compose.yml: judes-note-db + judes-note-minio (quay.io image,
host ports 19000/19001, POSTGRES_DB ecourt-note, pinned volume names)
- lib/openapi.ts: documents tag + paths for Swagger UI
- middleware: exempt api/documents from login redirect
- unit tests tests/lib/document-versioning.test.ts (23 cases)
wordaddin untouched — swap the mock later via NEXT_PUBLIC_ADDIN_API_BASE_URL.
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:52:35 +07:00
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
DOCVER_MESSAGE_MAX,
|
|
|
|
|
|
contentDispositionValue,
|
|
|
|
|
|
docverError,
|
|
|
|
|
|
isDocxBytes,
|
|
|
|
|
|
isPdfBytes,
|
|
|
|
|
|
parseDocId,
|
|
|
|
|
|
parseDownloadFormat,
|
|
|
|
|
|
parseFieldsJson,
|
|
|
|
|
|
parseVersionIdParam,
|
|
|
|
|
|
parseVersionUploadForm,
|
|
|
|
|
|
sanitizeDisplayFileName,
|
|
|
|
|
|
sanitizeFilePartName,
|
|
|
|
|
|
} from "@/lib/document-versioning";
|
|
|
|
|
|
|
|
|
|
|
|
// docver (PLAN-DOCVER.md) — validation ล้วนของ /api/documents/*: ไฟล์นี้ตั้งใจไม่แตะ
|
|
|
|
|
|
// module ใดที่ลาก @/db หรือ minio มาด้วย (pattern เดียวกับ external-api-auth.test.ts
|
|
|
|
|
|
// — vitest ไม่โหลด .env จึงต้องรันได้โดยไม่ตั้ง DATABASE_URL/MINIO_*)
|
|
|
|
|
|
|
|
|
|
|
|
function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T {
|
|
|
|
|
|
expect(result.ok).toBe(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
describe("parseDocId", () => {
|
|
|
|
|
|
it("รับ GUID / รหัส e-Court / charset ที่อนุญาตทั้งหมด และ trim รอบ ๆ", () => {
|
|
|
|
|
|
for (const raw of [
|
|
|
|
|
|
"123e4567-e89b-12d3-a456-426614174000",
|
|
|
|
|
|
"ecourt:123",
|
|
|
|
|
|
"DOC_2026.10",
|
|
|
|
|
|
" doc1 ",
|
|
|
|
|
|
"a".repeat(200),
|
|
|
|
|
|
]) {
|
|
|
|
|
|
const result = parseDocId(raw);
|
|
|
|
|
|
ok(result);
|
|
|
|
|
|
expect(result.docId).toBe(raw.trim());
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("ตกเมื่อว่าง / เกิน 200 / มีช่องว่างกลาง / ไทย / slash / '..'", () => {
|
|
|
|
|
|
for (const raw of ["", " ", "a".repeat(201), "doc 1", "เอกสาร1", "a/b", "..", "a\\b"]) {
|
|
|
|
|
|
expect(parseDocId(raw).ok).toBe(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("parseVersionIdParam", () => {
|
|
|
|
|
|
it("รับ UUID ทั้งพิมพ์ใหญ่/เล็ก (normalize ลงตัวพิมพ์เล็ก) + trim", () => {
|
|
|
|
|
|
const result = parseVersionIdParam(" 6F9619FF-8B86-D011-B42D-00C04FC964FF ");
|
|
|
|
|
|
ok(result);
|
|
|
|
|
|
expect(result.versionId).toBe("6f9619ff-8b86-d011-b42d-00c04fc964ff");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("ตกเมื่อไม่ใช่รูป UUID", () => {
|
|
|
|
|
|
for (const raw of ["not-a-uuid", "123", "{6f9619ff-8b86-d011-b42d-00c04fc964ff}", ""]) {
|
|
|
|
|
|
expect(parseVersionIdParam(raw).ok).toBe(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("parseDownloadFormat", () => {
|
|
|
|
|
|
it("ไม่ส่ง/ว่าง/ข้างในมี whitespace = docx (default ตาม contract)", () => {
|
|
|
|
|
|
for (const raw of [null, "", " ", " docx "]) {
|
|
|
|
|
|
const result = parseDownloadFormat(raw);
|
|
|
|
|
|
ok(result);
|
|
|
|
|
|
expect(result.format).toBe("docx");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("รับ pdf ตรง ๆ และตกเมื่อค่าอื่น (รวมพิมพ์ใหญ่)", () => {
|
|
|
|
|
|
const pdf = parseDownloadFormat("pdf");
|
|
|
|
|
|
ok(pdf);
|
|
|
|
|
|
expect(pdf.format).toBe("pdf");
|
|
|
|
|
|
for (const raw of ["exe", "PDF", "docxx"]) {
|
|
|
|
|
|
expect(parseDownloadFormat(raw).ok).toBe(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("isDocxBytes / isPdfBytes", () => {
|
|
|
|
|
|
it("ผ่านเมื่อ magic bytes ตรง (แม้ buffer สั้นกว่าที่อ่าน)", () => {
|
|
|
|
|
|
expect(isDocxBytes(Buffer.from("PK\x03\x04rest-of-zip"))).toBe(true);
|
|
|
|
|
|
expect(isPdfBytes(Buffer.from("%PDF-1.7\n..."))).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("ตกเมื่อ signature ผิดหรือ buffer ว่าง/สั้นกว่า signature", () => {
|
|
|
|
|
|
expect(isDocxBytes(Buffer.from("%PDF-1.7"))).toBe(false);
|
|
|
|
|
|
expect(isDocxBytes(Buffer.from("PK"))).toBe(false);
|
|
|
|
|
|
expect(isDocxBytes(Buffer.alloc(0))).toBe(false);
|
|
|
|
|
|
expect(isPdfBytes(Buffer.from("PK\x03\x04"))).toBe(false);
|
|
|
|
|
|
expect(isPdfBytes(Buffer.from("%PD"))).toBe(false);
|
|
|
|
|
|
expect(isPdfBytes(Buffer.alloc(0))).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("parseFieldsJson", () => {
|
|
|
|
|
|
it("แยก JSON object ล้วนได้ และ `{}` = null (ถือว่าไม่ส่ง)", () => {
|
|
|
|
|
|
const result = parseFieldsJson('{"blackno":"คดีหมายเลขดำที่"}');
|
|
|
|
|
|
ok(result);
|
|
|
|
|
|
expect(result.fields).toEqual({ blackno: "คดีหมายเลขดำที่" });
|
|
|
|
|
|
const empty = parseFieldsJson("{}");
|
|
|
|
|
|
ok(empty);
|
|
|
|
|
|
expect(empty.fields).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("ตกเมื่อ JSON เพี้ยน / ไม่ใช่ object / value ไม่ใช่ string", () => {
|
|
|
|
|
|
for (const raw of ["ไม่ใช่ JSON", "[1,2]", '"string"', "null", '{"a":1}']) {
|
|
|
|
|
|
expect(parseFieldsJson(raw).ok).toBe(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("parseVersionUploadForm", () => {
|
2026-10-26 06:29:19 +07:00
|
|
|
|
// identity (userId/userName) กับ templateId ถอดออกจาก form แล้ว — route แกะจาก
|
|
|
|
|
|
// Bearer token ผ่าน lib/docver-token.ts (เคสของ token อยู่ docver-token.test.ts)
|
feat(docver): add document versioning API (docx+pdf in MinIO)
Stand-alone API group at /api/documents/{docId}/versions — one document
per docId (unique), each version stores .docx + optional .pdf as MinIO
objects with uploader userId/userName/createdAt (no auth, like
api/template; callers identify themselves per docs/PLAN-DOCVER.md).
- POST versions (multipart): validates fields/magic bytes/100MB cap,
uploads to MinIO first, cleans up objects if the DB insert fails;
returns {hasPdf, versionId, versionNumber} with server-authoritative
numbering (tx: upsert + SELECT FOR UPDATE, safe under parallel calls)
- GET versions: DocumentVersionDTO[] newest-first (404 for unknown docId)
- GET versions/{versionId}/download-link?format=docx|pdf: presigned URL
(docId+versionId matched together; pdf-missing = 404)
- tables documents/document_versions + migration drizzle/0005
- docker-compose.yml: judes-note-db + judes-note-minio (quay.io image,
host ports 19000/19001, POSTGRES_DB ecourt-note, pinned volume names)
- lib/openapi.ts: documents tag + paths for Swagger UI
- middleware: exempt api/documents from login redirect
- unit tests tests/lib/document-versioning.test.ts (23 cases)
wordaddin untouched — swap the mock later via NEXT_PUBLIC_ADDIN_API_BASE_URL.
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:52:35 +07:00
|
|
|
|
function baseForm(): FormData {
|
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
|
form.append("file", new File([Buffer.from("PK\x03\x04")], "draft.docx"));
|
|
|
|
|
|
return form;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it("happy path: แยกทุก field + trim ค่าว่างเป็น null + fileName ตัด path ที่ฝังมา", () => {
|
|
|
|
|
|
const form = baseForm();
|
|
|
|
|
|
form.append("clientVersionId", " 11111111-1111-4111-8111-111111111111 ");
|
|
|
|
|
|
form.append("message", " ");
|
|
|
|
|
|
form.append("fields", '{"blackno":"1"}');
|
|
|
|
|
|
form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx");
|
|
|
|
|
|
const result = parseVersionUploadForm(form);
|
|
|
|
|
|
ok(result);
|
|
|
|
|
|
expect(result.form).toEqual({
|
|
|
|
|
|
clientVersionId: "11111111-1111-4111-8111-111111111111",
|
|
|
|
|
|
message: null,
|
|
|
|
|
|
fileName: "คำสั่งเรียก.docx",
|
|
|
|
|
|
fields: { blackno: "1" },
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("ตกเมื่อมี field ที่ไม่รู้จัก (กัน typo เงียบ ๆ)", () => {
|
|
|
|
|
|
const form = baseForm();
|
|
|
|
|
|
form.append("oops", "1");
|
|
|
|
|
|
const result = parseVersionUploadForm(form);
|
|
|
|
|
|
expect(result.ok).toBe(false);
|
|
|
|
|
|
if (!result.ok) expect(result.error).toContain("oops");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-26 06:29:19 +07:00
|
|
|
|
it("ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field (identity มาจาก token)", () => {
|
|
|
|
|
|
for (const legacy of ["userId", "userName", "templateId"]) {
|
|
|
|
|
|
const form = baseForm();
|
|
|
|
|
|
form.append(legacy, "x");
|
|
|
|
|
|
const result = parseVersionUploadForm(form);
|
|
|
|
|
|
expect(result.ok).toBe(false);
|
|
|
|
|
|
if (!result.ok) expect(result.error).toContain(legacy);
|
|
|
|
|
|
}
|
feat(docver): add document versioning API (docx+pdf in MinIO)
Stand-alone API group at /api/documents/{docId}/versions — one document
per docId (unique), each version stores .docx + optional .pdf as MinIO
objects with uploader userId/userName/createdAt (no auth, like
api/template; callers identify themselves per docs/PLAN-DOCVER.md).
- POST versions (multipart): validates fields/magic bytes/100MB cap,
uploads to MinIO first, cleans up objects if the DB insert fails;
returns {hasPdf, versionId, versionNumber} with server-authoritative
numbering (tx: upsert + SELECT FOR UPDATE, safe under parallel calls)
- GET versions: DocumentVersionDTO[] newest-first (404 for unknown docId)
- GET versions/{versionId}/download-link?format=docx|pdf: presigned URL
(docId+versionId matched together; pdf-missing = 404)
- tables documents/document_versions + migration drizzle/0005
- docker-compose.yml: judes-note-db + judes-note-minio (quay.io image,
host ports 19000/19001, POSTGRES_DB ecourt-note, pinned volume names)
- lib/openapi.ts: documents tag + paths for Swagger UI
- middleware: exempt api/documents from login redirect
- unit tests tests/lib/document-versioning.test.ts (23 cases)
wordaddin untouched — swap the mock later via NEXT_PUBLIC_ADDIN_API_BASE_URL.
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:52:35 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => {
|
|
|
|
|
|
const form = baseForm();
|
2026-10-26 06:29:19 +07:00
|
|
|
|
form.append("message", new File(["x"], "m.txt"));
|
feat(docver): add document versioning API (docx+pdf in MinIO)
Stand-alone API group at /api/documents/{docId}/versions — one document
per docId (unique), each version stores .docx + optional .pdf as MinIO
objects with uploader userId/userName/createdAt (no auth, like
api/template; callers identify themselves per docs/PLAN-DOCVER.md).
- POST versions (multipart): validates fields/magic bytes/100MB cap,
uploads to MinIO first, cleans up objects if the DB insert fails;
returns {hasPdf, versionId, versionNumber} with server-authoritative
numbering (tx: upsert + SELECT FOR UPDATE, safe under parallel calls)
- GET versions: DocumentVersionDTO[] newest-first (404 for unknown docId)
- GET versions/{versionId}/download-link?format=docx|pdf: presigned URL
(docId+versionId matched together; pdf-missing = 404)
- tables documents/document_versions + migration drizzle/0005
- docker-compose.yml: judes-note-db + judes-note-minio (quay.io image,
host ports 19000/19001, POSTGRES_DB ecourt-note, pinned volume names)
- lib/openapi.ts: documents tag + paths for Swagger UI
- middleware: exempt api/documents from login redirect
- unit tests tests/lib/document-versioning.test.ts (23 cases)
wordaddin untouched — swap the mock later via NEXT_PUBLIC_ADDIN_API_BASE_URL.
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:52:35 +07:00
|
|
|
|
const result = parseVersionUploadForm(form);
|
|
|
|
|
|
expect(result.ok).toBe(false);
|
2026-10-26 06:29:19 +07:00
|
|
|
|
if (!result.ok) expect(result.error).toContain("message");
|
feat(docver): add document versioning API (docx+pdf in MinIO)
Stand-alone API group at /api/documents/{docId}/versions — one document
per docId (unique), each version stores .docx + optional .pdf as MinIO
objects with uploader userId/userName/createdAt (no auth, like
api/template; callers identify themselves per docs/PLAN-DOCVER.md).
- POST versions (multipart): validates fields/magic bytes/100MB cap,
uploads to MinIO first, cleans up objects if the DB insert fails;
returns {hasPdf, versionId, versionNumber} with server-authoritative
numbering (tx: upsert + SELECT FOR UPDATE, safe under parallel calls)
- GET versions: DocumentVersionDTO[] newest-first (404 for unknown docId)
- GET versions/{versionId}/download-link?format=docx|pdf: presigned URL
(docId+versionId matched together; pdf-missing = 404)
- tables documents/document_versions + migration drizzle/0005
- docker-compose.yml: judes-note-db + judes-note-minio (quay.io image,
host ports 19000/19001, POSTGRES_DB ecourt-note, pinned volume names)
- lib/openapi.ts: documents tag + paths for Swagger UI
- middleware: exempt api/documents from login redirect
- unit tests tests/lib/document-versioning.test.ts (23 cases)
wordaddin untouched — swap the mock later via NEXT_PUBLIC_ADDIN_API_BASE_URL.
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:52:35 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it(`message ยาว ${DOCVER_MESSAGE_MAX} ผ่าน แต่เกิน = ตก`, () => {
|
|
|
|
|
|
const edge = baseForm();
|
|
|
|
|
|
edge.append("message", "a".repeat(DOCVER_MESSAGE_MAX));
|
|
|
|
|
|
ok(parseVersionUploadForm(edge));
|
|
|
|
|
|
const over = baseForm();
|
|
|
|
|
|
over.append("message", "a".repeat(DOCVER_MESSAGE_MAX + 1));
|
|
|
|
|
|
expect(parseVersionUploadForm(over).ok).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("fields ใน form ผิดกติกาส่งต่อ error ของ parseFieldsJson", () => {
|
|
|
|
|
|
const form = baseForm();
|
|
|
|
|
|
form.append("fields", '{"a":1}');
|
|
|
|
|
|
expect(parseVersionUploadForm(form).ok).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("sanitizeDisplayFileName / sanitizeFilePartName", () => {
|
|
|
|
|
|
it("ตัด path ฝังมา (ทั้ง / และ \\) + trim ว่าง = null + cap 200", () => {
|
|
|
|
|
|
expect(sanitizeDisplayFileName("C:\\Users\\judge\\a.docx")).toBe("a.docx");
|
|
|
|
|
|
expect(sanitizeDisplayFileName("/tmp/b.pdf")).toBe("b.pdf");
|
|
|
|
|
|
expect(sanitizeDisplayFileName(" ")).toBeNull();
|
|
|
|
|
|
expect(sanitizeDisplayFileName(null)).toBeNull();
|
|
|
|
|
|
expect(sanitizeDisplayFileName(`${"a".repeat(250)}.docx`)).toHaveLength(200);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("File.name ที่ undici ถอดเป็น latin1 (ชื่อไทยเพี้ยนแบบ N4) กู้คืนเป็น UTF-8 ได้", () => {
|
|
|
|
|
|
const original = "คำสั่งเรียกเอกสาร.docx";
|
|
|
|
|
|
const mangled = Buffer.from(original, "utf8").toString("latin1");
|
|
|
|
|
|
expect(mangled).not.toBe(original); // ต้องเพี้ยนจริงก่อน ไม่ใช่ทดสอบตัวเอง
|
|
|
|
|
|
expect(sanitizeFilePartName(mangled)).toBe(original);
|
|
|
|
|
|
// ชื่อ ASCII ไม่มีตัว U+0080–U+00FF จึงไม่ถูกแตะ
|
|
|
|
|
|
expect(sanitizeFilePartName("plain.docx")).toBe("plain.docx");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("docverError", () => {
|
|
|
|
|
|
it("สร้าง Response JSON ไทยพร้อม status ที่ขอ", async () => {
|
|
|
|
|
|
const res = docverError("ไม่พบ version นี้ของเอกสาร", 404);
|
|
|
|
|
|
expect(res.status).toBe(404);
|
|
|
|
|
|
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
|
|
|
|
|
|
expect(await res.json()).toEqual({ error: "ไม่พบ version นี้ของเอกสาร" });
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("contentDispositionValue", () => {
|
|
|
|
|
|
it("ไม่มีชื่อ = ชื่อ default อย่างเดียว", () => {
|
|
|
|
|
|
expect(contentDispositionValue(null, "docx")).toBe('attachment; filename="document.docx"');
|
|
|
|
|
|
expect(contentDispositionValue(" ", "pdf")).toBe('attachment; filename="document.pdf"');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("ชื่อ ASCII: เติม extension ที่ขาด / คงชื่อที่มีอยู่แล้ว / ตัด quote-backslash", () => {
|
|
|
|
|
|
expect(contentDispositionValue("Report Final", "docx")).toBe(
|
|
|
|
|
|
'attachment; filename="Report Final.docx"; filename*=UTF-8\'\'Report%20Final',
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(contentDispositionValue("Report Final.docx", "docx")).toBe(
|
|
|
|
|
|
'attachment; filename="Report Final.docx"; filename*=UTF-8\'\'Report%20Final.docx',
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(contentDispositionValue('bad"name\\', "pdf")).toBe(
|
|
|
|
|
|
'attachment; filename="badname.pdf"; filename*=UTF-8\'\'bad%22name%5C',
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("ชื่อไทยล้วน = ASCII fallback เป็นชื่อ default แต่ filename* ยังชื่อเต็ม", () => {
|
|
|
|
|
|
expect(contentDispositionValue("คำสั่งเรียก", "docx")).toBe(
|
|
|
|
|
|
'attachment; filename="document.docx"; filename*=UTF-8\'\''
|
|
|
|
|
|
+ encodeURIComponent("คำสั่งเรียก"),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("ชื่อผสมไทย+เลข = ส่วน ASCII ที่เหลือใช้เป็น fallback ได้", () => {
|
|
|
|
|
|
expect(contentDispositionValue("รายงาน 2026", "pdf")).toBe(
|
|
|
|
|
|
'attachment; filename="2026.pdf"; filename*=UTF-8\'\''
|
|
|
|
|
|
+ encodeURIComponent("รายงาน 2026"),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|