Bladeren bron

feat(admin): add audited store credit funding and grants

彭峰 7 uur geleden
bovenliggende
commit
c778d64a5a

+ 38 - 0
docs/qa/store-credit-phase-one-2026-10-15.md

@@ -0,0 +1,38 @@
+# 门店额度第一阶段验收(2026-10-15)
+
+## 交付范围
+
+总部为门店增加 30 分钟课时、60 分钟课时、体验次数、陪练小时;门店独立登录及数据隔离属于后续阶段。
+
+- 门店列表增加「充值额度」入口,详情页顶部显示四个账户的余额、预留和可用额度。
+- 单次填写四项增量,零值不提交;服务器先计算变动预览,再统一入账。课时及体验次数为整数,陪练小时最多两位小数。
+- 超级管理员可操作,运营管理员须单独授权;授权与撤销必须填写原因并记录审计。其他角色不能充值。
+- 四项余额、凭据及操作日志在同一事务保存。请求编号防重复,余额发生变化后必须重新预览。
+- 流水支持账户、业务和日期筛选、分页与 Excel 导出。纠错关联原总部入账凭据,累计不得超过原入账,且不能扣回已经划拨或预留的额度。
+- 学生开课、充值划拨、排课预留与结课扣减规则保持原有行为。
+- 旧单账户客户端仍能使用原接口;发布前已经提交成功的请求跨版本重试时返回原凭据,不再次入账。
+
+## 验证结果
+
+1. `node --test scripts/tests/store-credit.test.mjs scripts/tests/member-credit-ledger.test.mjs scripts/tests/member-enrollment.test.mjs scripts/tests/member-enrollment-gateway.test.mjs`:44 项通过。
+2. 管理端门店相关 Angular 测试:11 项通过。覆盖四项预览、提交、重复点击、超时重试、输入变化、授权与既有门店页面。
+3. `npm run build:admin`:成功;现有样式预算和 CommonJS 警告不影响构建。
+4. `npm run cloud:validate`:11 个云函数定义通过。
+5. Chrome 本地管理端连接线上接口验收:门店列表充值入口、四项真实服务端预览、权限名单、详情顶部账户、流水读取、Excel 下载均成功;1440px 与 390px 视口无弹窗横向溢出;无页面运行错误。
+6. 浏览器验收主动拦截充值和授权写入接口,实际写入次数为 0。选择的门店现有流水为空,验证了空结果及 Excel 下载;有数据的流水筛选与归并由数据库及组件回归覆盖,未伪造历史流水。
+7. 真实数据库事务验证:四项入账、凭据数量和幂等重试通过,事务主动回滚。验证结束后测试凭据、正式充值操作及授权记录均未新增,部署前后门店余额摘要一致。
+8. 发布后线上回读:账户四项、充值资格、流水及可授权运营管理员名单接口正常。流水与权限读取单次约 1.4–1.5 秒,仅为本次回读结果,不代表性能基准。
+
+PGlite 的并发请求测试验证请求防重,但其串行执行不等同于真实多连接压力测试。本次不包含实际生产充值、真实账号授权、手机或微信 WebView 验收。
+
+## 发布与迁移
+
+- `scripts/sql/store-credit.sql` 为增量迁移,新增授权、授权审计、幂等操作表及数据库函数;不重写存量余额、课时或历史凭据。
+- SQL 和两个现有运营网关已发布。发布先保存真实线上函数及 SQL 基线,以哈希核对基线后定点替换,发布后再次回读。
+- 旧客户端账户接口保留 `transactions`;新账户概览使用 `summaryOnly:true` 减少不必要明细读取。
+- 前端在本地 `http://127.0.0.1:4300/admin/stores` 可用,生产构建已验证;本轮未部署独立的生产前端站点。
+- 发布证据及受保护基线保存在本机 `/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-15-store-credit/`,不提交凭据或线上完整函数快照到 Git。
+
+## 回滚
+
+前端可回退此次组件及入口改动;后端应依据发布目录保存的实际基线恢复本次修改的函数段,先核对线上哈希,避免覆盖后续他人发布。保留新增表、收入/额度凭据、授权审计和幂等约束,不以删除数据的方式回滚。若已经发生正式充值,余额和原凭据必须保留,纠正通过关联原单的调整操作完成。旧网关如不包含新权限校验,应暂停门店充值写入后再恢复,防止撤销本次授权限制。

+ 2 - 0
projects/xiaoshu-admin/src/app/admin.models.ts

