押金退还自动生成扣除金额、账单生成允许账单选择任意月份生成,不再限制只能生成已结束月份 #13

Closed
xiongyuxing wants to merge 2 commits from xiongyuxing/gongxue-base:main into main
8 changed files with 171 additions and 51 deletions

View File

@@ -356,14 +356,13 @@ const BillsPage: React.FC = () => {
name="billingMonth"
label="账单月份"
rules={[{ required: true, message: '请选择账单月份' }]}
extra="只能选择已结束月份,每个月只能生成一次账单"
extra="选择任意账单月份;重复生成时无变化不生成,有变化则生成差额账单"
>
<DatePicker
style={{ width: '100%' }}
picker="month"
placeholder="选择月份"
format="YYYY-MM"
disabledDate={(current) => !!current && !current.endOf('month').isBefore(dayjs(), 'day')}
/>
</Form.Item>
</Form>

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[]>([]);
@@ -118,6 +120,7 @@ const DepositsPage: React.FC = () => {
const values = await refundForm.validateFields();
await api.put(`/deposits/${refundModal.id}/refund`, {
refundDate: values.refundDate.format('YYYY-MM-DD'),
deductionAmount: values.deductionAmount || 0,
notes: values.notes,
});
message.success('退还操作完成');
@@ -208,7 +211,13 @@ const DepositsPage: React.FC = () => {
type="primary"
onClick={() => {
setRefundModal(record);
refundForm.setFieldsValue({ refundDate: dayjs() });
refundForm.setFieldsValue({
refundDate: dayjs(),
deductionAmount: Math.min(
moneyNumber(record.amount),
moneyNumber(record.personalExpenseAmount),
),
});
}}
>
退
@@ -350,11 +359,33 @@ 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 }}>
</div>
</div>
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item
name="deductionAmount"
label="扣除金额(元)"
extra="默认填入未出账个人附加费,不能超过当前可用押金"
>
<InputNumber
min={0}
max={moneyNumber(refundModal?.amount)}
precision={2}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>

View File

@@ -36,30 +36,8 @@ export class BillsService {
const pEnd = new Date(periodEnd);
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
if (existingBills.length > 0) {
throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`);
}
const existingDrafts: Bill[] = [];
if (existingDrafts.length > 0) {
const draftIds = existingDrafts.map((b) => b.id);
await this.personalExpRepo
.createQueryBuilder()
.update()
.set({ billId: null })
.where('billId IN (:...ids)', { ids: draftIds })
.execute();
await this.itemRepo
.createQueryBuilder()
.delete()
.where('billId IN (:...ids)', { ids: draftIds })
.execute();
await this.billRepo
.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids: draftIds })
.execute();
}
const existingBatchBills = existingBills.filter((bill) => bill.source === 'batch');
const existingBatchBillIds = existingBatchBills.map((bill) => bill.id);
// 获取所有有费用的宿舍
const roomExpenses = await this.roomExpRepo
@@ -169,7 +147,12 @@ export class BillsService {
periodStart,
periodEnd,
})
.andWhere('pe.billId IS NULL')
.andWhere(
existingBatchBillIds.length > 0
? '(pe.billId IS NULL OR pe.billId IN (:...existingBatchBillIds))'
: 'pe.billId IS NULL',
existingBatchBillIds.length > 0 ? { existingBatchBillIds } : {},
)
.getMany();
const personalMap = new Map<number, number>();
@@ -198,18 +181,58 @@ export class BillsService {
const personal = personalMap.get(studentId) || 0;
const total = Number((shared + personal).toFixed(2));
const existingStudentBills = existingBatchBills.filter(
(bill) => bill.studentId === studentId && bill.status !== 'cancelled',
);
const existingTotal = this.roundMoney(
existingStudentBills.reduce((sum, bill) => sum + Number(bill.totalAmount || 0), 0),
);
const paidAmount = this.roundMoney(
existingStudentBills.reduce((sum, bill) => sum + Number(bill.paidAmount || 0), 0),
);
if (existingStudentBills.length > 0 && this.moneyEquals(existingTotal, total)) {
continue;
}
const remainingTotal = this.roundMoney(Math.max(0, total - paidAmount));
const ratio = total > 0 ? remainingTotal / total : 0;
const remainingShared = this.roundMoney(shared * ratio);
const remainingPersonal = this.roundMoney(remainingTotal - remainingShared);
const savedBill = await this.dataSource.transaction(async (manager) => {
const deletableBills = existingStudentBills.filter((bill) => Number(bill.paidAmount || 0) <= 0);
const fundedBills = existingStudentBills.filter((bill) => Number(bill.paidAmount || 0) > 0);
if (deletableBills.length) {
const ids = deletableBills.map((bill) => bill.id);
await manager.update(PersonalExpense, { billId: In(ids) }, { billId: null });
await manager.delete(BillItem, { billId: In(ids) });
await manager.delete(Bill, { id: In(ids) });
}
for (const bill of fundedBills) {
const paid = this.roundMoney(Number(bill.paidAmount || 0));
await manager.update(Bill, bill.id, {
totalAmount: paid,
outstandingAmount: 0,
status: 'paid',
});
}
if (remainingTotal <= 0) return null;
let bill = await manager.save(
manager.create(Bill, {
studentId,
periodStart,
periodEnd,
sharedAmount: Number(shared.toFixed(2)),
personalAmount: personal,
totalAmount: total,
sharedAmount: remainingShared,
personalAmount: remainingPersonal,
totalAmount: remainingTotal,
source: 'batch',
paidAmount: 0,
outstandingAmount: total,
outstandingAmount: remainingTotal,
status: 'unpaid',
}),
);
@@ -218,7 +241,13 @@ export class BillsService {
...(personalItems.get(studentId) || []),
];
for (const item of items) {
await manager.save(manager.create(BillItem, { ...item, billId: bill.id }));
await manager.save(
manager.create(BillItem, {
...item,
studentAmount: this.roundMoney(Number(item.studentAmount || 0) * ratio),
billId: bill.id,
}),
);
}
const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);
if (includedPersonal.length) {
@@ -232,10 +261,24 @@ export class BillsService {
bill = await this.walletsService.debitBill(manager, bill);
return bill;
});
bills.push(savedBill);
if (savedBill) bills.push(savedBill);
}
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd };
return {
message: bills.length > 0 ? `成功生成 ${bills.length} 条差额账单` : '账单无变化,未生成新账单',
count: bills.length,
bills,
periodStart,
periodEnd,
};
}
private roundMoney(value: number) {
return Number(Number(value || 0).toFixed(2));
}
private moneyEquals(left: number, right: number) {
return this.roundMoney(left) === this.roundMoney(right);
}
private resolveBillingPeriod(billingMonth: string) {
@@ -244,11 +287,6 @@ export class BillsService {
const year = Number(matched[1]);
const month = Number(matched[2]);
if (month < 1 || month > 12) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
const targetMonthStart = new Date(year, month - 1, 1);
const currentMonthStart = new Date();
currentMonthStart.setDate(1);
currentMonthStart.setHours(0, 0, 0, 0);
if (targetMonthStart >= currentMonthStart) throw new BadRequestException('只能生成已结束月份的账单');
const targetMonthEnd = new Date(year, month, 0);
const pad = (value: number) => String(value).padStart(2, '0');
return { periodStart: `${year}-${pad(month)}-01`, periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}` };

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 the submitted amount before refunding the remaining balance', async () => {
const deposit = {
id: 1,
studentId: 10,
amount: 500,
status: 'paid',
} as Deposit;
@@ -12,18 +13,20 @@ 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 service = new DepositsService(repo as never, {} as never, {} as never, {} as never);
const result = await service.refund(
1,
{ refundDate: '2026-07-13', notes: '退还剩余押金' },
{ refundDate: '2026-07-13', deductionAmount: 120, notes: '退还剩余押金' },
42,
);
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,19 @@ export class DepositsService {
throw new BadRequestException('该学生当前没有可退押金');
}
const refundAmount = Number(deposit.amount);
const depositAmount = Number(deposit.amount);
const deductionAmount = Number(Number(dto.deductionAmount || 0).toFixed(2));
if (deductionAmount < 0) throw new BadRequestException('扣除金额不能小于0');
if (deductionAmount > depositAmount) throw new BadRequestException('扣除金额不能大于当前可用押金');
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 +147,32 @@ 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 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;
}
}

View File

@@ -20,6 +20,10 @@ export class RefundDepositDto {
@IsDateString()
refundDate: string;
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
deductionAmount?: number;
@IsOptional()
@IsString()
notes?: string;