瀏覽代碼

split stockledgerfixservice file

fix負數倉
CANCERYS\kw093 11 小時之前
父節點
當前提交
3976d28c88
共有 7 個檔案被更改,包括 3762 行新增3628 行删除
  1. +35
    -3628
      src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt
  2. +613
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt
  3. +1403
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt
  4. +625
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt
  5. +246
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt
  6. +703
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt
  7. +137
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt

+ 35
- 3628
src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt
文件差異過大導致無法顯示
查看文件


+ 613
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt 查看文件

@@ -0,0 +1,613 @@
package com.ffii.fpsms.modules.master.service.ledgerfix

import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjPreview
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjResponse
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjRow
import org.slf4j.LoggerFactory
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
import org.springframework.jdbc.support.GeneratedKeyHolder
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate
import java.math.BigDecimal
import java.time.LocalDate

@Service
open class StockLedgerFixAdjService(
private val support: StockLedgerFixSupport,
private val backfill: StockLedgerFixBackfillService,
) {
private val log = LoggerFactory.getLogger(StockLedgerFixAdjService::class.java)

/**
* Line in/out vs Σ ledger in/out. Any non-zero gap becomes ADJ on [adjDate]
* (default yesterday; pass today on freeze-night dump).
* Step 1: over-issue (`lineIn < lineOut`) sets lot inQty = outQty (trigger updates inventory)
* and writes SIL + ledger ADJ in. Step 2: miss ADJ (no further lot qty write).
* Positive miss → ADJ in/out; negative miss → reverse ADJ out/in.
* All inbound lines share one stock_in; all outbound lines share one stock_out (per adjDate).
* Ledger gap sum uses `date < adjDate+1`.
*/
open fun adjPreview(rowLimit: Int = 20, adjDateRaw: String? = null): StockLedgerFixAdjPreview {
val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw)
val t0 = System.nanoTime()
val gaps = loadAdjGaps(asOfExclusive)
val work = gaps.filter { it.hasWork() }
val over = work.filter { it.hasOverIssue() }
val miss = work.filter { it.hasMiss() }
val sumIn = miss.fold(BigDecimal.ZERO) { a, g -> a + g.missIn }
val sumOut = miss.fold(BigDecimal.ZERO) { a, g -> a + g.missOut }
val sumOver = over.fold(BigDecimal.ZERO) { a, g -> a + g.overIssue }
val limit = rowLimit.coerceIn(0, 50)
log.info(
"stock-ledger-fix adj preview lots={} over={} in={} out={} revIn={} revOut={} {}ms",
work.size,
over.size,
miss.count { it.missIn > BigDecimal.ZERO },
miss.count { it.missOut > BigDecimal.ZERO },
miss.count { it.missIn < BigDecimal.ZERO },
miss.count { it.missOut < BigDecimal.ZERO },
(System.nanoTime() - t0) / 1_000_000,
)
val rows = (over + miss.filter { !it.hasOverIssue() }).distinctBy { it.lotLineId }.take(limit)
return StockLedgerFixAdjPreview(
adjDate = adjDate.toString(),
lotCount = work.size,
adjInCount = miss.count { it.missIn > BigDecimal.ZERO },
adjOutCount = miss.count { it.missOut > BigDecimal.ZERO },
skippedNegCount = miss.count { it.missIn < BigDecimal.ZERO || it.missOut < BigDecimal.ZERO },
sumMissIn = sumIn.stripTrailingZeros().toPlainString(),
sumMissOut = sumOut.stripTrailingZeros().toPlainString(),
skuNet = (sumIn - sumOut).stripTrailingZeros().toPlainString(),
overIssueCount = over.size,
sumOverIssue = sumOver.stripTrailingZeros().toPlainString(),
rows = rows.map { it.toRow() },
)
}

open fun applyAdjAlign(adjDateRaw: String? = null): StockLedgerFixAdjResponse {
if (!support.tryLock(StockLedgerFixSupport.RUN_LOCK)) {
throw IllegalStateException("fix already running")
}
val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw)
val t0 = System.nanoTime()
try {
val gaps = loadAdjGaps(asOfExclusive)
val work = gaps.filter { it.hasWork() }
val over = work.filter { it.hasOverIssue() }
val miss = work.filter { it.hasMiss() }
var insertedIn = 0
var insertedOut = 0
var overIssuePatched = 0
if (work.isNotEmpty()) {
val insTx = TransactionTemplate(support.transactionManager)
insTx.timeout = 300
insTx.execute {
var stockInId: Long? = null
var stockOutId: Long? = null
fun inHeader(): Long =
stockInId ?: ensureAdjStockInHeader(adjDate).also { stockInId = it }
fun outHeader(): Long =
stockOutId ?: ensureAdjStockOutHeader(adjDate).also { stockOutId = it }
over.forEach { g ->
if (patchLotRemainToZero(g.lotLineId)) {
insertAdjLedger(
g, adjDate, inQty = g.overIssue, outQty = null,
remarks = OVER_ISSUE_REMARKS, stockInId = inHeader(),
)
insertedIn++
overIssuePatched++
}
}
miss.forEach { g ->
if (g.missIn.compareTo(BigDecimal.ZERO) > 0) {
insertAdjLedger(g, adjDate, inQty = g.missIn, outQty = null, stockInId = inHeader())
insertedIn++
} else if (g.missIn.compareTo(BigDecimal.ZERO) < 0) {
insertAdjLedger(
g, adjDate, inQty = null, outQty = g.missIn.negate(), stockOutId = outHeader(),
)
insertedOut++
}
if (g.missOut.compareTo(BigDecimal.ZERO) > 0) {
insertAdjLedger(g, adjDate, inQty = null, outQty = g.missOut, stockOutId = outHeader())
insertedOut++
} else if (g.missOut.compareTo(BigDecimal.ZERO) < 0) {
insertAdjLedger(
g, adjDate, inQty = g.missOut.negate(), outQty = null, stockInId = inHeader(),
)
insertedIn++
}
}
}
}
val lotIds = linkedSetOf<Long>()
fun addLot(lotId: Long, invId: Long?) {
if (lotId > 0) lotIds.add(lotId)
}
work.forEach { addLot(it.lotLineId, it.inventoryId) }
support.jdbcDao.queryForList(
"""
SELECT DISTINCT inventoryLotLineId AS lotId, inventoryId AS invId
FROM stock_ledger
WHERE deleted = 0
AND date >= :adjDate AND date < :asOf
AND type = 'ADJ'
AND createdBy = 'stock-ledger-fix'
""".trimIndent(),
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 range = if (lotIds.isEmpty()) null else backfill.dateRangeForFilter(
"inventoryLotLineId IN (:lotIds)",
mapOf("today" to asOfExclusive, "lotIds" to lotIds.toList()),
)
val dayRows = if (range != null && lotIds.isNotEmpty()) {
support.timed("adj 2.6") { backfill.upsertStockLotDayRange(lotIds.toList(), range.first, range.second) }
} else 0
log.info(
"stock-ledger-fix adj {} over={} in={} out={} lotQty={} bal={} day={} total={}ms",
adjDate, overIssuePatched, insertedIn, insertedOut, filledLotQty, filledBalance, dayRows,
(System.nanoTime() - t0) / 1_000_000,
)
return StockLedgerFixAdjResponse(
adjDate = adjDate.toString(),
insertedIn = insertedIn,
insertedOut = insertedOut,
overIssuePatched = overIssuePatched,
filledLotQty = filledLotQty,
filledBalance = filledBalance,
dayRowsWritten = dayRows,
)
} catch (e: Exception) {
log.warn("stock-ledger-fix adj FAILED after {}ms: {}", (System.nanoTime() - t0) / 1_000_000, e.message)
throw e
} finally {
support.unlock(StockLedgerFixSupport.RUN_LOCK)
}
}

/**
* ADJ window: ledger date = [adjDate], gap sums use `date < adjDate+1`.
* Default adjDate = yesterday. Pass today for freeze-night dump (e.g. 31/8).
*/
private fun resolveAdjWindow(adjDateRaw: String?): Pair<LocalDate, LocalDate> {
val today = LocalDate.now()
val adjDate = if (adjDateRaw.isNullOrBlank()) {
today.minusDays(1)
} else {
support.parseDay(adjDateRaw)
}
if (adjDate.isAfter(today)) {
throw IllegalArgumentException("adjDate cannot be in the future")
}
return adjDate to adjDate.plusDays(1)
}

private fun loadAdjGaps(today: LocalDate): List<AdjGap> {
val tx = TransactionTemplate(support.transactionManager)
tx.timeout = 300
val args = mapOf("today" to today)
return tx.execute {
support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_line")
support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_led")
support.jdbcDao.executeUpdate(
"""
CREATE TEMPORARY TABLE tmp_adj_line (
lotLineId BIGINT NOT NULL PRIMARY KEY,
inventoryId INT NULL,
itemId INT NULL,
itemCode VARCHAR(64) NULL,
uomId INT NULL,
lineIn DECIMAL(14,2) NOT NULL,
lineOut DECIMAL(14,2) NOT NULL
)
""".trimIndent(),
)
support.jdbcDao.executeUpdate(
"""
CREATE TEMPORARY TABLE tmp_adj_led (
inventoryLotLineId BIGINT NOT NULL PRIMARY KEY,
ledger_in DECIMAL(14,2) NOT NULL,
ledger_out DECIMAL(14,2) NOT NULL
)
""".trimIndent(),
)
try {
support.timed("adj line-all") {
support.jdbcDao.executeUpdate(
"""
INSERT INTO tmp_adj_line (
lotLineId, inventoryId, itemId, itemCode, uomId, lineIn, lineOut
)
SELECT
ill.id,
inv.inventoryId,
il.itemId,
it.code,
iu.uomId,
CAST(COALESCE(ill.inQty, 0) AS DECIMAL(14,2)),
CAST(COALESCE(ill.outQty, 0) AS DECIMAL(14,2))
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
LEFT JOIN items it ON it.id = il.itemId
LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId
LEFT JOIN (
SELECT itemId, COALESCE(stockUomId, uomId) AS bucketUomId, MIN(id) AS inventoryId
FROM inventory
WHERE IFNULL(deleted, 0) = 0
GROUP BY itemId, COALESCE(stockUomId, uomId)
) inv ON inv.itemId = il.itemId AND inv.bucketUomId = iu.uomId
WHERE IFNULL(ill.deleted, 0) = 0
AND (
COALESCE(ill.inQty, 0) <> 0
OR COALESCE(ill.outQty, 0) <> 0
)
""".trimIndent(),
)
}
val lineIds = support.jdbcDao.queryForList(
"SELECT lotLineId FROM tmp_adj_line",
).map { support.toLong(it["lotLineId"]) }.filter { it > 0 }
log.info("stock-ledger-fix adj line rows={}", lineIds.size)
if (lineIds.isEmpty()) return@execute emptyList()
support.timed("adj ledger-by-line") {
lineIds.chunked(40).sumOf { chunk ->
support.jdbcDao.executeUpdate(
"""
INSERT INTO tmp_adj_led (inventoryLotLineId, ledger_in, ledger_out)
SELECT
sl.inventoryLotLineId,
CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)),
CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2))
FROM stock_ledger sl FORCE INDEX (idx_ledger_lot_date_id)
WHERE sl.deleted = 0
AND sl.date < :today
AND sl.inventoryLotLineId IN (:ids)
GROUP BY sl.inventoryLotLineId
""".trimIndent(),
mapOf("today" to today, "ids" to chunk),
)
}
}
support.jdbcDao.queryForList(
"""
SELECT
l.lotLineId,
l.inventoryId,
l.itemId,
l.itemCode,
l.uomId,
l.lineIn,
l.lineOut,
CAST(COALESCE(agg.ledger_in, 0) AS DECIMAL(14,2)) AS ledgerIn,
CAST(COALESCE(agg.ledger_out, 0) AS DECIMAL(14,2)) AS ledgerOut
FROM tmp_adj_line l
LEFT JOIN tmp_adj_led agg ON agg.inventoryLotLineId = l.lotLineId
WHERE l.lineIn <> CAST(COALESCE(agg.ledger_in, 0) AS DECIMAL(14,2))
OR l.lineOut <> CAST(COALESCE(agg.ledger_out, 0) AS DECIMAL(14,2))
OR l.lineIn < l.lineOut
""".trimIndent(),
).map { row ->
val lineIn = support.toDecimal(row["lineIn"])
val lineOut = support.toDecimal(row["lineOut"])
val ledgerIn = support.toDecimal(row["ledgerIn"])
val ledgerOut = support.toDecimal(row["ledgerOut"])
AdjGap(
lotLineId = support.toLong(row["lotLineId"]),
inventoryId = support.toLongOrNull(row["inventoryId"]),
itemId = support.toLongOrNull(row["itemId"]),
itemCode = row["itemCode"]?.toString(),
uomId = support.toLongOrNull(row["uomId"]),
lineIn = lineIn,
lineOut = lineOut,
ledgerIn = ledgerIn,
ledgerOut = ledgerOut,
missIn = lineIn.subtract(ledgerIn),
missOut = lineOut.subtract(ledgerOut),
)
}
} finally {
try {
support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_led")
support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_line")
} catch (e: Exception) {
log.warn("stock-ledger-fix drop tmp_adj skipped: {}", e.message)
}
}
} ?: emptyList()
}

