chore: checkpoint before performance tuning

This commit is contained in:
Codex
2026-05-11 07:39:37 +00:00
parent a278de032d
commit 5a198baf77
57 changed files with 14768 additions and 1962 deletions
@@ -1,5 +1,7 @@
FROM oven/bun:1-debian
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
@@ -31,12 +33,14 @@ RUN apt-get update \
&& mkdir -p /usr/local/lib/docker/cli-plugins /root/.docker/cli-plugins \
&& ln -sf /usr/bin/docker-compose /usr/local/lib/docker/cli-plugins/docker-compose \
&& ln -sf /usr/bin/docker-compose /root/.docker/cli-plugins/docker-compose \
&& npm install -g @openai/codex@0.128.0 \
&& npm install -g @openai/codex@0.128.0 playwright@1.59.1 \
&& playwright install --with-deps chromium \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY src/components/microservices/codex-queue/package.json ./package.json
RUN bun install --production
COPY src/components/microservices/codex-queue/tsconfig.json ./tsconfig.json
COPY src/components/microservices/codex-queue/src ./src
@@ -5,5 +5,8 @@
"scripts": {
"start": "bun run src/index.ts",
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"postgres": "latest"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
FROM oven/bun:1-alpine
WORKDIR /app
COPY src/components/microservices/project-manager/package.json ./package.json
RUN bun install --production
COPY src/components/microservices/project-manager/tsconfig.json ./tsconfig.json
COPY src/components/microservices/project-manager/src ./src
EXPOSE 4233
CMD ["bun", "run", "src/index.ts"]
@@ -0,0 +1,34 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@unidesk/project-manager",
"dependencies": {
"postgres": "latest",
"xlsx": "0.18.5",
},
},
},
"packages": {
"adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
"cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="],
"codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
"ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
"wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
"word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="],
"xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="],
}
}
@@ -0,0 +1,13 @@
{
"name": "@unidesk/project-manager",
"private": true,
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"postgres": "latest",
"xlsx": "0.18.5"
}
}
@@ -0,0 +1,612 @@
import { createHash, randomUUID } from "node:crypto";
import { appendFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import postgres from "postgres";
import * as XLSX from "xlsx";
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
type JsonRecord = Record<string, JsonValue>;
type ProjectStatusFilter = "all" | "completed" | "active" | "unpaid";
interface RuntimeConfig {
host: string;
port: number;
databaseUrl: string;
logFile: string;
}
interface ProjectRow {
id: string;
sequence_no: number | null;
contract_no: string;
name: string;
current_status: string;
pending: string;
payment_status: string;
payment_ratio: string | number | null;
notes: string;
raw: JsonValue;
source_file: string;
imported_at: string | null;
created_at: string;
updated_at: string;
}
interface ProjectRecord extends JsonRecord {
id: string;
sequenceNo: number | null;
contractNo: string;
name: string;
currentStatus: string;
pending: string;
paymentStatus: string;
paymentRatio: number | null;
notes: string;
raw: JsonValue;
sourceFile: string;
importedAt: string | null;
createdAt: string;
updatedAt: string;
}
interface ProjectInput {
sequenceNo?: unknown;
contractNo?: unknown;
name?: unknown;
currentStatus?: unknown;
status?: unknown;
pending?: unknown;
todo?: unknown;
paymentStatus?: unknown;
paymentRatio?: unknown;
notes?: unknown;
raw?: unknown;
sourceFile?: unknown;
}
interface ImportCandidate {
id?: string;
sequenceNo: number | null;
contractNo: string;
name: string;
currentStatus: string;
pending: string;
paymentStatus: string;
paymentRatio: number | null;
notes: string;
raw: JsonRecord;
sourceFile: string;
}
const serviceStartedAt = new Date().toISOString();
const recentLogs: JsonRecord[] = [];
const EXCEL_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
function configFromEnv(): RuntimeConfig {
const databaseUrl = process.env.DATABASE_URL || "";
if (!databaseUrl) throw new Error("DATABASE_URL is required");
return {
host: process.env.HOST || "0.0.0.0",
port: Number(process.env.PORT || 4233),
databaseUrl,
logFile: process.env.LOG_FILE || "",
};
}
const config = configFromEnv();
const sql = postgres(config.databaseUrl, { max: 8, idle_timeout: 20, connect_timeout: 10 });
function log(event: string, detail: JsonRecord = {}): void {
const record: JsonRecord = { at: new Date().toISOString(), event, ...detail };
recentLogs.push(record);
if (recentLogs.length > 300) recentLogs.shift();
if (config.logFile) {
try {
mkdirSync(dirname(config.logFile), { recursive: true });
appendFileSync(config.logFile, `${JSON.stringify(record)}\n`, "utf8");
} catch {
// Logging must not break request handling.
}
}
}
function jsonResponse(body: JsonValue, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
function errorToJson(error: unknown): JsonRecord {
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack || "" };
return { message: String(error) };
}
function errorResponse(error: unknown, status = 500): Response {
log("request_error", { status, error: errorToJson(error) });
return jsonResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }, status);
}
function textValue(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value.trim();
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value).trim();
return "";
}
function numberOrNull(value: unknown): number | null {
if (value === null || value === undefined || value === "") return null;
if (typeof value === "number") return Number.isFinite(value) ? value : null;
const raw = String(value).trim();
if (!raw) return null;
const percent = raw.endsWith("%");
const normalized = raw.replace(/[%\s,]/g, "");
const parsed = Number(normalized);
if (!Number.isFinite(parsed)) return null;
return percent ? parsed / 100 : parsed;
}
function integerOrNull(value: unknown): number | null {
const number = numberOrNull(value);
if (number === null) return null;
return Number.isInteger(number) ? number : Math.round(number);
}
function jsonRecordOrEmpty(value: unknown): JsonRecord {
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as JsonRecord;
return {};
}
function projectIdFromContract(contractNo: string): string {
const compact = contractNo.trim().replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 96);
if (compact) return `pm_${compact}`;
return `pm_${randomUUID()}`;
}
function projectFromRow(row: ProjectRow): ProjectRecord {
return {
id: row.id,
sequenceNo: row.sequence_no,
contractNo: row.contract_no,
name: row.name,
currentStatus: row.current_status,
pending: row.pending,
paymentStatus: row.payment_status,
paymentRatio: numberOrNull(row.payment_ratio),
notes: row.notes,
raw: row.raw,
sourceFile: row.source_file,
importedAt: row.imported_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function rowHash(input: ImportCandidate): string {
return createHash("sha256").update(JSON.stringify({ contractNo: input.contractNo, name: input.name, raw: input.raw })).digest("hex");
}
async function ensureSchema(): Promise<void> {
await sql`
CREATE TABLE IF NOT EXISTS project_manager_projects (
id TEXT PRIMARY KEY,
sequence_no INTEGER,
contract_no TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
current_status TEXT NOT NULL DEFAULT '',
pending TEXT NOT NULL DEFAULT '',
payment_status TEXT NOT NULL DEFAULT '',
payment_ratio NUMERIC,
notes TEXT NOT NULL DEFAULT '',
raw JSONB NOT NULL DEFAULT '{}'::jsonb,
source_file TEXT NOT NULL DEFAULT '',
imported_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_project_manager_projects_sequence ON project_manager_projects(sequence_no NULLS LAST, created_at ASC)`;
await sql`CREATE INDEX IF NOT EXISTS idx_project_manager_projects_contract ON project_manager_projects(contract_no)`;
await sql`CREATE INDEX IF NOT EXISTS idx_project_manager_projects_updated ON project_manager_projects(updated_at DESC)`;
}
async function waitForSchema(): Promise<void> {
const started = Date.now();
let lastError: unknown = null;
for (let attempt = 1; attempt <= 30; attempt += 1) {
try {
await ensureSchema();
log("schema_ready", { attempt });
return;
} catch (error) {
lastError = error;
log("schema_wait", { attempt, error: errorToJson(error) });
await Bun.sleep(Math.min(1000 + attempt * 250, 5000));
}
}
const elapsedMs = Date.now() - started;
throw new Error(`Project Manager schema initialization failed after ${elapsedMs}ms: ${JSON.stringify(errorToJson(lastError))}`);
}
async function readJson(req: Request): Promise<unknown> {
const text = await req.text();
if (text.length > 1_000_000) throw new Error("request body is too large");
if (!text.trim()) return {};
return JSON.parse(text) as unknown;
}
function normalizeProjectInput(input: ProjectInput, existing?: ProjectRecord): ImportCandidate {
const currentStatus = "currentStatus" in input ? textValue(input.currentStatus) : "status" in input ? textValue(input.status) : existing?.currentStatus ?? "";
const pending = "pending" in input ? textValue(input.pending) : "todo" in input ? textValue(input.todo) : existing?.pending ?? "";
const contractNo = "contractNo" in input ? textValue(input.contractNo) : existing?.contractNo ?? "";
const name = "name" in input ? textValue(input.name) : existing?.name ?? "";
const paymentRatio = "paymentRatio" in input ? numberOrNull(input.paymentRatio) : existing?.paymentRatio ?? null;
const raw = "raw" in input ? jsonRecordOrEmpty(input.raw) : jsonRecordOrEmpty(existing?.raw);
if (!contractNo && !name) throw new Error("contractNo or name is required");
return {
sequenceNo: "sequenceNo" in input ? integerOrNull(input.sequenceNo) : existing?.sequenceNo ?? null,
contractNo,
name,
currentStatus,
pending,
paymentStatus: "paymentStatus" in input ? textValue(input.paymentStatus) : existing?.paymentStatus ?? "",
paymentRatio,
notes: "notes" in input ? textValue(input.notes) : existing?.notes ?? "",
raw,
sourceFile: "sourceFile" in input ? textValue(input.sourceFile) : existing?.sourceFile ?? "manual",
};
}
async function getProject(id: string): Promise<ProjectRecord | null> {
const rows = await sql<ProjectRow[]>`
SELECT id, sequence_no, contract_no, name, current_status, pending, payment_status, payment_ratio, notes, raw, source_file, imported_at, created_at, updated_at
FROM project_manager_projects
WHERE id = ${id}
LIMIT 1
`;
return rows[0] ? projectFromRow(rows[0]) : null;
}
async function insertProject(candidate: ImportCandidate, id = projectIdFromContract(candidate.contractNo)): Promise<ProjectRecord> {
const rows = await sql<ProjectRow[]>`
INSERT INTO project_manager_projects (id, sequence_no, contract_no, name, current_status, pending, payment_status, payment_ratio, notes, raw, source_file, imported_at)
VALUES (${id}, ${candidate.sequenceNo}, ${candidate.contractNo}, ${candidate.name}, ${candidate.currentStatus}, ${candidate.pending}, ${candidate.paymentStatus}, ${candidate.paymentRatio}, ${candidate.notes}, ${sql.json(candidate.raw)}, ${candidate.sourceFile}, now())
RETURNING id, sequence_no, contract_no, name, current_status, pending, payment_status, payment_ratio, notes, raw, source_file, imported_at, created_at, updated_at
`;
return projectFromRow(rows[0]!);
}
async function updateProject(id: string, candidate: ImportCandidate): Promise<ProjectRecord> {
const rows = await sql<ProjectRow[]>`
UPDATE project_manager_projects
SET sequence_no = ${candidate.sequenceNo},
contract_no = ${candidate.contractNo},
name = ${candidate.name},
current_status = ${candidate.currentStatus},
pending = ${candidate.pending},
payment_status = ${candidate.paymentStatus},
payment_ratio = ${candidate.paymentRatio},
notes = ${candidate.notes},
raw = ${sql.json(candidate.raw)},
source_file = ${candidate.sourceFile},
updated_at = now()
WHERE id = ${id}
RETURNING id, sequence_no, contract_no, name, current_status, pending, payment_status, payment_ratio, notes, raw, source_file, imported_at, created_at, updated_at
`;
if (!rows[0]) throw new Error(`project not found: ${id}`);
return projectFromRow(rows[0]);
}
function andFragments(fragments: any[]): any {
if (fragments.length === 0) return sql`TRUE`;
return fragments.slice(1).reduce((acc, fragment) => sql`${acc} AND ${fragment}`, fragments[0]);
}
function statusFilterFromUrl(url: URL): ProjectStatusFilter {
const value = String(url.searchParams.get("status") || "all").toLowerCase();
if (value === "completed" || value === "active" || value === "unpaid") return value;
return "all";
}
function projectFilters(url: URL): any[] {
const filters: any[] = [];
const q = String(url.searchParams.get("q") || "").trim();
if (q) {
const like = `%${q}%`;
filters.push(sql`(contract_no ILIKE ${like} OR name ILIKE ${like} OR current_status ILIKE ${like} OR pending ILIKE ${like} OR notes ILIKE ${like})`);
}
const status = statusFilterFromUrl(url);
if (status === "completed") filters.push(sql`(current_status ILIKE ${"%完成%"} OR pending ILIKE ${"%完成%"} OR notes ILIKE ${"%完成%"})`);
if (status === "active") filters.push(sql`NOT (current_status ILIKE ${"%完成%"} AND pending ILIKE ${"%完成%"})`);
if (status === "unpaid") filters.push(sql`COALESCE(payment_ratio, 0) < 1`);
return filters;
}
async function listProjects(url: URL): Promise<Response> {
const page = Math.max(1, Number(url.searchParams.get("page") || 1) || 1);
const pageSize = Math.max(1, Math.min(200, Number(url.searchParams.get("pageSize") || 100) || 100));
const offset = (page - 1) * pageSize;
const where = andFragments(projectFilters(url));
const countRows = await sql<{ count: number }[]>`SELECT count(*)::int AS count FROM project_manager_projects WHERE ${where}`;
const rows = await sql<ProjectRow[]>`
SELECT id, sequence_no, contract_no, name, current_status, pending, payment_status, payment_ratio, notes, raw, source_file, imported_at, created_at, updated_at
FROM project_manager_projects
WHERE ${where}
ORDER BY sequence_no NULLS LAST, created_at ASC, id ASC
LIMIT ${pageSize}
OFFSET ${offset}
`;
const summaryRows = await sql<{ total: number; completed: number; unpaid: number; average_payment: number | null }[]>`
SELECT
count(*)::int AS total,
count(*) FILTER (WHERE current_status ILIKE ${"%完成%"} OR pending ILIKE ${"%完成%"} OR notes ILIKE ${"%完成%"})::int AS completed,
count(*) FILTER (WHERE COALESCE(payment_ratio, 0) < 1)::int AS unpaid,
avg(payment_ratio)::float8 AS average_payment
FROM project_manager_projects
`;
const summary = summaryRows[0] ?? { total: 0, completed: 0, unpaid: 0, average_payment: null };
return jsonResponse({
ok: true,
projects: rows.map(projectFromRow),
page,
pageSize,
total: Number(countRows[0]?.count ?? 0),
summary: {
total: Number(summary.total || 0),
completed: Number(summary.completed || 0),
active: Math.max(0, Number(summary.total || 0) - Number(summary.completed || 0)),
unpaid: Number(summary.unpaid || 0),
averagePayment: numberOrNull(summary.average_payment),
},
});
}
async function createProject(req: Request): Promise<Response> {
const body = jsonRecordOrEmpty(await readJson(req));
const candidate = normalizeProjectInput(body);
const id = textValue(body.id) || projectIdFromContract(candidate.contractNo);
const existing = await getProject(id);
if (existing !== null) return jsonResponse({ ok: false, error: `project already exists: ${id}` }, 409);
const project = await insertProject(candidate, id);
log("project_created", { id: project.id, contractNo: project.contractNo, name: project.name });
return jsonResponse({ ok: true, project }, 201);
}
async function updateProjectRoute(req: Request, id: string): Promise<Response> {
const existing = await getProject(id);
if (existing === null) return jsonResponse({ ok: false, error: `project not found: ${id}` }, 404);
const body = jsonRecordOrEmpty(await readJson(req));
const candidate = normalizeProjectInput(body, existing);
const project = await updateProject(id, candidate);
log("project_updated", { id, contractNo: project.contractNo, name: project.name });
return jsonResponse({ ok: true, project });
}
async function deleteProjectRoute(id: string): Promise<Response> {
const rows = await sql<{ id: string }[]>`DELETE FROM project_manager_projects WHERE id = ${id} RETURNING id`;
if (!rows[0]) return jsonResponse({ ok: false, error: `project not found: ${id}` }, 404);
log("project_deleted", { id });
return jsonResponse({ ok: true, id });
}
function normalizeHeader(value: unknown): string {
return textValue(value).replace(/\s+/g, "");
}
function readCell(row: unknown[], index: number): string {
return textValue(row[index]);
}
function sheetRowsFromWorkbook(buffer: Buffer, sheetName?: string): unknown[][] {
const workbook = XLSX.read(buffer, { type: "buffer", cellDates: false });
const effectiveSheet = sheetName && workbook.Sheets[sheetName] ? sheetName : workbook.SheetNames[0];
if (!effectiveSheet) throw new Error("Excel workbook has no sheets");
return XLSX.utils.sheet_to_json(workbook.Sheets[effectiveSheet]!, { header: 1, raw: false, defval: "" }) as unknown[][];
}
function parseExcelProjects(buffer: Buffer, sourceFile: string, sheetName?: string): ImportCandidate[] {
const rows = sheetRowsFromWorkbook(buffer, sheetName);
const headerIndex = rows.findIndex((row) => row.some((cell) => normalizeHeader(cell) === "合同号") && row.some((cell) => normalizeHeader(cell) === "项目名称"));
if (headerIndex < 0) throw new Error("Excel header row not found; expected 合同号 and 项目名称");
const headerRow = rows[headerIndex] ?? [];
const headers = headerRow.map((cell) => normalizeHeader(cell));
const indexOf = (header: string): number => headers.findIndex((cell) => cell === header);
const sequenceIndex = indexOf("序号");
const contractIndex = indexOf("合同号");
const nameIndex = indexOf("项目名称");
const statusIndex = indexOf("当前状况");
const pendingIndex = indexOf("待完成");
const paymentIndex = indexOf("付款情况");
const notesIndex = indexOf("其它");
const projects: ImportCandidate[] = [];
for (const row of rows.slice(headerIndex + 1)) {
const record: JsonRecord = {};
headers.forEach((header, index) => {
if (header) record[header] = readCell(row, index);
});
const contractNo = contractIndex >= 0 ? readCell(row, contractIndex) : "";
const name = nameIndex >= 0 ? readCell(row, nameIndex) : "";
if (!contractNo && !name) continue;
const paymentStatus = paymentIndex >= 0 ? readCell(row, paymentIndex) : "";
const candidate: ImportCandidate = {
sequenceNo: sequenceIndex >= 0 ? integerOrNull(readCell(row, sequenceIndex)) : null,
contractNo,
name,
currentStatus: statusIndex >= 0 ? readCell(row, statusIndex) : "",
pending: pendingIndex >= 0 ? readCell(row, pendingIndex) : "",
paymentStatus,
paymentRatio: numberOrNull(paymentStatus),
notes: notesIndex >= 0 ? readCell(row, notesIndex) : "",
raw: { ...record, _sourceRowHash: "" },
sourceFile,
};
candidate.raw._sourceRowHash = rowHash(candidate);
projects.push(candidate);
}
return projects;
}
async function importProjects(candidates: ImportCandidate[], replace: boolean): Promise<JsonRecord> {
const unique = new Map<string, ImportCandidate>();
for (const candidate of candidates) {
const id = candidate.id || projectIdFromContract(candidate.contractNo);
unique.set(id, { ...candidate, id });
}
let imported = 0;
await sql.begin(async (tx) => {
if (replace) await tx`DELETE FROM project_manager_projects`;
for (const [id, candidate] of unique.entries()) {
await tx`
INSERT INTO project_manager_projects (id, sequence_no, contract_no, name, current_status, pending, payment_status, payment_ratio, notes, raw, source_file, imported_at, updated_at)
VALUES (${id}, ${candidate.sequenceNo}, ${candidate.contractNo}, ${candidate.name}, ${candidate.currentStatus}, ${candidate.pending}, ${candidate.paymentStatus}, ${candidate.paymentRatio}, ${candidate.notes}, ${tx.json(candidate.raw)}, ${candidate.sourceFile}, now(), now())
ON CONFLICT (id) DO UPDATE SET
sequence_no = EXCLUDED.sequence_no,
contract_no = EXCLUDED.contract_no,
name = EXCLUDED.name,
current_status = EXCLUDED.current_status,
pending = EXCLUDED.pending,
payment_status = EXCLUDED.payment_status,
payment_ratio = EXCLUDED.payment_ratio,
notes = EXCLUDED.notes,
raw = EXCLUDED.raw,
source_file = EXCLUDED.source_file,
imported_at = EXCLUDED.imported_at,
updated_at = now()
`;
imported += 1;
}
});
const totalRows = await sql<{ count: number }[]>`SELECT count(*)::int AS count FROM project_manager_projects`;
return { imported, replace, total: Number(totalRows[0]?.count ?? 0) };
}
async function importExcelRoute(req: Request): Promise<Response> {
const body = jsonRecordOrEmpty(await readJson(req));
const base64 = textValue(body.contentBase64);
if (!base64) return jsonResponse({ ok: false, error: "contentBase64 is required" }, 400);
const fileName = textValue(body.fileName) || "import.xlsx";
const sheetName = textValue(body.sheetName) || undefined;
const replace = body.replace === true;
const buffer = Buffer.from(base64, "base64");
const candidates = parseExcelProjects(buffer, fileName, sheetName);
const result = await importProjects(candidates, replace);
log("projects_imported_excel", { fileName, sheetName: sheetName || "", imported: result.imported, replace });
return jsonResponse({ ok: true, fileName, parsed: candidates.length, ...result });
}
async function importRowsRoute(req: Request): Promise<Response> {
const body = jsonRecordOrEmpty(await readJson(req));
const rows = Array.isArray(body.projects) ? body.projects : Array.isArray(body.rows) ? body.rows : [];
const sourceFile = textValue(body.sourceFile) || "api";
const candidates = rows.map((row) => normalizeProjectInput(jsonRecordOrEmpty(row), undefined)).map((candidate) => ({ ...candidate, sourceFile }));
const result = await importProjects(candidates, body.replace === true);
log("projects_imported_rows", { sourceFile, imported: result.imported, replace: body.replace === true });
return jsonResponse({ ok: true, parsed: candidates.length, ...result });
}
async function exportProjects(): Promise<Response> {
const rows = await sql<ProjectRow[]>`
SELECT id, sequence_no, contract_no, name, current_status, pending, payment_status, payment_ratio, notes, raw, source_file, imported_at, created_at, updated_at
FROM project_manager_projects
ORDER BY sequence_no NULLS LAST, created_at ASC, id ASC
`;
const table = [
["序号", "合同号", "项目名称", "当前状况", "待完成", "付款情况", "其它", "ID", "来源", "更新时间"],
...rows.map((row) => {
const project = projectFromRow(row);
return [
project.sequenceNo ?? "",
project.contractNo,
project.name,
project.currentStatus,
project.pending,
project.paymentStatus,
project.notes,
project.id,
project.sourceFile,
project.updatedAt,
];
}),
];
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.aoa_to_sheet(table);
worksheet["!cols"] = [
{ wch: 8 }, { wch: 22 }, { wch: 28 }, { wch: 32 }, { wch: 38 }, { wch: 12 }, { wch: 18 }, { wch: 30 }, { wch: 24 }, { wch: 24 },
];
XLSX.utils.book_append_sheet(workbook, worksheet, "项目列表");
const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const body = new Uint8Array(buffer.byteLength);
body.set(buffer);
return new Response(body.buffer, {
status: 200,
headers: {
"content-type": EXCEL_CONTENT_TYPE,
"content-disposition": `attachment; filename="project-manager-${stamp}.xlsx"`,
"cache-control": "no-store",
},
});
}
async function health(): Promise<Response> {
const rows = await sql<{ count: number }[]>`SELECT count(*)::int AS count FROM project_manager_projects`;
return jsonResponse({
ok: true,
service: "project-manager",
storage: { primary: "postgres", table: "project_manager_projects", projects: Number(rows[0]?.count ?? 0) },
capabilities: ["crud", "excel-import", "excel-export"],
startedAt: serviceStartedAt,
});
}
function notFound(pathname: string): Response {
return jsonResponse({ ok: false, error: `not found: ${pathname}` }, 404);
}
async function handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url);
const method = req.method.toUpperCase();
if (method === "OPTIONS") return new Response(null, { status: 204 });
if (url.pathname === "/health" && (method === "GET" || method === "HEAD")) return health();
if (url.pathname === "/logs" && (method === "GET" || method === "HEAD")) return jsonResponse({ ok: true, logs: recentLogs.slice(-200) });
if (url.pathname === "/api/projects/export.xlsx" && (method === "GET" || method === "HEAD")) return exportProjects();
if (url.pathname === "/api/projects" && (method === "GET" || method === "HEAD")) return listProjects(url);
if (url.pathname === "/api/projects" && method === "POST") return createProject(req);
if (url.pathname === "/api/import/excel" && method === "POST") return importExcelRoute(req);
if (url.pathname === "/api/import/projects" && method === "POST") return importRowsRoute(req);
if (url.pathname.startsWith("/api/projects/")) {
const id = decodeURIComponent(url.pathname.slice("/api/projects/".length));
if (!id) return jsonResponse({ ok: false, error: "project id is required" }, 400);
if (method === "GET" || method === "HEAD") {
const project = await getProject(id);
return project === null ? jsonResponse({ ok: false, error: `project not found: ${id}` }, 404) : jsonResponse({ ok: true, project });
}
if (method === "PUT" || method === "POST") return updateProjectRoute(req, id);
if (method === "DELETE") return deleteProjectRoute(id);
}
return notFound(url.pathname);
}
async function main(): Promise<void> {
await waitForSchema();
const server = Bun.serve({
hostname: config.host,
port: config.port,
async fetch(req): Promise<Response> {
try {
return await handleRequest(req);
} catch (error) {
return errorResponse(error, 500);
}
},
});
log("server_started", { host: config.host, port: config.port, url: server.url.toString() });
console.log(`project-manager listening on ${server.url.toString()}`);
}
main().catch((error) => {
log("server_failed", { error: errorToJson(error) });
console.error(error);
process.exit(1);
});
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}