Просмотр исходного кода

feat: rebuild payroll settlement workflow

彭峰 1 месяц назад
Родитель
Сommit
cca1db67ab

+ 2 - 0
package.json

@@ -15,6 +15,8 @@
     "cloud:deploy": "node scripts/deploy-admin-functions.mjs",
     "cloud:smoke": "node scripts/smoke-admin-functions.mjs",
     "ops:smoke": "node scripts/smoke-operations-functions.mjs",
+    "payroll:verify-source": "node scripts/verify-payroll-source.mjs",
+    "payroll:backfill": "node scripts/backfill-payroll-lessons.mjs",
     "sync-bridge:build": "dotnet build legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj",
     "watch": "ng build --watch --configuration development",
     "test": "ng test",

+ 20 - 0
scripts/backfill-payroll-lessons.mjs

@@ -0,0 +1,20 @@
+#!/usr/bin/env node
+
+const APP_ID=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',MASTER_KEY=process.env.XIAOSHU_MASTER_KEY||'',PARSE_URL=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,'');
+const commit=process.argv.includes('--commit');if(!MASTER_KEY)throw new Error('缺少 XIAOSHU_MASTER_KEY');
+const headers={'X-Parse-Application-Id':APP_ID,'X-Parse-Master-Key':MASTER_KEY,'Content-Type':'application/json'};
+async function parse(path,init={}){const response=await fetch(PARSE_URL+path,{...init,headers:{...headers,...(init.headers||{})}}),payload=await response.json().catch(()=>({}));if(!response.ok||payload.error)throw new Error(typeof payload.error==='string'?payload.error:JSON.stringify(payload.error||{status:response.status}));return payload;}
+const config=(await parse('/config')).params||{},legacyUrl=String(config.legacyScheduleApiUrl||''),apiId=String(config.legacyScheduleApiId||''),apiKey=String(config.legacyScheduleApiKey||'');if(!legacyUrl||!apiId||!apiKey)throw new Error('生产 Parse Config 未配置旧系统接口');
+const company=(await parse('/classes/Company?limit=1&keys=objectId')).results?.[0];if(!company)throw new Error('生产环境没有帐套');const companyPointer={__type:'Pointer',className:'Company',objectId:company.objectId};
+async function legacyPage(month,cpage,psize=1000){const values={action:'content_list',nid:'296',psize:String(psize),cpage:String(cpage),apiId,apiKey};if(month)values.myfield=`plpjsj=${month}`;const query=new URLSearchParams(values),response=await fetch(`${legacyUrl}${legacyUrl.includes('?')?'&':'?'}${query}`),payload=await response.json();if(!response.ok||Number(payload.retcode)===-1)throw new Error(`${month||'all'}: `+(payload.retmsg||`旧接口失败:${response.status}`));let result=payload.result;if(typeof result==='string')result=JSON.parse(result);return{items:Array.isArray(result)?result:[],page:payload.page||{}};}
+async function allExisting(className,keyField,extra={}){const map=new Map();let cursor=-1;for(;;){const where={company:companyPointer,...extra,[keyField]:{$gt:cursor}},query=new URLSearchParams({where:JSON.stringify(where),order:keyField,limit:'1000',keys:`objectId,${keyField},itemId`}),rows=(await parse(`/classes/${className}?${query}`)).results||[];for(const row of rows)map.set(Number(row[keyField]),row);if(rows.length<1000)break;cursor=Number(rows.at(-1)?.[keyField]);if(!Number.isFinite(cursor))break;}return map;}
+function dateIso(value){const raw=String(value||'').trim();if(!/^\d{4}-\d{1,2}-\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?/.test(raw)||raw.startsWith('{')||raw.startsWith('['))return'';const normalized=raw.replace(/^(\d{4})-(\d)(?=-)/,'$1-0$2').replace(/-(\d)(?=[ T])/,'-0$1').replace(' ','T'),date=new Date(normalized+(normalized.includes('T')?'+08:00':'T00:00:00+08:00'));return Number.isNaN(date.getTime())?'':date.toISOString();}
+const first=await legacyPage('',1),total=Number(first.page.itemCount||first.items.length),pageCount=Math.max(1,Number(first.page.pageCount||Math.ceil(total/1000))),source=[...first.items];for(let start=2;start<=pageCount;start+=6){const pages=Array.from({length:Math.min(6,pageCount-start+1)},(_,index)=>start+index),rows=await Promise.all(pages.map((page)=>legacyPage('',page)));for(const result of rows)source.push(...result.items);console.log(`read old lessons ${source.length}/${total}`);}const deduped=Array.from(new Map(source.map((row)=>[Number(row.GeneralID||0),row])).values()).filter((row)=>Number(row.GeneralID||0));source.length=0;source.push(...deduped);
+const lessonById=await allExisting('LessonRecordFact','legacyGeneralId');let creates=0,updates=0,invalidDates=0;const requests=[];
+for(const row of source){const generalId=Number(row.GeneralID||0);if(!generalId)continue;const lesson=lessonById.get(generalId),lessonAt=dateIso(row.plpjsj)||dateIso(row.CreateTime);if(!lessonAt)invalidDates++;
+ const lessonBody={company:companyPointer,sourceKey:`legacy-payroll:lesson:${generalId}`,legacyGeneralId:generalId,lessonAt:lessonAt?{__type:'Date',iso:lessonAt}:null,sourceCreatedAt:dateIso(row.CreateTime)?{__type:'Date',iso:dateIso(row.CreateTime)}:null,studentName:String(row.Title||row.xymz||''),coachName:String(row.plxm||row.jsmc||row.jsmz||''),courseName:String(row.Subtitle||row.kcmc||''),storeId:Number(row.szmdid||0),pf:String(row.pf||''),jffs:String(row.jffs||''),jsmz:String(row.jsmz||''),kcid:String(row.kcid||''),kclx:String(row.kclx||''),kcmc:String(row.kcmc||row.Subtitle||''),pjnr:String(row.pjnr||''),pldp:String(row.pldp||''),xymz:String(row.xymz||row.Title||''),yyds:String(row.yyds||''),plpjsj:String(row.plpjsj||''),szmdid:String(row.szmdid||'')};if(!lesson)lessonBody.kzsj=String(row.kzsj||'');
+ requests.push({method:lesson?'PUT':'POST',path:`/parse/classes/LessonRecordFact${lesson?'/'+lesson.objectId:''}`,body:lessonBody});if(lesson)updates++;else creates++;
+}
+console.log(JSON.stringify({mode:commit?'commit':'dry-run',sourceTotal:total,sourceRows:source.length,existingLessons:lessonById.size,creates,updates,invalidDates,requestCount:requests.length},null,2));
+if(!commit)process.exit(0);for(let index=0;index<requests.length;index+=50){const batch=requests.slice(index,index+50),result=await parse('/batch',{method:'POST',body:JSON.stringify({requests:batch})});const failures=(result||[]).filter((item)=>item.error);if(failures.length)throw new Error(`批次 ${index/50+1} 写入失败:${JSON.stringify(failures.slice(0,3))}`);if(index%500===0||index+50>=requests.length)console.log(`wrote ${Math.min(index+50,requests.length)}/${requests.length}`);}
+console.log(`lesson backfill complete: ${source.length} source records`);