private fun insertAdjLedger(
g: AdjGap,
adjDate: LocalDate,
inQty: BigDecimal?,
outQty: BigDecimal?,
remarks: String = ADJ_DOC_REMARKS,
stockInId: Long? = null,
stockOutId: Long? = null,
) {
val silId = if (inQty != null) {
insertAdjStockInLine(g, adjDate, inQty, remarks, stockInId ?: error("stockInId required for ADJ in"))
} else {
null
}
val solId = if (outQty != null) {
insertAdjStockOutLine(g, adjDate, outQty, remarks, stockOutId ?: error("stockOutId required for ADJ out"))
} else {
null
}
val args = hashMapOf<String, Any?>(
"adjDate" to adjDate,
"silId" to silId,
"solId" to solId,
"lotLineId" to g.lotLineId,
"inventoryId" to g.inventoryId,
"itemId" to g.itemId,
"itemCode" to g.itemCode,
"inQty" to inQty,
"outQty" to outQty,
"uomId" to g.uomId,
)
support.jdbcDao.executeUpdate(
"""
INSERT INTO stock_ledger (
created, createdBy, version, modified, modifiedBy, deleted,
date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId,
itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter,
uomId, type
) VALUES (
NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0,
:adjDate, :silId, :solId, :lotLineId, :inventoryId,
:itemId, :itemCode, :inQty, :outQty, NULL, NULL, NULL,
:uomId, 'ADJ'
)
""".trimIndent(),
args,
)
}

/** Sets lot inQty = outQty so remain = 0; trigger recomputes inventory. */
private fun patchLotRemainToZero(lotLineId: Long): Boolean {
val n = support.jdbcDao.executeUpdate(
"""
UPDATE inventory_lot_line
SET inQty = CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)),
status = 'unavailable',
modified = NOW(),
modifiedBy = 'stock-ledger-fix',
version = IFNULL(version, 0) + 1
WHERE id = :lotLineId
AND IFNULL(deleted, 0) = 0
AND CAST(COALESCE(inQty, 0) AS DECIMAL(14,2))
< CAST(COALESCE(outQty, 0) AS DECIMAL(14,2))
""".trimIndent(),
mapOf("lotLineId" to lotLineId),
)
if (n == 0) {
log.warn("stock-ledger-fix over-issue skip lot update: lotLineId={}", lotLineId)
}
return n > 0
}

private fun adjStockInCode(adjDate: LocalDate): String = "SLF-I-$adjDate"

private fun adjStockOutRemarks(adjDate: LocalDate): String = "$ADJ_HEADER_REMARKS $adjDate"

/** One stock_in per ADJ date (reused if Apply runs again). */
private fun ensureAdjStockInHeader(adjDate: LocalDate): Long {
val code = adjStockInCode(adjDate)
val existing = support.jdbcDao.queryForMap(
"""
SELECT id FROM stock_in
WHERE code = :code AND IFNULL(deleted, 0) = 0
LIMIT 1
""".trimIndent(),
mapOf("code" to code),
).orElse(null)
if (existing != null) return support.toLong(existing["id"])
val at = adjDate.atTime(12, 0)
return insertReturningId(
"""
INSERT INTO stock_in (
created, createdBy, version, modified, modifiedBy, deleted,
code, status, orderDate, completeDate
) VALUES (
NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0,
:code, 'completed', :at, :at
)
""".trimIndent(),
mapOf("code" to code, "at" to at),
)
}

/** One stock_out per ADJ date (reused if Apply runs again). */
private fun ensureAdjStockOutHeader(adjDate: LocalDate): Long {
val remarks = adjStockOutRemarks(adjDate)
val existing = support.jdbcDao.queryForMap(
"""
SELECT id FROM stock_out
WHERE createdBy = 'stock-ledger-fix'
AND type = 'ADJ'
AND remarks = :remarks
AND IFNULL(deleted, 0) = 0
ORDER BY id
LIMIT 1
""".trimIndent(),
mapOf("remarks" to remarks),
).orElse(null)
if (existing != null) return support.toLong(existing["id"])
val at = adjDate.atTime(12, 0)
return insertReturningId(
"""
INSERT INTO stock_out (
created, createdBy, version, modified, modifiedBy, deleted,
type, completeDate, status, remarks
) VALUES (
NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0,
'ADJ', :at, 'completed', :remarks
)
""".trimIndent(),
mapOf("at" to at, "remarks" to remarks),
)
}

/** Line on shared stock_in; does not UPDATE inventory_lot_line or inventory. */
private fun insertAdjStockInLine(
g: AdjGap,
adjDate: LocalDate,
qty: BigDecimal,
remarks: String,
stockInId: Long,
): Long? {
val itemId = g.itemId
if (itemId == null) {
log.warn("stock-ledger-fix adj skip SIL: lotLineId={} has no itemId", g.lotLineId)
return null
}
val lot = loadAdjLotLink(g.lotLineId) ?: return null
val at = adjDate.atTime(12, 0)
return insertReturningId(
"""
INSERT INTO stock_in_line (
created, createdBy, version, modified, modifiedBy, deleted,
itemId, itemNo, stockInId, acceptedQty, receiptDate, status,
inventoryLotId, inventoryLotLineId, lotNo, remarks, type
) VALUES (
NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0,
:itemId, :itemNo, :stockInId, :qty, :at, 'completed',
:inventoryLotId, :lotLineId, :lotNo, :remarks, 'ADJ'
)
""".trimIndent(),
mapOf(
"itemId" to itemId,
"itemNo" to adjItemNo(g.itemCode),
"stockInId" to stockInId,
"qty" to qty,
"at" to at,
"inventoryLotId" to lot.inventoryLotId,
"lotLineId" to g.lotLineId,
"lotNo" to lot.lotNo,
"remarks" to remarks,
),
)
}

/** Line on shared stock_out; does not UPDATE inventory_lot_line or inventory. */
private fun insertAdjStockOutLine(
g: AdjGap,
adjDate: LocalDate,
qty: BigDecimal,
remarks: String,
stockOutId: Long,
): Long? {
val itemId = g.itemId
if (itemId == null) {
log.warn("stock-ledger-fix adj skip SOL: lotLineId={} has no itemId", g.lotLineId)
return null
}
if (loadAdjLotLink(g.lotLineId) == null) return null
val at = adjDate.atTime(12, 0)
return insertReturningId(
"""
INSERT INTO stock_out_line (
created, createdBy, version, modified, modifiedBy, deleted,
itemId, qty, stockOutId, inventoryLotLineId, status, pickTime, type
) VALUES (
NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0,
:itemId, :qty, :stockOutId, :lotLineId, 'completed', :at, 'ADJ'
)
""".trimIndent(),
mapOf(
"itemId" to itemId,
"qty" to qty,
"stockOutId" to stockOutId,
"lotLineId" to g.lotLineId,
"at" to at,
),
)
}

private fun loadAdjLotLink(lotLineId: Long): AdjLotLink? {
val row = support.jdbcDao.queryForMap(
"""
SELECT ill.inventoryLotId AS inventoryLotId, il.lotNo AS lotNo
FROM inventory_lot_line ill
LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId
WHERE ill.id = :lotLineId
AND IFNULL(ill.deleted, 0) = 0
""".trimIndent(),
mapOf("lotLineId" to lotLineId),
).orElse(null)
if (row == null) {
log.warn("stock-ledger-fix adj skip SIL/SOL: lotLineId={} not found", lotLineId)
return null
}
return AdjLotLink(
inventoryLotId = support.toLongOrNull(row["inventoryLotId"]),
lotNo = row["lotNo"]?.toString(),
)
}

private fun insertReturningId(sql: String, args: Map<String, Any?>): Long {
val keyHolder = GeneratedKeyHolder()
support.namedJdbc.update(sql, MapSqlParameterSource(args), keyHolder, arrayOf("id"))
val key = keyHolder.key?.toLong()
?: keyHolder.keyList.firstOrNull()?.values?.firstOrNull()?.let { (it as Number).toLong() }
return key ?: error("stock-ledger-fix: insert did not return id")
}

private fun adjItemNo(itemCode: String?): String {
val raw = itemCode?.trim().orEmpty()
return if (raw.isEmpty()) "SLF" else raw.take(20)
}

private data class AdjLotLink(
val inventoryLotId: Long?,
val lotNo: String?,
)

