Skip to content

Commit 93a76bc

Browse files
committed
WIP: Apply new-style corrections to invoice rows
1 parent fd7339d commit 93a76bc

File tree

6 files changed

+276
-4
lines changed

6 files changed

+276
-4
lines changed

service/src/integrationTest/kotlin/fi/espoo/evaka/invoicing/service/InvoiceCorrectionsIntegrationTest.kt

+7-2
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import fi.espoo.evaka.testChild_1
3131
import fi.espoo.evaka.testDaycare
3232
import fi.espoo.evaka.testDecisionMaker_1
3333
import java.time.Month
34+
import java.time.YearMonth
3435
import kotlin.test.assertEquals
3536
import org.junit.jupiter.api.BeforeEach
3637
import org.junit.jupiter.api.Test
@@ -556,9 +557,13 @@ class InvoiceCorrectionsIntegrationTest : PureJdbiTest(resetDbBeforeEach = true)
556557
invoices: List<Invoice>,
557558
month: Month,
558559
): List<Invoice> {
559-
val period = FiniteDateRange.ofMonth(2020, month)
560560
return generator
561-
.applyCorrections(this, invoices, period, mapOf(testDaycare.id to testArea.id))
561+
.applyCorrections(
562+
this,
563+
invoices,
564+
YearMonth.of(2020, month),
565+
mapOf(testDaycare.id to testArea.id),
566+
)
562567
.shuffled() // randomize order to expose assumptions
563568
}
564569

service/src/main/kotlin/fi/espoo/evaka/invoicing/domain/Invoices.kt

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import fi.espoo.evaka.shared.db.DatabaseEnum
2121
import fi.espoo.evaka.shared.domain.HelsinkiDateTime
2222
import java.time.DayOfWeek
2323
import java.time.LocalDate
24+
import java.time.YearMonth
2425
import java.time.temporal.TemporalAdjusters
2526
import org.jdbi.v3.core.mapper.Nested
2627
import org.jdbi.v3.json.Json
@@ -98,6 +99,8 @@ data class InvoiceDetailed(
9899
val account: Int = 3295
99100
val totalPrice
100101
get() = invoiceRowTotal(rows)
102+
103+
fun targetMonth(): YearMonth = YearMonth.of(periodStart.year, periodStart.month)
101104
}
102105

