Regeltransparenz
Mahnregeln sind benannt statt in IF-Blöcken versteckt.
Ein PL/SQL-Package entscheidet Mahnstufe, Gebühren, Sperren und Historie in einem datenbanknahen Block. Das Refactoring extrahiert Regeln kontrolliert in einen Domain Service.
| Aspekt | Beschreibung |
|---|---|
| Symptom | Fachregeln liegen in Cursor-Schleifen, Magic Numbers, Tabellenflags und Exception-Blöcken. |
| Risiko | Eine kleine Änderung am Mahnstatus wirkt auf Reporting, Sperrlogik, Schreiben von Historie und Monatsabschluss. |
| Refactoring-Ziel | Regeln lesbar machen, Golden-Master-Daten sichern und schrittweise neuen Domain Service parallel rechnen lassen. |
CREATE OR REPLACE PACKAGE BODY DUNNING_PKG AS
PROCEDURE RUN_DUNNING(p_run_id IN VARCHAR2) IS
CURSOR c_open IS
SELECT i.invoice_id, i.customer_id, i.due_date, i.amount_open,
c.risk_class, c.vip_flag, c.blocked_flag,
NVL(MAX(h.level_no), 0) AS last_level
FROM invoice i
JOIN customer c ON c.customer_id = i.customer_id
LEFT JOIN dunning_history h ON h.invoice_id = i.invoice_id
WHERE i.status = 'OPEN'
AND i.amount_open > 0
AND i.due_date < SYSDATE
GROUP BY i.invoice_id, i.customer_id, i.due_date, i.amount_open,
c.risk_class, c.vip_flag, c.blocked_flag;
v_days_overdue NUMBER;
v_next_level NUMBER;
v_fee NUMBER;
BEGIN
FOR r IN c_open LOOP
v_days_overdue := TRUNC(SYSDATE) - TRUNC(r.due_date);
v_next_level := r.last_level;
v_fee := 0;
IF r.vip_flag = 'Y' THEN
IF v_days_overdue > 30 AND r.amount_open > 1000 THEN
v_next_level := 1;
v_fee := 0;
END IF;
ELSE
IF v_days_overdue BETWEEN 7 AND 13 THEN
v_next_level := GREATEST(r.last_level, 1);
v_fee := 5;
ELSIF v_days_overdue BETWEEN 14 AND 29 THEN
v_next_level := GREATEST(r.last_level, 2);
v_fee := 12;
ELSIF v_days_overdue >= 30 THEN
v_next_level := GREATEST(r.last_level, 3);
v_fee := CASE WHEN r.risk_class = 'HIGH' THEN 25 ELSE 18 END;
UPDATE customer SET blocked_flag = 'Y' WHERE customer_id = r.customer_id;
END IF;
END IF;
IF v_next_level > r.last_level THEN
INSERT INTO dunning_history(run_id, invoice_id, customer_id, level_no, fee, created_at)
VALUES(p_run_id, r.invoice_id, r.customer_id, v_next_level, v_fee, SYSTIMESTAMP);
UPDATE invoice
SET dunning_level = v_next_level,
dunning_fee = NVL(dunning_fee,0) + v_fee,
updated_at = SYSTIMESTAMP
WHERE invoice_id = r.invoice_id;
END IF;
END LOOP;
COMMIT;
EXCEPTION
WHEN OTHERS THEN
INSERT INTO batch_error(run_id, error_text, created_at)
VALUES(p_run_id, SQLERRM, SYSTIMESTAMP);
ROLLBACK;
RAISE;
END;
END DUNNING_PKG;
golden-master-dunning:
business-date: 2026-07-07
cases:
- invoice: INV-1001
customer: C-77
vip: false
days-overdue: 8
last-level: 0
expected-level: 1
expected-fee: 5.00
- invoice: INV-1002
customer: C-88
vip: true
amount-open: 1200.00
days-overdue: 31
expected-level: 1
expected-fee: 0.00
compare:
legacy-source: DUNNING_PKG.RUN_DUNNING
new-source: DunningDecisionService
tolerated-difference: none
// Pattern: Domain Service - fachliche Entscheidung ohne JDBC/PLSQL-Seiteneffekte.
public final class DunningDecisionService {
private final List<DunningSpecification> specifications;
public DunningDecisionService() {
this.specifications = List.of(
new VipGracePeriodSpecification(),
new FirstReminderSpecification(),
new SecondReminderSpecification(),
new FinalReminderSpecification()
);
}
public DunningDecision decide(DunningCandidate candidate, LocalDate businessDate) {
return specifications.stream()
.filter(rule -> rule.matches(candidate, businessDate))
.findFirst()
.map(rule -> rule.decision(candidate, businessDate))
.orElse(DunningDecision.noAction(candidate.invoiceId()));
}
}
// Pattern: Specification - jede Regel ist benannt und einzeln testbar.
final class FinalReminderSpecification implements DunningSpecification {
public boolean matches(DunningCandidate c, LocalDate businessDate) {
return !c.isVip()
&& c.daysOverdue(businessDate) >= 30
&& c.lastLevel() < 3;
}
public DunningDecision decision(DunningCandidate c, LocalDate businessDate) {
Money fee = c.riskClass() == RiskClass.HIGH ? Money.euros("25.00") : Money.euros("18.00");
return DunningDecision.raiseToLevel(c.invoiceId(), 3, fee, true);
}
}
@Stateless
public class DunningRunUseCase {
@EJB private DunningCandidateRepository candidates; // Pattern: Repository
@EJB private DunningHistoryRepository history; // Pattern: Repository
@EJB private CustomerBlockPort customerBlockPort; // Pattern: Port/Adapter
private final DunningDecisionService decisionService = new DunningDecisionService();
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public DunningRunReport run(DunningRunId runId, LocalDate businessDate) {
DunningRunReport report = new DunningRunReport(runId);
for (DunningCandidate candidate : candidates.findOpenOverdueInvoices()) {
DunningDecision decision = decisionService.decide(candidate, businessDate);
if (decision.requiresHistoryEntry()) {
history.append(runId, candidate, decision);
candidates.applyDecision(candidate.invoiceId(), decision);
if (decision.blockCustomer()) {
customerBlockPort.block(candidate.customerId(), "DUNNING_LEVEL_3");
}
}
report.add(decision);
}
return report;
}
}
Mahnregeln sind benannt statt in IF-Blöcken versteckt.
Alt-PL/SQL und neuer Service können Ergebnisse vergleichen.
DB-Schreiboperationen bleiben zunächst im Adapter kontrolliert.
Neue Mahnstufe wird als neue Specification ergänzt.