private data class AdjGap(
val lotLineId: Long,
val inventoryId: Long?,
val itemId: Long?,
val itemCode: String?,
val uomId: Long?,
val lineIn: BigDecimal,
val lineOut: BigDecimal,
val ledgerIn: BigDecimal,
val ledgerOut: BigDecimal,
val missIn: BigDecimal,
val missOut: BigDecimal,
) {
val overIssue: BigDecimal =
if (lineOut.compareTo(lineIn) > 0) lineOut.subtract(lineIn) else BigDecimal.ZERO

fun hasOverIssue(): Boolean = overIssue.compareTo(BigDecimal.ZERO) > 0

fun hasMiss(): Boolean =
missIn.compareTo(BigDecimal.ZERO) != 0 || missOut.compareTo(BigDecimal.ZERO) != 0

fun hasWork(): Boolean = hasOverIssue() || hasMiss()

fun toRow() = StockLedgerFixAdjRow(
lotLineId = lotLineId,
inventoryId = inventoryId,
itemCode = itemCode,
lineIn = lineIn.stripTrailingZeros().toPlainString(),
lineOut = lineOut.stripTrailingZeros().toPlainString(),
ledgerIn = ledgerIn.stripTrailingZeros().toPlainString(),
ledgerOut = ledgerOut.stripTrailingZeros().toPlainString(),
missIn = missIn.stripTrailingZeros().toPlainString(),
missOut = missOut.stripTrailingZeros().toPlainString(),
overIssue = overIssue.stripTrailingZeros().toPlainString(),
)
}

companion object {
private const val ADJ_DOC_REMARKS = "stock-ledger-fix miss ADJ (no lot qty change)"
private const val OVER_ISSUE_REMARKS = "stock-ledger-fix over-issue remain→0 (lot inQty patched)"
private const val ADJ_HEADER_REMARKS = "stock-ledger-fix 2.7 ADJ"
}
}

+ 1403
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt
文件差異過大導致無法顯示
查看文件


+ 625
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt 查看文件

@@ -0,0 +1,625 @@
package com.ffii.fpsms.modules.master.service.ledgerfix

import org.slf4j.LoggerFactory
import org.springframework.jdbc.core.RowCallbackHandler
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Service
import java.io.BufferedWriter
import java.io.OutputStream
import java.io.OutputStreamWriter
import java.math.BigDecimal
import java.nio.charset.StandardCharsets
import java.sql.ResultSet
import java.time.LocalDate