@@ -488,6 +488,8 @@ export interface StoreOption extends Record<string, unknown> {
 }
 
 export interface StorePage extends OperationsPage<StoreSummary> {
+  canFund?: boolean;
+  canGrantCredit?: boolean;
   canManage: boolean;
   activeStudyingMemberCountError?: string;
   summary: {

+ 29 - 0
projects/xiaoshu-admin/src/app/components/store-credit-account.component.html

@@ -0,0 +1,29 @@
+@if(!compact){
+<section class="credit-card" aria-label="门店额度账户">
+ <header><div><h2>门店额度账户</h2><span>{{data()?.storeName}}</span></div><div class="actions"><button type="button" (click)="load()" [disabled]="loading()">刷新额度</button>@if(data()?.canPost){<button class="primary" type="button" (click)="start()">充值额度</button>}</div></header>
+ @if(error()){<p class="error" role="alert">{{error()}}</p>}@if(message()){<p class="success" role="status">{{message()}}</p>}
+ @if(loading()&&!data()){<p>正在读取门店额度…</p>}
+ @if(data()){<div class="balances">@for(a of accounts;track a.key){<article><span>{{a.label}}</span><strong>{{balance(a.key)?.balance | number:'1.0-2'}} <small>{{a.unit}}</small></strong><small>可用 {{balance(a.key)?.available | number:'1.0-2'}} · 预留 {{balance(a.key)?.reserved | number:'1.0-2'}}</small></article>}</div>}
+ <nav><button type="button" [class.active]="tab()==='accounts'" (click)="tab.set('accounts')">额度概览</button><button type="button" [class.active]="tab()==='ledger'" (click)="showLedger()">额度流水</button></nav>
+ @if(tab()==='accounts'){<p>总部为门店入账,门店再向所属学生划拨。四类额度分别记账。</p>@if(data()&&!data()?.canPost){<p>当前账号可查看额度;充值需由超级管理员单独授权。</p>}}
+ @if(tab()==='ledger'){
+ <div class="filters"><label>账户<select [(ngModel)]="accountFilter"><option value="">全部账户</option>@for(a of accounts;track a.key){<option [value]="a.key">{{a.label}}</option>}</select></label><label>业务<select [(ngModel)]="kindFilter"><option value="">全部业务</option>@for(k of ['fund','transfer','refund','adjust','recharge','deduction','lesson'];track k){<option [value]="k">{{kindLabel(k)}}</option>}</select></label><label>开始日期<input type="date" [(ngModel)]="dateFrom"/></label><label>结束日期<input type="date" [(ngModel)]="dateTo"/></label><button type="button" (click)="loadLedger(true)" [disabled]="ledgerBusy()">查询</button><button type="button" (click)="export()" [disabled]="exporting()">{{exporting()?'导出中…':'导出 Excel'}}</button></div>
+ @if(ledgerError()){<p class="error" role="alert">{{ledgerError()}}</p>}@if(ledgerBusy()){<p>正在读取流水…</p>}
+ <div class="scroll"><table><thead><tr><th>时间 / 单号</th><th>账户 / 业务</th><th>变动</th><th>前后余额</th><th>操作人 / 原因</th><th>原单 / 操作</th></tr></thead><tbody>@for(row of ledger()?.items||[];track row.groupId){@for(l of row.lines;track $index){<tr><td>{{row.at|date:'yyyy-MM-dd HH:mm':'+0800'}}<small>{{row.groupId}}</small></td><td>{{label(l.account)}}<small>{{kindLabel(l.kind)}}</small></td><td [class.negative]="l.delta<0">{{l.delta>0?'+':''}}{{l.delta|number:'1.0-2'}}</td><td>{{l.before??'未记录'}} → {{l.after??'未记录'}}</td><td>{{l.operator||'未记录'}}<small>{{l.reason}}</small></td><td><small>{{l.sourceTransactionId||'—'}}</small>@if(data()?.canPost&&l.kind==='fund'&&l.reference){<button type="button" (click)="reverse(l)">纠正入账</button>}</td></tr>}}@empty{<tr><td colspan="6">{{ledgerError()?'流水暂不可用':ledgerBusy()?'读取中…':'没有符合条件的流水'}}</td></tr>}</tbody></table></div>
+ <footer><span>共 {{ledger()?.total??'—'}} 笔 · 第 {{page}} 页</span><button type="button" (click)="page=page-1;loadLedger()" [disabled]="page<=1||ledgerBusy()">上一页</button><button type="button" (click)="page=page+1;loadLedger()" [disabled]="page*20>=(ledger()?.total||0)||ledgerBusy()">下一页</button></footer>
+ }
+</section>}
+@if(open()){
+<div class="backdrop" (click)="close()"></div><section class="credit-dialog" role="dialog" aria-modal="true" aria-label="门店额度充值">
+<header><div><h2>{{direction==='reverse'?'纠正总部入账':'门店充值额度'}}</h2><span>{{data()?.storeName||'正在读取门店…'}} · ID {{data()?.storeId||'—'}}</span></div><button type="button" (click)="close()" [disabled]="busy()">关闭</button></header>
+@if(error()){<p class="error" role="alert">{{error()}}</p>}
+@if(loading()){<p>正在读取账户…</p>}
+@if(data()&&!data()?.canPost){<p>当前账号没有门店充值权限,请由超级管理员授权。</p>}
+@if(data()?.canPost){<form (ngSubmit)="check()"><fieldset [disabled]="busy()||loading()">
+@if(direction==='reverse'){<p>关联原单:{{sourceId}}</p><p>填写需要扣回的错误数量;已划拨或预留的额度不能扣回。</p>}@else{<p>填写本次增加的额度,不充值的项目保留 0。</p>}
+<div class="amount-grid">@for(a of accounts;track a.key){@if(direction!=='reverse'||sourceAccount===a.key){<label>{{a.label}}({{a.unit}})<small>当前 {{balance(a.key)?.balance??'—'}}</small><input [name]="a.key" type="number" min="0" [step]="a.key==='UserPoint'?0.01:1" [(ngModel)]="amounts[a.key]" (ngModelChange)="resetPreview()"/></label>}}</div>
+<label>记账方式<select name="method" [(ngModel)]="method" (ngModelChange)="resetPreview()"><option value="">请选择</option><option>微信</option><option>支付宝</option><option>转账</option><option>线下收款</option><option>运营记账</option></select></label>
+<label>{{direction==='reverse'?'纠正原因及核对依据':'操作原因'}}<textarea name="reason" [(ngModel)]="reason" (ngModelChange)="resetPreview()" minlength="2" maxlength="500" required rows="2"></textarea></label>
+<button class="primary" type="submit">{{busy()?'处理中…':'预览额度变动'}}</button></fieldset></form>
+@if(preview();as p){<section class="preview" aria-label="额度变动预览"><h3>确认本次变动</h3><div class="scroll"><table><thead><tr><th>账户</th><th>增减</th><th>原余额</th><th>新余额</th></tr></thead><tbody>@for(l of p.lines;track l.account){<tr><td>{{label(l.account)}}</td><td>{{l.delta>0?'+':''}}{{l.delta}}</td><td>{{l.before}}</td><td>{{l.after}}</td></tr>}</tbody></table></div><button class="primary" type="button" (click)="submit()" [disabled]="busy()">{{busy()?'正在记账…':'确认统一入账'}}</button></section>}}
+</section>}

File diff suppressed because it is too large
+ 0 - 0
projects/xiaoshu-admin/src/app/components/store-credit-account.component.scss


+ 23 - 0
projects/xiaoshu-admin/src/app/components/store-credit-account.component.spec.ts

@@ -0,0 +1,23 @@
+import {ComponentFixture,TestBed} from '@angular/core/testing';
+import {StoreCreditAccountComponent} from './store-credit-account.component';
+import {OperationsService} from '../operations.service';
+import {StoreCreditGrantsComponent} from './store-credit-grants.component';
+
+describe('门店额度业务',()=>{
+ let fixture:ComponentFixture<StoreCreditAccountComponent>,c:StoreCreditAccountComponent,request:jasmine.Spy,mutate:jasmine.Spy;
+ const data={storeId:10,storeName:'测试门店',canPost:true,accounts:[{account:'Purse',balance:4,available:4,reserved:0}],transactions:[]};
+ const plan={version:'v1',storeId:10,lines:[{account:'Purse',delta:2,before:4,after:6}]};
+ beforeEach(async()=>{request=jasmine.createSpy('request').and.callFake(async(op:string)=>op.endsWith('credit-accounts')?data:op.endsWith('credit-preview')?plan:{items:[],total:0,version:'ledger-v1'});mutate=jasmine.createSpy('mutate').and.resolveTo({});await TestBed.configureTestingModule({imports:[StoreCreditAccountComponent],providers:[{provide:OperationsService,useValue:{request,mutate}}]}).compileComponents();fixture=TestBed.createComponent(StoreCreditAccountComponent);fixture.componentRef.setInput('objectId','store');fixture.detectChanges();await fixture.whenStable();c=fixture.componentInstance;});
+ afterEach(()=>fixture.destroy());
+ function fill(){c.start();c.amounts={Purse:2,SilverCoin:3,UserExp:4,UserPoint:2.5};c.method='运营记账';c.reason='总部额度入账';}
+ it('一次预览四类增量,只在确认后发送同一门店和版本',async()=>{fill();await c.check();expect(request.calls.mostRecent().args[1].payload.lines.length).toBe(4);expect(mutate).not.toHaveBeenCalled();await c.submit();expect(mutate.calls.mostRecent().args.slice(0,5)).toEqual(['ops/stores/credit-post','store','总部额度入账',{direction:'add',payMethod:'运营记账',lines:[{account:'Purse',amount:2},{account:'SilverCoin',amount:3},{account:'UserExp',amount:4},{account:'UserPoint',amount:2.5}]},'v1']);});
+ it('预览返回后修改金额必须重新预览',async()=>{fill();await c.check();c.amounts['Purse']=9;await c.submit();expect(mutate).not.toHaveBeenCalled();});
+ it('超时重试保留原请求号,防止重复入账',async()=>{fill();await c.check();mutate.and.rejectWith(new Error('网络超时'));await c.submit();const key=mutate.calls.mostRecent().args[5];expect(c.open()).toBeTrue();expect(c.preview()).not.toBeNull();mutate.and.resolveTo({});await c.submit();expect(mutate.calls.mostRecent().args[5]).toBe(key);});
+ it('慢预览不能覆盖修改后的额度',async()=>{fill();let finish!:(value:unknown)=>void;request.and.callFake(()=>new Promise(resolve=>finish=resolve));const pending=c.check();c.amounts['Purse']=9;finish(plan);await pending;expect(c.preview()).toBeNull();});
+ it('原单纠错只提交对应账户及原凭据',async()=>{c.reverse({account:'Purse',reference:'original-fund',delta:10,before:0,after:10});c.amounts['Purse']=2;c.reason='原单数量填错';await c.check();expect(request.calls.mostRecent().args[1].payload).toEqual({direction:'reverse',payMethod:'运营记账',sourceTransactionId:'original-fund',lines:[{account:'Purse',amount:2}]});});
+ it('只读账号显示余额但不显示充值按钮,流水查询保留筛选',async()=>{c.data.set({...data,canPost:false} as never);fixture.detectChanges();expect(fixture.nativeElement.textContent).toContain('单独授权');expect([...fixture.nativeElement.querySelectorAll('button')].some((b:any)=>b.textContent==='充值额度')).toBeFalse();c.accountFilter='UserExp';c.dateFrom='2026-10-01';await c.loadLedger(true);expect(request.calls.mostRecent().args).toEqual(['ops/stores/credit-ledger',jasmine.objectContaining({objectId:'store',account:'UserExp',dateFrom:'2026-10-01',page:1})]);});
+ it('空项不入账,非法数字不请求服务器',async()=>{fill();c.amounts['Purse']=0.5;const count=request.calls.count();await c.check();expect(request.calls.count()).toBe(count);c.amounts['Purse']=0;await c.check();expect(request.calls.mostRecent().args[1].payload.lines.map((x:{account:string})=>x.account)).not.toContain('Purse');});
+});
+describe('门店充值独立授权',()=>{
+ it('授权与撤销通过专用接口并要求原因',async()=>{const request=jasmine.createSpy().and.resolveTo({items:[]}),mutate=jasmine.createSpy().and.resolveTo({});await TestBed.configureTestingModule({imports:[StoreCreditGrantsComponent],providers:[{provide:OperationsService,useValue:{request,mutate}}]}).compileComponents();const fixture=TestBed.createComponent(StoreCreditGrantsComponent),c=fixture.componentInstance,row={objectId:'manager',name:'运营',username:'ops',enabled:false,role:'ops-manager'};await c.save(row);expect(mutate).not.toHaveBeenCalled();c.reason='负责门店充值';await c.save(row);expect(mutate).toHaveBeenCalledWith('ops/stores/credit-grants/save','manager','负责门店充值',{enabled:true});fixture.destroy();});
+});

+ 35 - 0
projects/xiaoshu-admin/src/app/components/store-credit-account.component.ts

@@ -0,0 +1,35 @@
+import { CommonModule } from '@angular/common';
+import { Component, EventEmitter, Input, OnChanges, OnDestroy, Output, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { OperationsService } from '../operations.service';
+import { CreditAccounts } from '../member-enrollment.models';
+
+interface CreditLine {account:string;delta:number;before:number|null;after:number|null;availableBefore?:number;availableAfter?:number;kind?:string;operator?:string;reason?:string;reference?:string;sourceTransactionId?:string;source?:string;}
+interface CreditPlan {version:string;storeId:number;lines:CreditLine[];}
+interface Ledger {total:number;version:string;items:Array<{groupId:string;at:string;lines:CreditLine[]}>;}
+@Component({selector:'app-store-credit-account',standalone:true,imports:[CommonModule,FormsModule],templateUrl:'./store-credit-account.component.html',styleUrls:['./store-credit-account.component.scss']})
+export class StoreCreditAccountComponent implements OnChanges,OnDestroy {
+ @Input({required:true}) objectId='';@Input() compact=false;@Input() initialOpen=false;@Output() closed=new EventEmitter<void>();@Output() posted=new EventEmitter<string>();
+ private readonly operations=inject(OperationsService);private serial=0;private ledgerSerial=0;private destroyed=false;private key='';private fingerprint='';private previewFingerprint='';
+ readonly accounts=[{key:'Purse',label:'30分钟课时',unit:'节'},{key:'SilverCoin',label:'60分钟课时',unit:'节'},{key:'UserExp',label:'体验次数',unit:'次'},{key:'UserPoint',label:'陪练时长',unit:'小时'}];
+ data=signal<CreditAccounts|null>(null);loading=signal(false);error=signal('');message=signal('');open=signal(false);busy=signal(false);preview=signal<CreditPlan|null>(null);ledger=signal<Ledger|null>(null);ledgerBusy=signal(false);ledgerError=signal('');exporting=signal(false);tab=signal<'accounts'|'ledger'>('accounts');
+ amounts:Record<string,number>={Purse:0,SilverCoin:0,UserExp:0,UserPoint:0};method='';reason='';direction='add';sourceId='';sourceAccount='';accountFilter='';kindFilter='';dateFrom='';dateTo='';page=1;
+ ngOnChanges(){++this.serial;++this.ledgerSerial;this.open.set(this.initialOpen);this.preview.set(null);this.data.set(null);this.ledger.set(null);this.key='';this.fingerprint='';void this.load();}
+ ngOnDestroy(){this.destroyed=true;++this.serial;++this.ledgerSerial;}
+ label(key:string){return this.accounts.find(a=>a.key===key)?.label||key;}
+ kindLabel(kind?:string){return ({fund:'总部入账',transfer:'划拨给学生',refund:'学生退回',adjust:'差错纠正',recharge:'历史入账',deduction:'历史扣减',lesson:'历史课时扣减'} as Record<string,string>)[kind||'']||kind||'—';}
+ balance(key:string){return this.data()?.accounts.find(a=>a.account===key);}
+ async load(){const serial=++this.serial;this.loading.set(true);this.error.set('');try{const data=await this.operations.request<CreditAccounts>('ops/stores/credit-accounts',{objectId:this.objectId,summaryOnly:true});if(serial===this.serial&&!this.destroyed)this.data.set(data);}catch(e){if(serial===this.serial)this.error.set(e instanceof Error?e.message:'门店额度加载失败');}finally{if(serial===this.serial)this.loading.set(false);}}
+ start(){this.direction='add';this.sourceId='';this.sourceAccount='';this.amounts={Purse:0,SilverCoin:0,UserExp:0,UserPoint:0};this.reason='';this.preview.set(null);this.error.set('');this.message.set('');this.open.set(true);}
+ close(){if(this.busy())return;this.open.set(false);this.closed.emit();}
+ reverse(line:CreditLine){this.start();this.direction='reverse';this.sourceId=line.reference||'';this.sourceAccount=line.account;this.method='运营记账';}
+ payload(){return {direction:this.direction,payMethod:this.method,...(this.direction==='reverse'?{sourceTransactionId:this.sourceId}:{}),lines:this.accounts.filter(a=>this.direction!=='reverse'||a.key===this.sourceAccount).filter(a=>Number(this.amounts[a.key])!==0).map(a=>({account:a.key,amount:this.amounts[a.key]}))};}
+ resetPreview(){this.preview.set(null);}
+ private currentFingerprint(){return JSON.stringify([this.objectId,this.payload(),this.reason.trim()]);}
+ async check(){if(this.busy())return;this.error.set('');this.preview.set(null);const p=this.payload(),fingerprint=this.currentFingerprint();if(!p.lines.length||p.lines.some(l=>!Number.isFinite(l.amount)||l.amount<=0||l.amount>1e9||(l.account!=='UserPoint'&&!Number.isInteger(l.amount))||Math.abs(l.amount*100-Math.round(l.amount*100))>0.000001)||!this.method||this.reason.trim().length<2){this.error.set('请填写有效额度、记账方式和至少两个字的操作原因');return;}this.busy.set(true);try{const result=await this.operations.request<CreditPlan>('ops/stores/credit-preview',{objectId:this.objectId,payload:p});if(!this.destroyed&&fingerprint===this.currentFingerprint()){this.previewFingerprint=fingerprint;this.preview.set(result);}}catch(e){this.error.set(e instanceof Error?e.message:'预览失败');}finally{this.busy.set(false);}}
+ async submit(){const plan=this.preview();if(this.busy()||!plan||this.previewFingerprint!==this.currentFingerprint())return;const fingerprint=this.currentFingerprint();if(this.fingerprint!==fingerprint){this.key=crypto.randomUUID();this.fingerprint=fingerprint;}this.busy.set(true);this.error.set('');try{await this.operations.mutate('ops/stores/credit-post',this.objectId,this.reason.trim(),this.payload(),plan.version,this.key);this.preview.set(null);this.open.set(false);this.message.set(this.direction==='reverse'?'已纠正原入账,余额与流水已更新':'门店充值成功,四类额度已统一入账');this.key='';this.fingerprint='';await this.load();if(this.tab()==='ledger')await this.loadLedger();this.posted.emit(this.message());if(this.compact)this.closed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'提交未确认,请保留当前内容重试');}finally{this.busy.set(false);}}
+ params(page=this.page){return {objectId:this.objectId,page,pageSize:20,account:this.accountFilter,kind:this.kindFilter,dateFrom:this.dateFrom,dateTo:this.dateTo};}
+ async showLedger(){this.tab.set('ledger');if(!this.ledger())await this.loadLedger();}
+ async loadLedger(reset=false){if(reset)this.page=1;const serial=++this.ledgerSerial;this.ledgerBusy.set(true);this.ledgerError.set('');try{const result=await this.operations.request<Ledger>('ops/stores/credit-ledger',this.params());if(serial===this.ledgerSerial&&!this.destroyed)this.ledger.set(result);}catch(e){if(serial===this.ledgerSerial)this.ledgerError.set(e instanceof Error?e.message:'流水加载失败');}finally{if(serial===this.ledgerSerial)this.ledgerBusy.set(false);}}
+ async export(){if(this.exporting())return;this.exporting.set(true);this.ledgerError.set('');const params={...this.params(1),pageSize:100};try{const first=await this.operations.request<Ledger>('ops/stores/credit-ledger',params),rows=[...first.items];for(let page=2;rows.length<first.total;page++){const next=await this.operations.request<Ledger>('ops/stores/credit-ledger',{...params,page});if(!next.items.length||next.version!==first.version||next.total!==first.total)throw Error('导出期间流水变化,请重试');rows.push(...next.items);}const latest=await this.operations.request<Ledger>('ops/stores/credit-ledger',params);if(latest.version!==first.version||new Set(rows.map(r=>r.groupId)).size!==rows.length)throw Error('导出期间流水变化,请重试');const xlsx=await import('xlsx'),book=xlsx.utils.book_new();xlsx.utils.book_append_sheet(book,xlsx.utils.json_to_sheet(rows.flatMap(r=>r.lines.map(l=>({'业务单号':r.groupId,'时间(北京时间)':new Date(r.at).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}),'账户':this.label(l.account),'业务':this.kindLabel(l.kind),'变动':l.delta,'变动前':l.before??'未记录','变动后':l.after??'未记录','操作人':l.operator||'未记录','原因':l.reason||'','凭据':l.reference||'','关联原单':l.sourceTransactionId||'','来源':l.source==='legacy'?'历史流水':'新系统'})))),'门店额度流水');xlsx.writeFile(book,`门店${this.data()?.storeId||''}额度流水.xlsx`);}catch(e){this.ledgerError.set(e instanceof Error?e.message:'导出失败');}finally{this.exporting.set(false);}}
+}

