Commit bbb6ce14 bbb6ce1447e193340855290021205a90c07891fe by 沈秋雨

feat(review): 优化审核结果提交和重复数据合并逻辑

1 parent 2393078b
...@@ -646,6 +646,7 @@ ...@@ -646,6 +646,7 @@
646 <option value="unsure">待确认</option> 646 <option value="unsure">待确认</option>
647 <option value="reviewed">已审核</option> 647 <option value="reviewed">已审核</option>
648 <option value="submitted">已提交</option> 648 <option value="submitted">已提交</option>
649 <option value="deleted">已删除</option>
649 <option value="">全部</option> 650 <option value="">全部</option>
650 <option value="l1_hit">L1 命中</option> 651 <option value="l1_hit">L1 命中</option>
651 <option value="conflict">L1/L2 冲突</option> 652 <option value="conflict">L1/L2 冲突</option>
...@@ -661,7 +662,7 @@ ...@@ -661,7 +662,7 @@
661 <option value="200">200</option> 662 <option value="200">200</option>
662 </select> 663 </select>
663 <button id="exportBtn">导出标注</button> 664 <button id="exportBtn">导出标注</button>
664 <button id="importReviewedBtn">入库审核通过</button> 665 <button id="importReviewedBtn">提交审核结果</button>
665 <div class="toolbar-right"> 666 <div class="toolbar-right">
666 <span>审核人:</span><span id="reviewerDisplay"></span> 667 <span>审核人:</span><span id="reviewerDisplay"></span>
667 <button id="logoutBtn" class="secondary">登出</button> 668 <button id="logoutBtn" class="secondary">登出</button>
...@@ -1002,7 +1003,7 @@ ...@@ -1002,7 +1003,7 @@
1002 group.review_claim_expires_at = record.review_claim_expires_at || ''; 1003 group.review_claim_expires_at = record.review_claim_expires_at || '';
1003 group.claimed_by_me = group.review_claimed_by === state.reviewer; 1004 group.claimed_by_me = group.review_claimed_by === state.reviewer;
1004 group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(record.biz_review_status); 1005 group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(record.biz_review_status);
1005 group.submitted = group.reviewed && record.staging_status === 'imported'; 1006 group.submitted = group.reviewed && ['imported', 'skipped'].includes(record.staging_status);
1006 } 1007 }
1007 1008
1008 function currentQuery() { 1009 function currentQuery() {
...@@ -1059,12 +1060,12 @@ ...@@ -1059,12 +1060,12 @@
1059 metric('待审核', row.pending_review_count || '0'), 1060 metric('待审核', row.pending_review_count || '0'),
1060 metric('已审核', row.reviewed_count || '0'), 1061 metric('已审核', row.reviewed_count || '0'),
1061 metric('已提交', row.submitted_count || '0'), 1062 metric('已提交', row.submitted_count || '0'),
1062 metric('确认入库', row.approved_count || '0'), 1063 metric('确认不重复', row.approved_count || '0'),
1063 metric('确认不入库', row.rejected_count || '0'), 1064 metric('确认重复', row.rejected_count || '0'),
1064 metric('已删除', row.deleted_count || '0'), 1065 metric('已删除', row.deleted_count || '0'),
1065 metric('new', row.new_count || '-'), 1066 metric('新歌', row.new_count || '-'),
1066 metric('merge', row.duplicate_count || '0'), 1067 metric('重复', row.duplicate_count || '0'),
1067 metric('skip', row.skip_count || '0') 1068 metric('已跳过', row.skip_count || '0')
1068 ].join(''); 1069 ].join('');
1069 } 1070 }
1070 1071
...@@ -1182,20 +1183,21 @@ ...@@ -1182,20 +1183,21 @@
1182 </div>`; 1183 </div>`;
1183 } 1184 }
1184 1185
1185 async function updateStagingReview(query, decision, note) { 1186 async function updateStagingReview(query, decision, note, candidateId = '') {
1186 return reviewPostJSON('/api/staging-review', { 1187 return reviewPostJSON('/api/staging-review', {
1187 staging_id: String(query.staging_id), 1188 staging_id: String(query.staging_id),
1188 expected_version: Number(query.review_version || 0), 1189 expected_version: Number(query.review_version || 0),
1189 client_token: state.clientToken, 1190 client_token: state.clientToken,
1190 reviewer: state.reviewer, 1191 reviewer: state.reviewer,
1191 decision, 1192 decision,
1192 note 1193 note,
1194 candidate_id: candidateId
1193 }); 1195 });
1194 } 1196 }
1195 1197
1196 const _DB_REVIEW_LABELS = { 1198 const _DB_REVIEW_LABELS = {
1197 approved_import: '确认入库(待入库)', 1199 approved_import: '确认不重复(待入库)',
1198 rejected_duplicate: '确认不入库', 1200 rejected_duplicate: '确认重复(待合并作者)',
1199 unsure: '待确认', 1201 unsure: '待确认',
1200 deleted: '已删除', 1202 deleted: '已删除',
1201 }; 1203 };
...@@ -1293,8 +1295,9 @@ ...@@ -1293,8 +1295,9 @@
1293 const needsManualReview = rowDecision(query) === 'review'; 1295 const needsManualReview = rowDecision(query) === 'review';
1294 const hasFinalReview = Boolean(query.review_status) && 1296 const hasFinalReview = Boolean(query.review_status) &&
1295 !['pending', 'not_required', 'unsure'].includes(query.review_status); 1297 !['pending', 'not_required', 'unsure'].includes(query.review_status);
1296 const canUndoReview = hasFinalReview && query.staging_status !== 'imported'; 1298 const isSubmitted = ['imported', 'skipped'].includes(query.staging_status);
1297 const canDeleteRecord = query.staging_status !== 'imported'; 1299 const canUndoReview = hasFinalReview && query.staging_status === 'staged';
1300 const canDeleteRecord = !['imported', 'skipped'].includes(query.staging_status);
1298 1301
1299 // Sticky area: 新入库歌词 + 召回候选(各含音频播放器) 1302 // Sticky area: 新入库歌词 + 召回候选(各含音频播放器)
1300 els.stickyInfo.innerHTML = ` 1303 els.stickyInfo.innerHTML = `
...@@ -1339,7 +1342,7 @@ ...@@ -1339,7 +1342,7 @@
1339 <div class="section-head"> 1342 <div class="section-head">
1340 <h2 class="section-title">人工标注</h2> 1343 <h2 class="section-title">人工标注</h2>
1341 ${hasFinalReview 1344 ${hasFinalReview
1342 ? `<span class="pill ${query.review_status === 'approved_import' ? 'new' : query.review_status === 'rejected_duplicate' ? 'duplicate' : query.review_status === 'deleted' ? 'duplicate' : ''}">${query.review_status === 'deleted' ? '已删除' : query.staging_status === 'imported' ? '已提交' : '已审核'}</span>` 1345 ? `<span class="pill ${query.review_status === 'approved_import' ? 'new' : query.review_status === 'rejected_duplicate' ? 'duplicate' : query.review_status === 'deleted' ? 'duplicate' : ''}">${query.review_status === 'deleted' ? '已删除' : isSubmitted ? '已提交' : '已审核'}</span>`
1343 : ''} 1346 : ''}
1344 </div> 1347 </div>
1345 <div class="section-body"> 1348 <div class="section-body">
...@@ -1347,7 +1350,9 @@ ...@@ -1347,7 +1350,9 @@
1347 <div class="review-bar" style="align-items:center"> 1350 <div class="review-bar" style="align-items:center">
1348 <span style="flex:1"> 1351 <span style="flex:1">
1349 <b>${esc(query.review_status === 'approved_import' && query.staging_status === 'imported' 1352 <b>${esc(query.review_status === 'approved_import' && query.staging_status === 'imported'
1350 ? '确认入库(已提交)' 1353 ? '确认不重复(已入库)'
1354 : query.review_status === 'rejected_duplicate' && query.staging_status === 'skipped'
1355 ? '确认重复(作者已合并)'
1351 : (_DB_REVIEW_LABELS[query.review_status] || query.review_status))}</b> 1356 : (_DB_REVIEW_LABELS[query.review_status] || query.review_status))}</b>
1352 ${query.reviewed_by ? ` · 审核人 ${esc(query.reviewed_by)}` : ''} 1357 ${query.reviewed_by ? ` · 审核人 ${esc(query.reviewed_by)}` : ''}
1353 ${query.reviewed_at ? ` · ${esc(query.reviewed_at)}` : ''} 1358 ${query.reviewed_at ? ` · ${esc(query.reviewed_at)}` : ''}
...@@ -1359,7 +1364,7 @@ ...@@ -1359,7 +1364,7 @@
1359 <div class="review-bar"> 1364 <div class="review-bar">
1360 ${['duplicate', 'not_duplicate', 'unsure'].map(value => ` 1365 ${['duplicate', 'not_duplicate', 'unsure'].map(value => `
1361 <button class="review-choice ${selectedReview.final_decision === value ? 'active' : ''}" data-review="${value}"> 1366 <button class="review-choice ${selectedReview.final_decision === value ? 'active' : ''}" data-review="${value}">
1362 ${value === 'duplicate' ? '确认不入库' : value === 'not_duplicate' ? '确认入库' : '待确认'} 1367 ${value === 'duplicate' ? '确认重复' : value === 'not_duplicate' ? '确认不重复' : '待确认'}
1363 </button>`).join('')} 1368 </button>`).join('')}
1364 <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注"> 1369 <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注">
1365 <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button> 1370 <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button>
...@@ -1411,7 +1416,9 @@ ...@@ -1411,7 +1416,9 @@
1411 renderDetail(); 1416 renderDetail();
1412 return; 1417 return;
1413 } 1418 }
1414 const payload = await updateStagingReview(query, dbDecision, note); 1419 const payload = await updateStagingReview(
1420 query, dbDecision, note, localDecision === 'duplicate' ? (candidate?.candidate_id || '') : ''
1421 );
1415 for (const row of state.queryRows) applyReviewSnapshot(row, payload.record); 1422 for (const row of state.queryRows) applyReviewSnapshot(row, payload.record);
1416 // 刷新统计和加载列表并行 1423 // 刷新统计和加载列表并行
1417 await Promise.all([reloadSummary(), loadGroups()]); 1424 await Promise.all([reloadSummary(), loadGroups()]);
...@@ -1530,12 +1537,12 @@ ...@@ -1530,12 +1537,12 @@
1530 alert('历史报表仅供查看,请在 staging-db 中执行入库。'); 1537 alert('历史报表仅供查看,请在 staging-db 中执行入库。');
1531 return; 1538 return;
1532 } 1539 }
1533 if (!confirm('确认入库数据库中所有"确认入库(待入库)"样本?')) return; 1540 if (!confirm('确认提交所有审核结果?“确认不重复”将入库,“确认重复”将把词/曲作者增量合并到已有记录。')) return;
1534 els.importReviewedBtn.disabled = true; 1541 els.importReviewedBtn.disabled = true;
1535 els.importReviewedBtn.textContent = '入库中...'; 1542 els.importReviewedBtn.textContent = '入库中...';
1536 try { 1543 try {
1537 const payload = await reviewPostJSON('/api/import-reviewed'); 1544 const payload = await reviewPostJSON('/api/import-reviewed');
1538 alert(`入库完成:${payload.inserted_count} 条。结果文件:${payload.review_csv}`); 1545 alert(`提交完成:不重复入库 ${payload.inserted_count} 条,重复样本 ${payload.duplicate_count || 0} 条,其中作者有增量 ${payload.author_merged_count || 0} 条。结果文件:${payload.review_csv}`);
1539 // 刷新统计和加载列表并行 1546 // 刷新统计和加载列表并行
1540 await Promise.all([reloadSummary(), loadGroups()]); 1547 await Promise.all([reloadSummary(), loadGroups()]);
1541 renderAll(); 1548 renderAll();
...@@ -1543,14 +1550,17 @@ ...@@ -1543,14 +1550,17 @@
1543 if (err.status === 409 && err.payload?.code === 'import_conflict') { 1550 if (err.status === 409 && err.payload?.code === 'import_conflict') {
1544 const conflicts = err.payload.conflicts || []; 1551 const conflicts = err.payload.conflicts || [];
1545 const details = conflicts.slice(0, 20).map((row, index) => { 1552 const details = conflicts.slice(0, 20).map((row, index) => {
1546 const types = (row.conflict_types || []).map(type => 1553 const typeLabels = {
1547 type === 'primary_key' ? '主键 ID 冲突' : '来源键冲突' 1554 primary_key: '主键 ID 冲突',
1548 ).join(' + ') || '唯一键冲突'; 1555 source_key: '来源键冲突',
1556 duplicate_target_missing: '未找到已入库的重复目标'
1557 };
1558 const types = (row.conflict_types || []).map(type => typeLabels[type] || type).join(' + ') || '数据冲突';
1549 const targetIds = [row.id_conflict_target_id, row.source_conflict_target_id] 1559 const targetIds = [row.id_conflict_target_id, row.source_conflict_target_id]
1550 .filter(value => value !== null && value !== undefined) 1560 .filter(value => value !== null && value !== undefined)
1551 .filter((value, i, values) => values.indexOf(value) === i) 1561 .filter((value, i, values) => values.indexOf(value) === i)
1552 .join('/'); 1562 .join('/');
1553 return `${index + 1}. ${row.name || '(无歌名)'} | source=${row.source_table_name || '-'}:${row.source_song_id || '-'} | staging_id=${row.staging_id} | 待入库ID=${row.incoming_id || '-'} | ${types} | 正式表ID=${targetIds || '-'}`; 1563 return `${index + 1}. ${row.name || '(无歌名)'} | source=${row.source_table_name || '-'}:${row.source_song_id || '-'} | staging_id=${row.staging_id} | 待入库ID=${row.incoming_id || '-'} | 重复候选=${row.matched_song_id || '-'} | ${types} | 正式表ID=${targetIds || '-'}`;
1554 }); 1564 });
1555 if (conflicts.length > 20) details.push(`……另有 ${conflicts.length - 20} 条未展开`); 1565 if (conflicts.length > 20) details.push(`……另有 ${conflicts.length - 20} 条未展开`);
1556 alert([ 1566 alert([
...@@ -1565,7 +1575,7 @@ ...@@ -1565,7 +1575,7 @@
1565 } 1575 }
1566 } finally { 1576 } finally {
1567 els.importReviewedBtn.disabled = false; 1577 els.importReviewedBtn.disabled = false;
1568 els.importReviewedBtn.textContent = '入库审核通过'; 1578 els.importReviewedBtn.textContent = '提交审核结果';
1569 } 1579 }
1570 } 1580 }
1571 1581
...@@ -1659,7 +1669,7 @@ ...@@ -1659,7 +1669,7 @@
1659 group.review_claim_expires_at = remote.review_claim_expires_at || ''; 1669 group.review_claim_expires_at = remote.review_claim_expires_at || '';
1660 group.claimed_by_me = Boolean(remote.claimed_by_me); 1670 group.claimed_by_me = Boolean(remote.claimed_by_me);
1661 group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(remote.biz_review_status); 1671 group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(remote.biz_review_status);
1662 group.submitted = group.reviewed && remote.staging_status === 'imported'; 1672 group.submitted = group.reviewed && ['imported', 'skipped'].includes(remote.staging_status);
1663 } 1673 }
1664 const query = currentQuery(); 1674 const query = currentQuery();
1665 const selectedRemote = query ? statuses.get(String(query.staging_id)) : null; 1675 const selectedRemote = query ? statuses.get(String(query.staging_id)) : null;
......
...@@ -8,17 +8,20 @@ import csv ...@@ -8,17 +8,20 @@ import csv
8 import json 8 import json
9 import mimetypes 9 import mimetypes
10 import os 10 import os
11 import re
11 import secrets 12 import secrets
12 import subprocess 13 import subprocess
13 import sys 14 import sys
14 import threading 15 import threading
15 import time 16 import time
17 import unicodedata
16 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 18 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
17 from pathlib import Path 19 from pathlib import Path
18 from urllib.parse import parse_qs, unquote, urlparse 20 from urllib.parse import parse_qs, unquote, urlparse
19 21
20 import pymysql 22 import pymysql
21 import requests 23 import requests
24 import opencc
22 from dbutils.pooled_db import PooledDB 25 from dbutils.pooled_db import PooledDB
23 from dotenv import load_dotenv 26 from dotenv import load_dotenv
24 27
...@@ -79,6 +82,9 @@ REVIEW_SCHEMA_COLUMNS = { ...@@ -79,6 +82,9 @@ REVIEW_SCHEMA_COLUMNS = {
79 "review_claim_expires_at": "datetime DEFAULT NULL COMMENT '领取过期时间'", 82 "review_claim_expires_at": "datetime DEFAULT NULL COMMENT '领取过期时间'",
80 "review_version": "bigint(20) NOT NULL DEFAULT '0' COMMENT '审核乐观锁版本'", 83 "review_version": "bigint(20) NOT NULL DEFAULT '0' COMMENT '审核乐观锁版本'",
81 } 84 }
85 _AUTHOR_SEP_RE = re.compile(r"[,,/;;、]")
86 _AUTHOR_T2S = opencc.OpenCC("t2s")
87 _AUTHOR_METADATA_PUNCT = set(' \t\n\r,。!?;:、"“”‘’·…—~!¥()【】《》〈〉「」『』﹏,.:;!?()[]{}<>|/\\_-')
82 88
83 89
84 class ReviewConflictError(Exception): 90 class ReviewConflictError(Exception):
...@@ -250,8 +256,8 @@ def _staging_summary() -> list[dict[str, str]]: ...@@ -250,8 +256,8 @@ def _staging_summary() -> list[dict[str, str]]:
250 SUM(CASE WHEN biz_review_status = 'approved_import' THEN 1 ELSE 0 END) AS approved_count, 256 SUM(CASE WHEN biz_review_status = 'approved_import' THEN 1 ELSE 0 END) AS approved_count,
251 SUM(CASE WHEN biz_review_status = 'rejected_duplicate' THEN 1 ELSE 0 END) AS rejected_count, 257 SUM(CASE WHEN biz_review_status = 'rejected_duplicate' THEN 1 ELSE 0 END) AS rejected_count,
252 SUM(CASE WHEN biz_review_status = 'deleted' THEN 1 ELSE 0 END) AS deleted_count, 258 SUM(CASE WHEN biz_review_status = 'deleted' THEN 1 ELSE 0 END) AS deleted_count,
253 SUM(CASE WHEN biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted') AND staging_status <> 'imported' THEN 1 ELSE 0 END) AS reviewed_count, 259 SUM(CASE WHEN biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted') AND staging_status NOT IN ('imported', 'skipped') THEN 1 ELSE 0 END) AS reviewed_count,
254 SUM(CASE WHEN biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted') AND staging_status = 'imported' THEN 1 ELSE 0 END) AS submitted_count 260 SUM(CASE WHEN biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted') AND staging_status IN ('imported', 'skipped') THEN 1 ELSE 0 END) AS submitted_count
255 FROM ( 261 FROM (
256 SELECT source_song_id, dedup_action, biz_review_status, staging_status 262 SELECT source_song_id, dedup_action, biz_review_status, staging_status
257 FROM {TARGET_TABLE_NAME_TMP} 263 FROM {TARGET_TABLE_NAME_TMP}
...@@ -356,10 +362,13 @@ def _staging_groups_response( ...@@ -356,10 +362,13 @@ def _staging_groups_response(
356 where_clauses.append("s.dedup_action = 'skip'") 362 where_clauses.append("s.dedup_action = 'skip'")
357 elif decision == 'reviewed': 363 elif decision == 'reviewed':
358 where_clauses.append("s.biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted')") 364 where_clauses.append("s.biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted')")
359 where_clauses.append("s.staging_status <> 'imported'") 365 where_clauses.append("s.staging_status NOT IN ('imported', 'skipped')")
360 elif decision == 'submitted': 366 elif decision == 'submitted':
361 where_clauses.append("s.biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted')") 367 where_clauses.append("s.biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted')")
362 where_clauses.append("s.staging_status = 'imported'") 368 where_clauses.append("s.staging_status IN ('imported', 'skipped')")
369 elif decision == 'deleted':
370 where_clauses.append("s.biz_review_status = 'deleted'")
371 where_clauses.append("s.staging_status = 'deleted'")
363 elif decision == 'pending': 372 elif decision == 'pending':
364 where_clauses.append("(s.biz_review_status IS NULL OR s.biz_review_status = 'pending')") 373 where_clauses.append("(s.biz_review_status IS NULL OR s.biz_review_status = 'pending')")
365 where_clauses.append("s.dedup_action = 'review'") 374 where_clauses.append("s.dedup_action = 'review'")
...@@ -427,7 +436,7 @@ def _staging_groups_response( ...@@ -427,7 +436,7 @@ def _staging_groups_response(
427 "reviewed": row.get("biz_review_status") in {"approved_import", "rejected_duplicate", "deleted"}, 436 "reviewed": row.get("biz_review_status") in {"approved_import", "rejected_duplicate", "deleted"},
428 "submitted": ( 437 "submitted": (
429 row.get("biz_review_status") in {"approved_import", "rejected_duplicate", "deleted"} 438 row.get("biz_review_status") in {"approved_import", "rejected_duplicate", "deleted"}
430 and row.get("staging_status") == "imported" 439 and row.get("staging_status") in {"imported", "skipped"}
431 ), 440 ),
432 "staging_id": str(row.get("staging_id") or ""), 441 "staging_id": str(row.get("staging_id") or ""),
433 "review_version": int(row.get("review_version") or 0), 442 "review_version": int(row.get("review_version") or 0),
...@@ -674,6 +683,7 @@ def _update_staging_review( ...@@ -674,6 +683,7 @@ def _update_staging_review(
674 reviewer: str, 683 reviewer: str,
675 expected_version: int, 684 expected_version: int,
676 client_token: str, 685 client_token: str,
686 candidate_id: str = "",
677 ) -> dict[str, object]: 687 ) -> dict[str, object]:
678 """Persist one claimed review using optimistic concurrency control.""" 688 """Persist one claimed review using optimistic concurrency control."""
679 if decision == "pending": 689 if decision == "pending":
...@@ -686,6 +696,11 @@ def _update_staging_review( ...@@ -686,6 +696,11 @@ def _update_staging_review(
686 biz_review_status = 'deleted', reviewed_by = %s, reviewed_at = NOW(), 696 biz_review_status = 'deleted', reviewed_by = %s, reviewed_at = NOW(),
687 staging_status = 'deleted' 697 staging_status = 'deleted'
688 """ 698 """
699 elif decision == "rejected_duplicate":
700 status_fields = """
701 biz_review_status = %s, reviewed_by = %s, reviewed_at = NOW(),
702 matched_song_id = %s
703 """
689 else: 704 else:
690 status_fields = "biz_review_status = %s, reviewed_by = %s, reviewed_at = NOW()" 705 status_fields = "biz_review_status = %s, reviewed_by = %s, reviewed_at = NOW()"
691 706
...@@ -694,9 +709,31 @@ def _update_staging_review( ...@@ -694,9 +709,31 @@ def _update_staging_review(
694 params = [note, staging_id, expected_version, client_token, reviewer] 709 params = [note, staging_id, expected_version, client_token, reviewer]
695 elif decision == "deleted": 710 elif decision == "deleted":
696 params = [reviewer, note, staging_id, expected_version, client_token, reviewer] 711 params = [reviewer, note, staging_id, expected_version, client_token, reviewer]
712 elif decision == "rejected_duplicate":
713 candidate_id = candidate_id.strip()
714 if not candidate_id:
715 raise ValueError("确认重复前请先选择一条召回候选")
716 params = [decision, reviewer, candidate_id, note, staging_id, expected_version, client_token, reviewer]
697 else: 717 else:
698 params = [decision, reviewer, note, staging_id, expected_version, client_token, reviewer] 718 params = [decision, reviewer, note, staging_id, expected_version, client_token, reviewer]
699 with _target_conn() as conn, conn.cursor() as cursor: 719 with _target_conn() as conn, conn.cursor() as cursor:
720 if decision == "rejected_duplicate":
721 cursor.execute(
722 f"SELECT matched_song_id, recalled_candidates FROM {TARGET_TABLE_NAME_TMP} WHERE staging_id = %s",
723 (staging_id,),
724 )
725 before = cursor.fetchone() or {}
726 allowed_ids = {str(before.get("matched_song_id") or "")}
727 recalled = before.get("recalled_candidates")
728 try:
729 candidates = json.loads(recalled) if isinstance(recalled, str) else (recalled or [])
730 allowed_ids.update(str(item.get("id") or "") for item in candidates if isinstance(item, dict))
731 except (json.JSONDecodeError, TypeError):
732 pass
733 allowed_ids.discard("")
734 if candidate_id not in allowed_ids:
735 conn.rollback()
736 raise ValueError("选中的重复候选不在当前召回结果中,请刷新后重试")
700 cursor.execute( 737 cursor.execute(
701 f""" 738 f"""
702 UPDATE {TARGET_TABLE_NAME_TMP} 739 UPDATE {TARGET_TABLE_NAME_TMP}
...@@ -724,8 +761,36 @@ def _update_staging_review( ...@@ -724,8 +761,36 @@ def _update_staging_review(
724 return current 761 return current
725 762
726 763
764 def _merge_author_field(existing: str | None, incoming: str | None) -> str:
765 """Match import_hk_songs.py: preserve existing authors and append unique incoming authors."""
766 if not incoming:
767 return existing or ""
768 if not existing:
769 return incoming
770
771 def split_authors(text: str) -> list[str]:
772 return [author.strip() for author in _AUTHOR_SEP_RE.split(text) if author.strip()]
773
774 def normalize(author: str) -> str:
775 author = unicodedata.normalize("NFKC", author)
776 author = _AUTHOR_T2S.convert(author.strip().lower())
777 return "".join(char for char in author if char not in _AUTHOR_METADATA_PUNCT)
778
779 existing_authors = split_authors(existing)
780 incoming_authors = split_authors(incoming)
781 normalized = {normalize(author) for author in existing_authors}
782 result = list(existing_authors)
783 for author in incoming_authors:
784 key = normalize(author)
785 if key not in normalized:
786 result.append(author)
787 normalized.add(key)
788 merged = "、".join(result)
789 return merged if merged != existing else ""
790
791
727 def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, object]: 792 def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, object]:
728 """Import approved rows while holding staging-row locks to prevent duplicate imports.""" 793 """Finalize reviewed rows: import non-duplicates and merge duplicate authors."""
729 columns = ", ".join(f"`{column}`" for column in TARGET_COLUMNS) 794 columns = ", ".join(f"`{column}`" for column in TARGET_COLUMNS)
730 select_columns = ", ".join(f"s.`{column}`" for column in TARGET_COLUMNS) 795 select_columns = ", ".join(f"s.`{column}`" for column in TARGET_COLUMNS)
731 with _target_conn() as conn, conn.cursor() as cursor: 796 with _target_conn() as conn, conn.cursor() as cursor:
...@@ -737,7 +802,8 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o ...@@ -737,7 +802,8 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o
737 lock_params.extend(source_ids) 802 lock_params.extend(source_ids)
738 cursor.execute( 803 cursor.execute(
739 f""" 804 f"""
740 SELECT s.staging_id, s.source_song_id 805 SELECT s.staging_id, s.id AS incoming_id, s.source_song_id, s.source_table_name, s.name,
806 s.lyricist, s.composer, s.biz_review_status, s.matched_song_id
741 FROM {TARGET_TABLE_NAME_TMP} s 807 FROM {TARGET_TABLE_NAME_TMP} s
742 INNER JOIN ( 808 INNER JOIN (
743 SELECT source_song_id, MAX(staging_id) AS max_staging_id 809 SELECT source_song_id, MAX(staging_id) AS max_staging_id
...@@ -745,7 +811,7 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o ...@@ -745,7 +811,7 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o
745 GROUP BY source_song_id 811 GROUP BY source_song_id
746 ) latest ON s.staging_id = latest.max_staging_id 812 ) latest ON s.staging_id = latest.max_staging_id
747 WHERE s.dedup_action = 'review' 813 WHERE s.dedup_action = 'review'
748 AND s.biz_review_status = 'approved_import' 814 AND s.biz_review_status IN ('approved_import', 'rejected_duplicate')
749 AND s.staging_status = 'staged' 815 AND s.staging_status = 'staged'
750 AND (s.review_claim_expires_at IS NULL OR s.review_claim_expires_at <= NOW()) 816 AND (s.review_claim_expires_at IS NULL OR s.review_claim_expires_at <= NOW())
751 {source_filter} 817 {source_filter}
...@@ -754,12 +820,42 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o ...@@ -754,12 +820,42 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o
754 """, 820 """,
755 lock_params, 821 lock_params,
756 ) 822 )
757 approved_rows = cursor.fetchall() 823 reviewed_rows = cursor.fetchall()
758 if not approved_rows: 824 if not reviewed_rows:
759 conn.rollback() 825 conn.rollback()
760 raise ValueError("没有可入库的审核通过样本") 826 raise ValueError("没有可提交的已审核样本")
827 approved_rows = [row for row in reviewed_rows if row["biz_review_status"] == "approved_import"]
828 duplicate_rows = [row for row in reviewed_rows if row["biz_review_status"] == "rejected_duplicate"]
761 staging_ids = [row["staging_id"] for row in approved_rows] 829 staging_ids = [row["staging_id"] for row in approved_rows]
762 approved_source_ids = [str(row["source_song_id"]) for row in approved_rows] 830 approved_source_ids = [str(row["source_song_id"]) for row in approved_rows]
831 duplicate_targets: dict[object, int] = {}
832 unresolved_duplicates = []
833 for row in duplicate_rows:
834 target = _resolve_duplicate_target(cursor, row)
835 if not target:
836 unresolved_duplicates.append({
837 "staging_id": row["staging_id"],
838 "source_song_id": row["source_song_id"],
839 "source_table_name": row.get("source_table_name"),
840 "name": row.get("name"),
841 "incoming_id": row.get("incoming_id"),
842 "id_conflict_target_id": None,
843 "source_conflict_target_id": None,
844 "conflict_types": ["duplicate_target_missing"],
845 "matched_song_id": row.get("matched_song_id"),
846 })
847 else:
848 duplicate_targets[row["staging_id"]] = int(target["id"])
849 if unresolved_duplicates:
850 conn.rollback()
851 raise ImportConflictError(
852 f"有 {len(unresolved_duplicates)} 条“确认重复”记录无法找到已入库的重复目标,本次未写入任何数据",
853 unresolved_duplicates,
854 )
855
856 inserted_count = 0
857 id_map: dict[str, object] = {}
858 if approved_rows:
763 staging_placeholders = ",".join(["%s"] * len(staging_ids)) 859 staging_placeholders = ",".join(["%s"] * len(staging_ids))
764 conflicts = _find_import_conflicts(cursor, staging_ids) 860 conflicts = _find_import_conflicts(cursor, staging_ids)
765 if conflicts: 861 if conflicts:
...@@ -779,7 +875,6 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o ...@@ -779,7 +875,6 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o
779 staging_ids, 875 staging_ids,
780 ) 876 )
781 except pymysql.err.IntegrityError as exc: 877 except pymysql.err.IntegrityError as exc:
782 # 预检与 INSERT 之间仍可能有其他程序写入,回滚后再查一次以返回具体行。
783 conn.rollback() 878 conn.rollback()
784 conflicts = _find_import_conflicts(cursor, staging_ids) 879 conflicts = _find_import_conflicts(cursor, staging_ids)
785 raise ImportConflictError( 880 raise ImportConflictError(
...@@ -798,6 +893,7 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o ...@@ -798,6 +893,7 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o
798 approved_source_ids, 893 approved_source_ids,
799 ) 894 )
800 id_map = {str(row["source_song_id"]): row["target_id"] for row in cursor.fetchall()} 895 id_map = {str(row["source_song_id"]): row["target_id"] for row in cursor.fetchall()}
896
801 imported_updated = 0 897 imported_updated = 0
802 for approved_row in approved_rows: 898 for approved_row in approved_rows:
803 sid = str(approved_row["source_song_id"]) 899 sid = str(approved_row["source_song_id"])
...@@ -814,15 +910,89 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o ...@@ -814,15 +910,89 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o
814 (target_id, approved_row["staging_id"]), 910 (target_id, approved_row["staging_id"]),
815 ) 911 )
816 imported_updated += cursor.rowcount 912 imported_updated += cursor.rowcount
913
914 duplicate_updated = 0
915 author_merged = 0
916 for duplicate_row in duplicate_rows:
917 target_id = duplicate_targets[duplicate_row["staging_id"]]
918 cursor.execute(
919 f"SELECT id, lyricist, composer FROM {TARGET_TABLE_NAME} WHERE id = %s AND deleted = '0' FOR UPDATE",
920 (target_id,),
921 )
922 target = cursor.fetchone()
923 if not target:
924 raise RuntimeError(f"重复目标已失效: target_id={target_id}")
925 merged_lyricist = _merge_author_field(target.get("lyricist"), duplicate_row.get("lyricist"))
926 merged_composer = _merge_author_field(target.get("composer"), duplicate_row.get("composer"))
927 changed = False
928 if merged_lyricist:
929 cursor.execute(
930 f"UPDATE {TARGET_TABLE_NAME} SET lyricist = %s WHERE id = %s",
931 (merged_lyricist, target_id),
932 )
933 changed = True
934 if merged_composer:
935 cursor.execute(
936 f"UPDATE {TARGET_TABLE_NAME} SET composer = %s WHERE id = %s",
937 (merged_composer, target_id),
938 )
939 changed = True
940 if changed:
941 author_merged += 1
942 cursor.execute(
943 f"""
944 UPDATE {TARGET_TABLE_NAME_TMP}
945 SET staging_status = 'skipped', imported_song_id = %s, error_message = NULL,
946 merge_authors = %s, review_version = review_version + 1
947 WHERE staging_id = %s AND staging_status = 'staged'
948 AND biz_review_status = 'rejected_duplicate'
949 """,
950 (target_id, 1 if changed else 0, duplicate_row["staging_id"]),
951 )
952 if cursor.rowcount != 1:
953 raise RuntimeError(f"重复样本终态更新失败: staging_id={duplicate_row['staging_id']}")
954 duplicate_updated += 1
817 conn.commit() 955 conn.commit()
818 return { 956 return {
819 "approved_count": len(approved_rows), 957 "approved_count": len(approved_rows),
820 "inserted_count": inserted_count, 958 "inserted_count": inserted_count,
821 "imported_song_ids_updated": imported_updated, 959 "imported_song_ids_updated": imported_updated,
960 "duplicate_count": len(duplicate_rows),
961 "duplicate_staging_updated": duplicate_updated,
962 "author_merged_count": author_merged,
822 "source_ids": approved_source_ids, 963 "source_ids": approved_source_ids,
964 "duplicate_source_ids": [str(row["source_song_id"]) for row in duplicate_rows],
823 } 965 }
824 966
825 967
968 def _resolve_duplicate_target(cursor, row: dict[str, object]) -> dict[str, object] | None:
969 """Resolve a human-confirmed duplicate to an existing, active target record."""
970 matched_id = str(row.get("matched_song_id") or "").strip()
971 if not matched_id:
972 return None
973 try:
974 numeric_id = int(matched_id)
975 except (TypeError, ValueError):
976 numeric_id = None
977 if numeric_id is not None:
978 cursor.execute(
979 f"SELECT id FROM {TARGET_TABLE_NAME} WHERE id = %s AND deleted = '0' FOR UPDATE",
980 (numeric_id,),
981 )
982 target = cursor.fetchone()
983 if target:
984 return target
985 cursor.execute(
986 f"""
987 SELECT id FROM {TARGET_TABLE_NAME}
988 WHERE source_table_name = %s AND source_song_id = %s AND deleted = '0'
989 FOR UPDATE
990 """,
991 (row.get("source_table_name"), matched_id),
992 )
993 return cursor.fetchone()
994
995
826 def _find_import_conflicts(cursor, staging_ids: list[object]) -> list[dict[str, object]]: 996 def _find_import_conflicts(cursor, staging_ids: list[object]) -> list[dict[str, object]]:
827 """Return target-table key conflicts for the selected staging rows.""" 997 """Return target-table key conflicts for the selected staging rows."""
828 if not staging_ids: 998 if not staging_ids:
...@@ -1276,6 +1446,7 @@ class Handler(BaseHTTPRequestHandler): ...@@ -1276,6 +1446,7 @@ class Handler(BaseHTTPRequestHandler):
1276 staging_id = int(payload.get("staging_id") or 0) 1446 staging_id = int(payload.get("staging_id") or 0)
1277 decision = str(payload.get("decision", "")).strip() 1447 decision = str(payload.get("decision", "")).strip()
1278 note = str(payload.get("note", "")).strip() 1448 note = str(payload.get("note", "")).strip()
1449 candidate_id = str(payload.get("candidate_id", "")).strip()
1279 client_token = str(payload.get("client_token") or "").strip() 1450 client_token = str(payload.get("client_token") or "").strip()
1280 expected_version = int(payload.get("expected_version", -1)) 1451 expected_version = int(payload.get("expected_version", -1))
1281 if not staging_id: 1452 if not staging_id:
...@@ -1285,7 +1456,7 @@ class Handler(BaseHTTPRequestHandler): ...@@ -1285,7 +1456,7 @@ class Handler(BaseHTTPRequestHandler):
1285 if decision not in ("approved_import", "rejected_duplicate", "unsure", "pending", "deleted"): 1456 if decision not in ("approved_import", "rejected_duplicate", "unsure", "pending", "deleted"):
1286 raise ValueError(f"invalid decision: {decision}") 1457 raise ValueError(f"invalid decision: {decision}")
1287 result = _update_staging_review( 1458 result = _update_staging_review(
1288 staging_id, decision, note, reviewer, expected_version, client_token 1459 staging_id, decision, note, reviewer, expected_version, client_token, candidate_id
1289 ) 1460 )
1290 _json_response(self, {"record": result}) 1461 _json_response(self, {"record": result})
1291 except ReviewConflictError as exc: 1462 except ReviewConflictError as exc:
...@@ -1310,11 +1481,19 @@ class Handler(BaseHTTPRequestHandler): ...@@ -1310,11 +1481,19 @@ class Handler(BaseHTTPRequestHandler):
1310 if source_ids 1481 if source_ids
1311 else None 1482 else None
1312 ) 1483 )
1313 approved = [ 1484 finalized = [
1314 {"source_id": source_id, "review_decision": "import", "review_note": ""} 1485 {"source_id": source_id, "review_decision": "import", "review_note": ""}
1315 for source_id in result["source_ids"] 1486 for source_id in result["source_ids"]
1316 ] 1487 ]
1317 review_csv = _write_review_import_csv(approved) 1488 finalized.extend(
1489 {
1490 "source_id": source_id,
1491 "review_decision": "duplicate_merged",
1492 "review_note": "确认重复,词/曲作者已增量合并到已有记录",
1493 }
1494 for source_id in result["duplicate_source_ids"]
1495 )
1496 review_csv = _write_review_import_csv(finalized)
1318 _json_response(self, {"review_csv": str(review_csv), **result}) 1497 _json_response(self, {"review_csv": str(review_csv), **result})
1319 except ReviewAuthError as exc: 1498 except ReviewAuthError as exc:
1320 _error(self, str(exc), status=401) 1499 _error(self, str(exc), status=401)
......
...@@ -96,6 +96,45 @@ def test_stale_review_returns_conflict_without_overwrite(): ...@@ -96,6 +96,45 @@ def test_stale_review_returns_conflict_without_overwrite():
96 assert not conn.committed 96 assert not conn.committed
97 97
98 98
99 def test_rejected_duplicate_persists_the_selected_candidate():
100 snapshot = {
101 "staging_id": 42,
102 "matched_song_id": None,
103 "recalled_candidates": '[{"id":"existing-song"}]',
104 "biz_review_status": "rejected_duplicate",
105 "review_version": 8,
106 }
107 cursor = FakeCursor(update_rowcount=1, snapshot=snapshot)
108 conn = FakeConnection(cursor)
109
110 with patch.object(dashboard, "_target_conn", return_value=conn):
111 dashboard._update_staging_review(
112 42, "rejected_duplicate", "same", "alice", 7, "browser-token", "existing-song"
113 )
114
115 update_sql, params = cursor.executions[1]
116 assert "matched_song_id = %s" in update_sql
117 assert params[:3] == ["rejected_duplicate", "alice", "existing-song"]
118 assert conn.committed
119
120
121 def test_rejected_duplicate_rejects_a_candidate_outside_recall_results():
122 cursor = FakeCursor(
123 update_rowcount=1,
124 snapshot={"staging_id": 42, "matched_song_id": "candidate-a", "recalled_candidates": "[]"},
125 )
126 conn = FakeConnection(cursor)
127
128 with patch.object(dashboard, "_target_conn", return_value=conn):
129 with pytest.raises(ValueError, match="不在当前召回结果"):
130 dashboard._update_staging_review(
131 42, "rejected_duplicate", "", "alice", 7, "browser-token", "candidate-b"
132 )
133
134 assert conn.rolled_back
135 assert not any(sql.lstrip().startswith("UPDATE") for sql, _params in cursor.executions)
136
137
99 def test_pending_review_restores_a_deleted_staging_row(): 138 def test_pending_review_restores_a_deleted_staging_row():
100 snapshot = { 139 snapshot = {
101 "staging_id": 42, 140 "staging_id": 42,
...@@ -185,7 +224,7 @@ class ImportCursor(FakeCursor): ...@@ -185,7 +224,7 @@ class ImportCursor(FakeCursor):
185 def __init__(self) -> None: 224 def __init__(self) -> None:
186 super().__init__(update_rowcount=1, snapshot={}) 225 super().__init__(update_rowcount=1, snapshot={})
187 self.result_sets = [ 226 self.result_sets = [
188 [{"staging_id": 42, "source_song_id": "song-1"}], 227 [{"staging_id": 42, "source_song_id": "song-1", "biz_review_status": "approved_import"}],
189 [], 228 [],
190 [{"source_song_id": "song-1", "target_id": 9001}], 229 [{"source_song_id": "song-1", "target_id": 9001}],
191 ] 230 ]
...@@ -217,7 +256,7 @@ def test_import_locks_only_unclaimed_staged_rows(): ...@@ -217,7 +256,7 @@ def test_import_locks_only_unclaimed_staged_rows():
217 def test_import_conflict_rolls_back_and_exposes_the_staging_row(): 256 def test_import_conflict_rolls_back_and_exposes_the_staging_row():
218 cursor = ImportCursor() 257 cursor = ImportCursor()
219 cursor.result_sets = [ 258 cursor.result_sets = [
220 [{"staging_id": 42, "source_song_id": "song-1"}], 259 [{"staging_id": 42, "source_song_id": "song-1", "biz_review_status": "approved_import"}],
221 [{ 260 [{
222 "staging_id": 42, 261 "staging_id": 42,
223 "source_song_id": "song-1", 262 "source_song_id": "song-1",
...@@ -241,6 +280,69 @@ def test_import_conflict_rolls_back_and_exposes_the_staging_row(): ...@@ -241,6 +280,69 @@ def test_import_conflict_rolls_back_and_exposes_the_staging_row():
241 assert not any(sql.lstrip().startswith("INSERT") for sql, _params in cursor.executions) 280 assert not any(sql.lstrip().startswith("INSERT") for sql, _params in cursor.executions)
242 281
243 282
283 class DuplicateImportCursor:
284 def __init__(self) -> None:
285 self.rowcount = 0
286 self.executions: list[tuple[str, object]] = []
287 self.current = None
288
289 def __enter__(self):
290 return self
291
292 def __exit__(self, *_args):
293 return False
294
295 def execute(self, sql, params=None):
296 self.executions.append((sql, params))
297 compact = " ".join(sql.split())
298 self.rowcount = 0
299 if "SELECT s.staging_id" in compact and "FOR UPDATE" in compact:
300 self.current = [{
301 "staging_id": 42,
302 "source_song_id": "new-song",
303 "source_table_name": "hk_song_platform",
304 "name": "same song",
305 "lyricist": "Alice/Bob",
306 "composer": "Composer B",
307 "biz_review_status": "rejected_duplicate",
308 "matched_song_id": "existing-song",
309 }]
310 elif "SELECT id FROM" in compact and "source_table_name = %s" in compact:
311 self.current = {"id": 9001}
312 elif "SELECT id, lyricist, composer" in compact:
313 self.current = {"id": 9001, "lyricist": "Alice", "composer": "Composer A"}
314 elif compact.startswith("UPDATE"):
315 self.current = None
316 self.rowcount = 1
317 else:
318 raise AssertionError(f"unexpected SQL: {compact}")
319
320 def fetchall(self):
321 return self.current or []
322
323 def fetchone(self):
324 return self.current
325
326
327 def test_confirmed_duplicate_merges_authors_without_inserting_a_new_song():
328 cursor = DuplicateImportCursor()
329 conn = FakeConnection(cursor)
330
331 with patch.object(dashboard, "_target_conn", return_value=conn):
332 result = dashboard._import_approved_staging()
333
334 sql_params = [(" ".join(sql.split()), params) for sql, params in cursor.executions]
335 assert not any(sql.startswith("INSERT") for sql, _params in sql_params)
336 assert any("SET lyricist = %s" in sql and params == ("Alice、Bob", 9001) for sql, params in sql_params)
337 assert any("SET composer = %s" in sql and params == ("Composer A、Composer B", 9001) for sql, params in sql_params)
338 assert any("SET staging_status = 'skipped'" in sql for sql, _params in sql_params)
339 assert result["duplicate_count"] == 1
340 assert result["author_merged_count"] == 1
341 assert result["inserted_count"] == 0
342 assert result["duplicate_source_ids"] == ["new-song"]
343 assert conn.committed
344
345
244 def test_browsing_a_list_item_can_auto_claim_without_reloading_groups(): 346 def test_browsing_a_list_item_can_auto_claim_without_reloading_groups():
245 html = Path("l2_review_dashboard.html").read_text(encoding="utf-8") 347 html = Path("l2_review_dashboard.html").read_text(encoding="utf-8")
246 start = html.index("for (const btn of els.queryList.querySelectorAll('.query-item'))") 348 start = html.index("for (const btn of els.queryList.querySelectorAll('.query-item'))")
...@@ -274,7 +376,17 @@ def test_dashboard_distinguishes_reviewed_from_submitted(): ...@@ -274,7 +376,17 @@ def test_dashboard_distinguishes_reviewed_from_submitted():
274 assert row["staging_status"] == "imported" 376 assert row["staging_status"] == "imported"
275 assert '<option value="reviewed">已审核</option>' in html 377 assert '<option value="reviewed">已审核</option>' in html
276 assert '<option value="submitted">已提交</option>' in html 378 assert '<option value="submitted">已提交</option>' in html
277 assert "hasFinalReview && query.staging_status !== 'imported'" in html 379 assert "hasFinalReview && query.staging_status === 'staged'" in html
380
381
382 def test_dashboard_has_a_dedicated_deleted_filter():
383 html = Path("l2_review_dashboard.html").read_text(encoding="utf-8")
384
385 assert '<option value="deleted">已删除</option>' in html
386 source = Path("serve_l2_dashboard.py").read_text(encoding="utf-8")
387 assert "elif decision == 'deleted':" in source
388 assert "s.biz_review_status = 'deleted'" in source
389 assert "s.staging_status = 'deleted'" in source
278 390
279 391
280 def test_review_access_code_issues_and_validates_edit_session(): 392 def test_review_access_code_issues_and_validates_edit_session():
......