103106
@JsonIgnoreProperties(ignoreUnknown = true)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package fi.espoo.evaka.invoicing.service
2+
3+
import fi.espoo.evaka.invoicing.domain.InvoiceDetailed
4+
import fi.espoo.evaka.shared.ChildId
5+
import fi.espoo.evaka.shared.DaycareId
6+
import fi.espoo.evaka.shared.InvoiceCorrectionId
7+
import fi.espoo.evaka.shared.PersonId
8+
import fi.espoo.evaka.shared.db.Database
9+
import fi.espoo.evaka.shared.domain.FiniteDateRange
10+
import java.time.YearMonth
11+
12+
data class InvoiceCorrection(
13+
val id: InvoiceCorrectionId,
14+
val targetMonth: YearMonth,
15+
val headOfFamilyId: PersonId,
16+
val childId: ChildId,
17+
val unitId: DaycareId,
18+
val product: ProductKey,
19+
val period: FiniteDateRange,
20+
val amount: Int,
21+
val unitPrice: Int,
22+
val description: String,
23+
val note: String,
24+
) {
25+
fun toInsert() =
26+
InvoiceCorrectionInsert(
27+
targetMonth = targetMonth,
28+
headOfFamilyId = headOfFamilyId,
29+
childId = childId,
30+
unitId = unitId,
31+
product = product,
32+
period = period,
33+
amount = amount,
34+
unitPrice = unitPrice,
35+
description = description,
36+
note = note,
37+
)
38+
}
39+
40+
fun updateCorrectionsOfInvoices(tx: Database.Transaction, sentInvoices: List<InvoiceDetailed>) {
41+
val rows =
42+
sentInvoices.flatMap { invoice -> invoice.rows.map { row -> invoice.targetMonth() to row } }
43+
val corrections =
44+
tx.getInvoiceCorrectionsByIds(rows.mapNotNull { (_, row) -> row.correctionId }.toSet())
45+
.associateBy { it.id }
46+
47+
val (updatedCorrections, newCorrections) =
48+
rows
49+
.mapNotNull { (targetMonth, row) ->
50+
if (row.correctionId == null) return@mapNotNull null
51+
val correction = corrections[row.correctionId] ?: return@mapNotNull null
52+
53+
val total = correction.amount * correction.unitPrice
54+
val appliedTotal = row.amount * row.unitPrice
55+
val outstandingTotal = total - appliedTotal
56+
57+
if (outstandingTotal == 0) {
58+
// Correction is fully applied
59+
return@mapNotNull null
60+
}
61+
62+
val (amount, unitPrice) =
63+
if (outstandingTotal % correction.unitPrice == 0) {
64+
outstandingTotal / correction.unitPrice to correction.unitPrice
65+
} else {
66+
1 to outstandingTotal
67+
}
68+
69+
val assignedInvoiceCorrection =
70+
InvoiceCorrectionUpdate(
71+
id = correction.id,
72+
amount = row.amount,
73+
unitPrice = row.unitPrice,
74+
)
75+
76+
val nextMonth = targetMonth.plusMonths(1)
77+
val newCorrection =
78+
correction
79+
.copy(targetMonth = nextMonth, amount = amount, unitPrice = unitPrice)
80+
.toInsert()
81+
82+
assignedInvoiceCorrection to newCorrection
83+
}
84+
.unzip()
85+
86+
tx.updateInvoiceCorrections(updatedCorrections)
87+
tx.insertInvoiceCorrections(newCorrections)
88+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package fi.espoo.evaka.invoicing.service
2+
3+
import fi.espoo.evaka.shared.ChildId
4+
import fi.espoo.evaka.shared.DaycareId
5+
import fi.espoo.evaka.shared.InvoiceCorrectionId
6+
import fi.espoo.evaka.shared.PersonId
7+
import fi.espoo.evaka.shared.db.Database
8+
import fi.espoo.evaka.shared.db.Predicate
9+
import fi.espoo.evaka.shared.db.PredicateSql
10+
import fi.espoo.evaka.shared.domain.FiniteDateRange
11+
import java.time.LocalDate
12+
import java.time.YearMonth
13+
14+
private fun Database.Read.getInvoiceCorrections(where: Predicate) =
15+
createQuery {
16+
sql(
17+
"""
18+
SELECT
19+
id,
20+
target_month,
21+
head_of_family_id,
22+
child_id,
23+
unit_id,
24+
product,
25+
period,
26+
amount,
27+
unit_price,
28+
description,
29+
note
30+
FROM invoice_correction
31+
WHERE
32+
target_month IS NOT NULL AND
33+
${predicate(where.forTable("invoice_correction"))}
34+
"""
35+
)
36+
}
37+
.toList {
38+
InvoiceCorrection(
39+
id = column("id"),
40+
targetMonth = YearMonth.from(column<LocalDate>("target_month")),
41+
headOfFamilyId = column("head_of_family_id"),
42+
childId = column("child_id"),
43+
unitId = column("unit_id"),
44+
product = column("product"),
45+
period = column("period"),
46+
amount = column("amount"),
47+
unitPrice = column("unit_price"),
48+
description = column("description"),
49+
note = column("note"),
50+
)
51+
}
52+
53+
// TODO: indeksi
54+
// CREATE INDEX idx$invoice_correction_target ON invoice_correction(target)
55+
fun Database.Read.getInvoiceCorrectionsForMonth(
56+
month: YearMonth
57+
): List<InvoiceGenerator.InvoiceCorrection> =
58+
getInvoiceCorrections(Predicate { where("$it.target_month = ${bind(month.atDay(1))}") }).map {
59+
InvoiceGenerator.InvoiceCorrection(
60+
id = it.id,
61+
headOfFamilyId = it.headOfFamilyId,
62+
childId = it.childId,
63+
unitId = it.unitId,
64+
product = it.product,
65+
period = it.period,
66+
amount = it.amount,
67+
unitPrice = it.unitPrice,
68+
description = it.description,
69+
)
70+
}
71+
72+
fun Database.Read.getInvoiceCorrectionsByIds(
73+
ids: Set<InvoiceCorrectionId>
74+
): List<InvoiceCorrection> =
75+
getInvoiceCorrections(Predicate { where("$it.id = ANY(${bind(ids)})") })
76+
77+
fun Database.Transaction.movePastUnappliedInvoiceCorrections(
78+
headOfFamilyIds: Set<PersonId>?,
79+
targetMonth: YearMonth,
80+
) {
81+
val firstOfMonth = targetMonth.atDay(1)
82+
val headOfFamilyPredicate =
83+
if (headOfFamilyIds != null) {
84+
PredicateSql { where("head_of_family_id = ANY (${bind(headOfFamilyIds)})") }
85+
} else {
86+
PredicateSql.alwaysTrue()
87+
}
88+
89+
// TODO: This query has to scan all past invoice corrections
90+
execute {
91+
sql(
92+
"""
93+
UPDATE invoice_correction
94+
SET target_month = ${bind(firstOfMonth)}
95+
WHERE
96+
target_month < ${bind(firstOfMonth)} AND
97+
${predicate(headOfFamilyPredicate)} AND
98+
NOT EXISTS (SELECT FROM invoice_row WHERE correction_id = invoice_correction.id)
99+
"""
100+
)
101+
}
102+
}
103+
104+
data class InvoiceCorrectionInsert(
105+
val targetMonth: YearMonth,
106+
val headOfFamilyId: PersonId,
107+
val childId: ChildId,
108+
val unitId: DaycareId,
109+
val product: ProductKey,
110+
val period: FiniteDateRange,
111+
val amount: Int,
112+
val unitPrice: Int,
113+
val description: String,
114+
val note: String,
115+
)
116+
117+
fun Database.Transaction.insertInvoiceCorrections(corrections: Iterable<InvoiceCorrectionInsert>) =
118+
executeBatch(corrections) {
119+
sql(
120+
"""
121+
INSERT INTO invoice_correction (target_month, head_of_family_id, child_id, unit_id, product, period, amount, unit_price, description, note)
122+
VALUES (
123+
${bind { it.targetMonth.atDay(1) }},
124+
${bind { it.headOfFamilyId }},
125+
${bind { it.childId }},
126+
${bind { it.unitId }},
127+
${bind { it.product }},
128+
${bind { it.period }},
129+
${bind { it.amount }},
130+
${bind { it.unitPrice }},
131+
${bind { it.description }},
132+
${bind { it.note }}
133+
)
134+
"""
135+
)
136+
}
137+
138+
data class InvoiceCorrectionUpdate(
139+
val id: InvoiceCorrectionId,
140+
val amount: Int,
141+
val unitPrice: Int,
142+
)
143+
144+
fun Database.Transaction.updateInvoiceCorrections(items: Iterable<InvoiceCorrectionUpdate>) =
145+
executeBatch(items) {
146+
sql(
147+
"""
148+
UPDATE invoice_correction
149+
SET amount = ${bind { it.amount }}, unit_price = ${bind { it.unitPrice }}
150+
WHERE id = ${bind { it.id }}
151+
"""
152+
)
153+
}

service/src/main/kotlin/fi/espoo/evaka/invoicing/service/InvoiceGenerator.kt

+16-2
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class InvoiceGenerator(
6363
tx.setStatementTimeout(Duration.ofMinutes(10))
6464
tx.setLockTimeout(Duration.ofSeconds(15))
6565
tx.createUpdate { sql("LOCK TABLE invoice IN EXCLUSIVE MODE") }.execute()
66+
tx.movePastUnappliedInvoiceCorrections(null, month)
6667
val invoiceCalculationData =
6768
tracer.withSpan("calculateInvoiceData") { calculateInvoiceData(tx, range) }
6869
val invoices = draftInvoiceGenerator.generateDraftInvoices(tx, invoiceCalculationData)
@@ -206,7 +207,7 @@ class InvoiceGenerator(
206207
areaIds: Map<DaycareId, AreaId>,
207208
): List<Invoice> {
208209
val invoicePeriod = FiniteDateRange.ofMonth(targetMonth)
209-
val corrections = getUninvoicedCorrections(tx)
210+
val corrections = getCorrectionsForMonth(tx, targetMonth)
210211

211212
val invoicesWithCorrections =
212213
corrections
@@ -330,13 +331,26 @@ HAVING c.amount * c.unit_price != coalesce(sum(r.amount * r.unit_price) FILTER (
330331
}
331332
}
332333

334+
private fun getCorrectionsForMonth(
335+
tx: Database.Read,
336+
targetMonth: YearMonth,
337+
): Map<PersonId, List<InvoiceCorrection>> {
338+
val result = mutableMapOf<PersonId, List<InvoiceCorrection>>()
339+
result.putAll(getUninvoicedCorrections(tx))
340+
tx.getInvoiceCorrectionsForMonth(targetMonth).forEach { correction ->
341+
result[correction.headOfFamilyId] =
342+
result.getOrDefault(correction.headOfFamilyId, emptyList()) + correction
343+
}
344+
return result.toMap()
345+
}
346+
333347
private data class InvoicedTotal(
334348
val amount: Int,
335349
val unitPrice: Int,
336350
val periodStart: LocalDate,
337351
)
338352

339-
private data class InvoiceCorrection(
353+
data class InvoiceCorrection(
340354
val id: InvoiceCorrectionId,
341355
val headOfFamilyId: PersonId,
342356
val childId: ChildId,

service/src/main/kotlin/fi/espoo/evaka/invoicing/service/InvoiceService.kt

+9
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import fi.espoo.evaka.shared.domain.EvakaClock
2323
import fi.espoo.evaka.shared.domain.FiniteDateRange
2424
import fi.espoo.evaka.shared.domain.HelsinkiDateTime
2525
import java.time.LocalDate
26+
import java.time.YearMonth
2627
import org.springframework.stereotype.Component
2728

2829
data class InvoiceDaycare(val id: DaycareId, val name: String, val costCenter: String?)
@@ -72,6 +73,14 @@ class InvoiceService(
7273
sendResult.succeeded.map { it.id } + sendResult.manuallySent.map { it.id }
7374
)
7475
tx.markInvoicedCorrectionsAsComplete()
76+
updateCorrectionsOfInvoices(tx, invoices)
77+
78+
// If any corrections didn't "fit" to these invoices, move them to next month right away
79+
val invoiceMonth = YearMonth.from(invoices.maxOf { it.periodStart })
80+
tx.movePastUnappliedInvoiceCorrections(
81+
invoices.map { it.headOfFamily.id }.toSet(),
82+
invoiceMonth.plusMonths(1),
83+
)
7584
}
7685

7786
fun getInvoiceIds(

0 commit comments

Comments
 (0)