+ 15 - 0
projects/xiaoshu-admin/src/app/components/store-credit-grants.component.ts

@@ -0,0 +1,15 @@
+import {Component,inject,signal} from '@angular/core';
+import {FormsModule} from '@angular/forms';
+import {OperationsService} from '../operations.service';
+interface Grant {objectId:string;name:string;username:string;role:string;enabled:boolean;}
+@Component({selector:'app-store-credit-grants',standalone:true,imports:[FormsModule],styleUrls:['./store-credit-account.component.scss'],template:`
+<button type="button" (click)="show()">充值权限</button>
+@if(open()) {<div class="backdrop" (click)="close()"></div><section class="credit-dialog" role="dialog" aria-modal="true" aria-label="门店充值权限"><header><h2>门店充值权限</h2><button type="button" (click)="close()" [disabled]="busy()">关闭</button></header><p>仅授权本帐套运营管理员办理门店充值及原单纠错,不授予超级管理员权限。</p>@if(error()){<p class="error" role="alert">{{error()}}</p>}@if(message()){<p class="success" role="status">{{message()}}</p>}
+<label>授权或撤销原因<input [(ngModel)]="reason" minlength="2" maxlength="500" [disabled]="busy()"/></label><div class="scroll"><table><thead><tr><th>账号</th><th>状态</th><th>操作</th></tr></thead><tbody>@for(row of items();track row.objectId){<tr><td>{{row.name}}<small>{{row.username}}</small></td><td>{{row.enabled?'已授权':'未授权'}}@if(row.role!=='ops-manager'){<small>账号或角色已失效</small>}</td><td><button type="button" (click)="save(row)" [disabled]="busy()||(!row.enabled&&row.role!=='ops-manager')">{{row.enabled?'撤销授权':'授权充值'}}</button></td></tr>}@empty{<tr><td colspan="3">{{busy()?'正在读取…':'暂无可授权的运营管理员'}}</td></tr>}</tbody></table></div></section>}`})
+export class StoreCreditGrantsComponent {
+ private readonly operations=inject(OperationsService);open=signal(false);busy=signal(false);error=signal('');message=signal('');items=signal<Grant[]>([]);reason='';
+ close(){if(!this.busy())this.open.set(false);}
+ async show(){this.open.set(true);this.error.set('');this.message.set('');this.busy.set(true);try{await this.load();}catch(e){this.error.set(e instanceof Error?e.message:'授权信息读取失败');}finally{this.busy.set(false);}}
+ private async load(){this.items.set((await this.operations.request<{items:Grant[]}>('ops/stores/credit-grants/list')).items);}
+ async save(row:Grant){if(this.busy())return;if(this.reason.trim().length<2){this.error.set('请填写至少两个字的授权或撤销原因');return;}this.busy.set(true);this.error.set('');try{await this.operations.mutate('ops/stores/credit-grants/save',row.objectId,this.reason.trim(),{enabled:!row.enabled});this.message.set(row.enabled?'充值权限已撤销':'充值权限已授予');await this.load();}catch(e){this.error.set(e instanceof Error?e.message:'授权变更失败');}finally{this.busy.set(false);}}
+}

+ 1 - 2
projects/xiaoshu-admin/src/app/pages/operations-store-detail.component.html

@@ -10,6 +10,7 @@
   </div>
 </header>
 
+<app-store-credit-account [objectId]="objectId" />
 @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
 @if(loading()&&!data()){
   <section class="loading-card" aria-live="polite"><lucide-icon [img]="icons.RefreshCw" [size]="22" class="spin"/><strong>正在归集门店经营数据</strong><span>会员、排课和工资来自不同业务投影,首次读取可能需要数秒</span></section>
@@ -70,5 +71,3 @@
     }
   </section>
 }
-
-<app-member-credit-account [objectId]="objectId" [store]="true" />

+ 2 - 2
projects/xiaoshu-admin/src/app/pages/operations-store-detail.component.ts

@@ -1,4 +1,4 @@
-import { MemberCreditAccountComponent } from '../components/member-credit-account.component';
+import { StoreCreditAccountComponent } from '../components/store-credit-account.component';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, HostListener, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -16,7 +16,7 @@ const CURRENT_MONTH = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/S
 @Component({
   selector: 'app-operations-store-detail',
   standalone: true,
-  imports: [MemberCreditAccountComponent,CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  imports: [StoreCreditAccountComponent,CommonModule, FormsModule, RouterLink, LucideAngularModule],
   templateUrl: './operations-store-detail.component.html',
   styleUrls: ['./operations-professional.scss', './operations-store-detail.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,

+ 5 - 2
projects/xiaoshu-admin/src/app/pages/operations-stores.component.html

@@ -1,6 +1,6 @@
 <header class="page-header">
   <div><p>STORE OPERATIONS</p><h1>门店管理</h1><span>统一管理门店账号、上下级关系、会员、陪练与排课情况</span></div>
-  <div class="header-actions">
+  <div class="header-actions">@if(page()?.canGrantCredit){<app-store-credit-grants/>}
     <button class="ui-button secondary" type="button" (click)="load(page()?.page||1)" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()"/>刷新</button>
     @if(page()?.canManage){<button class="ui-button primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="17"/>新增门店</button>}
   </div>
@@ -21,6 +21,7 @@
   <button class="ui-button secondary" type="button" (click)="reset()">重置</button>
 </section>
 
+@if(creditMessage()){<p class="notice" role="status">{{creditMessage()}}</p>}
 @if(message()){<p class="notice" role="status">{{message()}}</p>}
 @if(error()&&!drawerOpen()){<p class="notice error" role="alert">{{error()}}</p>}
 @if(page()?.activeStudyingMemberCountError){<p class="notice warning" role="alert">{{page()?.activeStudyingMemberCountError}}</p>}
@@ -38,7 +39,7 @@
       <td><strong>{{row.memberCount}}</strong> 人</td><td><strong>{{row.activeStudyingMemberCount ?? '—'}}</strong>@if(row.activeStudyingMemberCount != null){ 人}</td><td><strong>{{row.coachCount}}</strong> 人</td><td>{{row.todayAppointments}} 节</td><td>{{row.totalAppointments}} 节</td>
       <td>@if(row.lastAppointmentAt){<span>{{row.lastAppointmentAt|date:'yyyy-MM-dd'}}</span><small>{{row.lastAppointmentAt|date:'HH:mm'}}</small>}@else{暂无}</td><td>@if(row.lastLoginAt){<span>{{row.lastLoginAt|date:'yyyy-MM-dd'}}</span><small>{{row.lastLoginAt|date:'HH:mm'}}</small>}@else{从未登录}</td>
       <td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span></td>
-      <td><div class="row-actions">@if(!row.readOnly){<a class="icon-action" [routerLink]="['/admin/stores',row.objectId]" aria-label="查看门店经营详情" title="经营详情"><lucide-icon [img]="icons.Eye" [size]="16"/></a>}@else{<span class="readonly-label">待同步</span>}@if(page()?.canManage&&!row.readOnly){<button class="icon-action" type="button" (click)="openEdit(row)" aria-label="编辑门店" title="编辑"><lucide-icon [img]="icons.Pencil" [size]="16"/></button><button class="icon-action danger" type="button" (click)="openEdit(row,true)" aria-label="删除门店" title="删除"><lucide-icon [img]="icons.Trash2" [size]="16"/></button>}</div></td>
+      <td><div class="row-actions">@if(page()?.canFund&&!row.readOnly&&row.status!=='disabled'){<button type="button" class="ui-button secondary" (click)="creditTarget.set(row)">充值额度</button>}@if(!row.readOnly){<a class="icon-action" [routerLink]="['/admin/stores',row.objectId]" aria-label="查看门店经营详情" title="经营详情"><lucide-icon [img]="icons.Eye" [size]="16"/></a>}@else{<span class="readonly-label">待同步</span>}@if(page()?.canManage&&!row.readOnly){<button class="icon-action" type="button" (click)="openEdit(row)" aria-label="编辑门店" title="编辑"><lucide-icon [img]="icons.Pencil" [size]="16"/></button><button class="icon-action danger" type="button" (click)="openEdit(row,true)" aria-label="删除门店" title="删除"><lucide-icon [img]="icons.Trash2" [size]="16"/></button>}</div></td>
     </tr>}@empty{<tr><td colspan="12"><div class="empty">没有符合条件的门店</div></td></tr>}
   </tbody></table></div>
   <footer class="pagination"><span>第 {{page()?.page||1}} / {{pageCount()}} 页</span><div><button aria-label="上一页" type="button" [disabled]="(page()?.page||1)<=1" (click)="load((page()?.page||1)-1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17"/></button><button type="button" [disabled]="(page()?.page||1)>=pageCount()" (click)="load((page()?.page||1)+1)"><lucide-icon [img]="icons.ChevronRight" [size]="17"/></button></div></footer>
@@ -63,3 +64,5 @@
     <footer>@if(editing()){<button class="ui-button danger push-left" type="button" (click)="remove()" [disabled]="saving()"><lucide-icon [img]="icons.Trash2" [size]="16"/>{{deleteArmed()?'确认删除':'删除门店'}}</button>}<button class="ui-button secondary" type="button" (click)="closeDrawer()">取消</button><button class="ui-button primary" type="button" (click)="save()" [disabled]="saving()">{{saving()?'保存中…':'保存资料'}}</button></footer>
   </aside>
 }
+
+@if(creditTarget();as store){<app-store-credit-account [objectId]="store.objectId" [compact]="true" [initialOpen]="true" (posted)="creditMessage.set($event)" (closed)="creditTarget.set(null)"/>}

+ 3 - 0
projects/xiaoshu-admin/src/app/pages/operations-stores.component.scss

@@ -121,3 +121,6 @@
   .toolbar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
   .toolbar label:not(.grow) { grid-column: 1 / -1; }
 }
+
+.row-actions { flex-wrap: wrap; }
+.row-actions > .ui-button { padding: 7px 10px; white-space: nowrap; }

+ 5 - 1
projects/xiaoshu-admin/src/app/pages/operations-stores.component.ts

@@ -1,3 +1,5 @@
+import { StoreCreditAccountComponent } from '../components/store-credit-account.component';
+import { StoreCreditGrantsComponent } from '../components/store-credit-grants.component';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
@@ -12,12 +14,14 @@ import { OperationsLiveRefreshService } from '../operations-live-refresh.service
 @Component({
   selector: 'app-operations-stores',
   standalone: true,
-  imports: [DropdownDirective, CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  imports: [StoreCreditAccountComponent, StoreCreditGrantsComponent, DropdownDirective, CommonModule, FormsModule, RouterLink, LucideAngularModule],
   templateUrl: './operations-stores.component.html',
   styleUrls: ['./operations-professional.scss', './operations-stores.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class OperationsStoresComponent implements OnInit, OnDestroy {
+  readonly creditTarget = signal<StoreSummary|null>(null);
+  readonly creditMessage = signal('');
   readonly page = signal<StorePage | null>(null);
   readonly query = signal('');
   readonly status = signal('all');

+ 2 - 2
scripts/cloud/member-credit-ledger.js

@@ -2,8 +2,8 @@ async function memberCreditPreview(context,input,payload){
  requireRole(context,['ops-manager']);const target=await memberTarget(context,input,text(input.objectId||input.targetId));
  return(await enrollmentSql('SELECT xs_credit_plan($1,$2,$3::jsonb) result',[pointerId(target.get('company')),target.id,JSON.stringify(payload)])).result;
 }
-async function memberLedger(context,input){
- const target=await memberTarget(context,input,text(input.objectId||input.targetId)),co=pointerId(target.get('company')),uid=number(target.get('legacyUserId'));
+async function memberLedger(context,input,store=false){
+ const target=store?await storeCreditTarget(context,input):await memberTarget(context,input,text(input.objectId||input.targetId));const co=pointerId(target.get('company')),uid=number(target.get('legacyUserId'));
  const page=Math.max(1,Math.floor(number(input.page,1))),size=[20,50,100].includes(number(input.pageSize))?number(input.pageSize):20;
  const from=text(input.dateFrom),to=text(input.dateTo);for(const d of [from,to])if(d&&!/^\d{4}-\d{2}-\d{2}$/.test(d))fail(400,'日期格式无效');if(from&&to&&from>to)fail(400,'开始日期不能晚于结束日期');
  const result=await Psql.one(`WITH grouped AS (

+ 3 - 1
scripts/cloud/member-enrollment.js

@@ -4,6 +4,7 @@ async function enrollmentSql(sql, args) {
   catch(error) { if(/does not exist/i.test(text(error.message))) fail(503,'用户开课数据迁移尚未完成'); fail(409,text(error.message).replace(/^.*ERROR:\s*/,'')); }
 }
 async function creditAccounts(context,input,store=false) {
+  if(store)return storeCreditAccounts(context,input);
   const target=store?await scopedStoreObject(context,{...input,targetId:input.objectId||input.targetId}):await memberTarget(context,input,text(input.objectId||input.targetId));
   const uid=number(target.get('legacyUserId')),co=pointerId(target.get('company')),legacy=target.get('legacyUserData')||{},reserved=await reservedCreditsFor(context,input,uid);
   const accounts=Object.entries(TEACHING_CREDIT_LABELS).map(([account,label])=>({account,label,balance:number(legacy[account]),reserved:number(reserved[account]),available:number(legacy[account])-number(reserved[account])}));
@@ -13,7 +14,8 @@ async function creditAccounts(context,input,store=false) {
   return{trialStatus:trial?.status||'pending',accounts,transactions:transactions.filter(t=>store||!t.operationId||t.account!=='UserPoint'||!transactions.some(o=>o.operationId===t.operationId&&o.account!=='UserPoint')),storeId:store?uid:number(legacy.ParentUserID),storeName:store?displayName(target.toJSON()):parent?displayName(parent):'',canPost:store?context.isSuperAdmin:roleOf(context)==='ops-manager'};
 }
 async function postCredits(context,input,payload,store=false) {
-  if(store)requireSuperAdmin(context);else requireRole(context,['ops-manager']);
+  if(store)return storeCreditPost(context,input,payload);
+  requireRole(context,['ops-manager']);
   const target=store?await scopedStoreObject(context,input):await memberTarget(context,input,text(input.targetId));
   const account=text(payload.account)||({1:'Purse',2:'SilverCoin',3:'UserExp',4:'UserPoint'})[number(payload.type)],kind=store?'fund':payload.direction==='deduct'?'refund':'transfer';
   if(!store){const result=await enrollmentSql('SELECT xs_credit_operation($1,$2,$3,$4::jsonb,$5,$6,$7) AS result',[pointerId(target.get('company')),context.current.id,target.id,JSON.stringify({...payload,account}),reasonOf(input),text(input.idempotencyKey),text(input.expectedUpdatedAt)]);return result.result;}

+ 41 - 0
scripts/cloud/store-credit.js

@@ -0,0 +1,41 @@
+async function storeCreditAllowed(context,input) {
+ return Boolean((await Psql.one('SELECT xs_store_credit_allowed($1,$2) allowed',[companyIdOf(context,input),context.current.id])).allowed);
+}
+async function storeCreditTarget(context,input) {
+ return scopedStoreObject(context,{...input,targetId:text(input.objectId||input.targetId)});
+}
+async function storeCreditAccounts(context,input) {
+ const target=await storeCreditTarget(context,input),uid=number(target.get('legacyUserId')),legacy=target.get('legacyUserData')||{},reserved=await reservedCreditsFor(context,input,uid);
+ const transactions=input.summaryOnly===true?[]:await Psql.query('SELECT t."objectId",t.kind,t.account,t.amount,t."storeId",t."studentId",t."sourceTransactionId",t."operatorId",t.reason,t."payMethod",t.balances,t."createdAt",t."operationId" FROM "CreditTransaction" t WHERE t.company=$1 AND t."storeId"=$2 ORDER BY t."createdAt" DESC',[pointerId(target.get('company')),uid]);
+ return {storeId:uid,storeName:storeNameOf(target.toJSON()),canPost:await storeCreditAllowed(context,input),canGrant:context.isSuperAdmin,accounts:Object.entries(TEACHING_CREDIT_LABELS).map(([account,label])=>({account,label,balance:number(legacy[account]),reserved:number(reserved[account]),available:number(legacy[account])-number(reserved[account])})),transactions};
+}
+async function storeCreditPreview(context,input,payload) {
+ if(!await storeCreditAllowed(context,input))fail(403,'当前账号未获门店额度充值权限');
+ const target=await storeCreditTarget(context,input);
+ return(await enrollmentSql('SELECT xs_store_credit_plan($1,$2,$3,$4::jsonb) result',[pointerId(target.get('company')),context.current.id,target.id,JSON.stringify(payload)])).result;
+}
+async function storeCreditPost(context,input,payload) {
+ if(!await storeCreditAllowed(context,input))fail(403,'当前账号未获门店额度充值权限');
+ const target=await storeCreditTarget(context,input),reason=reasonOf(input),co=pointerId(target.get('company'));
+ // Existing single-account clients remain compatible, through the same transaction.
+ const legacy=!Array.isArray(payload.lines),p=legacy?{direction:'add',payMethod:text(payload.payMethod),lines:[{account:text(payload.account)||({1:'Purse',2:'SilverCoin',3:'UserExp',4:'UserPoint'})[number(payload.type)],amount:payload.amount}]}:payload;
+ if(legacy){
+  const prior=await Psql.oneOrNone('SELECT request,to_jsonb(t)-\'request\' receipt FROM "CreditTransaction" t WHERE company=$1 AND "idempotencyKey"=$2',[co,text(input.idempotencyKey)]);
+  if(prior){const req={actor:context.current.id,kind:'fund',target:target.id,account:p.lines[0].account,amount:number(p.lines[0].amount),original:text(payload.sourceTransactionId),reason,method:p.payMethod};const match=await Psql.one('SELECT $1::jsonb=$2::jsonb same',[JSON.stringify(prior.request),JSON.stringify(req)]);if(!match.same)fail(409,'请求编号已用于其他操作');return prior.receipt;}
+ }
+ const expected=legacy?(await storeCreditPreview(context,input,p)).version:text(input.expectedUpdatedAt);
+ return(await enrollmentSql('SELECT xs_store_credit_post($1,$2,$3,$4::jsonb,$5,$6,$7) result',[co,context.current.id,target.id,JSON.stringify(p),reason,text(input.idempotencyKey),expected])).result;
+}
+async function storeCreditLedger(context,input) {
+ const result=await memberLedger(context,input,true),refs=result.items.flatMap(item=>item.lines.map(line=>line.reference)).filter(Boolean);
+ const sources=refs.length?await Psql.query('SELECT "objectId","sourceTransactionId" FROM "CreditTransaction" WHERE company=$1 AND "objectId"=ANY($2::text[])',[companyIdOf(context,input),refs]):[],byId=new Map(sources.map(row=>[row.objectId,row.sourceTransactionId]));
+ for(const item of result.items)for(const line of item.lines)line.sourceTransactionId=byId.get(line.reference)||'';
+ return result;
+}
+async function storeCreditGrants(context,input,payload,save=false) {
+ if(!context.isSuperAdmin)fail(403,'只有超级管理员可以管理门店充值授权');
+ const co=companyIdOf(context,input);if(!co)fail(400,'请选择帐套');
+ if(save){if(typeof payload.enabled!=='boolean')fail(400,'授权状态无效');return(await enrollmentSql('SELECT xs_store_credit_grant($1,$2,$3,$4,$5) result',[co,context.current.id,text(input.targetId),payload.enabled,reasonOf(input)])).result;}
+ const items=await Psql.query('SELECT u."objectId",COALESCE(NULLIF(u."realName",\'\'),NULLIF(u.nickname,\'\'),u.username) name,u.username,xs_store_credit_role(u."objectId") role,g."userId" IS NOT NULL enabled FROM "_User" u LEFT JOIN "StoreCreditGrant" g ON g.company=u.company AND g."userId"=u."objectId" WHERE u.company=$1 AND (xs_store_credit_role(u."objectId")=\'ops-manager\' OR g."userId" IS NOT NULL) ORDER BY name,u."objectId"',[co]);
+ return {items};
+}

+ 5 - 2
scripts/deploy-admin-functions.mjs

@@ -5,7 +5,7 @@ import { readFileSync } from 'node:fs';
 import { legacyAppointmentDefaults } from './legacy-appointment-transform.mjs';
 const legacyAppointmentCode = readFileSync(new URL('./cloud/legacy-appointment.js', import.meta.url), 'utf8');
 const creditLedgerCode = readFileSync(new URL('./cloud/member-credit-ledger.js', import.meta.url), 'utf8') + readFileSync(new URL('./cloud/legacy-finance.js', import.meta.url), 'utf8');
-const enrollmentOpsCode = readFileSync(new URL('./cloud/member-enrollment.js', import.meta.url), 'utf8');
+const enrollmentOpsCode = readFileSync(new URL('./cloud/member-enrollment.js', import.meta.url), 'utf8') + '\n' + readFileSync(new URL('./cloud/store-credit.js', import.meta.url), 'utf8');
 const lessonRecordsCode = readFileSync(new URL('./cloud/lesson-records.js', import.meta.url), 'utf8');
 const supportRequestsAppCode = readFileSync(new URL('./cloud/support-requests-app.js', import.meta.url), 'utf8');
 const supportRequestsCode = readFileSync(new URL('./cloud/support-requests.js', import.meta.url), 'utf8');
@@ -3814,6 +3814,9 @@ async function handler(request,response) {
     if(operation==='ops/sync/run') return response.json({success:true,data:await catchUpSync(context,input)});
     if(operation==='ops/sync/retry') return response.json({success:true,data:await retrySync(context,input)});
     // Member, enrollment and scheduling writes use the new database exclusively.
+    if(operation==='ops/stores/credit-preview') return response.json({success:true,data:await storeCreditPreview(context,input,payload)});
+    if(operation==='ops/stores/credit-ledger') return response.json({success:true,data:await storeCreditLedger(context,input)});
+    if(operation==='ops/stores/credit-grants/list'||operation==='ops/stores/credit-grants/save') return response.json({success:true,data:await storeCreditGrants(context,input,payload,operation.endsWith('/save'))});
     if(operation==='ops/users/credit-preview') return response.json({success:true,data:await memberCreditPreview(context,input,payload)});
     if(operation==='ops/users/credit-ledger') return response.json({success:true,data:await memberLedger(context,input)});
     if(operation==='ops/users/import-ledger'){requireRole(context,['ops-manager']);const target=await memberTarget(context,input,text(input.objectId));const cfg=await legacyScheduleConfig();return response.json({success:true,data:await importLegacyFinance(pointerId(target.get('company')),number(target.get('legacyUserId')),text(input.runId),async(args)=>legacyScheduleRequest(cfg,{action:'user_point_list',...args}))});}
@@ -3825,7 +3828,7 @@ async function handler(request,response) {
     if(LEGACY_MUTATIONS[operation]&&!/ops\/(users|relations|course-bindings|appointments)/.test(operation)&&syncBridgeWritesEnabled()) return response.json({success:true,data:await bridgeFirstWrite(context,input,operation,payload)});
     if(operation==='ops/dashboard/summary') return response.json({success:true,data:await professionalDashboardFast(context,input)});
     if(operation==='ops/dashboard/schedule-trend') return response.json({success:true,data:await scheduleTrendFast(context,input)});
-    if(operation==='ops/stores/list') return response.json({success:true,data:await storeRows(context,input)});
+    if(operation==='ops/stores/list') return response.json({success:true,data:{...await storeRows(context,input),canFund:await storeCreditAllowed(context,input),canGrantCredit:context.isSuperAdmin}});
     if(operation==='ops/stores/detail') return response.json({success:true,data:await storeDetailData(context,input)});
     if(operation==='ops/stores/create') return response.json({success:true,data:await createStore(context,input,payload)});
     if(operation==='ops/stores/update') return response.json({success:true,data:await updateStore(context,input,payload)});

+ 94 - 0
scripts/sql/store-credit.sql

@@ -0,0 +1,94 @@
+-- Additive only: existing balances and receipts are never rewritten.
+CREATE TABLE IF NOT EXISTS "StoreCreditGrant" (
+ company text NOT NULL, "userId" text NOT NULL, "grantedBy" text NOT NULL,
+ "updatedAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(company,"userId")
+);
+CREATE TABLE IF NOT EXISTS "StoreCreditGrantAudit" (
+ id bigserial PRIMARY KEY, company text NOT NULL, "userId" text NOT NULL,
+ actor text NOT NULL, enabled boolean NOT NULL, reason text NOT NULL, at timestamptz NOT NULL DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS "StoreCreditOperation" (
+ company text NOT NULL,"idempotencyKey" text NOT NULL, request jsonb NOT NULL,
+ result jsonb NOT NULL,"createdAt" timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(company,"idempotencyKey")
+);
+CREATE OR REPLACE FUNCTION xs_store_credit_role(actor text) RETURNS text LANGUAGE sql STABLE AS $$
+ SELECT CASE WHEN COALESCE(u."isDisabled",false) OR COALESCE((to_jsonb(u)->>'isDeleted')::boolean,false) THEN ''
+ ELSE COALESCE(NULLIF(to_jsonb(u)->>'adminRoleKey',''),
+ (SELECT v FROM jsonb_array_elements_text(COALESCE(NULLIF(to_jsonb(u)->'roles','null'::jsonb),'[]'::jsonb)) v WHERE v IN ('super-admin','ops-manager','ops-staff','ops-auditor') ORDER BY CASE v WHEN 'super-admin' THEN 0 WHEN 'ops-manager' THEN 1 WHEN 'ops-staff' THEN 2 ELSE 3 END LIMIT 1),
+ CASE WHEN to_jsonb(u)->>'isAdmin'='true' OR to_jsonb(u)->>'role'='admin' OR to_jsonb(u)->'roles' ? 'admin' THEN 'ops-manager' ELSE '' END) END
+ FROM "_User" u WHERE u."objectId"=actor
+$$;
+CREATE OR REPLACE FUNCTION xs_store_credit_allowed(co text,actor text) RETURNS boolean LANGUAGE sql STABLE AS $$
+ SELECT COALESCE(xs_store_credit_role(actor)='super-admin' OR
+ (xs_store_credit_role(actor)='ops-manager' AND EXISTS(SELECT 1 FROM "_User" u JOIN "StoreCreditGrant" g ON g.company=u.company AND g."userId"=u."objectId" WHERE u.company=co AND u."objectId"=actor)),false)
+$$;
+CREATE OR REPLACE FUNCTION xs_store_credit_grant(co text,actor text,target text,enabled boolean,reason text) RETURNS jsonb LANGUAGE plpgsql AS $$
+BEGIN
+ IF xs_store_credit_role(actor) IS DISTINCT FROM 'super-admin' THEN RAISE EXCEPTION '只有超级管理员可以授权门店充值';END IF;
+ IF length(trim(COALESCE(reason,'')))<2 OR length(reason)>500 OR enabled IS NULL THEN RAISE EXCEPTION '请填写授权原因';END IF;
+ IF NOT EXISTS(SELECT 1 FROM "_User" WHERE company=co AND "objectId"=target) OR (enabled AND xs_store_credit_role(target) IS DISTINCT FROM 'ops-manager') THEN RAISE EXCEPTION '请选择本帐套有效的运营管理员';END IF;
+ IF enabled THEN INSERT INTO "StoreCreditGrant" VALUES(co,target,actor,now()) ON CONFLICT(company,"userId") DO UPDATE SET "grantedBy"=actor,"updatedAt"=now();
+ ELSE DELETE FROM "StoreCreditGrant" WHERE company=co AND "userId"=target;END IF;
+ INSERT INTO "StoreCreditGrantAudit"(company,"userId",actor,enabled,reason) VALUES(co,target,actor,enabled,reason);
+ RETURN jsonb_build_object('userId',target,'enabled',enabled);
+END $$;
+CREATE OR REPLACE FUNCTION xs_store_credit_plan(co text,actor text,target text,p jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE st "_User"; src "CreditTransaction"; a text; n numeric; before_value numeric; held numeric; remaining numeric;
+ lines jsonb='[]'; item jsonb; direction text=COALESCE(p->>'direction','add'); version text;
+BEGIN
+ IF NOT xs_store_credit_allowed(co,actor) THEN RAISE EXCEPTION '当前账号未获门店额度充值权限';END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT * INTO st FROM "_User" WHERE company=co AND "objectId"=target FOR UPDATE;
+ IF NOT FOUND OR (st."identityType" IS DISTINCT FROM 'store' AND COALESCE(st."legacyGroupId",0)<>2) OR st."legacyUserId" IS NULL OR COALESCE(st."isDisabled",false) OR COALESCE((to_jsonb(st)->>'isDeleted')::boolean,false) THEN RAISE EXCEPTION '门店不存在或已停用';END IF;
+ IF direction NOT IN ('add','reverse') OR length(trim(COALESCE(p->>'payMethod','')))=0 THEN RAISE EXCEPTION '请选择业务类型和记账方式';END IF;
+ IF jsonb_typeof(p->'lines') IS DISTINCT FROM 'array' OR jsonb_array_length(p->'lines') NOT BETWEEN 1 AND 4 THEN RAISE EXCEPTION '请填写 1 至 4 项充值额度';END IF;
+ IF (SELECT count(DISTINCT v->>'account') FROM jsonb_array_elements(p->'lines') v)<>jsonb_array_length(p->'lines') THEN RAISE EXCEPTION '账户不能重复';END IF;
+ IF direction='reverse' THEN
+  IF jsonb_array_length(p->'lines')<>1 THEN RAISE EXCEPTION '每次纠正一条原入账凭据';END IF;
+  SELECT * INTO src FROM "CreditTransaction" WHERE company=co AND "objectId"=p->>'sourceTransactionId' AND kind='fund' AND "storeId"=st."legacyUserId";
+  IF NOT FOUND THEN RAISE EXCEPTION '原总部入账凭据不存在或不属于该门店';END IF;
+  remaining=src.amount-COALESCE((SELECT sum(amount) FROM "CreditTransaction" WHERE company=co AND "sourceTransactionId"=src."objectId" AND kind='adjust' AND "studentId" IS NULL),0);
+ END IF;
+ FOR item IN SELECT value FROM jsonb_array_elements(p->'lines') ORDER BY value->>'account' LOOP
+  a=item->>'account';n=(item->>'amount')::numeric;
+  IF a IS NULL OR a NOT IN ('Purse','SilverCoin','UserExp','UserPoint') OR n IS NULL OR n<=0 OR n>1000000000 OR n::text IN ('NaN','Infinity','-Infinity') OR round(n,2)<>n OR (a<>'UserPoint' AND trunc(n)<>n) THEN RAISE EXCEPTION '课时和体验次数必须为正整数,陪练小时最多两位小数';END IF;
+  before_value=COALESCE((st."legacyUserData"->>a)::numeric,0);held=xs_reserved(co,st."legacyUserId"::numeric,a);
+  IF direction='reverse' THEN
+   IF a<>src.account OR n>remaining THEN RAISE EXCEPTION '超过原入账凭据可纠正数量';END IF;
+   IF before_value-held<n THEN RAISE EXCEPTION '门店可用额度不足,已划拨或预留部分不能扣回';END IF;
+   n=-n;
+  END IF;
+  lines=lines||jsonb_build_array(jsonb_build_object('account',a,'delta',n,'before',before_value,'after',before_value+n,'availableBefore',before_value-held,'availableAfter',before_value-held+n));
+ END LOOP;
+ version=md5(jsonb_build_object('balances',st."legacyUserData",'updated',st."updatedAt",'lines',lines,'source',src."objectId",'remaining',remaining)::text);
+ RETURN jsonb_build_object('storeId',st."legacyUserId",'storeObjectId',target,'version',version,'lines',lines,'sourceTransactionId',src."objectId");
+END $$;
+CREATE OR REPLACE FUNCTION xs_store_credit_post(co text,actor text,target text,p jsonb,reason text,idem text,expected text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE prior "StoreCreditOperation"; req jsonb; plan jsonb; line jsonb; receipt jsonb; receipts jsonb='[]'; oid text=xs_object_id(); tx text; delta numeric;
+BEGIN
+ IF NOT xs_store_credit_allowed(co,actor) THEN RAISE EXCEPTION '当前账号未获门店额度充值权限';END IF;
+ IF length(COALESCE(idem,''))<8 OR length(trim(COALESCE(reason,'')))<2 OR length(reason)>500 THEN RAISE EXCEPTION '请填写操作原因和有效请求编号';END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ req=jsonb_build_object('actor',actor,'target',target,'payload',p,'reason',reason);
+ SELECT * INTO prior FROM "StoreCreditOperation" WHERE company=co AND "idempotencyKey"=idem;
+ IF FOUND THEN IF prior.request<>req THEN RAISE EXCEPTION '请求编号已用于其他充值';END IF;RETURN prior.result;END IF;
+ plan=xs_store_credit_plan(co,actor,target,p);
+ IF COALESCE(expected,'')='' OR expected<>plan->>'version' THEN RAISE EXCEPTION '余额已变化,请重新预览后提交';END IF;
+ FOR line IN SELECT value FROM jsonb_array_elements(plan->'lines') LOOP
+  delta=(line->>'delta')::numeric;
+  IF delta>0 THEN
+   receipt=xs_credit_post(co,actor,'fund',target,line->>'account',delta,'',reason,p->>'payMethod','store:'||idem||':'||(line->>'account'));
+   UPDATE "CreditTransaction" SET "operationId"=oid WHERE "objectId"=receipt->>'objectId';
+  ELSE
+   tx=xs_object_id();PERFORM set_config('xiaoshu.credit_write','yes',true);
+   UPDATE "_User" SET "legacyUserData"=jsonb_set(COALESCE("legacyUserData",'{}'),ARRAY[line->>'account'],to_jsonb((line->>'after')::numeric)),"updatedAt"=now() WHERE "objectId"=target AND company=co;
+   PERFORM xs_ledger(co,(plan->>'storeId')::numeric,line->>'account',delta,(line->>'before')::numeric,tx,actor,reason,p->>'payMethod');
+   receipt=jsonb_build_object('objectId',tx,'company',co,'createdAt',now(),'updatedAt',now(),'idempotencyKey','store:'||idem||':'||(line->>'account'),'request',req,'kind','adjust','account',line->>'account','amount',-delta,'storeId',plan->'storeId','sourceTransactionId',p->>'sourceTransactionId','operatorId',actor,'reason',reason,'payMethod',p->>'payMethod','operationId',oid,'balances',jsonb_build_object('storeBefore',line->'before','storeAfter',line->'after'));
+   PERFORM xs_insert('CreditTransaction',receipt);
+   PERFORM xs_insert('SysLog',jsonb_build_object('objectId',xs_object_id(),'company',co,'sourceKey','credit:'||tx,'createdAt',now(),'updatedAt',now(),'id',nextval('xiaoshu_business_id'),'cdate',now(),'cname','门店入账纠正','type1','operations-admin','type2','store-credit-reverse','type3','CreditTransaction','clevel',0,'detail',receipt::text,'content1',reason,'content2',actor));
+  END IF;
+  receipts=receipts||jsonb_build_array(receipt-'request');
+ END LOOP;
+ receipt=jsonb_build_object('operationId',oid,'storeId',plan->'storeId','lines',plan->'lines','transactions',receipts);
+ INSERT INTO "StoreCreditOperation" VALUES(co,idem,req,receipt,now());RETURN receipt;
+END $$;

+ 1 - 0
scripts/tests/member-enrollment.test.mjs

@@ -25,6 +25,7 @@ before(async()=>{
  CREATE TABLE "SysLog" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text NOT NULL UNIQUE,"id" double precision,"cdate" timestamptz,"cname" text,"type1" text,"type2" text,"type3" text,"clevel" double precision,"cadminId" double precision,"detail" text,"content1" text,"content2" text);
  INSERT INTO "Node" VALUES ('co',100,8,'词表',99),('co',101,8,'空词表',99);
  INSERT INTO "CommonModel" ("objectId","company","nodeId","modelId","status") SELECT 'word-'||i,'co',100,52,99 FROM generate_series(1,200) i;`);
+ await db.exec(`ALTER TABLE "Node" ADD COLUMN "sourceKey" text, ADD COLUMN "createdAt" timestamptz;`);
  await db.exec(sql);
  const workflow=readFileSync(new URL('../sql/lesson-workflow.sql',import.meta.url),'utf8');
  for(const name of ['xs_start_appointment_for_actor','xs_admin_lesson_correction'])await db.exec(workflow.match(new RegExp('CREATE OR REPLACE FUNCTION '+name+'[\\s\\S]*?END \\$\\$;'))[0]);

+ 102 - 0
scripts/tests/store-credit.test.mjs

@@ -0,0 +1,102 @@
+import {test,before,after} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import {PGlite} from '@electric-sql/pglite';
+const db=new PGlite();const sql=readFileSync(new URL('../sql/member-enrollment.sql',import.meta.url),'utf8');
+const query=async(s,p=[])=>(await db.query(s,p)).rows;
+const one=async(s,p=[])=>(await query(s,p))[0];
+before(async()=>{
+ await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,"company" text,"legacyUserId" double precision,"legacyGroupId" double precision,"identityType" text,"isDisabled" boolean,"legacyUserData" jsonb,"updatedAt" timestamptz);
+ CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"yhid" text,"kcid" text,"yxx" double precision,"cksl" double precision,"syjd" text);
+ CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"createTime" timestamptz,"sourceKey" text,"generalId" double precision,"itemId" double precision,"modelId" double precision,"nodeId" double precision,"tableName" text,"title" text,"subtitle" text,"status" double precision,"inputer" text);
+ CREATE TABLE "CourseAppointment" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"szyh" text,"kcid" text,"pl" text,"fxpl" text,"dszt" text,"dslx" text,"jffs" text,"jssj" text,"updatedAt" timestamptz,"creditCostsSnapshot" jsonb,"creditHoldStatus" text,"courseCategoryKey" text,"courseCategoryName" text,"teacherPaySnapshot" double precision);
+ CREATE TABLE "LessonRecord" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"xymz" text,"jsmz" text,"kcid" text,"kclx" text,"kcmc" text,"yyds" text,"jffs" text,"kzsj" text,"lessonAt" timestamptz);
+ CREATE TABLE "DailyStudyRecord" ("objectId" text);
+ CREATE TABLE "CourseTeachingProfile" ("company" text,"courseId" double precision,"categoryKey" text,"confirmed" boolean);
+ CREATE TABLE "CourseCreditReservation" ("objectId" text PRIMARY KEY,"company" text,"studentId" double precision,"appointmentId" text,"creditCosts" jsonb,"state" text,"consumedAt" timestamptz,"updatedAt" timestamptz);`);
+ for(const table of ['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'])await db.exec(`CREATE TABLE "${table}" ("objectId" text PRIMARY KEY,"company" text,"sourceKey" text UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" double precision,"score" double precision,"scoreBefore" double precision,"hisTime" timestamptz,"operator" double precision,"scoreType" double precision,"detail" text,"remark" text,"payMethod" text)`);
+ await db.exec(`INSERT INTO "_User" VALUES ('store','co',10,2,'store',false,'{}',now()),('student','co',20,1,'member',false,'{"ParentUserID":10}',now()),('empty','co',21,1,'member',false,'{}',now()),('foreign','other',22,1,'member',false,'{}',now());`);
+ await db.exec(`ALTER TABLE "CourseAppointment" ADD COLUMN "kssj" text,ADD COLUMN "plxm" text,ADD COLUMN "yykcid" text,ADD COLUMN "durationMinutes" numeric,ADD COLUMN "deliveryMode" text,ADD COLUMN "teachingRuleId" text,ADD COLUMN "bxrq" text,ADD COLUMN "sdsd" text,ADD COLUMN "yysj" text,ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz;
+ ALTER TABLE "CourseCreditReservation" ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz,ADD COLUMN "appointmentGeneralId" numeric,ADD COLUMN "ruleId" text,ADD COLUMN "reservedAt" timestamptz;`);
+ await db.exec(`CREATE TABLE "Node" ("company" text,"nodeId" numeric,"parentId" numeric,"nodeName" text,"zstatus" numeric);
+ CREATE TABLE "SysLog" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text NOT NULL UNIQUE,"id" double precision,"cdate" timestamptz,"cname" text,"type1" text,"type2" text,"type3" text,"clevel" double precision,"cadminId" double precision,"detail" text,"content1" text,"content2" text);
+ INSERT INTO "Node" VALUES ('co',100,8,'词表',99),('co',101,8,'空词表',99);
+ INSERT INTO "CommonModel" ("objectId","company","nodeId","modelId","status") SELECT 'word-'||i,'co',100,52,99 FROM generate_series(1,200) i;`);
+ await db.exec(sql);
+ await db.exec('ALTER TABLE "_User" ADD COLUMN "realName" text, ADD COLUMN nickname text, ADD COLUMN username text');
+ await db.exec(readFileSync(new URL('../sql/member-credit-ledger.sql',import.meta.url),'utf8'));
+ await db.exec(`ALTER TABLE "_User" ADD COLUMN "adminRoleKey" text,ADD COLUMN roles jsonb,ADD COLUMN "isAdmin" boolean,ADD COLUMN "isDeleted" boolean;
+ INSERT INTO "_User" ("objectId",company,"legacyUserId","identityType","adminRoleKey") VALUES ('root','co',90,'admin','super-admin'),('manager','co',91,'admin','ops-manager'),('reader','co',92,'admin','ops-auditor'),('outsider','other',93,'admin','ops-manager');`);
+ await db.exec(readFileSync(new URL('../sql/store-credit.sql',import.meta.url),'utf8'));
+});
+after(()=>db.close());
+const balances=async(id='store')=>(await one('SELECT "legacyUserData" b FROM "_User" WHERE "objectId"=$1',[id])).b;
+const grant=async(user,enabled,actor='root')=>one("SELECT xs_store_credit_grant('co',$1,$2,$3,'充值权限核验') r",[actor,user,enabled]);
+const payload={direction:'add',payMethod:'运营记账',lines:[{account:'Purse',amount:10},{account:'SilverCoin',amount:5},{account:'UserExp',amount:8},{account:'UserPoint',amount:10.5}]};
+const plan=async(p=payload,actor='root',target='store')=>(await one("SELECT xs_store_credit_plan('co',$1,$2,$3::jsonb) r",[actor,target,JSON.stringify(p)])).r;
+const post=async(p,key,version,actor='root')=>(await one("SELECT xs_store_credit_post('co',$1,'store',$2::jsonb,'总部额度测试',$3,$4) r",[actor,JSON.stringify(p),key,version])).r;
+test('独立授权、撤销、跨帐套与审计角色不能充值',async()=>{
+ await assert.rejects(plan(payload,'manager'),/权限/);await assert.rejects(grant('manager',true,'manager'),/超级管理员/);
+ await grant('manager',true);assert.equal((await plan(payload,'manager')).lines.length,4);
+ await assert.rejects(grant('reader',true),/运营管理员/);await assert.rejects(grant('outsider',true),/本帐套/);
+ await assert.rejects(plan(payload,'reader'),/权限/);await assert.rejects(plan(payload,'root','foreign'),/门店/);
+ await grant('manager',false);await assert.rejects(plan(payload,'manager'),/权限/);
+ assert.equal(Number((await one('SELECT count(*) n FROM "StoreCreditGrantAudit"')).n),2);
+});
+test('四项同时入账、原单与流水对应、重复提交不重复、旧预览阻止',async()=>{
+ const preview=await plan(),result=await post(payload,'store-fund-0001',preview.version);
+ assert.deepEqual(await balances(),{Purse:10,SilverCoin:5,UserExp:8,UserPoint:10.5});
+ assert.deepEqual(await post(payload,'store-fund-0001',preview.version),result);
+ assert.equal(result.transactions.length,4);assert.equal(new Set((await query('SELECT "operationId" FROM "CreditTransaction"')).map(r=>r.operationId)).size,1);
+ assert.equal((await query('SELECT * FROM xs_member_ledger WHERE company=\'co\' AND "studentId"=10')).length,4);
+ await assert.rejects(post({...payload,lines:[{account:'Purse',amount:1}]},'store-fund-0001',preview.version),/请求编号/);
+ await assert.rejects(post(payload,'stale-store-01',preview.version),/预览/);
+});
+test('拒绝非法数值、重复账户、空项及失效门店',async()=>{
+ for(const amount of [0,-1,0.5,'NaN',1000000001])await assert.rejects(plan({...payload,lines:[{account:'Purse',amount}]}));
+ await assert.rejects(plan({...payload,lines:[]}));await assert.rejects(plan({...payload,lines:[payload.lines[0],payload.lines[0]]}),/重复/);
+ await assert.rejects(plan({...payload,lines:[{account:'UserPoint',amount:1.001}]}));
+ await db.exec('UPDATE "_User" SET "isDeleted"=true WHERE "objectId"=\'store\'');await assert.rejects(plan(),/停用/);await db.exec('UPDATE "_User" SET "isDeleted"=false WHERE "objectId"=\'store\'');
+});
+test('任一账户记账或审计失败,全部余额、凭据和流水回滚',async()=>{
+ const before=await balances(),n=await one('SELECT count(*) n FROM "CreditTransaction"');
+ await db.exec(`CREATE FUNCTION store_test_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.account='UserPoint' THEN RAISE EXCEPTION 'simulated receipt failure';END IF;RETURN NEW;END $$;CREATE TRIGGER store_test_fail BEFORE INSERT ON "CreditTransaction" FOR EACH ROW EXECUTE FUNCTION store_test_fail();`);
+ await assert.rejects(post(payload,'failed-store-01',(await plan()).version),/simulated/);
+ assert.deepEqual(await balances(),before);assert.deepEqual(await one('SELECT count(*) n FROM "CreditTransaction"'),n);assert.equal(Number((await one('SELECT count(*) n FROM "StoreCreditOperation" WHERE "idempotencyKey"=\'failed-store-01\'')).n),0);
+ await db.exec('DROP TRIGGER store_test_fail ON "CreditTransaction"');
+});
+test('关联原单纠错、累计不能超原单、已划拨与预留不能扣回',async()=>{
+ const src=await one('SELECT * FROM "CreditTransaction" WHERE kind=\'fund\' AND account=\'Purse\'');
+ const p={direction:'reverse',payMethod:'运营记账',sourceTransactionId:src.objectId,lines:[{account:'Purse',amount:3}]};
+ const preview=await plan(p),r=await post(p,'reverse-store-01',preview.version);assert.equal((await balances()).Purse,7);assert.equal(r.transactions[0].sourceTransactionId,src.objectId);assert.deepEqual(await post(p,'reverse-store-01',preview.version),r);
+ await assert.rejects(plan({...p,lines:[{account:'Purse',amount:8}]}),/原入账/);
+ await db.exec(`INSERT INTO "CourseCreditReservation"("objectId",company,"studentId","appointmentId","creditCosts",state) VALUES('held-store','co',10,'absent','[{"account":"Purse","amount":6}]','reserved')`);
+ await assert.rejects(plan(p),/预留/);await db.exec('DELETE FROM "CourseCreditReservation" WHERE "objectId"=\'held-store\'');
+ await one("SELECT xs_credit_post('co','root','transfer','student','Purse',6,'','测试划拨','运营记账','store-transfer-01')");
+ await assert.rejects(plan(p),/已划拨/);assert.equal((await balances()).Purse,1);
+});
+test('门店四类入账不改变学生充值配套与一次体验资格',async()=>{
+ const p={account:'SilverCoin',amount:2,direction:'add',payMethod:'运营记账'};
+ const v=(await one("SELECT xs_credit_plan('co','student',$1::jsonb) r",[JSON.stringify(p)])).r;
+ await one("SELECT xs_credit_operation('co','root','student',$1::jsonb,'学生正常充值','student-paired-01',$2)",[JSON.stringify(p),v.version]);
+ assert.equal((await balances('student')).UserPoint,2);assert.equal((await balances()).UserPoint,8.5);
+ await db.exec(`UPDATE "TrialEligibility" SET status='eligible' WHERE company='co' AND "studentId"=20`);
+ await one("SELECT xs_credit_post('co','root','transfer','student','UserExp',1,'','一次体验','运营记账','store-trial-01')");
+ await assert.rejects(one("SELECT xs_credit_post('co','root','transfer','student','UserExp',1,'','重复体验','运营记账','store-trial-02')"),/体验/);
+});
+test('旧版单账户请求跨发布重试复用原凭据,新版入口仍受独立授权约束',async()=>{
+ const {default:vm}=await import('node:vm');
+ const context={current:{id:'root'},isSuperAdmin:true},target={id:'store',get:key=>key==='company'?{id:'co'}:10};
+ const sandbox={Psql:{one,oneOrNone:async(s,p)=>(await query(s,p))[0]||null,query},number:(x,f=0)=>Number.isFinite(Number(x))?Number(x):f,text:x=>String(x??'').trim(),companyIdOf:()=> 'co',pointerId:x=>x.id,scopedStoreObject:async()=>target,reasonOf:input=>input.reason,enrollmentSql:one,fail:(status,message)=>{throw Object.assign(new Error(message),{status});}};
+ vm.createContext(sandbox);vm.runInContext(readFileSync(new URL('../cloud/store-credit.js',import.meta.url),'utf8'),sandbox);
+ const original=(await one("SELECT xs_credit_post('co','root','fund','store','Purse',1,'','旧版请求','运营记账','legacy-store-001') r")).r,before=await balances();
+ const result=await sandbox.storeCreditPost(context,{targetId:'store',reason:'旧版请求',idempotencyKey:'legacy-store-001'},{account:'Purse',amount:1,payMethod:'运营记账'});
+ assert.equal(result.objectId,original.objectId);assert.deepEqual(await balances(),before);
+ await assert.rejects(sandbox.storeCreditPost(context,{targetId:'store',reason:'旧版请求',idempotencyKey:'legacy-store-001'},{account:'Purse',amount:2,payMethod:'运营记账'}),/请求编号/);
+ await assert.rejects(sandbox.storeCreditPost({current:{id:'reader'}},{targetId:'store',reason:'未授权操作',idempotencyKey:'denied-0001'},payload),/权限/);
+});
+test('相同请求并发调用只记一笔业务',async()=>{
+ const preview=await plan(),before=await balances();const results=await Promise.all([post(payload,'parallel-store-01',preview.version),post(payload,'parallel-store-01',preview.version)]);
+ assert.deepEqual(results[0],results[1]);assert.equal((await balances()).Purse,before.Purse+10);
+ assert.equal(Number((await one('SELECT count(*) n FROM "StoreCreditOperation" WHERE "idempotencyKey"=\'parallel-store-01\'')).n),1);
+});

Some files were not shown because too many files changed in this diff