@Service
open class StockLedgerFixExportService(
private val support: StockLedgerFixSupport,
) {
private val log = LoggerFactory.getLogger(StockLedgerFixExportService::class.java)

/**
* Export SQL patch for target DB (same dump lineage / freeze).
* [partsRaw] omit / empty / all = ledger + 2.6 (legacy). Else subset:
* 1.0 inventory, 2.3 inventoryId, ledger (lot/uom/lotQty/balance), 2.6 day, 2.7 SIL/SOL + ADJ INSERT.
* Apply order in file: 1.0 → ledger(+2.3) → 2.7 docs → 2.7 ADJ → 2.6.
*/
open fun writeExportSql(
from: LocalDate,
to: LocalDate,
out: OutputStream,
partsRaw: List<String>? = null,
) {
if (to.isBefore(from)) throw IllegalArgumentException("to must be on or after from")
if (from.plusDays(400).isBefore(to)) {
throw IllegalArgumentException("date range must be at most 400 days")
}
val today = LocalDate.now()
if (to.isAfter(today)) {
throw IllegalArgumentException("cannot export future dates")
}
val toExclusive = to.plusDays(1)
if (!from.isBefore(toExclusive)) {
throw IllegalArgumentException("from must be before toExclusive")
}
val parts = parseExportParts(partsRaw)
val want10 = "1.0" in parts
val want23 = "2.3" in parts
val wantLedger = "ledger" in parts
val want26 = "2.6" in parts
val want27 = "2.7" in parts
if (!want10 && !want23 && !wantLedger && !want26 && !want27) {
throw IllegalArgumentException("parts must include at least one of: 1.0, 2.3, ledger, 2.6, 2.7")
}
val args = mapOf("from" to from, "toExclusive" to toExclusive)
val ledgerCnt = if (wantLedger || want23 || want27) {
support.toInt(
support.jdbcDao.queryForList(
"""
SELECT COUNT(*) AS c FROM stock_ledger
WHERE deleted = 0 AND date >= :from AND date < :toExclusive
""".trimIndent(),
args,
).firstOrNull()?.get("c"),
)
} else {
0
}
val dayCnt = if (want26) {
support.toInt(
support.jdbcDao.queryForList(
"""
SELECT COUNT(*) AS c FROM stock_lot_day
WHERE IFNULL(deleted, 0) = 0 AND date >= :from AND date < :toExclusive
""".trimIndent(),
args,
).firstOrNull()?.get("c"),
)
} else {
0
}
val adjCnt = if (want27) {
support.toInt(
support.jdbcDao.queryForList(
"""
SELECT COUNT(*) AS c FROM stock_ledger
WHERE deleted = 0
AND date >= :from AND date < :toExclusive
AND type = 'ADJ'
AND createdBy = 'stock-ledger-fix'
""".trimIndent(),
args,
).firstOrNull()?.get("c"),
)
} else {
0
}
val invCnt = if (want10) {
support.toInt(
support.jdbcDao.queryForList(
"SELECT COUNT(*) AS c FROM inventory WHERE IFNULL(deleted, 0) = 0",
).firstOrNull()?.get("c"),
)
} else {
0
}
val writer = BufferedWriter(OutputStreamWriter(out, StandardCharsets.UTF_8), 65536)
val jdbc = NamedParameterJdbcTemplate(support.dataSource)
jdbc.jdbcTemplate.fetchSize = 2000
try {
writer.write("-- stock-ledger-fix patch\n")
writer.write("-- from $from inclusive to ${toExclusive.minusDays(1)} inclusive (date < $toExclusive)\n")
writer.write("-- parts=${parts.joinToString(",")}\n")
writer.write("-- apply order: 1.0 inventory → ledger(+2.3) UPDATE → 2.7 SIL/SOL then ADJ INSERT → 2.6 stock_lot_day\n")
writer.write("-- INNER JOIN id: target rows without matching id are skipped on UPDATE\n")
if (want23 && !want10) {
writer.write("-- WARN: 2.3 without 1.0 — new inventory ids may be missing on target; prefer include 1.0\n")
}
writer.write("-- please run in the target DB/schema, e.g. USE fpsmsdb;\n")
writer.write(
"-- expected inventoryRows=$invCnt ledgerRows=$ledgerCnt adjRows=$adjCnt stock_lot_dayRows=$dayCnt\n",
)
writer.write("-- SET NAMES utf8mb4;\n\n")

if (want10) {
writeExportInventory10(writer, jdbc)
}
if (wantLedger || want23) {
writeExportLedgerPatch(writer, jdbc, args, wantLedger, want23)
}
if (want27) {
writeExportAdjDocs(writer, jdbc, args)
writeExportAdjInsert(writer, jdbc, args)
}
if (want26) {
writeExportStockLotDay(writer, jdbc, args, from, toExclusive)
}

writer.write("\n-- done\n")
writer.flush()
log.info(
"stock-ledger-fix export {}..{} parts={} inv={} ledger={} adj={} day={}",
from, toExclusive.minusDays(1), parts.joinToString(","), invCnt, ledgerCnt, adjCnt, dayCnt,
)
} finally {
writer.flush()
}
}

private fun writeExportInventory10(writer: BufferedWriter, jdbc: NamedParameterJdbcTemplate) {
writer.write("-- === 1.0 inventory (INSERT with fixed id + UPDATE qty/status) ===\n")
var batch = 0
jdbc.query(
"""
SELECT id, created, createdBy, version, modified, modifiedBy, deleted,
itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty,
price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status
FROM inventory
WHERE IFNULL(deleted, 0) = 0
ORDER BY id
""".trimIndent(),
emptyMap<String, Any>(),
RowCallbackHandler { rs ->
if (batch == 0) {
writer.write(
"INSERT INTO inventory (\n" +
" id, created, createdBy, version, modified, modifiedBy, deleted,\n" +
" itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty,\n" +
" price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status\n" +
") VALUES\n",
)
} else {
writer.write(",\n")
}
writer.write(
"(${rsSql(rs, "id")}, ${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " +
"${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " +
"${rsSql(rs, "itemId")}, ${rsSql(rs, "uomId")}, ${rsSql(rs, "stockUomId")}, ${rsSql(rs, "onHandQty")}, " +
"${rsSql(rs, "onHoldQty")}, ${rsSql(rs, "unavailableQty")}, ${rsSql(rs, "price")}, " +
"${rsSql(rs, "currencyId")}, ${rsSql(rs, "cpu")}, ${rsSql(rs, "cpuUnit")}, " +
"${rsSql(rs, "cpm")}, ${rsSql(rs, "cpmUnit")}, ${rsSql(rs, "status")})",
)
batch++
if (batch >= 200) {
writer.write(
"\nON DUPLICATE KEY UPDATE " +
"onHandQty=VALUES(onHandQty), onHoldQty=VALUES(onHoldQty), " +
"unavailableQty=VALUES(unavailableQty), status=VALUES(status), " +
"uomId=VALUES(uomId), stockUomId=VALUES(stockUomId), " +
"price=VALUES(price), currencyId=VALUES(currencyId), " +
"cpu=VALUES(cpu), cpuUnit=VALUES(cpuUnit), cpm=VALUES(cpm), cpmUnit=VALUES(cpmUnit), " +
"modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=inventory.version+1, deleted=0;\n\n",
)
batch = 0
}
},
)
if (batch > 0) {
writer.write(
"\nON DUPLICATE KEY UPDATE " +
"onHandQty=VALUES(onHandQty), onHoldQty=VALUES(onHoldQty), " +
"unavailableQty=VALUES(unavailableQty), status=VALUES(status), " +
"uomId=VALUES(uomId), stockUomId=VALUES(stockUomId), " +
"price=VALUES(price), currencyId=VALUES(currencyId), " +
"cpu=VALUES(cpu), cpuUnit=VALUES(cpuUnit), cpm=VALUES(cpm), cpmUnit=VALUES(cpmUnit), " +
"modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=inventory.version+1, deleted=0;\n\n",
)
} else {
writer.write("-- (no inventory rows)\n\n")
}
}

private fun writeExportLedgerPatch(
writer: BufferedWriter,
jdbc: NamedParameterJdbcTemplate,
args: Map<String, Any>,
wantLedger: Boolean,
want23: Boolean,
) {
writer.write("-- === ledger UPDATE (JOIN id)")
if (wantLedger) writer.write(" lot/uom/lotQty/balance")
if (want23) writer.write(" inventoryId")
writer.write(" ===\n")
val cols = mutableListOf("id")
if (wantLedger) {
cols += listOf("inventoryLotLineId", "uomId", "lotQtyBefore", "lotQtyAfter", "balance")
}
if (want23) cols += "inventoryId"
writer.write("CREATE TEMPORARY TABLE tmp_sl_fix_patch (\n")
writer.write(" id INT NOT NULL PRIMARY KEY")
if (wantLedger) {
writer.write(",\n inventoryLotLineId BIGINT NULL,\n uomId INT NULL,\n")
writer.write(" lotQtyBefore DECIMAL(14,2) NULL,\n lotQtyAfter DECIMAL(14,2) NULL,\n")
writer.write(" balance DECIMAL(14,2) NULL")
}
if (want23) writer.write(",\n inventoryId INT NULL")
writer.write("\n);\n\n")
var batch = 0
val selectCols = cols.joinToString(", ")
jdbc.query(
"""
SELECT $selectCols
FROM stock_ledger
WHERE deleted = 0 AND date >= :from AND date < :toExclusive
""".trimIndent(),
args,
RowCallbackHandler { rs ->
if (batch == 0) {
writer.write("INSERT INTO tmp_sl_fix_patch (${cols.joinToString(", ")}) VALUES\n")
} else {
writer.write(",\n")
}
writer.write("(" + cols.joinToString(", ") { rsSql(rs, it) } + ")")
batch++
if (batch >= 500) {
writer.write(";\n\n")
batch = 0
}
},
)
if (batch > 0) writer.write(";\n\n")
val setParts = mutableListOf<String>()
if (wantLedger) {
setParts += listOf(
"sl.inventoryLotLineId = t.inventoryLotLineId",
"sl.uomId = t.uomId",
"sl.lotQtyBefore = t.lotQtyBefore",
"sl.lotQtyAfter = t.lotQtyAfter",
"sl.balance = t.balance",
)
}
if (want23) setParts += "sl.inventoryId = t.inventoryId"
writer.write(
"""
UPDATE stock_ledger sl
INNER JOIN tmp_sl_fix_patch t ON t.id = sl.id
SET
${setParts.joinToString(",\n ")}
WHERE sl.deleted = 0;

DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_patch;

""".trimIndent(),
)
writer.write("\n")
}

private fun writeExportAdjDocs(
writer: BufferedWriter,
jdbc: NamedParameterJdbcTemplate,
args: Map<String, Any>,
) {
writer.write("-- === 2.7 ADJ stock_in / stock_out (1 header + many lines; apply before ledger ADJ FKs) ===\n")
writeExportRows(
writer,
jdbc,
"""
SELECT DISTINCT si.id, si.created, si.createdBy, si.version, si.modified, si.modifiedBy, si.deleted,
si.code, si.status, si.orderDate, si.completeDate
FROM stock_in si
INNER JOIN stock_in_line sil ON sil.stockInId = si.id AND IFNULL(sil.deleted, 0) = 0
INNER JOIN stock_ledger sl ON sl.stockInLineId = sil.id
WHERE sl.deleted = 0
AND sl.date >= :from AND sl.date < :toExclusive
AND sl.type = 'ADJ'
AND sl.createdBy = 'stock-ledger-fix'
ORDER BY si.id
""".trimIndent(),
args,
table = "stock_in",
columns = listOf(
"id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted",
"code", "status", "orderDate", "completeDate",
),
onDup = "code=VALUES(code), status=VALUES(status), orderDate=VALUES(orderDate), " +
"completeDate=VALUES(completeDate), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=stock_in.version+1, deleted=0",
emptyComment = "-- (no ADJ stock_in headers in range)",
)
writeExportRows(
writer,
jdbc,
"""
SELECT sil.id, sil.created, sil.createdBy, sil.version, sil.modified, sil.modifiedBy, sil.deleted,
sil.itemId, sil.itemNo, sil.stockInId, sil.acceptedQty, sil.receiptDate, sil.status,
sil.inventoryLotId, sil.inventoryLotLineId, sil.lotNo, sil.remarks, sil.type
FROM stock_in_line sil
INNER JOIN stock_ledger sl ON sl.stockInLineId = sil.id
WHERE sl.deleted = 0
AND sl.date >= :from AND sl.date < :toExclusive
AND sl.type = 'ADJ'
AND sl.createdBy = 'stock-ledger-fix'
ORDER BY sil.id
""".trimIndent(),
args,
table = "stock_in_line",
columns = listOf(
"id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted",
"itemId", "itemNo", "stockInId", "acceptedQty", "receiptDate", "status",
"inventoryLotId", "inventoryLotLineId", "lotNo", "remarks", "type",
),
onDup = "acceptedQty=VALUES(acceptedQty), inventoryLotId=VALUES(inventoryLotId), " +
"inventoryLotLineId=VALUES(inventoryLotLineId), lotNo=VALUES(lotNo), status=VALUES(status), " +
"type=VALUES(type), remarks=VALUES(remarks), modified=VALUES(modified), " +
"modifiedBy=VALUES(modifiedBy), version=stock_in_line.version+1, deleted=0",
emptyComment = "-- (no ADJ stock_in_line in range)",
)
writeExportRows(
writer,
jdbc,
"""
SELECT DISTINCT so.id, so.created, so.createdBy, so.version, so.modified, so.modifiedBy, so.deleted,
so.type, so.completeDate, so.status, so.remarks
FROM stock_out so
INNER JOIN stock_out_line sol ON sol.stockOutId = so.id AND IFNULL(sol.deleted, 0) = 0
INNER JOIN stock_ledger sl ON sl.stockOutLineId = sol.id
WHERE sl.deleted = 0
AND sl.date >= :from AND sl.date < :toExclusive
AND sl.type = 'ADJ'
AND sl.createdBy = 'stock-ledger-fix'
ORDER BY so.id
""".trimIndent(),
args,
table = "stock_out",
columns = listOf(
"id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted",
"type", "completeDate", "status", "remarks",
),
onDup = "type=VALUES(type), completeDate=VALUES(completeDate), status=VALUES(status), " +
"remarks=VALUES(remarks), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=stock_out.version+1, deleted=0",
emptyComment = "-- (no ADJ stock_out headers in range)",
)
writeExportRows(
writer,
jdbc,
"""
SELECT sol.id, sol.created, sol.createdBy, sol.version, sol.modified, sol.modifiedBy, sol.deleted,
sol.itemId, sol.qty, sol.stockOutId, sol.inventoryLotLineId, sol.status, sol.pickTime, sol.type
FROM stock_out_line sol
INNER JOIN stock_ledger sl ON sl.stockOutLineId = sol.id
WHERE sl.deleted = 0
AND sl.date >= :from AND sl.date < :toExclusive
AND sl.type = 'ADJ'
AND sl.createdBy = 'stock-ledger-fix'
ORDER BY sol.id
""".trimIndent(),
args,
table = "stock_out_line",
columns = listOf(
"id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted",
"itemId", "qty", "stockOutId", "inventoryLotLineId", "status", "pickTime", "type",
),
onDup = "qty=VALUES(qty), inventoryLotLineId=VALUES(inventoryLotLineId), status=VALUES(status), " +
"pickTime=VALUES(pickTime), type=VALUES(type), modified=VALUES(modified), " +
"modifiedBy=VALUES(modifiedBy), version=stock_out_line.version+1, deleted=0",
emptyComment = "-- (no ADJ stock_out_line in range)",
)
}

private fun writeExportAdjInsert(
writer: BufferedWriter,
jdbc: NamedParameterJdbcTemplate,
args: Map<String, Any>,
) {
writer.write("-- === 2.7 ADJ INSERT (fixed id; rows missing on target are created) ===\n")
var batch = 0
jdbc.query(
"""
SELECT id, created, createdBy, version, modified, modifiedBy, deleted,
date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId,
itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter,
uomId, type
FROM stock_ledger
WHERE deleted = 0
AND date >= :from AND date < :toExclusive
AND type = 'ADJ'
AND createdBy = 'stock-ledger-fix'
ORDER BY id
""".trimIndent(),
args,
RowCallbackHandler { rs ->
if (batch == 0) {
writer.write(
"INSERT INTO stock_ledger (\n" +
" id, created, createdBy, version, modified, modifiedBy, deleted,\n" +
" date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId,\n" +
" itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter,\n" +
" uomId, type\n) VALUES\n",
)
} else {
writer.write(",\n")
}
writer.write(
"(${rsSql(rs, "id")}, ${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " +
"${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " +
"${rsSql(rs, "date")}, ${rsSql(rs, "stockInLineId")}, ${rsSql(rs, "stockOutLineId")}, " +
"${rsSql(rs, "inventoryLotLineId")}, ${rsSql(rs, "inventoryId")}, " +
"${rsSql(rs, "itemId")}, ${rsSql(rs, "itemCode")}, ${rsSql(rs, "inQty")}, ${rsSql(rs, "outQty")}, " +
"${rsSql(rs, "balance")}, ${rsSql(rs, "lotQtyBefore")}, ${rsSql(rs, "lotQtyAfter")}, " +
"${rsSql(rs, "uomId")}, ${rsSql(rs, "type")})",
)
batch++
if (batch >= 200) {
writer.write(
"\nON DUPLICATE KEY UPDATE " +
"inventoryLotLineId=VALUES(inventoryLotLineId), inventoryId=VALUES(inventoryId), " +
"stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " +
"inQty=VALUES(inQty), outQty=VALUES(outQty), balance=VALUES(balance), " +
"lotQtyBefore=VALUES(lotQtyBefore), lotQtyAfter=VALUES(lotQtyAfter), " +
"uomId=VALUES(uomId), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=stock_ledger.version+1, deleted=0;\n\n",
)
batch = 0
}
},
)
if (batch > 0) {
writer.write(
"\nON DUPLICATE KEY UPDATE " +
"inventoryLotLineId=VALUES(inventoryLotLineId), inventoryId=VALUES(inventoryId), " +
"stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " +
"inQty=VALUES(inQty), outQty=VALUES(outQty), balance=VALUES(balance), " +
"lotQtyBefore=VALUES(lotQtyBefore), lotQtyAfter=VALUES(lotQtyAfter), " +
"uomId=VALUES(uomId), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=stock_ledger.version+1, deleted=0;\n\n",
)
} else {
writer.write("-- (no ADJ rows from stock-ledger-fix in range)\n\n")
}
}

private fun writeExportStockLotDay(
writer: BufferedWriter,
jdbc: NamedParameterJdbcTemplate,
args: Map<String, Any>,
from: LocalDate,
toExclusive: LocalDate,
) {
writer.write("-- === 2.6 stock_lot_day ===\n")
writer.write(
"""
DELETE FROM stock_lot_day
WHERE `date` >= '$from' AND `date` < '$toExclusive';

""".trimIndent(),
)
writer.write("\n")
var batch = 0
jdbc.query(
"""
SELECT created, createdBy, version, modified, modifiedBy, deleted,
inventoryLotLineId, itemId, itemCode, uomId, lotNo, date,
opening, inQty, outQty, closing
FROM stock_lot_day
WHERE IFNULL(deleted, 0) = 0 AND date >= :from AND date < :toExclusive
ORDER BY date, inventoryLotLineId
""".trimIndent(),
args,
RowCallbackHandler { rs ->
if (batch == 0) {
writer.write(
"INSERT INTO stock_lot_day (\n" +
" created, createdBy, version, modified, modifiedBy, deleted,\n" +
" inventoryLotLineId, itemId, itemCode, uomId, lotNo, `date`,\n" +
" opening, inQty, outQty, closing\n) VALUES\n",
)
} else {
writer.write(",\n")
}
writer.write(
"(${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " +
"${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " +
"${rsSql(rs, "inventoryLotLineId")}, ${rsSql(rs, "itemId")}, ${rsSql(rs, "itemCode")}, " +
"${rsSql(rs, "uomId")}, ${rsSql(rs, "lotNo")}, ${rsSql(rs, "date")}, " +
"${rsSql(rs, "opening")}, ${rsSql(rs, "inQty")}, ${rsSql(rs, "outQty")}, " +
"${rsSql(rs, "closing")})",
)
batch++
if (batch >= 500) {
writer.write(
"\nON DUPLICATE KEY UPDATE " +
"itemId=VALUES(itemId), itemCode=VALUES(itemCode), uomId=VALUES(uomId), " +
"lotNo=VALUES(lotNo), opening=VALUES(opening), inQty=VALUES(inQty), " +
"outQty=VALUES(outQty), closing=VALUES(closing), " +
"modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=stock_lot_day.version+1, deleted=0;\n\n",
)
batch = 0
}
},
)
if (batch > 0) {
writer.write(
"\nON DUPLICATE KEY UPDATE " +
"itemId=VALUES(itemId), itemCode=VALUES(itemCode), uomId=VALUES(uomId), " +
"lotNo=VALUES(lotNo), opening=VALUES(opening), inQty=VALUES(inQty), " +
"outQty=VALUES(outQty), closing=VALUES(closing), " +
"modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=stock_lot_day.version+1, deleted=0;\n",
)
}
}

private fun parseExportParts(raw: List<String>?): Set<String> {
if (raw.isNullOrEmpty() || raw.any { it.trim().equals("all", ignoreCase = true) }) {
return EXPORT_PARTS_DEFAULT
}
val flat = raw.flatMap { it.split(",") }.map { it.trim() }.filter { it.isNotEmpty() }
if (flat.isEmpty() || flat.any { it.equals("all", ignoreCase = true) }) {
return EXPORT_PARTS_DEFAULT
}
val out = linkedSetOf<String>()
flat.forEach { token ->
val t = token.lowercase()
val key = when (t) {
"1.0", "10", "inventory" -> "1.0"
"2.3", "23", "inventoryid" -> "2.3"
"ledger", "2.1", "2.2", "2.4", "2.5" -> "ledger"
"2.6", "26", "day", "stock_lot_day" -> "2.6"
"2.7", "27", "adj" -> "2.7"
else -> throw IllegalArgumentException(
"unknown export part '$token' (use 1.0, 2.3, ledger, 2.6, 2.7, or all)",
)
}
out.add(key)
}
if (out.isEmpty()) throw IllegalArgumentException("parts must not be empty")
return out
}

private fun writeExportRows(
writer: BufferedWriter,
jdbc: NamedParameterJdbcTemplate,
sql: String,
args: Map<String, Any>,
table: String,
columns: List<String>,
onDup: String,
emptyComment: String,
) {
var batch = 0
jdbc.query(
sql,
args,
RowCallbackHandler { rs ->
if (batch == 0) {
writer.write("INSERT INTO $table (\n ${columns.joinToString(", ")}\n) VALUES\n")
} else {
writer.write(",\n")
}
writer.write("(" + columns.joinToString(", ") { rsSql(rs, it) } + ")")
batch++
if (batch >= 200) {
writer.write("\nON DUPLICATE KEY UPDATE $onDup;\n\n")
batch = 0
}
},
)
if (batch > 0) {
writer.write("\nON DUPLICATE KEY UPDATE $onDup;\n\n")
} else {
writer.write("$emptyComment\n\n")
}
}

private fun rsSql(rs: ResultSet, col: String): String {
val v = rs.getObject(col)
if (v == null || rs.wasNull()) return "NULL"
return when (v) {
is BigDecimal -> v.stripTrailingZeros().toPlainString()
is Number -> v.toString()
is java.sql.Date -> "'${v.toLocalDate()}'"
is java.sql.Timestamp -> "'${v.toLocalDateTime().toString().replace('T', ' ')}'"
is Boolean -> if (v) "1" else "0"
else -> "'" + v.toString().replace("\\", "\\\\").replace("'", "''") + "'"
}
}

companion object {
/** Legacy default export: ledger calc fields + stock_lot_day (no inventory / inventoryId / ADJ). */
private val EXPORT_PARTS_DEFAULT = linkedSetOf("ledger", "2.6")
}
}

