Przeglądaj źródła

feat: 上传时可选学校 + 同文件强制重传替换旧批次

- 上传弹窗新增「学校」下拉:自动读表格「学校名称」列 / 选已有学校 / 手动输入
- 指定学校时覆盖表内学校列(shopKey 随之一致);表内无学校列且未指定 → 400 提示补选
- 重新解析沿用上次选择的学校(report.schoolOverride)
- 强制重传同一文件时按 sha256 删除旧批次行并清理孤儿店铺,避免换学校后同批数据重复
- 补 4 条测试(学校覆盖 / 保留 / 无学校列 / sha 接管),共 34/34
liangzhao 2 tygodni temu
rodzic
commit
7006aa2871

+ 3 - 2
ops-import.js

@@ -176,11 +176,12 @@ function parseDateColumns(header, year) {
 /**
  * 人工统计横表 → 长表行
  * @param {Array<Array<any>>} cells
- * @param {{ year?: number }} options
+ * @param {{ year?: number, school?: string }} options  school 非空时覆盖表格里的「学校名称」列
  * @returns {{ rows: Array<object>, shops: Array<object>, warnings: Array<object>, dates: string[] }}
  */
 function parseManualOps(cells, options = {}) {
   const year = Number(options.year) || new Date().getFullYear();
+  const schoolOverride = String(options.school || '').trim();
   const header = (cells && cells[0]) || [];
   if (!header.length) throw parseError('表格为空');
   if (detect(cells) !== 'manual-ops') throw parseError('表头不符合人工统计表格式', 'OPS_FORMAT_UNSUPPORTED');
@@ -206,7 +207,7 @@ function parseManualOps(cells, options = {}) {
     const shopName = normalizeHeader(line[cols.shopName]);
     if (!shopName) continue; // 占位行 / 空行
 
-    const school = cols.school >= 0 ? normalizeHeader(line[cols.school]) : '';
+    const school = schoolOverride || (cols.school >= 0 ? normalizeHeader(line[cols.school]) : '');
     const shopId = cols.shopId >= 0 ? toShopId(line[cols.shopId]) : '';
     const status = cols.status >= 0 ? normalizeHeader(line[cols.status]) : '';
     const shopKey = shopId || `${school}|${shopName}`;

+ 9 - 0
public/index.html

@@ -1091,6 +1091,15 @@
         <input type="number" class="ops-input" id="opsYear" min="2000" max="2100" value="2026">
         <small>表里的日期只有「9.9」这种格式,没有年份,需要你确认</small>
       </div>
+      <div class="ops-field">
+        <label for="opsSchool">学校</label>
+        <select class="ops-input ops-select" id="opsSchool">
+          <option value="">自动(读取表格里的「学校名称」列)</option>
+          <option value="__custom">手动输入…</option>
+        </select>
+        <input type="text" class="ops-input" id="opsSchoolCustom" placeholder="输入学校名称" hidden>
+        <small>表格里没有「学校名称」列,或想把这批数据归到指定学校时,在这里选或手动填</small>
+      </div>
       <div class="ops-upload-result" id="opsUploadResult"></div>
     </div>
     <div class="modal-footer">

+ 33 - 1
public/ops.js

@@ -395,6 +395,28 @@
 
   // ---------- 上传 ----------
 
+  function fillSchoolOptions() {
+    const select = $('opsSchool');
+    if (!select) return;
+    const known = (state.summary && state.summary.schools) || [];
+    select.innerHTML = '<option value="">自动(读取表格里的「学校名称」列)</option>'
+      + known.map((s) => `<option value="${esc(s.name)}">${esc(s.name)}</option>`).join('')
+      + '<option value="__custom">手动输入…</option>';
+    select.value = '';
+    const custom = $('opsSchoolCustom');
+    if (custom) {
+      custom.value = '';
+      custom.hidden = true;
+    }
+  }
+
+  function selectedSchool() {
+    const select = $('opsSchool');
+    if (!select) return '';
+    if (select.value === '__custom') return String(($('opsSchoolCustom') || {}).value || '').trim();
+    return select.value;
+  }
+
   function openUpload() {
     const modal = $('opsUploadModal');
     if (!modal) return;
@@ -402,6 +424,7 @@
     $('opsFileName').textContent = '';
     $('opsUploadResult').innerHTML = '';
     $('opsYear').value = String(new Date().getFullYear());
+    fillSchoolOptions();
     $('opsUploadStart').disabled = false;
     modal.hidden = false;
   }
@@ -442,7 +465,7 @@
       const { res, body } = await api('/api/ops/upload', {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ fileName: file.name, contentBase64: base64, year, force: !!force }),
+        body: JSON.stringify({ fileName: file.name, contentBase64: base64, year, school, force: !!force }),
       });
       if (res.status === 409) {
         result.innerHTML = `<span class="ops-error">该文件已导入过(uploadId ${esc(body && body.uploadId)})</span>
@@ -544,6 +567,15 @@
     if (cancel) cancel.addEventListener('click', closeUpload);
     const closeBtn = $('opsUploadClose');
     if (closeBtn) closeBtn.addEventListener('click', closeUpload);
+    const schoolSelect = $('opsSchool');
+    if (schoolSelect) {
+      schoolSelect.addEventListener('change', () => {
+        const custom = $('opsSchoolCustom');
+        if (!custom) return;
+        custom.hidden = schoolSelect.value !== '__custom';
+        if (!custom.hidden) custom.focus();
+      });
+    }
     const file = $('opsFile');
     if (file) {
       file.addEventListener('change', () => {

+ 4 - 1
public/style.css

@@ -1236,7 +1236,6 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .ops-field { margin-top: 14px; display: flex; flex-direction: column; gap: 4px; }
 .ops-field label { font-size: 12.5px; color: var(--text-2); font-weight: 600; }
 .ops-field small { color: var(--text-3); font-size: 11.5px; }
-.ops-field .ops-input { width: 120px; }
 .ops-upload-result { margin-top: 14px; font-size: 12.5px; line-height: 1.6; }
 .ops-ok { padding: 10px 12px; color: var(--text); background: #f4fbf6; border: 1px solid #cfe9d8; border-radius: 10px; }
 .ops-error { color: #c0392b; }
@@ -1248,3 +1247,7 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .ops-empty { min-height: 320px; }
 .ops-school-row td { background: #f7f5f2; font-weight: 650; color: var(--text-2); border-top: 1px solid var(--border); }
 .ops-school-row em { font-style: normal; margin-left: 8px; font-weight: 450; color: var(--text-3); font-size: 11.5px; }
+
+.ops-select { width: 360px; max-width: 100%; height: 32px; }
+#opsSchoolCustom { width: 360px; max-width: 100%; }
+.ops-field .ops-input[type="number"] { width: 140px; }

+ 38 - 0
scripts/test-ops-import.js

@@ -143,6 +143,24 @@ async function main() {
     expect(noId.every((r) => r.shopKey.includes('|')), 'shopKey 兜底格式不对');
   });
 
+  await check('fixture 指定学校时覆盖表格里的学校列', () => {
+    const forced = importer.parseWorkbook(fixture, { year: 2026, school: '统一校区' });
+    expect(forced.rows.length === 1044, `rows ${forced.rows.length}`);
+    expect(forced.rows.every((r) => r.school === '统一校区'), '仍有行没被覆盖');
+    expect([...new Set(forced.shops.map((x) => x.school))].length === 1, '学校数应为 1');
+    expect(forced.shops.every((x) => x.school === '统一校区'), '店铺注册表学校未覆盖');
+  });
+
+  await check('无「学校名称」列时 school 为空(由上传接口要求补选)', () => {
+    const cells = [
+      ['店铺名称', 'ID', '9.9订单量', '9.9营收', '9.9曝光量', '9.9进店转换率'],
+      ['测试店', '123', 1, 2, 3, 0.1],
+    ];
+    const out = importer.parseManualOps(cells, { year: 2026 });
+    expect(out.rows.length === 1, `rows ${out.rows.length}`);
+    expect(out.rows[0].school === '', `school='${out.rows[0].school}'`);
+  });
+
   await check('fixture 占位行未产生数据', () => {
     expect(parsed.shops.every((s) => s.shopName && s.shopName.trim()), '存在空店铺名');
   });
@@ -284,6 +302,26 @@ async function main() {
       expect(after === before, '坏文件不应产生上传记录');
     });
 
+    await check('E2E 上传时指定学校 → 覆盖并写入', async () => {
+      const res = await fetch(base + '/api/ops/upload', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ fileName: '样例.xlsx', contentBase64: fixture.toString('base64'), year: 2026, force: true, school: '测试校区' }),
+      }).then(readJson);
+      expect(res.status === 200, `status ${res.status}`);
+      const sum = await fetch(base + '/api/ops/summary').then(readJson);
+      expect(sum.body.schools.length === 1 && sum.body.schools[0].name === '测试校区', JSON.stringify(sum.body.schools));
+    });
+
+    await check('E2E 重新解析保留指定学校', async () => {
+      const list = await fetch(base + '/api/ops/uploads').then(readJson);
+      const id = list.body.uploads[0].id;
+      const r = await fetch(`${base}/api/ops/uploads/${id}/reparse`, { method: 'POST' }).then(readJson);
+      expect(r.status === 200, `status ${r.status}`);
+      const sum = await fetch(base + '/api/ops/summary').then(readJson);
+      expect(sum.body.schools.length === 1 && sum.body.schools[0].name === '测试校区', JSON.stringify(sum.body.schools));
+    });
+
     await check('E2E 导入记录只含元数据', async () => {
       const r = await fetch(base + '/api/ops/uploads').then(readJson);
       expect(r.body.uploads.length >= 1, '记录为空');

+ 17 - 3
server/routes/ops.js

@@ -139,23 +139,31 @@ function register(app, ctx) {
     if (!(await requireHealthy(store, res))) return;
 
     const sha256 = crypto.createHash('sha256').update(content).digest('hex');
+    const existing = await store.findUploadBySha(sha256);
     if (!body.force) {
-      const existing = await store.findUploadBySha(sha256);
       if (existing) {
         return sendJson(res, 409, { error: '该文件已导入过', uploadId: existing, duplicate: true });
       }
     }
 
     const year = Number(body.year) || new Date().getFullYear();
+    const school = String(body.school || '').trim().slice(0, 60);
     let parsed;
     try {
-      parsed = opsImport.parseWorkbook(content, { year });
+      parsed = opsImport.parseWorkbook(content, { year, school });
     } catch (e) {
       return sendJson(res, 400, { error: e.message || '解析失败', code: e.code || 'OPS_PARSE_FAILED' });
     }
     if (!parsed.rows.length) {
       return sendJson(res, 400, { error: '未解析到有效数据行' });
     }
+    // 表格里没有「学校名称」列、上传时也没指定 → 让用户补选,避免落成空白分组
+    if (!parsed.shops.some((shop) => shop.school && shop.school.trim())) {
+      return sendJson(res, 400, {
+        error: '表格里没有「学校名称」列,请在上传时选择或填写学校',
+        code: 'OPS_SCHOOL_REQUIRED',
+      });
+    }
 
     const uploadId = newUploadId();
     const uploadedAt = new Date().toISOString();
@@ -177,12 +185,14 @@ function register(app, ctx) {
       warningCount: parsed.warnings.length,
       dates: { from, to },
       year,
+      schoolOverride: school,
       parser: { kind: 'manual-ops', version: PARSER_VERSION },
       warnings: parsed.warnings.slice(0, 500),
     };
 
     try {
       await store.withTransaction(async (tx) => {
+        if (body.force) await tx.deleteRowsBySha(sha256);
         await tx.insertUpload({
           id: uploadId,
           originalName: fileName,
@@ -197,6 +207,7 @@ function register(app, ctx) {
         });
         await tx.upsertRows(parsed.rows.map((row) => ({ ...row, uploadId })));
         await tx.upsertShops(parsed.shops.map((shop) => ({ ...shop, lastUploadId: uploadId })));
+        await tx.pruneOrphanShops();
       });
     } catch (e) {
       return sendJson(res, 500, { error: '写入失败', detail: e.message || String(e) });
@@ -297,9 +308,10 @@ function register(app, ctx) {
     if (!record) return sendJson(res, 404, { error: '找不到该上传记录' });
     const upload = await store.getUpload(params.id);
     const year = (upload && upload.year) || new Date().getFullYear();
+    const school = (upload && upload.report && upload.report.schoolOverride) || '';
     let parsed;
     try {
-      parsed = opsImport.parseWorkbook(record.content, { year });
+      parsed = opsImport.parseWorkbook(record.content, { year, school });
     } catch (e) {
       return sendJson(res, 400, { error: e.message || '解析失败', code: e.code || 'OPS_PARSE_FAILED' });
     }
@@ -312,6 +324,7 @@ function register(app, ctx) {
       warningCount: parsed.warnings.length,
       dates: { from, to },
       year,
+      schoolOverride: school,
       parser: { kind: 'manual-ops', version: PARSER_VERSION },
       warnings: parsed.warnings.slice(0, 500),
       reparsedAt: new Date().toISOString(),
@@ -321,6 +334,7 @@ function register(app, ctx) {
         await tx.deleteRowsByUpload(params.id);
         await tx.upsertRows(parsed.rows.map((row) => ({ ...row, uploadId: params.id })));
         await tx.upsertShops(parsed.shops.map((shop) => ({ ...shop, lastUploadId: params.id })));
+        await tx.pruneOrphanShops();
         await tx.updateReport(params.id, report);
       });
     } catch (e) {

+ 6 - 0
server/stores/ops-pg.js

@@ -194,6 +194,12 @@ function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public
       async deleteRowsByUpload(uploadId) {
         await client.query('DELETE FROM ops_row WHERE upload_id = $1', [uploadId]);
       },
+      async deleteRowsBySha(sha256) {
+        await client.query('DELETE FROM ops_row WHERE upload_id IN (SELECT id FROM ops_upload WHERE sha256 = $1)', [sha256]);
+      },
+      async pruneOrphanShops() {
+        await client.query('DELETE FROM ops_shop s WHERE NOT EXISTS (SELECT 1 FROM ops_row r WHERE r.shop_key = s.key)');
+      },
       async updateReport(id, report) {
         await client.query('UPDATE ops_upload SET report = $1 WHERE id = $2', [JSON.stringify(report || {}), id]);
       },

+ 9 - 1
server/stores/ops-sqlite.js

@@ -207,6 +207,14 @@ function createSqliteStore({ file }) {
     open().prepare('DELETE FROM ops_row WHERE upload_id = ?').run(uploadId);
   }
 
+  function deleteRowsBySha(sha256) {
+    open().prepare('DELETE FROM ops_row WHERE upload_id IN (SELECT id FROM ops_upload WHERE sha256 = ?)').run(sha256);
+  }
+
+  function pruneOrphanShops() {
+    open().prepare('DELETE FROM ops_shop WHERE key NOT IN (SELECT DISTINCT shop_key FROM ops_row)').run();
+  }
+
   function updateReport(id, report) {
     open().prepare('UPDATE ops_upload SET report = ? WHERE id = ?').run(JSON.stringify(report || {}), id);
   }
@@ -215,7 +223,7 @@ function createSqliteStore({ file }) {
     const handle = open();
     handle.exec('BEGIN IMMEDIATE');
     try {
-      const result = await fn({ insertUpload, upsertRows, upsertShops, deleteRowsByUpload, updateReport });
+      const result = await fn({ insertUpload, upsertRows, upsertShops, deleteRowsByUpload, deleteRowsBySha, pruneOrphanShops, updateReport });
       handle.exec('COMMIT');
       // 写事务结束后做一次 checkpoint:让 data/ops.db 自包含,备份时不必关心 -wal
       try { handle.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* 有读事务时跳过 */ }