|
@@ -7,6 +7,7 @@ const crypto = require('crypto');
|
|
|
const { sendJson, readBody } = require('../util.js');
|
|
const { sendJson, readBody } = require('../util.js');
|
|
|
const opsImport = require('../../ops-import.js');
|
|
const opsImport = require('../../ops-import.js');
|
|
|
const { createOpsStore } = require('../../ops-store.js');
|
|
const { createOpsStore } = require('../../ops-store.js');
|
|
|
|
|
+const { loadSchools } = require('../../schools-store.js');
|
|
|
|
|
|
|
|
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
|
const UPLOAD_ID_RE = /^\d{8}-\d{6}-[a-z0-9]{6}$/;
|
|
const UPLOAD_ID_RE = /^\d{8}-\d{6}-[a-z0-9]{6}$/;
|
|
@@ -113,6 +114,22 @@ function csvCell(value) {
|
|
|
return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
|
|
return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** 从文件名识别已登记学校(取最长匹配,如「浙江工商桐庐校区20261018…」→ 浙江工商大学桐庐校区) */
|
|
|
|
|
+function resolveSchoolFromFileName(fileName) {
|
|
|
|
|
+ let best = '';
|
|
|
|
|
+ let bestName = '';
|
|
|
|
|
+ for (const school of loadSchools()) {
|
|
|
|
|
+ for (const candidate of [school.name, ...(school.aliases || [])]) {
|
|
|
|
|
+ const name = String(candidate || '').trim();
|
|
|
|
|
+ if (name && fileName.includes(name) && name.length > bestName.length) {
|
|
|
|
|
+ best = school.name;
|
|
|
|
|
+ bestName = name;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return best;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function register(app, ctx) {
|
|
function register(app, ctx) {
|
|
|
const store = createOpsStore(ctx.config);
|
|
const store = createOpsStore(ctx.config);
|
|
|
|
|
|
|
@@ -147,16 +164,95 @@ function register(app, ctx) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const year = Number(body.year) || new Date().getFullYear();
|
|
const year = Number(body.year) || new Date().getFullYear();
|
|
|
- const school = String(body.school || '').trim().slice(0, 60);
|
|
|
|
|
|
|
+ let school = String(body.school || '').trim().slice(0, 60);
|
|
|
let parsed;
|
|
let parsed;
|
|
|
try {
|
|
try {
|
|
|
- parsed = opsImport.parseWorkbook(content, { year, school });
|
|
|
|
|
|
|
+ parsed = opsImport.parseAny(content, { year, school });
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
return sendJson(res, 400, { error: e.message || '解析失败', code: e.code || 'OPS_PARSE_FAILED' });
|
|
return sendJson(res, 400, { error: e.message || '解析失败', code: e.code || 'OPS_PARSE_FAILED' });
|
|
|
}
|
|
}
|
|
|
if (!parsed.rows.length) {
|
|
if (!parsed.rows.length) {
|
|
|
return sendJson(res, 400, { error: '未解析到有效数据行' });
|
|
return sendJson(res, 400, { error: '未解析到有效数据行' });
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ const uploadId = newUploadId();
|
|
|
|
|
+ const uploadedAt = new Date().toISOString();
|
|
|
|
|
+ const from = parsed.dates[0] || '';
|
|
|
|
|
+ const to = parsed.dates[parsed.dates.length - 1] || '';
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- 平台导出(全字段明细) ----------
|
|
|
|
|
+ if (parsed.kind === 'platform-export') {
|
|
|
|
|
+ // 表里没有学校列:优先用上传时选定的学校,其次从文件名识别(如「浙江工商桐庐校区20261018…」)
|
|
|
|
|
+ if (!school) school = resolveSchoolFromFileName(fileName);
|
|
|
|
|
+ if (!school) {
|
|
|
|
|
+ return sendJson(res, 400, { error: '请选择学校(平台导出表里没有学校列)', code: 'OPS_SCHOOL_REQUIRED' });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const existingRows = await store.queryPlatformRows({ from, to });
|
|
|
|
|
+ const known = new Map(existingRows.map((r) => [`${r.date}|${r.shopId}`, r]));
|
|
|
|
|
+ let newRows = 0;
|
|
|
|
|
+ let identicalRows = 0;
|
|
|
|
|
+ let conflictRows = 0;
|
|
|
|
|
+ const incoming = [];
|
|
|
|
|
+ for (const row of parsed.rows) {
|
|
|
|
|
+ const key = `${row.date}|${row.shopId}`;
|
|
|
|
|
+ const old = known.get(key);
|
|
|
|
|
+ if (!old) { newRows += 1; incoming.push(row); continue; }
|
|
|
|
|
+ const sameData = JSON.stringify(old.data) === JSON.stringify(row.data);
|
|
|
|
|
+ if (sameData && old.school === school) { identicalRows += 1; continue; }
|
|
|
|
|
+ conflictRows += 1;
|
|
|
|
|
+ if (body.force) incoming.push(row);
|
|
|
|
|
+ }
|
|
|
|
|
+ const toWrite = body.force ? parsed.rows : incoming;
|
|
|
|
|
+ const report = {
|
|
|
|
|
+ kind: 'platform-export',
|
|
|
|
|
+ rows: parsed.rows.length,
|
|
|
|
|
+ shops: parsed.shops.length,
|
|
|
|
|
+ newRows,
|
|
|
|
|
+ identicalRows,
|
|
|
|
|
+ conflictRows,
|
|
|
|
|
+ writtenRows: toWrite.length,
|
|
|
|
|
+ warningCount: parsed.warnings.length,
|
|
|
|
|
+ dates: { from, to },
|
|
|
|
|
+ year,
|
|
|
|
|
+ schoolOverride: school,
|
|
|
|
|
+ parser: { kind: 'platform-export', version: PARSER_VERSION },
|
|
|
|
|
+ warnings: parsed.warnings.slice(0, 500),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ await store.withTransaction(async (tx) => {
|
|
|
|
|
+ if (body.force) await tx.deletePlatformRowsBySha(sha256);
|
|
|
|
|
+ await tx.insertUpload({
|
|
|
|
|
+ id: uploadId,
|
|
|
|
|
+ originalName: fileName,
|
|
|
|
|
+ sha256,
|
|
|
|
|
+ sizeBytes: content.length,
|
|
|
|
|
+ uploadedAt,
|
|
|
|
|
+ year,
|
|
|
|
|
+ parserKind: 'platform-export',
|
|
|
|
|
+ parserVersion: PARSER_VERSION,
|
|
|
|
|
+ report,
|
|
|
|
|
+ content,
|
|
|
|
|
+ });
|
|
|
|
|
+ if (toWrite.length) {
|
|
|
|
|
+ await tx.upsertPlatformRows(toWrite.map((row) => ({ ...row, school, uploadId })));
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return sendJson(res, 500, { error: '写入失败', detail: e.message || String(e) });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return sendJson(res, 200, {
|
|
|
|
|
+ ok: true,
|
|
|
|
|
+ uploadId,
|
|
|
|
|
+ report,
|
|
|
|
|
+ warnings: parsed.warnings.slice(0, 200),
|
|
|
|
|
+ duplicateData: report.newRows === 0 && report.conflictRows === 0,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- 人工统计表 ----------
|
|
|
// 表格里没有「学校名称」列、上传时也没指定 → 让用户补选,避免落成空白分组
|
|
// 表格里没有「学校名称」列、上传时也没指定 → 让用户补选,避免落成空白分组
|
|
|
if (!parsed.shops.some((shop) => shop.school && shop.school.trim())) {
|
|
if (!parsed.shops.some((shop) => shop.school && shop.school.trim())) {
|
|
|
return sendJson(res, 400, {
|
|
return sendJson(res, 400, {
|
|
@@ -165,20 +261,16 @@ function register(app, ctx) {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const uploadId = newUploadId();
|
|
|
|
|
- const uploadedAt = new Date().toISOString();
|
|
|
|
|
- const from = parsed.dates[0] || '';
|
|
|
|
|
- const to = parsed.dates[parsed.dates.length - 1] || '';
|
|
|
|
|
-
|
|
|
|
|
// 覆盖计数:该区间内已存在的「日期 + 店铺」组合
|
|
// 覆盖计数:该区间内已存在的「日期 + 店铺」组合
|
|
|
let overwritten = 0;
|
|
let overwritten = 0;
|
|
|
try {
|
|
try {
|
|
|
- const existingRows = await store.queryRows({ from, to });
|
|
|
|
|
- const known = new Set(existingRows.map((r) => `${r.date}|${r.shopKey}`));
|
|
|
|
|
- for (const row of parsed.rows) if (known.has(`${row.date}|${row.shopKey}`)) overwritten += 1;
|
|
|
|
|
|
|
+ const existingDaily = await store.queryRows({ from, to });
|
|
|
|
|
+ const knownDaily = new Set(existingDaily.map((r) => `${r.date}|${r.shopKey}`));
|
|
|
|
|
+ for (const row of parsed.rows) if (knownDaily.has(`${row.date}|${row.shopKey}`)) overwritten += 1;
|
|
|
} catch { /* 计数失败不影响导入 */ }
|
|
} catch { /* 计数失败不影响导入 */ }
|
|
|
|
|
|
|
|
const report = {
|
|
const report = {
|
|
|
|
|
+ kind: 'manual-ops',
|
|
|
rows: parsed.rows.length,
|
|
rows: parsed.rows.length,
|
|
|
shops: parsed.shops.length,
|
|
shops: parsed.shops.length,
|
|
|
overwritten,
|
|
overwritten,
|
|
@@ -311,13 +403,44 @@ function register(app, ctx) {
|
|
|
const school = (upload && upload.report && upload.report.schoolOverride) || '';
|
|
const school = (upload && upload.report && upload.report.schoolOverride) || '';
|
|
|
let parsed;
|
|
let parsed;
|
|
|
try {
|
|
try {
|
|
|
- parsed = opsImport.parseWorkbook(record.content, { year, school });
|
|
|
|
|
|
|
+ parsed = opsImport.parseAny(record.content, { year, school });
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
return sendJson(res, 400, { error: e.message || '解析失败', code: e.code || 'OPS_PARSE_FAILED' });
|
|
return sendJson(res, 400, { error: e.message || '解析失败', code: e.code || 'OPS_PARSE_FAILED' });
|
|
|
}
|
|
}
|
|
|
const from = parsed.dates[0] || '';
|
|
const from = parsed.dates[0] || '';
|
|
|
const to = parsed.dates[parsed.dates.length - 1] || '';
|
|
const to = parsed.dates[parsed.dates.length - 1] || '';
|
|
|
|
|
+
|
|
|
|
|
+ if (parsed.kind === 'platform-export') {
|
|
|
|
|
+ const report = {
|
|
|
|
|
+ kind: 'platform-export',
|
|
|
|
|
+ rows: parsed.rows.length,
|
|
|
|
|
+ shops: parsed.shops.length,
|
|
|
|
|
+ newRows: parsed.rows.length,
|
|
|
|
|
+ identicalRows: 0,
|
|
|
|
|
+ conflictRows: 0,
|
|
|
|
|
+ writtenRows: parsed.rows.length,
|
|
|
|
|
+ warningCount: parsed.warnings.length,
|
|
|
|
|
+ dates: { from, to },
|
|
|
|
|
+ year,
|
|
|
|
|
+ schoolOverride: school,
|
|
|
|
|
+ parser: { kind: 'platform-export', version: PARSER_VERSION },
|
|
|
|
|
+ warnings: parsed.warnings.slice(0, 500),
|
|
|
|
|
+ reparsedAt: new Date().toISOString(),
|
|
|
|
|
+ };
|
|
|
|
|
+ try {
|
|
|
|
|
+ await store.withTransaction(async (tx) => {
|
|
|
|
|
+ await tx.deletePlatformRowsByUpload(params.id);
|
|
|
|
|
+ await tx.upsertPlatformRows(parsed.rows.map((row) => ({ ...row, school, uploadId: params.id })));
|
|
|
|
|
+ await tx.updateReport(params.id, report);
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return sendJson(res, 500, { error: '重新解析写入失败', detail: e.message || String(e) });
|
|
|
|
|
+ }
|
|
|
|
|
+ return sendJson(res, 200, { ok: true, uploadId: params.id, report });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const report = {
|
|
const report = {
|
|
|
|
|
+ kind: 'manual-ops',
|
|
|
rows: parsed.rows.length,
|
|
rows: parsed.rows.length,
|
|
|
shops: parsed.shops.length,
|
|
shops: parsed.shops.length,
|
|
|
overwritten: 0,
|
|
overwritten: 0,
|