Bläddra i källkod

2.7 b fix

fix負數倉
CANCERYS\kw093 3 veckor sedan
förälder
incheckning
e78b88c9d3
2 ändrade filer med 121 tillägg och 201 borttagningar
  1. +82
    -201
      src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt
  2. +39
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt

+ 82
- 201
src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt Visa fil

@@ -20,24 +20,22 @@ open class StockLedgerFixAdjService(
private val log = LoggerFactory.getLogger(StockLedgerFixAdjService::class.java)

/**
* 2.7b. One ADJ per inventory bucket on [adjDate] (default yesterday; pass today on freeze-night dump).
* Quantity is onHandQty minus the bucket ledger net, including rows with no lot.
* The row is attached to the lot line whose own net is furthest from its ledger.
* Lot lines and inventory are not updated, so the lot-line trigger does not run.
* Each ADJ line also gets one stock_adjustment_record. A later run fills any ADJ line that still has none.
* A bucket whose ledger net already equals onHandQty is skipped. A second run inserts no new ADJ.
* All inbound lines share one stock_in; all outbound lines share one stock_out (per adjDate).
* 2.7b. One ADJ per lot line whose remain (in − out) differs from its ledger net.
* Quantity is that lot's own gap. Out greater than in is left to 2.7a.
* Lot lines and inventory are not updated. After the ADJ, the bucket balance is
* rebuilt from live in/out so a stale balance is not copied forward.
* A lot that already matches is skipped. A second run inserts no new ADJ.
* Ledger net uses `date < adjDate+1`.
*/
open fun adjPreview(rowLimit: Int = 20, adjDateRaw: String? = null): StockLedgerFixAdjPreview {
val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw)
val t0 = System.nanoTime()
val plans = planBucketAdjs(asOfExclusive, resolveHost = false)
val plans = planLotAdjs(asOfExclusive)
val sumIn = plans.fold(BigDecimal.ZERO) { a, g -> a + g.missIn }
val sumOut = plans.fold(BigDecimal.ZERO) { a, g -> a + g.missOut }
val limit = rowLimit.coerceIn(0, 50)
log.info(
"stock-ledger-fix adj preview buckets={} in={} out={} {}ms",
"stock-ledger-fix 2.7b preview lots={} in={} out={} {}ms",
plans.size,
plans.count { it.missIn > BigDecimal.ZERO },
plans.count { it.missOut > BigDecimal.ZERO },
@@ -65,7 +63,7 @@ open class StockLedgerFixAdjService(
val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw)
val t0 = System.nanoTime()
try {
val plans = planBucketAdjs(asOfExclusive, resolveHost = true)
val plans = planLotAdjs(asOfExclusive)
var insertedIn = 0
var insertedOut = 0
val overIssuePatched = 0
@@ -118,7 +116,12 @@ open class StockLedgerFixAdjService(
mapOf("adjDate" to adjDate, "asOf" to asOfExclusive),
).forEach { addLot(support.toLong(it["lotId"]), support.toLongOrNull(it["invId"])) }
val filledLotQty = support.timed("adj 2.4") { backfill.fillLotQtyForLotIds(lotIds.toList(), asOfExclusive) }
val filledBalance = support.timed("adj 2.5") { backfill.fillBalanceOnNewAdjRows(adjDate, asOfExclusive) }
val filledBalance = support.timed("adj 2.5") {
backfill.fillBalanceForInventoryIds(
plans.mapNotNull { it.inventoryId }.distinct(),
asOfExclusive,
)
}
val range = if (lotIds.isEmpty()) null else backfill.dateRangeForFilter(
"inventoryLotLineId IN (:lotIds)",
mapOf("today" to asOfExclusive, "lotIds" to lotIds.toList()),
@@ -127,7 +130,7 @@ open class StockLedgerFixAdjService(
support.timed("adj 2.6") { backfill.upsertStockLotDayRange(lotIds.toList(), range.first, range.second) }
} else 0
log.info(
"stock-ledger-fix adj {} buckets={} in={} out={} adjRecord={} lotQty={} bal={} day={} total={}ms",
"stock-ledger-fix 2.7b {} lots={} in={} out={} adjRecord={} lotQty={} bal={} day={} total={}ms",
adjDate, plans.size, insertedIn, insertedOut, adjustmentRecords,
filledLotQty, filledBalance, dayRows,
(System.nanoTime() - t0) / 1_000_000,
@@ -295,202 +298,80 @@ open class StockLedgerFixAdjService(
}

/**
* One ADJ per bucket whose latest balance differs from onHandQty.
* After 2.5 that balance equals the bucket ledger net, so the ADJ qty is onHandQty − balance.
* Preview does not scan lot history. Apply resolves which lot line the ADJ is attached to.
*/
private fun planBucketAdjs(today: LocalDate, resolveHost: Boolean): List<AdjGap> {
val buckets = loadMismatchedByLastBalance(today)
if (buckets.isEmpty()) return emptyList()
if (!resolveHost) {
val lines = loadLotLines(buckets.mapNotNull { it.lotLineId }.distinct())
return buckets.map { bucket -> previewGap(bucket, lines[bucket.lotLineId]) }
}
val lotsByInv = loadLotsOnBuckets(today, buckets.map { it.inventoryId })
return buckets.mapNotNull { bucket ->
val host = chooseAdjHost(lotsByInv[bucket.inventoryId].orEmpty(), bucket.delta)
if (host == null) {
log.warn(
"stock-ledger-fix adj skip inventoryId={} delta={}: no lot line to attach",
bucket.inventoryId, bucket.delta,
)
return@mapNotNull null
}
val needIn = bucket.delta > BigDecimal.ZERO
AdjGap(
lotLineId = host.lotLineId,
inventoryId = bucket.inventoryId,
itemId = host.itemId,
itemCode = host.itemCode,
uomId = host.uomId,
lineIn = host.lineIn,
lineOut = host.lineOut,
ledgerIn = host.ledgerIn,
ledgerOut = host.ledgerOut,
missIn = if (needIn) bucket.delta else BigDecimal.ZERO,
missOut = if (needIn) BigDecimal.ZERO else bucket.delta.negate(),
)
}
}

private data class BucketDelta(
val inventoryId: Long,
val delta: BigDecimal,
val lotLineId: Long?,
val itemId: Long?,
val itemCode: String?,
)

/** Preview row. Ledger in/out stay 0; the bucket delta is missIn / missOut. */
private fun previewGap(bucket: BucketDelta, line: Map<String, Any?>?) = AdjGap(
lotLineId = bucket.lotLineId ?: 0L,
inventoryId = bucket.inventoryId,
itemId = support.toLongOrNull(line?.get("itemId") ?: bucket.itemId),
itemCode = (line?.get("itemCode") ?: bucket.itemCode)?.toString(),
uomId = support.toLongOrNull(line?.get("uomId")),
lineIn = support.toDecimal(line?.get("lineIn")),
lineOut = support.toDecimal(line?.get("lineOut")),
ledgerIn = BigDecimal.ZERO,
ledgerOut = BigDecimal.ZERO,
missIn = if (bucket.delta > BigDecimal.ZERO) bucket.delta else BigDecimal.ZERO,
missOut = if (bucket.delta > BigDecimal.ZERO) BigDecimal.ZERO else bucket.delta.negate(),
)

private data class LotOnBucket(
val lotLineId: Long,
val itemId: Long?,
val itemCode: String?,
val uomId: Long?,
val lineIn: BigDecimal,
val lineOut: BigDecimal,
val ledgerIn: BigDecimal,
val ledgerOut: BigDecimal,
) {
val lotGap: BigDecimal
get() = lineIn.subtract(lineOut).subtract(ledgerIn.subtract(ledgerOut))
}

/**
* Latest balance per bucket (date, then id). After 2.5 this equals the ledger net,
* so onHand − balance is the ADJ qty. One index seek per inventory, not a full sum.
* One ADJ per lot whose remain differs from its own ledger net.
* Gap = (line in − line out) − (ledger in − ledger out). Positive gap is an inbound ADJ.
* Lots with out greater than in are skipped; 2.7a owns those.
*/
private fun loadMismatchedByLastBalance(today: LocalDate): List<BucketDelta> {
return support.jdbcDao.queryForList(
private fun planLotAdjs(today: LocalDate): List<AdjGap> {
val lines = support.jdbcDao.queryForList(
"""
SELECT i.id AS inventoryId,
i.itemId AS itemId,
SELECT ill.id AS lotLineId,
CAST(COALESCE(ill.inQty, 0) AS DECIMAL(14,2)) AS lineIn,
CAST(COALESCE(ill.outQty, 0) AS DECIMAL(14,2)) AS lineOut,
il.itemId AS itemId,
it.code AS itemCode,
CAST(COALESCE(i.onHandQty, 0) AS DECIMAL(14,2)) AS onHand,
x.balance AS balance,
x.lotLineId AS lotLineId
FROM inventory i
LEFT JOIN items it ON it.id = i.itemId
INNER JOIN LATERAL (
SELECT s.balance AS balance,
s.inventoryLotLineId AS lotLineId
FROM stock_ledger s FORCE INDEX (idx_ledger_inv_last)
WHERE s.inventoryId = i.id
AND s.deleted = 0
AND s.date < :today
AND s.balance IS NOT NULL
ORDER BY s.date DESC, s.id DESC
LIMIT 1
) x ON TRUE
WHERE IFNULL(i.deleted, 0) = 0
AND ABS(CAST(x.balance AS DECIMAL(14,2))
- CAST(COALESCE(i.onHandQty, 0) AS DECIMAL(14,2))) > 0.01
iu.uomId AS uomId,
i.id AS inventoryId
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN items it ON it.id = il.itemId
INNER JOIN item_uom iu ON iu.id = ill.stockItemUomId
LEFT JOIN inventory i
ON i.itemId = il.itemId
AND i.stockUomId = iu.uomId
AND IFNULL(i.deleted, 0) = 0
WHERE IFNULL(ill.deleted, 0) = 0
AND COALESCE(ill.outQty, 0) <= COALESCE(ill.inQty, 0)
""".trimIndent(),
mapOf("today" to today),
).mapNotNull { row ->
val delta = support.toDecimal(row["onHand"]).subtract(support.toDecimal(row["balance"]))
if (delta.abs().compareTo(ADJ_TOLERANCE) <= 0) null
else BucketDelta(
inventoryId = support.toLong(row["inventoryId"]),
delta = delta,
lotLineId = support.toLongOrNull(row["lotLineId"]),
itemId = support.toLongOrNull(row["itemId"]),
itemCode = row["itemCode"]?.toString(),
)
}
}

/** Lot nets only for buckets that need an ADJ, so the host lot can be chosen. */
private fun loadLotsOnBuckets(today: LocalDate, inventoryIds: List<Long>): Map<Long, List<LotOnBucket>> {
val ledgerRows = mutableListOf<Map<String, Any?>>()
inventoryIds.chunked(50).forEach { chunk ->
ledgerRows.addAll(
support.jdbcDao.queryForList(
"""
SELECT sl.inventoryId,
sl.inventoryLotLineId AS lotLineId,
CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS ledgerIn,
CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS ledgerOut,
MIN(sl.itemId) AS itemId,
MIN(sl.itemCode) AS itemCode,
MIN(sl.uomId) AS uomId
FROM stock_ledger sl FORCE INDEX (idx_ledger_inv_date)
WHERE sl.deleted = 0
AND sl.date < :today
AND sl.inventoryId IN (:ids)
AND sl.inventoryLotLineId IS NOT NULL
GROUP BY sl.inventoryId, sl.inventoryLotLineId
""".trimIndent(),
mapOf("today" to today, "ids" to chunk),
),
)
}
if (ledgerRows.isEmpty()) return emptyMap()
val lines = loadLotLines(ledgerRows.map { support.toLong(it["lotLineId"]) }.distinct())
return ledgerRows.map { row ->
val lotLineId = support.toLong(row["lotLineId"])
val line = lines[lotLineId]
LotOnBucket(
lotLineId = lotLineId,
itemId = support.toLongOrNull(line?.get("itemId") ?: row["itemId"]),
itemCode = (line?.get("itemCode") ?: row["itemCode"])?.toString(),
uomId = support.toLongOrNull(row["uomId"] ?: line?.get("uomId")),
lineIn = support.toDecimal(line?.get("lineIn")),
lineOut = support.toDecimal(line?.get("lineOut")),
ledgerIn = support.toDecimal(row["ledgerIn"]),
ledgerOut = support.toDecimal(row["ledgerOut"]),
) to support.toLong(row["inventoryId"])
}.groupBy({ it.second }, { it.first })
}

private fun loadLotLines(lotIds: List<Long>): Map<Long, Map<String, Any?>> {
if (lotIds.isEmpty()) return emptyMap()
val lines = HashMap<Long, Map<String, Any?>>()
lotIds.chunked(200).forEach { chunk ->
)
if (lines.isEmpty()) return emptyList()
val byId = HashMap<Long, Map<String, Any?>>(lines.size)
lines.forEach { byId[support.toLong(it["lotLineId"])] = it }
val gaps = ArrayList<AdjGap>()
byId.keys.chunked(400).forEach { chunk ->
val netByLot = HashMap<Long, Map<String, Any?>>(chunk.size)
support.jdbcDao.queryForList(
"""
SELECT ill.id AS lotLineId,
CAST(COALESCE(ill.inQty, 0) AS DECIMAL(14,2)) AS lineIn,
CAST(COALESCE(ill.outQty, 0) AS DECIMAL(14,2)) AS lineOut,
il.itemId AS itemId,
it.code AS itemCode,
iu.uomId AS uomId
FROM inventory_lot_line ill
INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN items it ON it.id = il.itemId
LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId
WHERE ill.id IN (:ids)
SELECT sl.inventoryLotLineId AS lotLineId,
CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS ledgerIn,
CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS ledgerOut
FROM stock_ledger sl
WHERE sl.deleted = 0
AND sl.date < :today
AND sl.inventoryLotLineId IN (:ids)
GROUP BY sl.inventoryLotLineId
""".trimIndent(),
mapOf("ids" to chunk),
).forEach { lines[support.toLong(it["lotLineId"])] = it }
}
return lines
}

/** Prefer the lot whose own gap has the same sign as the bucket delta, largest first. */
private fun chooseAdjHost(lots: List<LotOnBucket>, delta: BigDecimal): LotOnBucket? {
if (lots.isEmpty()) return null
val sameSign = lots.filter {
it.lotGap.abs().compareTo(ADJ_TOLERANCE) > 0 && it.lotGap.signum() == delta.signum()
mapOf("today" to today, "ids" to chunk),
).forEach { netByLot[support.toLong(it["lotLineId"])] = it }
chunk.forEach lotLoop@{ lotId ->
val line = byId.getValue(lotId)
val lineIn = support.toDecimal(line["lineIn"])
val lineOut = support.toDecimal(line["lineOut"])
val net = netByLot[lotId]
val ledgerIn = support.toDecimal(net?.get("ledgerIn"))
val ledgerOut = support.toDecimal(net?.get("ledgerOut"))
val gap = lineIn.subtract(lineOut).subtract(ledgerIn.subtract(ledgerOut))
if (gap.abs().compareTo(ADJ_TOLERANCE) <= 0) return@lotLoop
val needIn = gap.signum() > 0
gaps.add(
AdjGap(
lotLineId = lotId,
inventoryId = support.toLongOrNull(line["inventoryId"]),
itemId = support.toLongOrNull(line["itemId"]),
itemCode = line["itemCode"]?.toString(),
uomId = support.toLongOrNull(line["uomId"]),
lineIn = lineIn,
lineOut = lineOut,
ledgerIn = ledgerIn,
ledgerOut = ledgerOut,
missIn = if (needIn) gap else BigDecimal.ZERO,
missOut = if (needIn) BigDecimal.ZERO else gap.negate(),
),
)
}
}
if (sameSign.isNotEmpty()) return sameSign.maxBy { it.lotGap.abs() }
val anyGap = lots.filter { it.lotGap.abs().compareTo(ADJ_TOLERANCE) > 0 }
if (anyGap.isNotEmpty()) return anyGap.maxBy { it.lotGap.abs() }
return lots.maxBy { it.ledgerIn.add(it.ledgerOut) }
return gaps
}


@@ -806,7 +687,7 @@ open class StockLedgerFixAdjService(

companion object {
private val ADJ_TOLERANCE = BigDecimal("0.01")
private const val ADJ_DOC_REMARKS = "stock-ledger-fix bucket ADJ (lot line unchanged)"
private const val ADJ_DOC_REMARKS = "stock-ledger-fix 2.7b ADJ"
private const val ADJ_LOT_LINE_REMARKS = "stock-ledger-fix 2.7a ADJ"
private const val ADJ_HEADER_REMARKS = "stock-ledger-fix 2.7 ADJ"
}


+ 39
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt Visa fil

@@ -1556,6 +1556,45 @@ open class StockLedgerFixBackfillService(
* Chain balance on new ADJ rows only; older rows for the same SKU are unchanged.
* Opening balance is the latest date, then the highest id on that date (same rule as 2.5 lookback).
*/
/**
* Rebuild bucket balance from live in/out (date, then id), so a stale stored balance is not kept.
*/
open internal fun fillBalanceForInventoryIds(inventoryIds: List<Long>, today: LocalDate): Int {
if (inventoryIds.isEmpty()) return 0
var rows = 0
inventoryIds.distinct().forEach { invId ->
rows += support.jdbcDao.executeUpdate(
"""
UPDATE stock_ledger sl
INNER JOIN (
SELECT
sl2.id,
CAST(
SUM(
CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2))
- CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2))
) OVER (
PARTITION BY sl2.inventoryId
ORDER BY sl2.date, sl2.id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)
AS DECIMAL(14,2)) AS newBalance
FROM stock_ledger sl2
WHERE sl2.deleted = 0
AND sl2.date < :today
AND sl2.inventoryId = :inventoryId
) calc ON calc.id = sl.id
SET sl.balance = calc.newBalance
WHERE sl.deleted = 0
AND sl.date < :today
AND sl.inventoryId = :inventoryId
""".trimIndent(),
mapOf("today" to today, "inventoryId" to invId),
)
}
return rows
}

open internal fun fillBalanceOnNewAdjRows(adjDate: LocalDate, today: LocalDate): Int {
val rows = support.jdbcDao.queryForList(
"""


Laddar…
Avbryt
Spara