| @@ -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" | |||
| } | |||
| } | |||
| @@ -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") | |||
| } | |||
| } | |||
| @@ -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 | |||
| """ | |||
| } | |||
| @@ -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"), | |||
| ) | |||
| } | |||
| } | |||
| @@ -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") | |||
| } | |||
| } | |||