review-rewards.js 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142
  1. // Included verbatim in both gateway bundles. Read-only queries share the immutable ledger.
  2. async function reviewIncomeTotal(companyId, coachId) {
  3. const row = await Psql.one('SELECT COALESCE(SUM("amount"),0) AS total FROM "ReviewReward" WHERE "company"=$1 AND "coachId"=$2 AND "status"=\'earned\'', [companyId, Number(coachId)]);
  4. return Number(row.total);
  5. }
  6. function rewardPayrollLine(row, month) {
  7. const iso = row.completedAt ? new Date(row.completedAt).toISOString() : '';
  8. return { objectId: 'review:' + row.objectId, kind: 'review_reward', reviewRewardId: row.objectId,
  9. reviewTaskId: row.taskId, plannedDate: row.plannedDate, coachId: Number(row.coachId), coachName: row.coachName,
  10. storeId: Number(row.storeId), storeName: row.storeName, studentName: row.studentName, courseName: row.courseName,
  11. legacyGeneralId: 0, lessonId: '', lessonAt: iso, originalMonth: row.incomeMonth, settlementMonth: month,
  12. classType: 0, classTypeLabel: '抗遗忘工资', courseCategoryName: '抗遗忘工资', courseCategoryKey: '',
  13. durationMinutes: 0, durationHours: 0, deliveryMode: '', deliveryLabel: '', rateAmount: Number(row.amount),
  14. amount: Number(row.amount), score: 0, exception: '', description: '计划复习首次完成 · ' + row.plannedDate };
  15. }
  16. async function reviewRewardPayrollLines(companyId, month, includeUsed) {
  17. if (!companyId) throw new Error('抗遗忘工资必须指定帐套');
  18. if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) throw new Error('月份格式无效');
  19. const rows = await Psql.query('SELECT r.* FROM "ReviewReward" r WHERE r."company"=$1 AND r."status"=\'earned\' AND ' +
  20. (includeUsed ? 'r."incomeMonth"=$2' : 'r."incomeMonth"<=$2 AND NOT EXISTS (SELECT 1 FROM "PayrollLine" l WHERE l."company"=r."company" AND l."reviewRewardId"=r."objectId" AND l."kind"=\'review_reward\' AND COALESCE(l."excluded",false)=false)') +
  21. ' ORDER BY r."completedAt",r."objectId"', [companyId, month]);
  22. return rows.map((row) => rewardPayrollLine(row, month));
  23. }
  24. async function reviewRewardPage(companyId, input) {
  25. if (!companyId) throw new Error('抗遗忘工资必须指定帐套');
  26. const page = Math.max(1, Math.floor(Number(input.page) || 1)), pageSize = Math.min(100, Math.max(1, Math.floor(Number(input.pageSize) || 20)));
  27. const values = [companyId], clauses = ['r."company"=$1'];
  28. const add = (condition, value) => { values.push(value); clauses.push(condition.replaceAll('?', '$' + values.length)); };
  29. if (input.month) { if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(input.month)) throw new Error('月份格式无效'); add('(r."incomeMonth"=? OR r."incomeMonth"=\'\')', input.month); }
  30. if (Number(input.storeId)) add('r."storeId"=?', Number(input.storeId));
  31. if (Number(input.coachId)) add('r."coachId"=?', Number(input.coachId));
  32. if (input.search) add('(r."coachName" ILIKE ? OR r."coachId"::text ILIKE ?)', '%' + String(input.search).trim() + '%');
  33. if (['earned','historical','exception'].includes(input.status)) add('r."status"=?', input.status);
  34. const snapshot=input.exportMode?(input.snapshot||new Date().toISOString()):'';
  35. if(snapshot){if(Number.isNaN(new Date(snapshot).getTime()))throw new Error('导出快照无效');add('r."createdAt"<=?::timestamptz',snapshot);}
  36. const summaryWhere=clauses.join(' AND '),summaryValues=[...values];
  37. if(input.exportMode&&input.afterId)add('r."objectId">?',String(input.afterId));
  38. const where = clauses.join(' AND ');
  39. const summary = await Psql.one('SELECT COUNT(*)::int total, COUNT(*) FILTER (WHERE r."status"=\'earned\')::int "reviewCount", COALESCE(SUM(r."amount"),0) "reviewAmount",COUNT(*) FILTER (WHERE r."status"=\'historical\')::int "historicalCount",COUNT(*) FILTER (WHERE r."status"=\'exception\')::int "exceptionCount" FROM "ReviewReward" r WHERE '+summaryWhere, summaryValues);
  40. const rows = await Psql.query('SELECT r.*,m."sourceKey" AS "taskSourceKey",m."fxzt" AS "taskStatus",m."rewardDuplicate" AS "taskDuplicate",l."batchId",b."month" AS "settlementMonth",b."status" AS "batchStatus" FROM "ReviewReward" r LEFT JOIN "MemoryPracticeRecord" m ON m."company"=r."company" AND m."objectId"=r."taskId" LEFT JOIN "PayrollLine" l ON l."company"=r."company" AND l."reviewRewardId"=r."objectId" AND l."kind"=\'review_reward\' AND COALESCE(l."excluded",false)=false LEFT JOIN "PayrollBatch" b ON b."company"=l."company" AND b."objectId"=l."batchId" WHERE '+where+(input.exportMode?' ORDER BY r."objectId"':' ORDER BY r."completedAt" DESC NULLS FIRST,r."objectId"')+' LIMIT $'+(values.length+1)+' OFFSET $'+(values.length+2), [...values,pageSize,input.exportMode?0:(page-1)*pageSize]);
  41. return { snapshot,nextCursor:input.exportMode&&rows.length===pageSize?rows[rows.length-1].objectId:'',items: rows.map((row)=>{const completedAt=row.completedAt?new Date(row.completedAt).toISOString():'',businessDay=completedAt?new Date(completedAt).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}):'',canConfirm=row.status==='historical'&&!row.exception&&businessDay>='2026-09-01'&&row.taskStatus==='1'&&row.taskDuplicate!==true&&(/^(legacy:model:60:|legacy-sync:memory-records:addon:)/.test(row.taskSourceKey||''));const {taskSourceKey,taskStatus,taskDuplicate,...visible}=row;return {...visible,amount:Number(row.amount),completedAt,canConfirm,statusLabel:row.status==='historical'?'历史待核对':row.status==='exception'?'异常待核对':({draft:'已入草稿',reviewed:'已审核',paid:'已发放'}[row.batchStatus]||'待结算')};}), total:Number(summary.total), page, pageSize, summary:{...summary,reviewAmount:Number(summary.reviewAmount)} };
  42. }