operations.service.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. import { inject, Injectable } from '@angular/core';
  2. import { CoachAvailability, CoachDetailData, CoachPage, LearningReportDetail, LearningReportPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberImportPreview, MemberImportResult, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollExportData, PayrollOverview, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingPage, StoreDetailData, StorePage, TeachingStandardsData, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
  3. import { CloudFunctionsService } from './cloud-functions.service';
  4. import { AdminSessionService } from './admin-session.service';
  5. export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs';
  6. @Injectable({ providedIn: 'root' })
  7. export class OperationsService {
  8. private readonly functions = inject(CloudFunctionsService);
  9. private readonly sessions = inject(AdminSessionService);
  10. readonly dataChanges$ = this.functions.dataChanges$;
  11. private dashboardCache: { token: string; data: OperationsDashboardData; savedAt: number } | null = null;
  12. private dashboardInFlight: { token: string; promise: Promise<OperationsDashboardData> } | null = null;
  13. private dashboardRevision = 0;
  14. constructor() {
  15. this.dataChanges$?.subscribe(() => { this.dashboardRevision++; this.dashboardCache = null; this.dashboardInFlight = null; });
  16. }
  17. dashboardSnapshot(): { data: OperationsDashboardData; ageMs: number } | null {
  18. const cached = this.dashboardCache;
  19. const token = this.sessions.session()?.sessionToken;
  20. const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
  21. return cached && token && cached.token === token && cached.data.date === today
  22. ? { data: cached.data, ageMs: Date.now() - cached.savedAt }
  23. : null;
  24. }
  25. dashboard(): Promise<OperationsDashboardData> {
  26. const token = this.sessions.session()?.sessionToken || '';
  27. if (this.dashboardInFlight?.token === token) return this.dashboardInFlight.promise;
  28. const revision = this.dashboardRevision;
  29. const promise = this.functions.operations<OperationsDashboardData>('ops/dashboard/summary').then((data) => {
  30. if (token && this.sessions.session()?.sessionToken === token && revision === this.dashboardRevision) {
  31. this.dashboardCache = { token, data, savedAt: Date.now() };
  32. }
  33. return data;
  34. }).finally(() => { if (this.dashboardInFlight?.promise === promise) this.dashboardInFlight = null; });
  35. this.dashboardInFlight = { token, promise };
  36. return promise;
  37. }
  38. syncStatus(probe = true): Promise<LegacySyncStatus> {
  39. return this.functions.operations<LegacySyncStatus>('ops/sync/status', { probe });
  40. }
  41. runSync(dataset: string, full = false): Promise<LegacySyncRunResult> {
  42. return this.functions.operations<LegacySyncRunResult>('ops/sync/run', { dataset, full, pageSize: 500, maxPages: 5 });
  43. }
  44. retrySyncFailures(dataset = ''): Promise<LegacySyncRunResult> {
  45. return this.functions.operations<LegacySyncRunResult>('ops/sync/retry', { dataset });
  46. }
  47. scheduleTrend(period: ScheduleTrendPeriod): Promise<ScheduleTrendData> {
  48. return this.functions.operations<ScheduleTrendData>('ops/dashboard/schedule-trend', { period });
  49. }
  50. request<T>(operation: string, params: Record<string, unknown> = {}): Promise<T> {
  51. return this.functions.operations<T>(operation, params);
  52. }
  53. users(page: number, pageSize: number, search: string, filters: Record<string, unknown> = {}, sort: MemberListSort = { field: 'registeredAt', direction: 'desc' }, refresh = false): Promise<MemberPage> {
  54. return this.functions.operations<MemberPage>('ops/users/list', { page, pageSize, search, filters, sort, refresh });
  55. }
  56. userDetail(objectId: string): Promise<OperationsUserDetail> {
  57. return this.functions.operations<OperationsUserDetail>('ops/users/detail', { objectId });
  58. }
  59. memberRows(operation: 'ops/users/notes' | 'ops/users/audit', objectId: string): Promise<{ items: Record<string, unknown>[]; refreshedAt: string }> {
  60. return this.functions.operations<{ items: Record<string, unknown>[]; refreshedAt: string }>(operation, { objectId });
  61. }
  62. exportMembers(search: string, filters: Record<string, unknown>, sort: MemberListSort): Promise<{ fileName: string; items: Record<string, unknown>[] }> {
  63. return this.functions.operations<{ fileName: string; items: Record<string, unknown>[] }>('ops/users/export', { search, filters, sort });
  64. }
  65. previewMemberImport(fileName: string, rows: Record<string, unknown>[], reason: string, idempotencyKey: string): Promise<MemberImportPreview> {
  66. return this.functions.operations<MemberImportPreview>('ops/users/import-preview', { reason, idempotencyKey, payload: { fileName, rows } });
  67. }
  68. commitMemberImport(batchId: string, fileName: string, rows: Record<string, unknown>[], reason: string, idempotencyKey: string): Promise<MemberImportResult> {
  69. return this.functions.operations<MemberImportResult>('ops/users/import-commit', { targetId: batchId, reason, idempotencyKey, payload: { batchId, fileName, rows } });
  70. }
  71. coaches(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}, refresh = false): Promise<CoachPage> {
  72. return this.functions.operations<CoachPage>('ops/coaches/list', { page, pageSize, search, filters, refresh });
  73. }
  74. async stores(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}): Promise<StorePage> {
  75. const result = await this.functions.operations<StorePage>('ops/stores/list', { page, pageSize, search, filters });
  76. let countFailed = false;
  77. // Older gateways lack this field. Use the existing member query's total, not its page length.
  78. const items = await Promise.all(result.items.map(async (store) => {
  79. if (store.activeStudyingMemberCount != null) return store;
  80. try {
  81. if (!(store.userId > 0)) throw new Error('门店编号缺失');
  82. const members = await this.users(1, 1, '', { parentUserId: store.userId, learningStatus: 'studying', status: 'active' });
  83. if (!Number.isInteger(members.total) || members.total < 0) throw new Error('人数统计无效');
  84. return { ...store, activeStudyingMemberCount: members.total };
  85. } catch {
  86. countFailed = true;
  87. return store;
  88. }
  89. }));
  90. return { ...result, items, activeStudyingMemberCountError: countFailed ? '部分门店的在学正常学员人数加载失败,请点击刷新重试。' : '' };
  91. }
  92. storeDetail(objectId: string, month: string, refresh = false): Promise<StoreDetailData> {
  93. return this.functions.operations<StoreDetailData>('ops/stores/detail', { objectId, month, refresh });
  94. }
  95. coachDetail(objectId: string, month = '', refresh = false): Promise<CoachDetailData> {
  96. return this.functions.operations<CoachDetailData>('ops/coaches/detail', { objectId, month, refresh });
  97. }
  98. learningReports(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}, refresh = false): Promise<LearningReportPage> {
  99. return this.functions.operations<LearningReportPage>('ops/learning-reports/list', { page, pageSize, search, filters, sort: { field: 'studyDate', direction: 'desc' }, refresh });
  100. }
  101. learningReportDetail(targetId: string): Promise<LearningReportDetail> {
  102. return this.functions.operations<LearningReportDetail>('ops/learning-reports/detail', { targetId });
  103. }
  104. learningReportExport(targetId: string): Promise<LearningReportDetail> {
  105. return this.functions.operations<LearningReportDetail>('ops/learning-reports/export', { targetId });
  106. }
  107. schedule(dateFrom: string, dateTo: string, coachId = 0): Promise<ScheduleCalendarData> {
  108. return this.functions.operations<ScheduleCalendarData>('ops/schedule/calendar', { dateFrom, dateTo, filters: { coachId } });
  109. }
  110. availability(coachId: number): Promise<OperationsPage<CoachAvailability>> {
  111. return this.functions.operations<OperationsPage<CoachAvailability>>('ops/coach-availability/list', { page: 1, pageSize: 100, filters: { coachId } });
  112. }
  113. payRates(): Promise<OperationsPage<PayRate>> {
  114. return this.functions.operations<OperationsPage<PayRate>>('ops/pay-rates/list', { page: 1, pageSize: 100 });
  115. }
  116. teachingStandards(): Promise<TeachingStandardsData> {
  117. return this.functions.operations<TeachingStandardsData>('ops/teaching-rules/list');
  118. }
  119. reviewRewards(filters: import('./admin.models').ReviewRewardFilters, page = 1, status = '', pageSize = 20): Promise<import('./admin.models').ReviewRewardPage> {
  120. return this.functions.operations('ops/payroll/review-rewards', { ...filters, page, pageSize, status });
  121. }
  122. confirmLegacyReviewRewards(rewardIds: string[], reason: string): Promise<{ confirmed: number; alreadyConfirmed: number }> {
  123. return this.mutate('ops/payroll/review-rewards/confirm', '', reason, { rewardIds });
  124. }
  125. payrolls(page = 1, pageSize = 20, month = ''): Promise<OperationsPage<PayrollSummary>> {
  126. return this.functions.operations<OperationsPage<PayrollSummary>>('ops/payroll/list', { page, pageSize, month });
  127. }
  128. payrollPreview(month: string): Promise<PayrollDetail> {
  129. return this.functions.operations<PayrollDetail>('ops/payroll/preview', { month });
  130. }
  131. payrollOverview(month: string, storeId = 0, search = '', refresh = false): Promise<PayrollOverview> {
  132. return this.functions.operations<PayrollOverview>('ops/payroll/overview', { month, storeId, search, refresh });
  133. }
  134. payrollDetail(targetId: string): Promise<PayrollDetail> {
  135. return this.functions.operations<PayrollDetail>('ops/payroll/detail', { targetId });
  136. }
  137. payrollExport(month: string, targetId = '', storeId = 0, search = '', coachId = 0): Promise<PayrollExportData> {
  138. return this.functions.operations<PayrollExportData>('ops/payroll/export', { month, targetId, storeId, search, coachId });
  139. }
  140. vocabularyTree(): Promise<VocabularyTree> {
  141. return this.functions.operations<VocabularyTree>('ops/vocabulary/tree');
  142. }
  143. vocabulary(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}): Promise<OperationsPage<VocabularyWord>> {
  144. return this.functions.operations<OperationsPage<VocabularyWord>>('ops/vocabulary/list', { page, pageSize, search, filters });
  145. }
  146. specialTraining(page = 1, pageSize = 50, search = '', filters: Record<string, unknown> = {}, refresh = false): Promise<SpecialTrainingPage> {
  147. return this.functions.operations<SpecialTrainingPage>('ops/special-training/list', { page, pageSize, search, filters, refresh });
  148. }
  149. previewVocabularyImport(fileName: string, rows: Record<string, unknown>[], reason: string): Promise<VocabularyImportPreview> {
  150. return this.functions.operations<VocabularyImportPreview>('ops/vocabulary/import-preview', { reason, idempotencyKey: crypto.randomUUID(), payload: { fileName, rows } });
  151. }
  152. commitVocabularyImport(batchId: string, reason: string): Promise<Record<string, unknown>> {
  153. return this.functions.operations<Record<string, unknown>>('ops/vocabulary/import-commit', { targetId: batchId, reason, idempotencyKey: crypto.randomUUID() });
  154. }
  155. async uploadCover(file: File, reason: string, width: number, height: number): Promise<string> {
  156. const dataUrl = await new Promise<string>((resolve, reject) => {
  157. const reader = new FileReader();
  158. reader.onload = () => typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('无法读取封面文件'));
  159. reader.onerror = () => reject(new Error('无法读取封面文件'));
  160. reader.readAsDataURL(file);
  161. });
  162. const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
  163. const result = await this.functions.operations<{ publicUrl: string }>('ops/media/upload-inline', {
  164. reason,
  165. idempotencyKey: crypto.randomUUID(),
  166. payload: { fileName: file.name, contentType: file.type, size: file.size, kind: 'image', base64, width, height },
  167. });
  168. return result.publicUrl;
  169. }
  170. async uploadMedia(file: File, kind: 'image' | 'audio' | 'video', reason: string): Promise<string> {
  171. const ticket = await this.functions.operations<MediaUploadTicket>('ops/media/presign', { reason, idempotencyKey: crypto.randomUUID(), payload: { fileName: file.name, contentType: file.type, size: file.size, kind } });
  172. const response = await fetch(ticket.uploadUrl, { method: ticket.method, headers: ticket.headers, body: file });
  173. if (!response.ok) throw new Error(`媒体上传失败(${response.status})`);
  174. const completed = await this.functions.operations<{ publicUrl: string }>('ops/media/complete', { targetId: ticket.ticketId, reason, idempotencyKey: crypto.randomUUID() });
  175. return completed.publicUrl;
  176. }
  177. list(kind: OperationsListKind, page: number, pageSize: number, search: string, filters: Record<string, unknown> = {}): Promise<OperationsPage> {
  178. const operation = kind === 'relations' ? 'ops/relations/list'
  179. : kind === 'course-bindings' ? 'ops/course-bindings/list'
  180. : kind === 'appointments' ? 'ops/appointments/list'
  181. : kind === 'lessons' ? 'ops/lessons/list'
  182. : kind === 'learning-records' ? 'ops/learning-records/list'
  183. : kind === 'finance' ? 'ops/finance/list'
  184. : 'ops/money-logs/list';
  185. return this.functions.operations<OperationsPage>(operation, { page, pageSize, search, filters });
  186. }
  187. mutate<T>(operation: string, targetId: string, reason: string, payload: Record<string, unknown> = {}, expectedUpdatedAt = '', idempotencyKey = ''): Promise<T> {
  188. return this.functions.operations<T>(operation, { targetId, reason, payload, expectedUpdatedAt, idempotencyKey: idempotencyKey || crypto.randomUUID() });
  189. }
  190. }