|
|
@@ -4,26 +4,29 @@ import {CommonModule} from '@angular/common';
|
|
|
import {Component,Input,Output,EventEmitter,OnChanges,ElementRef,computed,inject,signal} from '@angular/core';
|
|
|
import {FormsModule} from '@angular/forms';
|
|
|
import {RouterLink} from '@angular/router';
|
|
|
+import {CloudFunctionError} from '../cloud-functions.service';
|
|
|
import {OperationsService} from '../operations.service';
|
|
|
import {CreditAccounts,CreditTransaction} from '../member-enrollment.models';
|
|
|
import {TeachingCreditAccount} from '../admin.models';
|
|
|
import {OperationsDateFilterComponent} from './operations-date-filter.component';
|
|
|
import {LessonRecordDetailComponent} from '../pages/lesson-record-detail.component';
|
|
|
interface CreditLine {account:string;delta:number;before:number|null;after:number|null;availableBefore:number;availableAfter:number;storeBefore:number|null;storeAfter:number|null;storeAvailableBefore:number|null;storeAvailableAfter:number|null;kind:string;reason:string;source:string;operator:string;reference:string;lessonId:string;storeId:number;balances?:{storeBefore:number;storeAfter:number};}
|
|
|
+interface PendingCredit {key:string;fingerprint:string;payload:Record<string,unknown>;reason:string;version:string;}
|
|
|
interface CreditPreview {version:string;studentId:number;storeId:number;lines:CreditLine[];}
|
|
|
interface LedgerPage {total:number;version:string;items:{groupId:string;at:string;lines:CreditLine[]}[];}
|
|
|
@Component({selector:'app-member-credit-account',standalone:true,imports:[DropdownDirective,CommonModule,FormsModule,RouterLink,OperationsDateFilterComponent,LessonRecordDetailComponent],templateUrl:'./member-credit-account.component.html',styleUrls:['./member-enrollment.scss']})
|
|
|
export class MemberCreditAccountComponent implements OnChanges {
|
|
|
readonly isStore=(()=>{const sessions=inject(AdminSessionService);return computed(()=>sessions.session()?.operationsRole==='store-admin');})();
|
|
|
@Input({required:true}) objectId='';@Input() store=false;@Input() refreshVersion=0;@Output() changed=new EventEmitter<void>();
|
|
|
+ private sessions=inject(AdminSessionService);private pending:PendingCredit|null=null;
|
|
|
private host=inject(ElementRef<HTMLElement>);private operations=inject(OperationsService);loading=signal(false);private serial=0;data=signal<CreditAccounts|null>(null);error=signal('');message=signal('');saving=signal(false);preview=signal<CreditPreview|null>(null);
|
|
|
ledger=signal<LedgerPage|null>(null);ledgerBusy=signal(false);exporting=signal(false);ledgerError=signal('');private ledgerSerial=0;lessonId='';
|
|
|
account:TeachingCreditAccount='Purse';amount=0;direction='add';sourceId='';method='';reason='';adjustSign='deduct';adjustMode='paired';evidence='';private fingerprint='';private key='';private previewFingerprint='';
|
|
|
readonly expandedLedger=signal(new Set<string>());
|
|
|
toggleLedger(groupId:string){this.expandedLedger.update(current=>{const next=new Set(current);if(next.has(groupId))next.delete(groupId);else next.add(groupId);return next;});}
|
|
|
filterAccount='';filterKind='';dateFrom='';dateTo='';page=1;pageSize=20;
|
|
|
- ngOnChanges(){this.expandedLedger.set(new Set());this.preview.set(null);void this.load();if(!this.store)void this.loadLedger();}
|
|
|
- async load(){if(!this.objectId)return;const serial=++this.serial;this.loading.set(true);this.error.set('');try{const result=await this.operations.request<CreditAccounts>(this.store?'ops/stores/credit-accounts':'ops/users/credit-accounts',{objectId:this.objectId});if(serial===this.serial)this.data.set(result);}catch(e){if(serial===this.serial){this.data.set(null);this.error.set(e instanceof Error?e.message:'账户加载失败');}}finally{if(serial===this.serial)this.loading.set(false);}}
|
|
|
+ ngOnChanges(){this.restorePending();this.expandedLedger.set(new Set());this.preview.set(null);void this.load();if(!this.store)void this.loadLedger();}
|
|
|
+ async load(){if(!this.objectId)return;const serial=++this.serial;this.loading.set(true);this.error.set('');try{await this.reconcile();const result=await this.operations.request<CreditAccounts>(this.store?'ops/stores/credit-accounts':'ops/users/credit-accounts',{objectId:this.objectId});if(serial===this.serial){this.data.set(result);if(this.pending)this.error.set('上一笔充值结果尚待核对,原请求号已保留;请先核对流水,同一笔重试不会重复记账。');}}catch(e){if(serial===this.serial){this.data.set(null);this.error.set(e instanceof Error?e.message:'账户加载失败');}}finally{if(serial===this.serial)this.loading.set(false);}}
|
|
|
label(account:string){return this.data()?.accounts.find(a=>a.account===account)?.label||account;}
|
|
|
kindLabel(kind:string){return ({fund:'总部入账',transfer:'门店充值',refund:'原单扣回',adjust:'差错调整',lesson:'完课扣减',recharge:'历史充值',deduction:'历史扣减'} as Record<string,string>)[kind]||kind;}
|
|
|
refundable():CreditTransaction[]{return(this.data()?.transactions||[]).filter(t=>t.kind==='transfer'&&Number(t.unreturned)>0);}
|
|
|
@@ -32,9 +35,17 @@ export class MemberCreditAccountComponent implements OnChanges {
|
|
|
chooseAccount(account:TeachingCreditAccount,history=false){this.account=account;this.resetPreview();if(history){this.filterAccount=account;this.page=1;void this.loadLedger();}else{this.direction='add';if(account==='UserExp')this.amount=1;}requestAnimationFrame(()=>this.host.nativeElement.querySelector(history?'.ledger-panel':'form')?.scrollIntoView({behavior:'smooth',block:'start'}));}
|
|
|
payload(){return{account:this.account,amount:this.amount,direction:this.direction,sourceTransactionId:this.sourceId,payMethod:this.method,...(this.direction==='adjust'?{adjustSign:this.adjustSign,adjustMode:this.adjustMode,evidence:this.evidence}:{})};}
|
|
|
private valid(){if(!Number.isFinite(this.amount)||this.amount<=0||(this.account!=='UserPoint'&&!Number.isInteger(this.amount))||Math.abs(this.amount*100-Math.round(this.amount*100))>0.000001||!this.method||this.reason.trim().length<2||(!this.store&&this.direction==='deduct'&&!this.sourceId)||(this.direction==='adjust'&&this.evidence.trim().length<2)){this.error.set('请填写有效数量、方式和原因;扣回需原单,差错调整需核对依据');return false;}return true;}
|
|
|
- async check(){if(this.saving()||!this.valid())return;this.saving.set(true);this.error.set('');this.preview.set(null);try{const payload=this.payload();this.previewFingerprint=JSON.stringify([this.objectId,payload,this.reason]);this.preview.set(await this.operations.request<CreditPreview>('ops/users/credit-preview',{objectId:this.objectId,payload}));}catch(e){this.error.set(e instanceof Error?e.message:'预览失败');}finally{this.saving.set(false);}}
|
|
|
- async submit(){if(this.saving()||this.loading()||!this.valid())return;this.error.set('');this.message.set('');const payload=this.payload(),fingerprint=JSON.stringify([this.objectId,payload,this.reason]);if(!this.store&&(!this.preview()||fingerprint!==this.previewFingerprint)){this.preview.set(null);this.error.set('请先预览本次账户变动');return;}if(fingerprint!==this.fingerprint){this.fingerprint=fingerprint;this.key=crypto.randomUUID();}
|
|
|
- this.saving.set(true);try{await this.operations.mutate(this.store?'ops/stores/credit-post':'ops/users/adjust-balance',this.objectId,this.reason,payload,this.preview()?.version||'',this.key);this.amount=0;this.reason='';this.key='';this.fingerprint='';this.preview.set(null);this.message.set('课时业务已完成,余额和流水已更新');await this.load();if(!this.store)await this.loadLedger();this.changed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'课时业务失败');}finally{this.saving.set(false);}}
|
|
|
+ private storageKey(){const s=this.sessions.session();return s?.objectId&&!this.store?'xiaoshu:pending-credit:'+JSON.stringify([s.company?.objectId||'',s.objectId,this.objectId]):'';}
|
|
|
+ private savePending(value:PendingCredit|null){this.pending=value;const key=this.storageKey();if(key)try{if(value)sessionStorage.setItem(key,JSON.stringify(value));else sessionStorage.removeItem(key);}catch{/* Same-page retries still retain the key when storage is unavailable. */}}
|
|
|
+ private restorePending(){const key=this.storageKey();if(!key)return;try{const raw=sessionStorage.getItem(key);if(!raw){this.pending=null;return;}const p=JSON.parse(raw) as PendingCredit;if(!p.key||!p.payload||!p.reason||!p.fingerprint||!p.version)return;this.pending=p;this.key=p.key;this.fingerprint=p.fingerprint;this.account=p.payload['account'] as TeachingCreditAccount;this.amount=Number(p.payload['amount']);this.direction=String(p.payload['direction']);if(this.direction==='adjust'){this.adjustSign=String(p.payload['adjustSign']);this.adjustMode=String(p.payload['adjustMode']);this.evidence=String(p.payload['evidence']||'');}this.sourceId=String(p.payload['sourceTransactionId']||'');this.method=String(p.payload['payMethod']||'');this.reason=p.reason;}catch{/* Never send a malformed saved command. */}}
|
|
|
+ private completed(){this.savePending(null);this.amount=0;this.reason='';this.key='';this.fingerprint='';this.preview.set(null);this.error.set('');this.message.set('课时业务已完成,余额和流水已更新');}
|
|
|
+ private async reconcile():Promise<boolean>{if(!this.pending||this.store)return false;const key=this.pending.key,target=this.objectId,storage=this.storageKey();try{const status=await this.operations.request<{committed:boolean;operationId?:string}>('ops/users/credit-status',{objectId:target,idempotencyKey:key});if(target!==this.objectId||storage!==this.storageKey()||this.pending?.key!==key)return false;if(status.committed&&status.operationId){this.completed();return true;}}catch{/* A failed lookup cannot establish whether a write committed. */}return false;}
|
|
|
+ private pendingMatches(fingerprint:string){if(this.pending&&this.pending.fingerprint!==fingerprint){this.error.set('上一笔充值结果尚待核对,请先刷新账户;不能修改内容后另建充值。');return false;}return true;}
|
|
|
+ async check(){if(this.saving()||!this.valid())return;this.saving.set(true);this.error.set('');this.preview.set(null);try{if(await this.reconcile()){await this.load();await this.loadLedger();this.changed.emit();return;}const payload=this.payload(),fingerprint=JSON.stringify([this.objectId,payload,this.reason]);if(!this.pendingMatches(fingerprint))return;const result=await this.operations.request<CreditPreview>('ops/users/credit-preview',{objectId:this.objectId,payload});if(fingerprint!==JSON.stringify([this.objectId,this.payload(),this.reason]))return;this.previewFingerprint=fingerprint;this.preview.set(result);}catch(e){this.error.set(e instanceof Error?e.message:'预览失败');}finally{this.saving.set(false);}}
|
|
|
+ async submit(){if(this.saving()||this.loading()||!this.valid())return;this.error.set('');this.message.set('');const payload=this.payload(),fingerprint=JSON.stringify([this.objectId,payload,this.reason]);if(!this.pendingMatches(fingerprint))return;if(!this.store&&(!this.preview()||fingerprint!==this.previewFingerprint)){this.preview.set(null);this.error.set('请先预览本次账户变动');return;}if(fingerprint!==this.fingerprint){this.fingerprint=fingerprint;this.key=crypto.randomUUID();}
|
|
|
+ const version=this.preview()?.version||'',target=this.objectId,storage=this.storageKey(),requestKey=this.key,wasPending=Boolean(this.pending);const current=()=>target===this.objectId&&storage===this.storageKey()&&(!this.pending||this.pending.key===requestKey);if(!this.store)this.savePending({key:this.key,fingerprint,payload,reason:this.reason,version});
|
|
|
+ this.saving.set(true);try{await this.operations.mutate(this.store?'ops/stores/credit-post':'ops/users/adjust-balance',this.objectId,this.reason,payload,version,requestKey);if(storage)try{const saved=JSON.parse(sessionStorage.getItem(storage)||'null') as PendingCredit|null;if(saved?.key===requestKey)sessionStorage.removeItem(storage);}catch{}if(!current())return;this.completed();await this.load();if(!this.store)await this.loadLedger();this.changed.emit();}catch(e){if(!current())return;if(await this.reconcile()){await this.load();await this.loadLedger();this.changed.emit();}else{if(!wasPending&&e instanceof CloudFunctionError&&[400,403,404,409,422].includes(e.status))this.savePending(null);this.error.set(e instanceof Error?e.message:'课时业务失败');}}finally{this.saving.set(false);}}
|
|
|
+
|
|
|
params(){return{objectId:this.objectId,account:this.filterAccount,kind:this.filterKind,dateFrom:this.dateFrom,dateTo:this.dateTo,page:this.page,pageSize:this.pageSize};}
|
|
|
async loadLedger(){const serial=++this.ledgerSerial;this.ledgerBusy.set(true);this.ledgerError.set('');try{const result=await this.operations.request<LedgerPage>('ops/users/credit-ledger',this.params());if(serial===this.ledgerSerial){this.ledger.set(result);this.expandedLedger.set(new Set());}}catch(e){if(serial===this.ledgerSerial)this.ledgerError.set(e instanceof Error?e.message:'流水加载失败');}finally{if(serial===this.ledgerSerial)this.ledgerBusy.set(false);}}
|
|
|
query(clear=false){if(clear){this.filterAccount='';this.filterKind='';this.dateFrom='';this.dateTo='';}this.page=1;void this.loadLedger();}
|