Разница между файлами не показана из-за своего большого размера
+ 29 - 4
scripts/deploy-admin-functions.mjs


+ 9 - 0
scripts/smoke-operations-functions.mjs

@@ -91,6 +91,15 @@ try {
   if (rates.items?.some((item) => item.classType === 4 && item.isDefault)) throw new Error('课型4不应存在默认工资规则');
   const payrolls = (await ops({ operation: 'ops/payroll/list', page: 1, pageSize: 20 })).data;
   if (!Array.isArray(payrolls.items) || typeof payrolls.total !== 'number') throw new Error('工资批次未返回统一分页结构');
+  const payrollOverview = (await ops({ operation: 'ops/payroll/overview', month: '2026-08', storeId: 0, search: '', refresh: true })).data;
+  if (!payrollOverview.sourceStatus?.available || payrollOverview.sourceStatus.mode !== 'live') throw new Error(`工资实时上课记录不可用:${payrollOverview.sourceStatus?.message || 'unknown'}`);
+  if (!Array.isArray(payrollOverview.lines) || payrollOverview.lessonCount < 700 || !Array.isArray(payrollOverview.coachItems) || !payrollOverview.coachItems.length) throw new Error(`2026-08 工资收益投影数量异常:${payrollOverview.lessonCount}`);
+  if (payrollOverview.lines.some((item) => !item.legacyGeneralId || !item.lessonAt || String(item.lessonAt).startsWith('{'))) throw new Error('工资明细缺少旧记录ID或规范上课日期');
+  if (payrollOverview.lines.some((item) => item.classType === 4 && (item.amount !== 0 || item.durationHours !== 0 || item.exception))) throw new Error('课型4没有按只计课次、不计工资和工时处理');
+  for (const sampleId of [850746, 850469]) {
+    const sample = payrollOverview.lines.find((item) => item.legacyGeneralId === sampleId);
+    if (!sample || !sample.coachName || !sample.studentName || !sample.lessonAt.startsWith('2026-08-25')) throw new Error(`工资样本 ${sampleId} 字段对账失败`);
+  }
 
   const vocabularyTree = (await ops({ operation: 'ops/vocabulary/tree' })).data;
   if (!Array.isArray(vocabularyTree.items) || !vocabularyTree.items.some((item) => item.canContainWords) || !vocabularyTree.refreshedAt || vocabularyTree.totalWords < 80000) throw new Error('词库教材单元树结构无效');

+ 58 - 0
scripts/verify-payroll-source.mjs

@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const month = process.argv.find((value) => /^\d{4}-\d{2}$/.test(value)) || '2026-08';
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+const configResponse = await fetch(`${PARSE_URL}/config`, { headers: { 'X-Parse-Application-Id': APP_ID, 'X-Parse-Master-Key': MASTER_KEY } });
+const configPayload = await configResponse.json();
+if (!configResponse.ok) throw new Error(configPayload.error || `读取 Parse Config 失败:${configResponse.status}`);
+const params = configPayload.params || {};
+const url = String(params.legacyScheduleApiUrl || '');
+const apiId = String(params.legacyScheduleApiId || '');
+const apiKey = String(params.legacyScheduleApiKey || '');
+if (!url || !apiId || !apiKey) throw new Error('生产 Parse Config 未配置旧系统业务接口');
+
+async function page(cpage, psize = 1000) {
+  const query = new URLSearchParams({ action: 'e_plrecord_list', nid: '296', myfield: `plpjsj=${month}`, psize: String(psize), cpage: String(cpage), apiId, apiKey });
+  const response = await fetch(`${url}${url.includes('?') ? '&' : '?'}${query}`);
+  const payload = await response.json();
+  if (!response.ok || Number(payload.retcode) === -1) throw new Error(payload.retmsg || `旧系统接口失败:${response.status}`);
+  let result = payload.result;
+  if (typeof result === 'string') result = JSON.parse(result);
+  return { items: Array.isArray(result) ? result : [], page: payload.page || {} };
+}
+
+function validDate(value) {
+  const raw = String(value || '').trim();
+  return /^\d{4}-\d{1,2}-\d{1,2}(?:[ T]\d{1,2}:\d{2})?/.test(raw) && !raw.startsWith('{') && !raw.startsWith('[') ? raw : '';
+}
+
+const first = await page(1);
+const total = Number(first.page.itemCount || first.items.length);
+const pageCount = Math.max(1, Number(first.page.pageCount || Math.ceil(total / 1000)));
+const items = [...first.items];
+for (let index = 2; index <= pageCount; index += 1) items.push(...(await page(index)).items);
+const normalized = items.map((row) => ({
+  generalId: Number(row.GeneralID || 0),
+  lessonAt: validDate(row.plpjsj) || validDate(row.CreateTime),
+  studentName: String(row.Title || row.xymz || ''),
+  coachName: String(row.plxm || row.jsmc || row.jsmz || ''),
+  classType: Number(row.kclx || 0),
+})).filter((row) => row.lessonAt.startsWith(month));
+const typeCounts = Object.fromEntries([1, 2, 3, 4].map((type) => [type, normalized.filter((row) => row.classType === type).length]));
+const rateByType = { 1: { hours: 0.5, amount: 20 }, 2: { hours: 1, amount: 40 }, 3: { hours: 1, amount: 40 }, 4: { hours: 0, amount: 0 } };
+const totals = normalized.reduce((result, row) => {
+  const rate = rateByType[row.classType] || { hours: 0, amount: 0 };
+  result.hours += rate.hours;
+  result.amount += rate.amount;
+  return result;
+}, { hours: 0, amount: 0 });
+const samples = [850746, 850469].map((id) => normalized.find((row) => row.generalId === id) || { generalId: id, missing: true });
+const duplicateCount = normalized.length - new Set(normalized.map((row) => row.generalId)).size;
+if (normalized.length !== total) throw new Error(`月份记录归一化数量不一致:源端 ${total},有效 ${normalized.length}`);
+if (duplicateCount) throw new Error(`旧上课记录 GeneralID 重复:${duplicateCount} 条`);
+if (samples.some((sample) => sample.missing)) throw new Error('工资验收样本 850746 或 850469 缺失');
+console.log(JSON.stringify({ month, sourceTotal: total, normalizedCount: normalized.length, pageCount, typeCounts, totals, duplicateCount, samples }, null, 2));

+ 51 - 0
src/app/admin/admin.models.ts

@@ -473,6 +473,7 @@ export interface PayrollSummary extends Record<string, unknown> {
   adjustmentAmount: number;
   payableAmount: number;
   exceptionCount: number;
+  type4Count?: number;
   reviewedAt: string;
   paidAt: string;
   paymentMethod: string;
@@ -485,8 +486,15 @@ export interface PayrollLine extends Record<string, unknown> {
   kind: 'lesson' | 'adjustment';
   coachId: number;
   coachName: string;
+  storeId: number;
+  storeName: string;
+  studentName: string;
+  courseName: string;
+  legacyGeneralId: number;
   lessonId: string;
   lessonAt: string;
+  originalMonth: string;
+  settlementMonth: string;
   classType: number;
   classTypeLabel: string;
   durationHours: number;
@@ -500,6 +508,49 @@ export interface PayrollLine extends Record<string, unknown> {
 export interface PayrollDetail extends PayrollSummary {
   lines: PayrollLine[];
   exceptions: PayrollLine[];
+  coachItems?: PayrollCoachSummary[];
+  sourceStatus?: PayrollSourceStatus;
+  refreshedAt?: string;
+}
+
+export interface PayrollCoachSummary extends Record<string, unknown> {
+  coachId: number;
+  coachName: string;
+  storeId: number;
+  storeName: string;
+  type30Count: number;
+  type60Count: number;
+  trialCount: number;
+  type4Count: number;
+  lessonCount: number;
+  totalHours: number;
+  baseAmount: number;
+  adjustmentAmount: number;
+  payableAmount: number;
+  latestLessonAt: string;
+}
+
+export interface PayrollSourceStatus extends Record<string, unknown> {
+  available: boolean;
+  mode: 'live' | 'cache';
+  message: string;
+  total: number;
+  refreshedAt: string;
+}
+
+export interface PayrollOverview extends PayrollDetail {
+  coachItems: PayrollCoachSummary[];
+  sourceStatus: PayrollSourceStatus;
+  batches: PayrollSummary[];
+  stores: Array<{ storeId: number; storeName: string }>;
+}
+
+export interface PayrollExportData extends Record<string, unknown> {
+  fileName: string;
+  summary: PayrollSummary;
+  coachItems: PayrollCoachSummary[];
+  lines: PayrollLine[];
+  exceptions: PayrollLine[];
 }
 
 export interface VocabularyNode extends Record<string, unknown> {

+ 9 - 1
src/app/admin/operations.service.ts

@@ -1,5 +1,5 @@
 import { inject, Injectable } from '@angular/core';
-import { CoachAvailability, CoachDetailData, CoachPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingItem, StorePage, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
+import { CoachAvailability, CoachDetailData, CoachPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollExportData, PayrollOverview, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingItem, StorePage, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
 import { CloudFunctionsService } from './cloud-functions.service';
 
 export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs';
@@ -80,10 +80,18 @@ export class OperationsService {
     return this.functions.operations<PayrollDetail>('ops/payroll/preview', { month });
   }
 
+  payrollOverview(month: string, storeId = 0, search = '', refresh = true): Promise<PayrollOverview> {
+    return this.functions.operations<PayrollOverview>('ops/payroll/overview', { month, storeId, search, refresh });
+  }
+
   payrollDetail(targetId: string): Promise<PayrollDetail> {
     return this.functions.operations<PayrollDetail>('ops/payroll/detail', { targetId });
   }
 
+  payrollExport(month: string, targetId = '', storeId = 0, search = ''): Promise<PayrollExportData> {
+    return this.functions.operations<PayrollExportData>('ops/payroll/export', { month, targetId, storeId, search });
+  }
+
   vocabularyTree(): Promise<VocabularyTree> {
     return this.functions.operations<VocabularyTree>('ops/vocabulary/tree');
   }

+ 17 - 68
src/app/admin/pages/operations-payroll.component.html

@@ -1,71 +1,20 @@
-<header class="page-header">
-  <div><p>PAYROLL CENTER</p><h1>工资结算</h1><span>按有效上课记录生成月度工资,完成复核、锁定和发放登记</span></div>
-  <div class="header-actions"><button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()" />刷新</button></div>
-</header>
-
-@if(message()){<p class="notice" role="status">{{message()}}</p>}
-@if(error()){<p class="notice error" role="alert">{{error()}}</p>}
-
-<nav class="tabs" aria-label="工资结算功能">
-  <button type="button" [class.active]="tab()==='batches'" (click)="tab.set('batches')"><lucide-icon [img]="icons.Banknote" [size]="17" />月度工资</button>
-  <button type="button" [class.active]="tab()==='rates'" (click)="tab.set('rates')"><lucide-icon [img]="icons.Settings2" [size]="17" />计价规则</button>
-</nav>
-
-@if(tab()==='rates'){
-  <section class="content-card">
-    <header><strong>按生效日期管理课酬</strong><span>历史工资保存单价快照,不受后续改价影响</span></header>
-    @if(isManager()){
-      <div class="panel"><div class="form-grid">
-        <label>课程类型<select [ngModel]="rateType()" (ngModelChange)="rateType.set(+$event)"><option [ngValue]="1">30分钟</option><option [ngValue]="2">60分钟</option><option [ngValue]="3">体验课</option><option [ngValue]="4">陪练</option></select></label>
-        <label>每节课酬<input type="number" min="0" step="0.01" [ngModel]="rateAmount()" (ngModelChange)="rateAmount.set(+$event)" /></label>
-        <label>计薪工时<input type="number" min="0" step="0.5" [ngModel]="rateHours()" (ngModelChange)="rateHours.set(+$event)" /></label>
-        <label>生效日期<input type="date" [ngModel]="effectiveFrom()" (ngModelChange)="effectiveFrom.set($event)" /></label>
-        <button class="primary" type="button" (click)="saveRate()" [disabled]="saving()">保存规则</button>
-      </div></div>
-    }
-    <div class="table-scroll"><table class="data-table"><thead><tr><th>课型</th><th>每节课酬</th><th>计薪工时</th><th>生效日期</th><th>状态</th></tr></thead><tbody>
-      @for(rate of rates();track rate.objectId){<tr><td>{{rate.classTypeLabel}}</td><td class="money">¥{{rate.amount|number:'1.2-2'}}</td><td>{{rate.durationHours}}h</td><td>@if(rate.isDefault){系统默认}@else { {{rate.effectiveFrom|date:'yyyy-MM-dd'}} }</td><td><span class="badge">{{rate.active?'生效':'停用'}}</span>@if(rate.isDefault){<small>原系统默认规则</small>}</td></tr>}
-      @empty{<tr><td colspan="5"><div class="empty">暂无计价规则</div></td></tr>}
-    </tbody></table></div>
-  </section>
+<header class="page-header payroll-header"><div><p>PAYROLL CENTER</p><h1>工资结算</h1><span>按旧系统有效上课记录核算收益,并完成月度审核与发放登记</span></div><div class="header-actions"><button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()" />刷新实时数据</button><button type="button" (click)="exportExcel()" [disabled]="saving()"><lucide-icon [img]="icons.Download" [size]="17" />导出 Excel</button></div></header>
+@if(message()){<p class="notice" role="status">{{message()}}</p>} @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
+<nav class="tabs" aria-label="工资结算功能"><button type="button" [class.active]="tab()==='overview'" (click)="tab.set('overview')"><lucide-icon [img]="icons.TrendingUp" [size]="17" />收益统计</button><button type="button" [class.active]="tab()==='batches'" (click)="tab.set('batches')"><lucide-icon [img]="icons.Banknote" [size]="17" />工资批次</button><button type="button" [class.active]="tab()==='rates'" (click)="tab.set('rates')"><lucide-icon [img]="icons.Settings2" [size]="17" />计价规则</button></nav>
+@if(tab()!=='rates'){<section class="toolbar payroll-toolbar"><label><span>统计月份</span><input type="month" [ngModel]="month()" (ngModelChange)="month.set($event)" /></label><label><span>门店</span><select [ngModel]="storeId()" (ngModelChange)="storeId.set(+$event)"><option [ngValue]="0">全部门店</option>@for(store of overview()?.stores||[];track store.storeId){<option [ngValue]="store.storeId">{{store.storeName}}</option>}</select></label><label class="search-field"><span>陪练搜索</span><input [ngModel]="search()" (ngModelChange)="search.set($event)" placeholder="姓名或手机号" (keyup.enter)="applyFilters()" /></label><button type="button" (click)="applyFilters()">查询</button><span class="source-chip" [class.cache]="overview()?.sourceStatus?.mode==='cache'"><i></i>{{overview()?.sourceStatus?.mode==='live'?'实时数据':'缓存数据'}} · {{overview()?.sourceStatus?.refreshedAt|date:'MM-dd HH:mm:ss'}}</span></section>}
+@if(tab()==='overview'){
+ @if(overview();as data){
+  @if(data.sourceStatus.mode==='cache'){<p class="notice error">{{data.sourceStatus.message}}。当前可以查看缓存,但不能生成或审核工资单。</p>}
+  <section class="payroll-metrics" aria-label="工资收益摘要"><article><span>参与陪练</span><strong>{{data.coachCount}}</strong><small>本月有上课记录</small></article><article><span>有效课次</span><strong>{{data.lessonCount}}</strong><small>含课型4 {{data.type4Count||0}} 节</small></article><article><span>总工时</span><strong>{{data.totalHours|number:'1.1-1'}}h</strong><small>课型4不计工时</small></article><article><span>基础工资</span><strong>¥{{data.baseAmount|number:'1.2-2'}}</strong><small>按历史生效单价</small></article><article><span>应发工资</span><strong>¥{{data.payableAmount|number:'1.2-2'}}</strong><small>生成草稿后可调账</small></article><article class="exception-card"><span>异常记录</span><strong>{{data.exceptionCount}}</strong><small>生成工资前必须处理</small></article></section>
+  <section class="content-card"><header><div><strong>{{data.month}} 老师收益汇总</strong><span>点击老师可查看每一节课的计薪依据</span></div>@if(isManager()){<button class="primary" type="button" (click)="create()" [disabled]="saving()||!data.sourceStatus.available||data.exceptionCount>0"><lucide-icon [img]="icons.Plus" [size]="17" />生成全公司工资草稿</button>}</header><div class="table-scroll"><table class="data-table payroll-summary-table"><thead><tr><th>陪练老师</th><th>门店</th><th>30分钟</th><th>60分钟</th><th>体验课</th><th>课型4</th><th>总课次</th><th>工时</th><th>基础工资</th><th>应发</th><th>明细</th></tr></thead><tbody>@for(row of data.coachItems;track row.coachId){<tr><td><strong>{{row.coachName}}</strong><small>ID {{row.coachId}}</small></td><td>{{row.storeName}}</td><td>{{row.type30Count}}</td><td>{{row.type60Count}}</td><td>{{row.trialCount}}</td><td>{{row.type4Count}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td><button type="button" (click)="openCoach(row)" aria-label="查看老师上课明细"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}@empty{<tr><td colspan="11"><div class="empty">当前筛选范围没有有效上课记录</div></td></tr>}</tbody></table></div>@if(data.type4Count){<p class="type4-note">课型4“陪练”按原系统口径只计入课次,不计算工资和工时。</p>}</section>
+ }
+} @else if(tab()==='batches'){
+ <section class="content-card"><header><div><strong>全公司月度工资批次</strong><span>每月仅一张;门店筛选不会拆分工资单</span></div></header><div class="table-scroll"><table class="data-table"><thead><tr><th>月份</th><th>状态</th><th>陪练</th><th>有效课次</th><th>工时</th><th>基础工资</th><th>调整</th><th>应发</th><th>异常</th><th>操作</th></tr></thead><tbody>@for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.month}}</strong></td><td><span class="badge" [class.warn]="row.status==='draft'">{{row.statusLabel}}</span></td><td>{{row.coachCount}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>¥{{row.adjustmentAmount|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td [class.exception]="row.exceptionCount">{{row.exceptionCount}}</td><td><button type="button" aria-label="查看工资单" (click)="open(row)"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}@empty{<tr><td colspan="10"><div class="empty">尚未生成工资批次。请先在“收益统计”核对实时数据。</div></td></tr>}</tbody></table></div></section>
 } @else {
-  <section class="toolbar">
-    <label><span>工资月份</span><input type="month" [ngModel]="month()" (ngModelChange)="month.set($event)" /></label>
-    <button type="button" (click)="preview()">预览工资</button>
-    @if(isManager()){<button class="primary" type="button" (click)="create()" [disabled]="saving()"><lucide-icon [img]="icons.Plus" [size]="17" />生成草稿</button>}
-  </section>
-  <section class="content-card">
-    <header><strong>工资批次</strong><span>{{page()?.total||0}} 个批次</span></header>
-    <div class="table-scroll"><table class="data-table"><thead><tr><th>月份</th><th>状态</th><th>陪练</th><th>有效课次</th><th>工时</th><th>基础工资</th><th>调整</th><th>应发</th><th>异常</th><th>操作</th></tr></thead><tbody>
-      @for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.month}}</strong></td><td><span class="badge" [class.warn]="row.status==='draft'">{{row.statusLabel}}</span></td><td>{{row.coachCount}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>¥{{row.adjustmentAmount|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td [class.exception]="row.exceptionCount">{{row.exceptionCount}}</td><td><button type="button" aria-label="查看工资单" (click)="open(row)"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}
-      @empty{<tr><td colspan="10"><div class="empty">尚未生成工资批次,可先选择月份预览</div></td></tr>}
-    </tbody></table></div>
-  </section>
+ <section class="content-card"><header><div><strong>按生效日期管理课酬</strong><span>历史工资保存单价快照,不受后续改价影响</span></div></header>@if(isManager()){<div class="panel"><div class="form-grid"><label>课程类型<select [ngModel]="rateType()" (ngModelChange)="rateType.set(+$event)"><option [ngValue]="1">30分钟</option><option [ngValue]="2">60分钟</option><option [ngValue]="3">体验课</option><option [ngValue]="4">课型4(如需启用)</option></select></label><label>每节课酬<input type="number" min="0" step="0.01" [ngModel]="rateAmount()" (ngModelChange)="rateAmount.set(+$event)" /></label><label>计薪工时<input type="number" min="0" step="0.5" [ngModel]="rateHours()" (ngModelChange)="rateHours.set(+$event)" /></label><label>生效日期<input type="date" [ngModel]="effectiveFrom()" (ngModelChange)="effectiveFrom.set($event)" /></label><button class="primary" type="button" (click)="saveRate()" [disabled]="saving()">保存规则</button></div></div>}<div class="table-scroll"><table class="data-table"><thead><tr><th>课型</th><th>每节课酬</th><th>计薪工时</th><th>生效日期</th><th>状态</th></tr></thead><tbody>@for(rate of rates();track rate.objectId){<tr><td>{{rate.classTypeLabel}}</td><td class="money">¥{{rate.amount|number:'1.2-2'}}</td><td>{{rate.durationHours}}h</td><td>@if(rate.isDefault){系统默认}@else { {{rate.effectiveFrom|date:'yyyy-MM-dd'}} }</td><td><span class="badge">{{rate.active?'生效':'停用'}}</span>@if(rate.isDefault){<small>原系统默认规则</small>}</td></tr>}</tbody></table></div><p class="type4-note">课型4当前没有默认规则,保持“只计课次、不计工资、不计工时”。只有明确新增生效规则后才应改变口径。</p></section>
 }
-
-@if(detail();as batch){
-  <div class="backdrop" (click)="detail.set(null)"></div>
-  <aside class="drawer" role="dialog" aria-modal="true" aria-label="工资单详情">
-    <header><div><p>PAYROLL {{batch.month}}</p><h2>{{batch.statusLabel}} · 应发 ¥{{batch.payableAmount|number:'1.2-2'}}</h2><span>{{batch.lessonCount}} 节 / {{batch.totalHours|number:'1.1-1'}} 小时 / {{batch.coachCount}} 位陪练</span></div><button type="button" aria-label="关闭" (click)="detail.set(null)">×</button></header>
-    @if(batch.exceptionCount){<p class="notice error">存在 {{batch.exceptionCount}} 条异常。可先维护计价规则后重新计算,或填写原因排除异常,处理完才能审核。</p>}
-    <div class="table-scroll"><table class="data-table"><thead><tr><th>陪练</th><th>日期</th><th>课型</th><th>工时</th><th>单价</th><th>金额</th><th>说明</th><th>操作</th></tr></thead><tbody>
-      @for(line of batch.lines;track line.objectId){<tr [class.muted]="line.excluded"><td>{{line.coachName}}</td><td>{{line.lessonAt|date:'MM-dd HH:mm'}}</td><td>{{line.classTypeLabel}}</td><td>{{line.durationHours}}h</td><td>¥{{line.rateAmount|number:'1.2-2'}}</td><td class="money">¥{{line.amount|number:'1.2-2'}}</td><td [class.exception]="line.exception&&!line.excluded">{{line.excluded?'已排除':line.exception||line.description||'—'}}</td><td>@if(batch.status==='draft'&&isManager()&&line.exception&&!line.excluded){<button type="button" (click)="excludeLine(line.objectId)">排除异常</button>}</td></tr>}
-      @empty{<tr><td colspan="8"><div class="empty">暂无可计薪记录</div></td></tr>}
-    </tbody></table></div>
-    @if(batch.status==='draft'&&isManager()){
-      <section class="panel"><h3>新增调整项</h3><div class="form-grid"><label>陪练ID<input type="number" min="1" [ngModel]="adjustmentCoachId()" (ngModelChange)="adjustmentCoachId.set(+$event)" /></label><label>调整金额<input type="number" step="0.01" [ngModel]="adjustmentAmount()" (ngModelChange)="adjustmentAmount.set(+$event)" /></label><label class="wide">调整原因<input [ngModel]="adjustmentReason()" (ngModelChange)="adjustmentReason.set($event)" /></label><button type="button" (click)="addAdjustment()">添加调整</button></div></section>
-    }
-    <footer>
-      <button type="button" (click)="exportCsv()"><lucide-icon [img]="icons.Download" [size]="16" />导出CSV</button>
-      @if(isManager()&&batch.objectId){
-        @if(batch.status==='draft'){
-          <button type="button" (click)="refreshDraft()" [disabled]="saving()">按最新规则重算</button>
-          <button class="primary" type="button" (click)="workflow('ops/payroll/review')" [disabled]="batch.exceptionCount>0||saving()">审核并锁定</button>
-        } @else if(batch.status==='reviewed'){
-          <button type="button" (click)="workflow('ops/payroll/reopen')">重新打开</button>
-          <button class="primary" type="button" (click)="workflow('ops/payroll/mark-paid')">标记已发放</button>
-        }
-      }
-    </footer>
-  </aside>
+@if(detail()||selectedCoachId()){
+ <div class="backdrop" (click)="closeDrawer()"></div><aside class="drawer payroll-drawer" role="dialog" aria-modal="true" aria-label="工资明细"><header><div><p>{{detail()?.objectId?'PAYROLL BATCH':'COACH LESSONS'}}</p><h2>@if(detail();as batch){ {{batch.month}} · {{batch.statusLabel}} }@else{ 老师上课明细 }</h2><span>{{coachLines().length}} 条来源记录</span></div><button type="button" aria-label="关闭" (click)="closeDrawer()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>@if(detail()?.exceptionCount){<p class="notice error">存在 {{detail()?.exceptionCount}} 条异常,处理完成前不能审核。</p>}<div class="table-scroll"><table class="data-table"><thead><tr><th>陪练</th><th>学员 / 课程</th><th>日期</th><th>课型</th><th>工时</th><th>单价</th><th>金额</th><th>来源ID</th><th>说明</th></tr></thead><tbody>@for(line of coachLines();track line.objectId){<tr [class.muted]="line.excluded"><td>{{line.coachName}}<small>{{line.storeName}}</small></td><td>{{line.studentName}}<small>{{line.courseName}}</small></td><td>{{line.lessonAt|date:'MM-dd HH:mm'}}</td><td>{{line.classTypeLabel}}</td><td>{{line.durationHours}}h</td><td>¥{{line.rateAmount|number:'1.2-2'}}</td><td class="money">¥{{line.amount|number:'1.2-2'}}</td><td>{{line.legacyGeneralId||'—'}}</td><td [class.exception]="line.exception&&!line.excluded">{{line.excluded?'已排除':line.exception||line.description||'—'}} @if(detail()?.status==='draft'&&isManager()&&line.exception&&!line.excluded){<button type="button" (click)="excludeLine(line.objectId)">排除</button>}</td></tr>}@empty{<tr><td colspan="9"><div class="empty">暂无可查看记录</div></td></tr>}</tbody></table></div>
+ @if(detail();as batch){@if(batch.status==='draft'&&isManager()){<section class="panel"><h3>新增工资调整</h3><div class="form-grid"><label>陪练老师<select [ngModel]="adjustmentCoachId()" (ngModelChange)="adjustmentCoachId.set(+$event)"><option [ngValue]="0">请选择陪练</option>@for(coach of overview()?.coachItems||[];track coach.coachId){<option [ngValue]="coach.coachId">{{coach.coachName}} · {{coach.storeName}}</option>}</select></label><label>调整金额<input type="number" step="0.01" [ngModel]="adjustmentAmount()" (ngModelChange)="adjustmentAmount.set(+$event)" placeholder="负数表示扣减" /></label><label class="wide">调整 / 排除原因<input [ngModel]="adjustmentReason()" (ngModelChange)="adjustmentReason.set($event)" placeholder="必填,将写入审计日志" /></label><button type="button" (click)="addAdjustment()">添加调整</button></div></section>}@if(batch.status==='reviewed'&&isManager()){<section class="panel"><h3>登记线下发放</h3><div class="form-grid"><label>发放方式<input [ngModel]="paymentMethod()" (ngModelChange)="paymentMethod.set($event)" /></label><label>凭证编号<input [ngModel]="paymentReference()" (ngModelChange)="paymentReference.set($event)" placeholder="银行流水或内部凭证号" /></label></div></section>}}
+ <footer><button type="button" (click)="exportExcel()"><lucide-icon [img]="icons.Download" [size]="16" />导出 Excel</button>@if(detail();as batch){@if(isManager()&&batch.status==='draft'){<button type="button" (click)="refreshDraft()" [disabled]="saving()">刷新并增量重算</button><button class="primary" type="button" (click)="review()" [disabled]="batch.exceptionCount>0||saving()">审核并永久锁定</button>}@else if(isManager()&&batch.status==='reviewed'){<button class="primary" type="button" (click)="markPaid()" [disabled]="saving()">登记已发放</button>}}</footer></aside>
 }

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/app/admin/pages/operations-payroll.component.scss


+ 19 - 16
src/app/admin/pages/operations-payroll.component.ts

@@ -1,26 +1,29 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
-import { Banknote, Download, Eye, LucideAngularModule, Plus, RefreshCw, Settings2 } from 'lucide-angular';
-import { OperationsPage, PayRate, PayrollDetail, PayrollSummary } from '../admin.models';
+import { Banknote, Download, Eye, FileCheck2, LucideAngularModule, Plus, RefreshCw, Settings2, TrendingUp, X } from 'lucide-angular';
+import { utils, writeFile } from 'xlsx';
+import { OperationsPage, PayRate, PayrollCoachSummary, PayrollDetail, PayrollLine, PayrollOverview, PayrollSummary } from '../admin.models';
 import { AdminSessionService } from '../admin-session.service';
 import { OperationsService } from '../operations.service';
 
 const localDateKey=(value:Date):string=>`${value.getFullYear()}-${String(value.getMonth()+1).padStart(2,'0')}-${String(value.getDate()).padStart(2,'0')}`;
 
-@Component({selector:'app-operations-payroll',standalone:true,imports:[CommonModule,FormsModule,LucideAngularModule],templateUrl:'./operations-payroll.component.html',styleUrl:'./operations-professional.scss',changeDetection:ChangeDetectionStrategy.OnPush})
+@Component({selector:'app-operations-payroll',standalone:true,imports:[CommonModule,FormsModule,LucideAngularModule],templateUrl:'./operations-payroll.component.html',styleUrls:['./operations-professional.scss','./operations-payroll.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsPayrollComponent implements OnInit{
- readonly page=signal<OperationsPage<PayrollSummary>|null>(null);readonly detail=signal<PayrollDetail|null>(null);readonly rates=signal<PayRate[]>([]);readonly loading=signal(true);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'batches'|'rates'>('batches');readonly month=signal(localDateKey(new Date()).slice(0,7));readonly rateType=signal(1);readonly rateAmount=signal(20);readonly rateHours=signal(.5);readonly effectiveFrom=signal(localDateKey(new Date()));readonly adjustmentCoachId=signal(0);readonly adjustmentAmount=signal(0);readonly adjustmentReason=signal('');readonly icons={Banknote,Download,Eye,Plus,RefreshCw,Settings2};private readonly operations=inject(OperationsService);private readonly sessions=inject(AdminSessionService);
+ readonly overview=signal<PayrollOverview|null>(null);readonly page=signal<OperationsPage<PayrollSummary>|null>(null);readonly detail=signal<PayrollDetail|null>(null);readonly rates=signal<PayRate[]>([]);readonly loading=signal(true);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'overview'|'batches'|'rates'>('overview');readonly month=signal(localDateKey(new Date()).slice(0,7));readonly storeId=signal(0);readonly search=signal('');readonly selectedCoachId=signal(0);readonly rateType=signal(1);readonly rateAmount=signal(20);readonly rateHours=signal(.5);readonly effectiveFrom=signal(localDateKey(new Date()));readonly adjustmentCoachId=signal(0);readonly adjustmentAmount=signal(0);readonly adjustmentReason=signal('');readonly paymentMethod=signal('银行转账');readonly paymentReference=signal('');readonly icons={Banknote,Download,Eye,FileCheck2,Plus,RefreshCw,Settings2,TrendingUp,X};readonly coachLines=computed(()=>{const coachId=this.selectedCoachId(),source=this.detail()?.lines||this.overview()?.lines||[];return coachId?source.filter((line)=>line.coachId===coachId):source;});private readonly operations=inject(OperationsService);private readonly sessions=inject(AdminSessionService);
  ngOnInit():void{void this.load();}isManager():boolean{return this.sessions.session()?.operationsRole==='ops-manager'||this.sessions.session()?.isSuperAdmin===true;}
- async load():Promise<void>{this.loading.set(true);this.error.set('');try{const [page,rates]=await Promise.all([this.operations.payrolls(),this.operations.payRates()]);this.page.set(page);this.rates.set(rates.items);}catch(error){this.error.set(error instanceof Error?error.message:'工资数据加载失败');}finally{this.loading.set(false);}}
- async preview():Promise<void>{this.loading.set(true);this.error.set('');try{this.detail.set(await this.operations.payrollPreview(this.month()));}catch(error){this.error.set(error instanceof Error?error.message:'工资预览失败');}finally{this.loading.set(false);}}
- async open(row:PayrollSummary):Promise<void>{this.loading.set(true);try{this.detail.set(await this.operations.payrollDetail(row.objectId));}catch(error){this.error.set(error instanceof Error?error.message:'工资单加载失败');}finally{this.loading.set(false);}}
- async create():Promise<void>{this.saving.set(true);try{const result=await this.operations.mutate<PayrollSummary>('ops/payroll/create','',`生成 ${this.month()} 月工资草稿`,{month:this.month()});await this.load();await this.open(result);this.message.set('工资草稿已生成');}catch(error){this.error.set(error instanceof Error?error.message:'生成工资草稿失败');}finally{this.saving.set(false);}}
- async saveRate():Promise<void>{this.saving.set(true);try{await this.operations.mutate('ops/pay-rates/save','',`调整课型 ${this.rateType()} 的计价规则`,{classType:this.rateType(),amount:this.rateAmount(),durationHours:this.rateHours(),effectiveFrom:this.effectiveFrom(),active:true});this.message.set('计价规则已保存');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'计价规则保存失败');}finally{this.saving.set(false);}}
- async addAdjustment():Promise<void>{const batch=this.detail();if(!batch||!this.adjustmentCoachId()||!this.adjustmentAmount()||!this.adjustmentReason().trim())return;this.saving.set(true);try{await this.operations.mutate('ops/payroll/add-adjustment',batch.objectId,this.adjustmentReason(),{coachId:this.adjustmentCoachId(),amount:this.adjustmentAmount(),description:this.adjustmentReason()},batch.updatedAt);await this.open({...batch});this.adjustmentAmount.set(0);this.adjustmentReason.set('');}catch(error){this.error.set(error instanceof Error?error.message:'调整项保存失败');}finally{this.saving.set(false);}}
- async refreshDraft():Promise<void>{const batch=this.detail();if(!batch||batch.status!=='draft')return;this.saving.set(true);this.error.set('');try{this.detail.set(await this.operations.mutate<PayrollDetail>('ops/payroll/refresh',batch.objectId,'根据最新计价规则重新计算工资草稿',{},batch.updatedAt));this.message.set('工资草稿已按最新计价规则重新计算');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'工资草稿重新计算失败');}finally{this.saving.set(false);}}
- async excludeLine(objectId:string):Promise<void>{const batch=this.detail();if(!batch||batch.status!=='draft')return;const reason=globalThis.prompt('请输入排除该异常记录的原因');if(!reason?.trim())return;this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/payroll/exclude-line',objectId,reason,{},'');await this.open({...batch});this.message.set('异常记录已排除并保留审计原因');}catch(error){this.error.set(error instanceof Error?error.message:'异常记录排除失败');}finally{this.saving.set(false);}}
- async workflow(operation:string):Promise<void>{const batch=this.detail();if(!batch)return;let payload:Record<string,unknown>={};let reason=operation.endsWith('review')?'复核工资明细并锁定':operation.endsWith('reopen')?'发现差异,重新打开工资草稿':'确认工资已线下发放';if(operation.endsWith('mark-paid')){const method=globalThis.prompt('请输入发放方式,例如:银行转账');if(!method)return;const reference=globalThis.prompt('请输入发放凭证编号');if(!reference)return;payload={paymentMethod:method,paymentReference:reference};}this.saving.set(true);try{await this.operations.mutate(operation,batch.objectId,reason,payload,batch.updatedAt);await this.load();await this.open({...batch});}catch(error){this.error.set(error instanceof Error?error.message:'工资流程操作失败');}finally{this.saving.set(false);}}
- exportCsv():void{const batch=this.detail();if(!batch)return;const rows=[['老师','日期','课型','工时','单价','金额','说明'],...batch.lines.map(line=>[line.coachName,line.lessonAt,line.classTypeLabel,String(line.durationHours),String(line.rateAmount),String(line.amount),line.description])];const csv='\ufeff'+rows.map(row=>row.map(cell=>`"${String(cell).replaceAll('"','""')}"`).join(',')).join('\n');const url=URL.createObjectURL(new Blob([csv],{type:'text/csv;charset=utf-8'}));const anchor=document.createElement('a');anchor.href=url;anchor.download=`小树陪练工资_${batch.month}.csv`;anchor.click();URL.revokeObjectURL(url);}
- rateLabel(type:number):string{return({1:'30分钟',2:'60分钟',3:'体验课',4:'陪练'})[type]||`课型${type}`;}
+ async load():Promise<void>{this.loading.set(true);this.error.set('');try{const [overview,page,rates]=await Promise.all([this.operations.payrollOverview(this.month(),this.storeId(),this.search()),this.operations.payrolls(),this.operations.payRates()]);this.overview.set(overview);this.page.set(page);this.rates.set(rates.items);}catch(error){this.error.set(error instanceof Error?error.message:'工资数据加载失败');}finally{this.loading.set(false);}}
+ async applyFilters():Promise<void>{await this.load();}
+ async open(row:PayrollSummary):Promise<void>{this.loading.set(true);this.error.set('');try{this.selectedCoachId.set(0);this.detail.set(await this.operations.payrollDetail(row.objectId));}catch(error){this.error.set(error instanceof Error?error.message:'工资单加载失败');}finally{this.loading.set(false);}}
+ openCoach(row:PayrollCoachSummary):void{this.selectedCoachId.set(row.coachId);this.detail.set(null);}closeDrawer():void{this.detail.set(null);this.selectedCoachId.set(0);}
+ async create():Promise<void>{if(!this.overview()?.sourceStatus.available){this.error.set('实时上课记录不可用,不能生成工资草稿');return;}this.saving.set(true);this.error.set('');try{const result=await this.operations.mutate<PayrollSummary>('ops/payroll/create','',`生成 ${this.month()} 月全公司工资草稿`,{month:this.month()});await this.load();await this.open(result);this.message.set('全公司工资草稿已生成,尚未审核或发放');this.tab.set('batches');}catch(error){this.error.set(error instanceof Error?error.message:'生成工资草稿失败');}finally{this.saving.set(false);}}
+ async saveRate():Promise<void>{this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/pay-rates/save','',`调整课型 ${this.rateType()} 的计价规则`,{classType:this.rateType(),amount:this.rateAmount(),durationHours:this.rateHours(),effectiveFrom:this.effectiveFrom(),active:true});this.message.set('计价规则已保存');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'计价规则保存失败');}finally{this.saving.set(false);}}
+ async addAdjustment():Promise<void>{const batch=this.detail();if(!batch||!this.adjustmentCoachId()||!this.adjustmentAmount()||!this.adjustmentReason().trim()){this.error.set('请选择陪练并填写非零调整金额和原因');return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/payroll/add-adjustment',batch.objectId,this.adjustmentReason(),{coachId:this.adjustmentCoachId(),amount:this.adjustmentAmount(),description:this.adjustmentReason()},batch.updatedAt);await this.open(batch);this.adjustmentAmount.set(0);this.adjustmentReason.set('');this.message.set('调整项已保存');}catch(error){this.error.set(error instanceof Error?error.message:'调整项保存失败');}finally{this.saving.set(false);}}
+ async refreshDraft():Promise<void>{const batch=this.detail();if(!batch||batch.status!=='draft')return;this.saving.set(true);this.error.set('');try{this.detail.set(await this.operations.mutate<PayrollDetail>('ops/payroll/refresh',batch.objectId,'刷新实时上课记录并增量重算工资草稿',{},batch.updatedAt));this.message.set('工资草稿已吸收新增上课记录并重新计算');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'工资草稿刷新失败');}finally{this.saving.set(false);}}
+ async excludeLine(objectId:string):Promise<void>{const batch=this.detail(),reason=this.adjustmentReason().trim();if(!batch||batch.status!=='draft')return;if(!reason){this.error.set('请先在调整原因中填写排除该异常记录的原因');return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/payroll/exclude-line',objectId,reason,{},'');await this.open(batch);this.message.set('异常记录已排除并保留审计原因');}catch(error){this.error.set(error instanceof Error?error.message:'异常记录排除失败');}finally{this.saving.set(false);}}
+ async review():Promise<void>{const batch=this.detail();if(!batch)return;this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/payroll/review',batch.objectId,'实时刷新完成,复核工资明细并永久锁定',{},batch.updatedAt);await this.load();await this.open(batch);this.message.set('工资单已审核锁定');}catch(error){this.error.set(error instanceof Error?error.message:'工资审核失败');}finally{this.saving.set(false);}}
+ async markPaid():Promise<void>{const batch=this.detail();if(!batch||!this.paymentMethod().trim()||!this.paymentReference().trim()){this.error.set('请填写发放方式和凭证编号');return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/payroll/mark-paid',batch.objectId,'确认工资已线下发放',{paymentMethod:this.paymentMethod(),paymentReference:this.paymentReference()},batch.updatedAt);await this.load();await this.open(batch);this.message.set('工资发放信息已登记');}catch(error){this.error.set(error instanceof Error?error.message:'发放登记失败');}finally{this.saving.set(false);}}
+ async exportExcel():Promise<void>{this.saving.set(true);this.error.set('');try{const batch=this.detail(),data=await this.operations.payrollExport(this.month(),batch?.objectId||'',this.storeId(),this.search());const summary=data.coachItems.map((row)=>({'陪练老师':row.coachName,'门店':row.storeName,'30分钟课':row.type30Count,'60分钟课':row.type60Count,'体验课':row.trialCount,'课型4':row.type4Count,'总课次':row.lessonCount,'工时':row.totalHours,'基础工资':row.baseAmount,'调整':row.adjustmentAmount,'应发金额':row.payableAmount}));const details=data.lines.map((line)=>this.exportLine(line)),exceptions=data.exceptions.map((line)=>({...this.exportLine(line),'异常原因':line.exception}));const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(summary),'老师汇总');utils.book_append_sheet(book,utils.json_to_sheet(details),'上课明细');utils.book_append_sheet(book,utils.json_to_sheet(exceptions),'异常记录');writeFile(book,data.fileName);}catch(error){this.error.set(error instanceof Error?error.message:'工资 Excel 导出失败');}finally{this.saving.set(false);}}
+ private exportLine(line:PayrollLine):Record<string,unknown>{return{'来源记录ID':line.legacyGeneralId,'上课日期':line.lessonAt,'结算月份':line.settlementMonth,'原上课月份':line.originalMonth,'陪练老师':line.coachName,'门店':line.storeName,'学员':line.studentName,'课程':line.courseName,'课型':line.classTypeLabel,'工时':line.durationHours,'单价':line.rateAmount,'金额':line.amount,'说明':line.description};}
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов