feat: deduct personal expenses from deposit refunds

This commit is contained in:
2026-07-15 09:03:20 +08:00
parent 17a5046ea0
commit c86550f894
5 changed files with 105 additions and 10 deletions

View File

@@ -37,6 +37,8 @@ const isFormValidationError = (error: unknown) =>
&& error !== null
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
const moneyNumber = (value: unknown) => Number(Number(value || 0).toFixed(2));
const DepositsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [students, setStudents] = useState<any[]>([]);
@@ -350,7 +352,38 @@ const DepositsPage: React.FC = () => {
>
<Form form={refundForm} layout="vertical">
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
<div>
:{' '}
<strong>¥{moneyNumber(refundModal?.amount).toFixed(2)}</strong>
</div>
<div style={{ marginTop: 4 }}>
:{' '}
<strong>¥{moneyNumber(refundModal?.personalExpenseAmount).toFixed(2)}</strong>
</div>
<div style={{ marginTop: 4 }}>
:{' '}
<strong style={{ color: '#fa8c16' }}>
¥
{Math.min(
moneyNumber(refundModal?.amount),
moneyNumber(refundModal?.personalExpenseAmount),
).toFixed(2)}
</strong>
</div>
<div style={{ marginTop: 4 }}>
退:{' '}
<strong style={{ color: '#52c41a' }}>
¥
{Math.max(
0,
moneyNumber(refundModal?.amount) -
Math.min(
moneyNumber(refundModal?.amount),
moneyNumber(refundModal?.personalExpenseAmount),
),
).toFixed(2)}
</strong>
</div>
</div>
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />

View File

@@ -5,7 +5,7 @@ describe('DepositsService permission-scoped lookups', () => {
const studentRepo = {
find: jest.fn().mockResolvedValue([{ id: 2, name: '张三', studentNo: 'S2' }]),
};
const service = new DepositsService({} as never, {} as never, studentRepo as never);
const service = new DepositsService({} as never, {} as never, studentRepo as never, {} as never);
await expect(service.getStudentLookups()).resolves.toEqual([
{ id: 2, name: '张三', studentNo: 'S2' },

View File

@@ -3,13 +3,18 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Student } from '../entities/student.entity';
import { Deposit } from '../entities/deposit.entity';
import { DepositInstallment } from '../entities/deposit-installment.entity';
import { PersonalExpense } from '../entities/personal-expense.entity';
import { DepositsService } from './deposits.service';
import { DepositsController } from './deposits.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student]), OperationLogsModule, NotificationsModule],
imports: [
TypeOrmModule.forFeature([Deposit, DepositInstallment, Student, PersonalExpense]),
OperationLogsModule,
NotificationsModule,
],
controllers: [DepositsController],
providers: [DepositsService],
exports: [DepositsService],

View File

@@ -2,9 +2,10 @@ import { DepositsService } from './deposits.service';
import { Deposit } from '../entities/deposit.entity';
describe('DepositsService — direct refund', () => {
it('refunds the full available balance and stores audit fields', async () => {
it('deducts unbilled personal expenses before refunding the remaining balance', async () => {
const deposit = {
id: 1,
studentId: 10,
amount: 500,
status: 'paid',
} as Deposit;
@@ -12,7 +13,17 @@ describe('DepositsService — direct refund', () => {
findOne: jest.fn().mockResolvedValue(deposit),
save: jest.fn().mockImplementation(async (value: Deposit) => value),
};
const service = new DepositsService(repo as never, {} as never, {} as never);
const personalExpenseRepo = {
createQueryBuilder: jest.fn().mockReturnValue({
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([{ studentId: 10, amount: '120' }]),
}),
};
const service = new DepositsService(repo as never, {} as never, {} as never, personalExpenseRepo as never);
const result = await service.refund(
1,
@@ -23,7 +34,9 @@ describe('DepositsService — direct refund', () => {
expect(result).toMatchObject({
refundDate: '2026-07-13',
amount: 0,
refundAmount: 500,
refundAmount: 380,
deductionAmount: 120,
deductionReason: '自动扣除个人附加费用 ¥120.00',
notes: '退还剩余押金',
status: 'refunded',
refundedBy: 42,

View File

@@ -4,6 +4,7 @@ import { Repository } from 'typeorm';
import { Deposit } from '../entities/deposit.entity';
import { Student } from '../entities/student.entity';
import { DepositInstallment } from '../entities/deposit-installment.entity';
import { PersonalExpense } from '../entities/personal-expense.entity';
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
@@ -16,6 +17,8 @@ export class DepositsService {
private installmentRepo: Repository<DepositInstallment>,
@InjectRepository(Student)
private studentRepo: Repository<Student>,
@InjectRepository(PersonalExpense)
private personalExpenseRepo: Repository<PersonalExpense>,
) {}
async getStudentLookups() {
@@ -34,13 +37,15 @@ export class DepositsService {
.orderBy('d.createdAt', 'DESC');
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
if (query?.status) qb.andWhere('d.status = :status', { status: query.status });
return qb.getMany();
const deposits = await qb.getMany();
return this.attachPersonalExpenseAmount(deposits);
}
async findOne(id: number) {
const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] });
if (!deposit) throw new NotFoundException('押金记录不存在');
return deposit;
const [withPersonalExpense] = await this.attachPersonalExpenseAmount([deposit]);
return withPersonalExpense;
}
async create(dto: CreateDepositDto, userId?: number) {
@@ -106,12 +111,18 @@ export class DepositsService {
throw new BadRequestException('该学生当前没有可退押金');
}
const refundAmount = Number(deposit.amount);
const depositAmount = Number(deposit.amount);
const personalExpenseAmount = await this.getPersonalExpenseAmount(deposit.studentId);
const deductionAmount = Number(Math.min(depositAmount, personalExpenseAmount).toFixed(2));
const refundAmount = Number((depositAmount - deductionAmount).toFixed(2));
deposit.refundDate = dto.refundDate;
deposit.refundAmount = refundAmount;
deposit.deductionAmount = deductionAmount;
deposit.deductionReason =
deductionAmount > 0 ? `自动扣除个人附加费用 ¥${deductionAmount.toFixed(2)}` : '';
deposit.amount = 0;
deposit.status = 'refunded';
deposit.status = refundAmount > 0 ? 'refunded' : 'depleted';
if (dto.notes) deposit.notes = dto.notes;
deposit.refundedBy = userId ?? null;
deposit.refundedAt = new Date();
@@ -135,4 +146,37 @@ export class DepositsService {
qb.groupBy('d.status');
return qb.getRawMany();
}
private async attachPersonalExpenseAmount(deposits: Deposit[]) {
if (!deposits.length) return deposits;
const studentIds = Array.from(new Set(deposits.map((deposit) => deposit.studentId)));
const amountMap = await this.getPersonalExpenseAmountMap(studentIds);
return deposits.map((deposit) =>
Object.assign({}, deposit, {
personalExpenseAmount: amountMap.get(deposit.studentId) || 0,
}),
);
}
private async getPersonalExpenseAmount(studentId: number) {
const amountMap = await this.getPersonalExpenseAmountMap([studentId]);
return amountMap.get(studentId) || 0;
}
private async getPersonalExpenseAmountMap(studentIds: number[]) {
const amountMap = new Map<number, number>();
if (!studentIds.length) return amountMap;
const rows = await this.personalExpenseRepo
.createQueryBuilder('pe')
.select('pe.studentId', 'studentId')
.addSelect('SUM(pe.amount)', 'amount')
.where('pe.studentId IN (:...studentIds)', { studentIds })
.andWhere('pe.billId IS NULL')
.groupBy('pe.studentId')
.getRawMany<{ studentId: number | string; amount: string | number | null }>();
for (const row of rows) {
amountMap.set(Number(row.studentId), Number(Number(row.amount || 0).toFixed(2)));
}
return amountMap;
}
}