+ 246
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt 查看文件

@@ -0,0 +1,246 @@
package com.ffii.fpsms.modules.master.service.ledgerfix

internal object StockLedgerFixInventorySql {
internal const val LOT_UOM_PAIR_COUNT_SQL = """
SELECT COUNT(*) AS c FROM (
SELECT il.itemId, iu.uomId
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN item_uom iu
ON iu.id = ill.stockItemUomId
WHERE IFNULL(ill.deleted, 0) = 0
AND il.itemId IS NOT NULL
AND iu.uomId IS NOT NULL
GROUP BY il.itemId, iu.uomId
) x
"""

internal const val MISSING_UOM_PAIR_COUNT_SQL = """
SELECT COUNT(*) AS c FROM (
SELECT il.itemId, iu.uomId
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN item_uom iu
ON iu.id = ill.stockItemUomId
LEFT JOIN inventory inv
ON inv.itemId = il.itemId
AND COALESCE(inv.stockUomId, inv.uomId) = iu.uomId
AND IFNULL(inv.deleted, 0) = 0
WHERE IFNULL(ill.deleted, 0) = 0
AND il.itemId IS NOT NULL
AND iu.uomId IS NOT NULL
AND inv.id IS NULL
GROUP BY il.itemId, iu.uomId
) x
"""

internal const val NULL_STOCK_UOM_COUNT_SQL = """
SELECT COUNT(*) AS c
FROM inventory
WHERE IFNULL(deleted, 0) = 0
AND stockUomId IS NULL
"""

/**
* Old row: uomId already equals a lot stock UOM (stock, or base when base=stock).
* Fill stockUomId from that lot stock; move uomId to item base.
* One keeper per (itemId, uomId); skip if that stockUomId is already taken
* (including soft-deleted rows that still occupy the unique key).
*/
internal const val BACKFILL_STOCK_UOM_WHEN_UOM_MATCHES_LOT_SQL = """
UPDATE inventory i
INNER JOIN (
SELECT DISTINCT il.itemId, iu.uomId AS stockUomId
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN item_uom iu
ON iu.id = ill.stockItemUomId
WHERE IFNULL(ill.deleted, 0) = 0
AND il.itemId IS NOT NULL
AND iu.uomId IS NOT NULL
) b ON b.itemId = i.itemId AND b.stockUomId = i.uomId
INNER JOIN (
SELECT itemId, uomId, MIN(id) AS keepId
FROM inventory
WHERE IFNULL(deleted, 0) = 0
AND stockUomId IS NULL
GROUP BY itemId, uomId
) keep ON keep.keepId = i.id
LEFT JOIN item_uom baseIu
ON baseIu.itemId = i.itemId
AND baseIu.baseUnit = 1
AND IFNULL(baseIu.deleted, 0) = 0
LEFT JOIN inventory taken
ON taken.itemId = i.itemId
AND taken.stockUomId = b.stockUomId
AND taken.id <> i.id
SET
i.stockUomId = b.stockUomId,
i.uomId = COALESCE(baseIu.uomId, i.uomId),
i.modified = NOW(),
i.modifiedBy = 'stock-ledger-fix'
WHERE IFNULL(i.deleted, 0) = 0
AND i.stockUomId IS NULL
AND taken.id IS NULL
"""

/**
* Remaining NULL stockUomId: item has exactly one lot stock UOM
* (uomId already base, stock may differ). Keep MIN(id) only.
*/
internal const val BACKFILL_STOCK_UOM_SINGLE_LOT_SQL = """
UPDATE inventory i
INNER JOIN (
SELECT il.itemId, MIN(iu.uomId) AS stockUomId
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN item_uom iu
ON iu.id = ill.stockItemUomId
WHERE IFNULL(ill.deleted, 0) = 0
AND il.itemId IS NOT NULL
AND iu.uomId IS NOT NULL
GROUP BY il.itemId
HAVING COUNT(DISTINCT iu.uomId) = 1
) b ON b.itemId = i.itemId
INNER JOIN (
SELECT itemId, MIN(id) AS keepId
FROM inventory
WHERE IFNULL(deleted, 0) = 0
AND stockUomId IS NULL
GROUP BY itemId
) keep ON keep.keepId = i.id
LEFT JOIN item_uom baseIu
ON baseIu.itemId = i.itemId
AND baseIu.baseUnit = 1
AND IFNULL(baseIu.deleted, 0) = 0
LEFT JOIN inventory taken
ON taken.itemId = i.itemId
AND taken.stockUomId = b.stockUomId
AND taken.id <> i.id
SET
i.stockUomId = b.stockUomId,
i.uomId = COALESCE(baseIu.uomId, i.uomId),
i.modified = NOW(),
i.modifiedBy = 'stock-ledger-fix'
WHERE IFNULL(i.deleted, 0) = 0
AND i.stockUomId IS NULL
AND taken.id IS NULL
"""

internal const val INSERT_MISSING_INVENTORY_SQL = """
INSERT INTO inventory (
itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty,
price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status,
created, createdBy, modified, modifiedBy, version, deleted
)
SELECT
src.itemId,
COALESCE(baseIu.uomId, src.uomId),
src.uomId,
0, 0, 0,
COALESCE(tpl.price, 0),
tpl.currencyId,
COALESCE(tpl.cpu, 0),
COALESCE(tpl.cpuUnit, 'HKD'),
COALESCE(tpl.cpm, 0),
COALESCE(tpl.cpmUnit, 'HKD'),
'unavailable',
NOW(), 'stock-ledger-fix', NOW(), 'stock-ledger-fix', 0, 0
FROM (
SELECT il.itemId, iu.uomId
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN item_uom iu
ON iu.id = ill.stockItemUomId
WHERE IFNULL(ill.deleted, 0) = 0
AND il.itemId IS NOT NULL
AND iu.uomId IS NOT NULL
GROUP BY il.itemId, iu.uomId
) src
LEFT JOIN item_uom baseIu
ON baseIu.itemId = src.itemId
AND baseIu.baseUnit = 1
AND IFNULL(baseIu.deleted, 0) = 0
LEFT JOIN inventory existing
ON existing.itemId = src.itemId
AND IFNULL(existing.deleted, 0) = 0
AND (
existing.stockUomId = src.uomId
OR (existing.stockUomId IS NULL AND existing.uomId = src.uomId)
)
LEFT JOIN inventory occupied
ON occupied.itemId = src.itemId
AND occupied.stockUomId = src.uomId
LEFT JOIN inventory tpl
ON tpl.id = (
SELECT MIN(i.id)
FROM inventory i
WHERE i.itemId = src.itemId
AND IFNULL(i.deleted, 0) = 0
)
WHERE existing.id IS NULL
AND occupied.id IS NULL
"""

/**
* Extra old UOM rows left NULL after a keeper bucket exists for the item
* (e.g. 18918 id 5261). Soft-delete so search / COALESCE fallbacks ignore them.
*/
internal const val SOFT_DELETE_ORPHAN_NULL_STOCK_UOM_SQL = """
UPDATE inventory i
INNER JOIN (
SELECT DISTINCT itemId
FROM inventory
WHERE IFNULL(deleted, 0) = 0
AND stockUomId IS NOT NULL
) keeper ON keeper.itemId = i.itemId
SET
i.deleted = 1,
i.modified = NOW(),
i.modifiedBy = 'stock-ledger-fix'
WHERE IFNULL(i.deleted, 0) = 0
AND i.stockUomId IS NULL
"""

internal const val UPDATE_INVENTORY_QTY_SQL = """
UPDATE inventory i
LEFT JOIN (
SELECT
il.itemId,
iu.uomId,
SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHand,
SUM(
CASE
WHEN LOWER(ill.status) = 'unavailable'
THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)
ELSE 0
END
) AS unavailable
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN item_uom iu
ON iu.id = ill.stockItemUomId
WHERE IFNULL(ill.deleted, 0) = 0
AND il.itemId IS NOT NULL
AND iu.uomId IS NOT NULL
GROUP BY il.itemId, iu.uomId
) agg ON agg.itemId = i.itemId AND agg.uomId = COALESCE(i.stockUomId, i.uomId)
SET
i.onHandQty = COALESCE(agg.onHand, 0),
i.unavailableQty = COALESCE(agg.unavailable, 0),
i.status = IF(
COALESCE(agg.onHand, 0) - COALESCE(agg.unavailable, 0) > 0,
'available',
'unavailable'
),
i.modified = NOW(),
i.modifiedBy = 'stock-ledger-fix'
WHERE IFNULL(i.deleted, 0) = 0
"""
}

+ 703
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt 查看文件

@@ -0,0 +1,703 @@
package com.ffii.fpsms.modules.master.service.ledgerfix

import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCalendarResponse
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCheckPart
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayDetail
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayStatus
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryPreview
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixScopeDetail
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchInventoryHit
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchLotHit
import org.springframework.stereotype.Service
import java.time.LocalDate

@Service
open class StockLedgerFixQueryService(
private val support: StockLedgerFixSupport,
) {
open fun calendar(from: LocalDate, to: LocalDate): StockLedgerFixCalendarResponse {
if (to.isBefore(from)) {
throw IllegalArgumentException("to must be on or after from")
}
if (from.plusDays(62).isBefore(to)) {
throw IllegalArgumentException("date range must be at most 62 days")
}
val args = mapOf("from" to from, "toNext" to to.plusDays(1))
val rows = support.jdbcDao.queryForList(
"""
SELECT
DATE_FORMAT(sl.date, '%Y-%m-%d') AS d,
COUNT(*) AS cnt,
SUM(sl.inventoryLotLineId IS NULL) AS missLot,
SUM(sl.uomId IS NULL) AS missUom,
SUM(sl.inventoryId IS NULL) AS missInventoryId,
SUM(sl.lotQtyAfter IS NULL) AS missLotQty
FROM stock_ledger sl
WHERE sl.deleted = 0
AND sl.date >= :from
AND sl.date < :toNext
GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d')
ORDER BY d
""".trimIndent(),
args,
)
val dayLots = support.jdbcDao.queryForList(
"""
SELECT DATE_FORMAT(date, '%Y-%m-%d') AS d, COUNT(*) AS lots
FROM stock_lot_day
WHERE deleted = 0
AND date >= :from
AND date < :toNext
GROUP BY DATE_FORMAT(date, '%Y-%m-%d')
""".trimIndent(),
args,
).associate { row ->
row["d"].toString() to support.toInt(row["lots"])
}
val days = rows.map { row ->
val d = row["d"].toString()
StockLedgerFixDayStatus(
date = d,
cnt = support.toInt(row["cnt"]),
missLot = support.toInt(row["missLot"]),
missUom = support.toInt(row["missUom"]),
missInventoryId = support.toInt(row["missInventoryId"]),
missLotQty = support.toInt(row["missLotQty"]),
dayTableLots = dayLots[d] ?: 0,
)
}
return StockLedgerFixCalendarResponse(
from = from.toString(),
to = to.toString(),
days = days,
)
}

/**
* A: keep existing inventory ids;
* backfill stockUomId from lot stock UOM (and set uomId to base) — one row per
* (itemId, stockUomId) so uk_inventory_item_stock_uom is not violated;
* INSERT missing (itemId, stockUomId);
* soft-delete leftover NULL stockUomId rows on items that already have a bucket;
* recalc onHand/unavailable from Σ lot qty by item+stock UOM (0 if no lots).
* Does not write inventory.onHoldQty (hold stays on lot line). Does not UPDATE inventory_lot_line.
*/
open fun inventoryPreview(): StockLedgerFixInventoryPreview {
val inventoryRows = support.toInt(
support.jdbcDao.queryForList(
"SELECT COUNT(*) AS c FROM inventory WHERE IFNULL(deleted, 0) = 0",
).firstOrNull()?.get("c"),
)
val lotUomPairs = support.toInt(
support.jdbcDao.queryForList(StockLedgerFixInventorySql.LOT_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"),
)
val missingUomPairs = support.toInt(
support.jdbcDao.queryForList(StockLedgerFixInventorySql.MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"),
)
val nullStockUomId = support.toInt(
support.jdbcDao.queryForList(StockLedgerFixInventorySql.NULL_STOCK_UOM_COUNT_SQL).firstOrNull()?.get("c"),
)
return StockLedgerFixInventoryPreview(
inventoryRows = inventoryRows,
lotUomPairs = lotUomPairs,
missingUomPairs = missingUomPairs,
nullStockUomId = nullStockUomId,
)
}

open fun dayDetail(day: LocalDate): StockLedgerFixDayDetail {
val args = support.dayArgs(day)
val ledger = support.jdbcDao.queryForList(
"""
SELECT
COUNT(*) AS cnt,
SUM(sl.inventoryLotLineId IS NULL) AS lotMiss,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL
AND sl.inventoryLotLineId <> COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId)
) AS lotIncorrect,
SUM(sl.uomId IS NULL) AS uomMiss,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND sl.uomId IS NOT NULL
AND iu.uomId IS NOT NULL
AND sl.uomId <> iu.uomId
) AS uomIncorrect,
SUM(sl.inventoryId IS NULL) AS invMiss,
SUM(
sl.inventoryId IS NOT NULL
AND sl.itemId IS NOT NULL
AND sl.uomId IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM inventory inv
WHERE inv.id = sl.inventoryId
AND IFNULL(inv.deleted, 0) = 0
AND inv.itemId = sl.itemId
AND COALESCE(inv.stockUomId, inv.uomId) = sl.uomId
)
) AS invIncorrect,
SUM(sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) AS lotQtyMiss,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND (sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL)
) AS lotQtyMissWithLot,
SUM(
sl.lotQtyAfter IS NOT NULL
AND sl.lotQtyBefore IS NOT NULL
AND (
sl.lotQtyAfter - sl.lotQtyBefore
<> CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2))
- CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2))
)
) AS lotQtyIncorrect,
SUM(
sl.lotQtyAfter IS NOT NULL
AND sl.lotQtyBefore IS NOT NULL
AND sl.lotQtyAfter - sl.lotQtyBefore
= CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2))
- CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2))
AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0)
) AS overIssue,
SUM(sl.balance IS NULL) AS balanceMiss,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL
) AS fixable21a,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL
AND sil.inventoryLotId IS NOT NULL
AND IFNULL(lc.lineCnt, 0) = 1
) AS fixable21b,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL
AND sil.inventoryLotId IS NOT NULL
AND IFNULL(lc.lineCnt, 0) > 1
) AS cannotMultiLine,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL
AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0)
) AS cannotNoSource
FROM stock_ledger sl
LEFT JOIN stock_out_line sol ON sol.id = sl.stockOutLineId
LEFT JOIN stock_in_line sil ON sil.id = sl.stockInLineId
LEFT JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId
LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId
LEFT JOIN (
SELECT inventoryLotId, COUNT(*) AS lineCnt
FROM inventory_lot_line
WHERE IFNULL(deleted, 0) = 0
GROUP BY inventoryLotId
) lc ON lc.inventoryLotId = sil.inventoryLotId
WHERE sl.deleted = 0
AND sl.date >= :day AND sl.date < :dayNext
""".trimIndent(),
args,
).firstOrNull() ?: emptyMap()

val cnt = support.toInt(ledger["cnt"])
val lotMiss = support.toInt(ledger["lotMiss"])
val lotIncorrect = support.toInt(ledger["lotIncorrect"])
val uomMiss = support.toInt(ledger["uomMiss"])
val uomIncorrect = support.toInt(ledger["uomIncorrect"])
val invMiss = support.toInt(ledger["invMiss"])
val invIncorrect = support.toInt(ledger["invIncorrect"])
val lotQtyMissWithLot = support.toInt(ledger["lotQtyMissWithLot"])
val lotQtyIncorrect = support.toInt(ledger["lotQtyIncorrect"])
val overIssue = support.toInt(ledger["overIssue"])
val balanceMiss = support.toInt(ledger["balanceMiss"])

val dayTable = support.jdbcDao.queryForList(
"""
SELECT
COUNT(*) AS expected,
SUM(d.id IS NULL) AS miss,
SUM(
d.id IS NOT NULL AND (
(x.kind = 'move' AND (
d.closing <> lastSl.lotQtyAfter
OR d.opening <> firstSl.lotQtyBefore
OR d.inQty <> x.inQty
OR d.outQty <> x.outQty
))
OR (x.kind = 'carry' AND (
d.inQty <> 0
OR d.outQty <> 0
OR d.opening <> x.prevClosing
OR d.closing <> x.prevClosing
))
)
) AS incorrect
FROM (
SELECT
sl.inventoryLotLineId,
'move' AS kind,
MIN(sl.id) AS firstId,
MAX(sl.id) AS lastId,
CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS inQty,
CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS outQty,
CAST(NULL AS DECIMAL(14,2)) AS prevClosing
FROM stock_ledger sl
WHERE sl.deleted = 0
AND sl.date >= :day AND sl.date < :dayNext
AND sl.inventoryLotLineId IS NOT NULL
GROUP BY sl.inventoryLotLineId

UNION ALL

SELECT
prev.inventoryLotLineId,
'carry' AS kind,
NULL AS firstId,
NULL AS lastId,
CAST(0 AS DECIMAL(14,2)) AS inQty,
CAST(0 AS DECIMAL(14,2)) AS outQty,
prev.closing AS prevClosing
FROM stock_lot_day prev
WHERE prev.date = :prevDay
AND IFNULL(prev.deleted, 0) = 0
AND prev.closing > 0
AND NOT EXISTS (
SELECT 1 FROM stock_ledger sl
WHERE sl.deleted = 0
AND sl.date >= :day AND sl.date < :dayNext
AND sl.inventoryLotLineId = prev.inventoryLotLineId
)
) x
LEFT JOIN stock_ledger firstSl ON firstSl.id = x.firstId
LEFT JOIN stock_ledger lastSl ON lastSl.id = x.lastId
LEFT JOIN stock_lot_day d
ON d.inventoryLotLineId = x.inventoryLotLineId
AND d.date = :day
AND IFNULL(d.deleted, 0) = 0
""".trimIndent(),
args,
).firstOrNull() ?: emptyMap()

val dayLots = support.toInt(dayTable["expected"])
val dayMiss = support.toInt(dayTable["miss"])
val dayIncorrect = support.toInt(dayTable["incorrect"])
// No lot ids yet → SQL finds 0 lots, 0/0/0 would look "correct". Ledger exists but day table cannot be built.
val dayTablePending = cnt > 0 && dayLots == 0
val dayTableMiss = if (dayTablePending) 1 else dayMiss
val dayTableTotal = if (dayTablePending) 1 else dayLots

fun part(key: String, label: String, miss: Int, incorrect: Int, total: Int) =
StockLedgerFixCheckPart(
key = key,
label = label,
ok = (total - miss - incorrect).coerceAtLeast(0),
miss = miss,
incorrect = incorrect,
)

return StockLedgerFixDayDetail(
date = day.toString(),
cnt = cnt,
parts = fieldAndReasonParts(
cnt = cnt,
lotMiss = lotMiss,
lotIncorrect = lotIncorrect,
uomMiss = uomMiss,
uomIncorrect = uomIncorrect,
invMiss = invMiss,
invIncorrect = invIncorrect,
lotQtyMissWithLot = lotQtyMissWithLot,
lotQtyIncorrect = lotQtyIncorrect,
overIssue = overIssue,
balanceMiss = balanceMiss,
fixable21a = support.toInt(ledger["fixable21a"]),
fixable21b = support.toInt(ledger["fixable21b"]),
cannotMultiLine = support.toInt(ledger["cannotMultiLine"]),
cannotNoSource = support.toInt(ledger["cannotNoSource"]),
earlyTkeOver = 0,
otherOver = 0,
extra = listOf(
part("dayTable", "stock_lot_day", dayTableMiss, dayIncorrect, dayTableTotal),
),
),
)
}

open fun searchInventory(qRaw: String): List<StockLedgerFixSearchInventoryHit> {
val q = qRaw.trim()
if (q.isEmpty()) throw IllegalArgumentException("q is required")
val args = mapOf("q" to q, "like" to "%$q%")
return support.jdbcDao.queryForList(
"""
SELECT
i.id AS inventoryId,
i.itemId AS itemId,
it.code AS itemCode,
i.uomId AS uomId,
(
SELECT COUNT(*) FROM stock_ledger sl
WHERE sl.deleted = 0 AND sl.inventoryId = i.id
) AS ledgerCnt
FROM inventory i
LEFT JOIN items it ON it.id = i.itemId
WHERE IFNULL(i.deleted, 0) = 0
AND (
CAST(i.id AS CHAR) = :q
OR CAST(i.itemId AS CHAR) = :q
OR it.code LIKE :like
)
ORDER BY ledgerCnt DESC, i.id
LIMIT 20
""".trimIndent(),
args,
).map { row ->
StockLedgerFixSearchInventoryHit(
inventoryId = support.toLong(row["inventoryId"]),
itemId = support.toLongOrNull(row["itemId"]),
itemCode = row["itemCode"]?.toString(),
uomId = support.toLongOrNull(row["uomId"]),
ledgerCnt = support.toInt(row["ledgerCnt"]),
)
}
}

open fun searchLot(qRaw: String): List<StockLedgerFixSearchLotHit> {
val q = qRaw.trim()
if (q.isEmpty()) throw IllegalArgumentException("q is required")
val args = mapOf("q" to q, "like" to "%$q%")
return support.jdbcDao.queryForList(
"""
SELECT
ill.id AS inventoryLotLineId,
il.lotNo AS lotNo,
it.code AS itemCode,
(
SELECT sl.inventoryId FROM stock_ledger sl
WHERE sl.deleted = 0 AND sl.inventoryLotLineId = ill.id
AND sl.inventoryId IS NOT NULL
ORDER BY sl.id DESC LIMIT 1
) AS inventoryId,
(
SELECT COUNT(*) FROM stock_ledger sl
WHERE sl.deleted = 0 AND sl.inventoryLotLineId = ill.id
) AS ledgerCnt
FROM inventory_lot_line ill
INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN items it ON it.id = il.itemId
WHERE IFNULL(ill.deleted, 0) = 0
AND (
CAST(ill.id AS CHAR) = :q
OR il.lotNo LIKE :like
)
ORDER BY ledgerCnt DESC, ill.id
LIMIT 20
""".trimIndent(),
args,
).map { row ->
StockLedgerFixSearchLotHit(
inventoryLotLineId = support.toLong(row["inventoryLotLineId"]),
lotNo = row["lotNo"]?.toString(),
itemCode = row["itemCode"]?.toString(),
inventoryId = support.toLongOrNull(row["inventoryId"]),
ledgerCnt = support.toInt(row["ledgerCnt"]),
)
}
}

open fun inventoryScopeDetail(inventoryId: Long): StockLedgerFixScopeDetail {
val args = mapOf("inventoryId" to inventoryId, "today" to support.inventoryScopeToExclusive())
val head = support.jdbcDao.queryForList(
"""
SELECT
i.id AS inventoryId,
i.itemId AS itemId,
it.code AS itemCode,
i.uomId AS uomId
FROM inventory i
LEFT JOIN items it ON it.id = i.itemId
WHERE i.id = :inventoryId
""".trimIndent(),
args,
).firstOrNull() ?: throw IllegalArgumentException("inventory not found: $inventoryId")
val stats = ledgerScopeStats(
"sl.inventoryId = :inventoryId",
args,
itemId = support.toLongOrNull(head["itemId"]),
)
return StockLedgerFixScopeDetail(
kind = "inventory",
id = inventoryId,
itemCode = head["itemCode"]?.toString(),
lotNo = null,
inventoryId = inventoryId,
uomId = support.toLongOrNull(head["uomId"]),
firstDate = stats.firstDate,
lastDate = stats.lastDate,
cnt = stats.cnt,
lastBalance = stats.lastBalance,
lastLotQtyAfter = stats.lastLotQtyAfter,
parts = stats.parts,
)
}

open fun lotScopeDetail(lotLineId: Long): StockLedgerFixScopeDetail {
val today = LocalDate.now()
val args = mapOf("lotId" to lotLineId, "today" to today)
val head = support.jdbcDao.queryForList(
"""
SELECT
ill.id AS inventoryLotLineId,
il.lotNo AS lotNo,
il.itemId AS itemId,
it.code AS itemCode
FROM inventory_lot_line ill
INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN items it ON it.id = il.itemId
WHERE ill.id = :lotId
""".trimIndent(),
args,
).firstOrNull() ?: throw IllegalArgumentException("lot line not found: $lotLineId")
val stats = ledgerScopeStats(
"sl.inventoryLotLineId = :lotId",
args,
itemId = support.toLongOrNull(head["itemId"]),
)
return StockLedgerFixScopeDetail(
kind = "lot",
id = lotLineId,
itemCode = head["itemCode"]?.toString(),
lotNo = head["lotNo"]?.toString(),
inventoryId = support.toLongOrNull(stats.lastInventoryId),
uomId = null,
firstDate = stats.firstDate,
lastDate = stats.lastDate,
cnt = stats.cnt,
lastBalance = stats.lastBalance,
lastLotQtyAfter = stats.lastLotQtyAfter,
parts = stats.parts,
)
}

private data class ScopeStats(
val cnt: Int,
val firstDate: String?,
val lastDate: String?,
val lastBalance: String?,
val lastLotQtyAfter: String?,
val lastInventoryId: Any?,
val parts: List<StockLedgerFixCheckPart>,
)

private fun ledgerScopeStats(
filter: String,
args: Map<String, Any>,
itemId: Long? = null,
): ScopeStats {
val ledger = support.jdbcDao.queryForList(
"""
SELECT
COUNT(*) AS cnt,
DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate,
DATE_FORMAT(MAX(sl.date), '%Y-%m-%d') AS lastDate,
SUM(sl.inventoryLotLineId IS NULL) AS lotMiss,
SUM(sl.uomId IS NULL) AS uomMiss,
SUM(sl.inventoryId IS NULL) AS invMiss,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND (sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL)
) AS lotQtyMissWithLot,
SUM(
sl.lotQtyAfter IS NOT NULL
AND sl.lotQtyBefore IS NOT NULL
AND sl.lotQtyAfter - sl.lotQtyBefore
<> CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2))
- CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2))
) AS lotQtyIncorrect,
SUM(
sl.lotQtyAfter IS NOT NULL
AND sl.lotQtyBefore IS NOT NULL
AND sl.lotQtyAfter - sl.lotQtyBefore
= CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2))
- CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2))
AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0)
) AS overIssue,
SUM(sl.balance IS NULL) AS balanceMiss,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL
) AS fixable21a,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL
AND sil.inventoryLotId IS NOT NULL
AND IFNULL(lc.lineCnt, 0) = 1
) AS fixable21b,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL
AND sil.inventoryLotId IS NOT NULL
AND IFNULL(lc.lineCnt, 0) > 1
) AS cannotMultiLine,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL
AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0)
) AS cannotNoSource
FROM stock_ledger sl
LEFT JOIN stock_out_line sol
ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0
LEFT JOIN stock_in_line sil
ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0
LEFT JOIN (
SELECT inventoryLotId, COUNT(*) AS lineCnt
FROM inventory_lot_line
WHERE IFNULL(deleted, 0) = 0
GROUP BY inventoryLotId
) lc ON lc.inventoryLotId = sil.inventoryLotId
WHERE sl.deleted = 0
AND sl.date < :today
AND $filter
""".trimIndent(),
args,
).firstOrNull() ?: emptyMap()
val last = support.jdbcDao.queryForList(
"""
SELECT sl.balance, sl.lotQtyAfter, sl.inventoryId
FROM stock_ledger sl
WHERE sl.deleted = 0
AND sl.date < :today
AND $filter
ORDER BY sl.id DESC
LIMIT 1
""".trimIndent(),
args,
).firstOrNull() ?: emptyMap()
val cnt = support.toInt(ledger["cnt"])
val overIssue = support.toInt(ledger["overIssue"])
val earlyTke = if (overIssue > 0) overIssueEarlyTkeRows(filter, args, itemId) else 0
return ScopeStats(
cnt = cnt,
firstDate = ledger["firstDate"]?.toString(),
lastDate = ledger["lastDate"]?.toString(),
lastBalance = last["balance"]?.toString(),
lastLotQtyAfter = last["lotQtyAfter"]?.toString(),
lastInventoryId = last["inventoryId"],
parts = fieldAndReasonParts(
cnt = cnt,
lotMiss = support.toInt(ledger["lotMiss"]),
lotIncorrect = 0,
uomMiss = support.toInt(ledger["uomMiss"]),
uomIncorrect = 0,
invMiss = support.toInt(ledger["invMiss"]),
invIncorrect = 0,
lotQtyMissWithLot = support.toInt(ledger["lotQtyMissWithLot"]),
lotQtyIncorrect = support.toInt(ledger["lotQtyIncorrect"]),
overIssue = overIssue,
balanceMiss = support.toInt(ledger["balanceMiss"]),
fixable21a = support.toInt(ledger["fixable21a"]),
fixable21b = support.toInt(ledger["fixable21b"]),
cannotMultiLine = support.toInt(ledger["cannotMultiLine"]),
cannotNoSource = support.toInt(ledger["cannotNoSource"]),
earlyTkeOver = earlyTke,
otherOver = (overIssue - earlyTke).coerceAtLeast(0),
),
)
}

private fun fieldAndReasonParts(
cnt: Int,
lotMiss: Int,
lotIncorrect: Int,
uomMiss: Int,
uomIncorrect: Int,
invMiss: Int,
invIncorrect: Int,
lotQtyMissWithLot: Int,
lotQtyIncorrect: Int,
overIssue: Int,
balanceMiss: Int,
fixable21a: Int,
fixable21b: Int,
cannotMultiLine: Int,
cannotNoSource: Int,
earlyTkeOver: Int,
otherOver: Int,
extra: List<StockLedgerFixCheckPart> = emptyList(),
): List<StockLedgerFixCheckPart> {
fun field(key: String, label: String, miss: Int, incorrect: Int) =
StockLedgerFixCheckPart(
key = key,
label = label,
ok = (cnt - miss - incorrect).coerceAtLeast(0),
miss = miss,
incorrect = incorrect,
group = "field",
)
fun canFix(key: String, label: String, n: Int) =
StockLedgerFixCheckPart(key, label, ok = 0, miss = n, incorrect = 0, group = "canFix")
fun cannotFix(key: String, label: String, n: Int) =
StockLedgerFixCheckPart(key, label, ok = 0, miss = 0, incorrect = n, group = "cannotFix")
return buildList {
add(field("lot", "inventoryLotLineId", lotMiss, lotIncorrect))
add(field("uom", "uomId", uomMiss, uomIncorrect))
add(field("inventory", "inventoryId", invMiss, invIncorrect))
add(field("lotQty", "lotQtyBefore/After(lot 已有)", lotQtyMissWithLot, lotQtyIncorrect))
add(field("overIssue", "over-issue (lotQty < 0)", 0, overIssue))
add(field("balance", "balance", balanceMiss, 0))
addAll(extra)
add(canFix("fixable21a", "可修 2.1a:SIL/SOL 已有 lot line", fixable21a))
add(canFix("fixable21b", "可修 2.1b:inventoryLotId 僅一條 line", fixable21b))
add(canFix("fixableLotQty", "可修:lot 已有但缺 lotQty", lotQtyMissWithLot))
add(cannotFix("cannotNoSource", "不能修:沒有 SIL/SOL lot 來源", cannotNoSource))
add(cannotFix("cannotMultiLine", "不能修:同一 lot 有多條 warehouse line", cannotMultiLine))
add(cannotFix("earlyTke", "不能修:舊 TKE 開新批(sibling 盤盈)", earlyTkeOver))
add(cannotFix("realOver", "不能修:over-issue 非舊 TKE(真超發/帳不一致)", otherOver))
}
}

private fun overIssueEarlyTkeRows(filter: String, args: Map<String, Any>, itemId: Long?): Int {
if (itemId == null || itemId <= 0) return 0
val q = args.toMutableMap()
q["itemId"] = itemId
return support.toInt(
support.jdbcDao.queryForList(
"""
SELECT SUM(ok) AS c FROM (
SELECT EXISTS (
SELECT 1
FROM inventory_lot_line ill2
INNER JOIN inventory_lot il2
ON il2.id = ill2.inventoryLotId AND il2.itemId = :itemId
INNER JOIN (
SELECT sl3.inventoryLotLineId
FROM stock_ledger sl3
INNER JOIN inventory_lot_line ill3 ON ill3.id = sl3.inventoryLotLineId
INNER JOIN inventory_lot il3 ON il3.id = ill3.inventoryLotId AND il3.itemId = :itemId
WHERE sl3.deleted = 0
AND sl3.date < :today
GROUP BY sl3.inventoryLotLineId
HAVING SUM(COALESCE(sl3.inQty, 0)) > 0
AND SUM(CASE WHEN sl3.type = 'TKE' AND sl3.inQty > 0 THEN sl3.inQty ELSE 0 END)
>= SUM(COALESCE(sl3.inQty, 0)) * 0.99
) tke ON tke.inventoryLotLineId = ill2.id
WHERE IFNULL(ill2.deleted, 0) = 0
AND ill2.warehouseId = ill.warehouseId
AND ill2.id <> sl.inventoryLotLineId
) AS ok
FROM stock_ledger sl
INNER JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId
WHERE sl.deleted = 0
AND sl.date < :today
AND $filter
AND sl.lotQtyAfter IS NOT NULL
AND sl.lotQtyBefore IS NOT NULL
AND sl.lotQtyAfter - sl.lotQtyBefore
= CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2))
- CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2))
AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0)
) x
""".trimIndent(),
q,
).firstOrNull()?.get("c"),
)
}
}

+ 137
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt 查看文件

@@ -0,0 +1,137 @@
package com.ffii.fpsms.modules.master.service.ledgerfix

import com.ffii.core.support.JdbcDao
import org.slf4j.LoggerFactory
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Component
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.support.TransactionTemplate
import java.math.BigDecimal
import java.time.LocalDate
import java.time.format.DateTimeParseException
import java.util.concurrent.ConcurrentHashMap
import javax.sql.DataSource

/** Shared JDBC, conversions, and the single in-flight lock for stock-ledger-fix. */
@Component
open class StockLedgerFixSupport(
val jdbcDao: JdbcDao,
val transactionManager: PlatformTransactionManager,
val dataSource: DataSource,
) {
private val log = LoggerFactory.getLogger(StockLedgerFixSupport::class.java)
val namedJdbc = NamedParameterJdbcTemplate(dataSource)
private val inFlight = ConcurrentHashMap.newKeySet<String>()

fun tryLock(key: String): Boolean = inFlight.add(key)

fun unlock(key: String) {
inFlight.remove(key)
}

/** Exclusive bound for inventory-scope inspect/fix: include calendar today. */
fun inventoryScopeToExclusive(): LocalDate = LocalDate.now().plusDays(1)

fun dayArgs(day: LocalDate): Map<String, Any> =
mapOf(
"day" to day,
"dayNext" to day.plusDays(1),
"prevDay" to day.minusDays(1),
)

/** Inclusive [from]..[to]; same keys as [dayArgs] (used by stillMiss over the whole range). */
fun rangeArgs(from: LocalDate, to: LocalDate): Map<String, Any> =
mapOf(
"day" to from,
"dayNext" to to.plusDays(1),
"prevDay" to from.minusDays(1),
)

/** Same JDBC connection so TEMPORARY TABLEs survive across statements. */
fun withTempTx(block: () -> Int): Int {
return TransactionTemplate(transactionManager).execute { block() } ?: 0
}

fun timed(name: String, block: () -> Int): Int {
val t0 = System.nanoTime()
return try {
val n = block()
log.info("stock-ledger-fix step {} rows={} {}ms", name, n, (System.nanoTime() - t0) / 1_000_000)
n
} catch (e: Exception) {
log.warn("stock-ledger-fix step {} FAILED {}ms: {}", name, (System.nanoTime() - t0) / 1_000_000, e.message)
throw e
}
}

fun parseDay(raw: String): LocalDate {
val trimmed = raw.trim()
if (trimmed.isEmpty()) throw IllegalArgumentException("date is required (yyyy-MM-dd)")
return try {
LocalDate.parse(trimmed)
} catch (_: DateTimeParseException) {
throw IllegalArgumentException("date must be yyyy-MM-dd")
}
}


fun parseDaySteps(raw: List<String>?): Set<String> {
if (raw.isNullOrEmpty() || raw.any { it.trim().equals("all", ignoreCase = true) }) {
return DAY_STEPS
}
val out = linkedSetOf<String>()
raw.forEach { token ->
val t = token.trim().lowercase()
val key = when (t) {
"2.1", "21" -> "2.1"
"2.2", "22" -> "2.2"
"2.3", "23" -> "2.3"
"2.4", "24" -> "2.4"
"2.5", "25" -> "2.5"
"2.6", "26" -> "2.6"
else -> throw IllegalArgumentException("unknown step '$token' (use 2.1–2.6 or all)")
}
out.add(key)
}
if (out.isEmpty()) throw IllegalArgumentException("steps must not be empty")
return out
}

fun toInt(value: Any?): Int = when (value) {
null -> 0
is Number -> value.toInt()
else -> value.toString().toIntOrNull() ?: 0
}

fun toLong(value: Any?): Long = toLongOrNull(value) ?: 0L

fun toLongOrNull(value: Any?): Long? = when (value) {
null -> null
is Number -> value.toLong()
else -> value.toString().toLongOrNull()
}

fun toDecimal(value: Any?): BigDecimal = when (value) {
null -> BigDecimal.ZERO
is BigDecimal -> value
is Number -> BigDecimal(value.toString())
else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO
}

fun toLocalDate(value: Any?): LocalDate? = when (value) {
null -> null
is LocalDate -> value
is java.sql.Date -> value.toLocalDate()
is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate()
else -> try {
LocalDate.parse(value.toString().take(10))
} catch (_: Exception) {
null
}
}
companion object {
const val INVENTORY_LOCK = "inventory-1.0"
const val RUN_LOCK = "ledger-fix-run"
private val DAY_STEPS = linkedSetOf("2.1", "2.2", "2.3", "2.4", "2.5", "2.6")
}
}

Loading…
取消
儲存