CANCERYS\kw093 1 день назад
Родитель
Сommit
79fb2548b8
6 измененных файлов: 338 добавлений и 57 удалений
  1. +257
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt
  2. +58
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt
  3. +1
    -1
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTake.kt
  4. +6
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRepository.kt
  5. +3
    -49
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt
  6. +13
    -7
      src/main/resources/jasper/StockTakeVarianceReport.jrxml

+ 257
- 0
src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt Просмотреть файл

@@ -296,6 +296,263 @@ ORDER BY
return result
}

/**
* V2:依盤點輪次 + 可選物料編號;僅輸出該輪已有 `stocktakerecord` 且 `status='completed'` 的列(未建立盤點紀錄的批號不列出)。
* 期初/累計區間取該輪紀錄之 MIN(`date`)~MAX(`date`)(與 V1 之 in/out 聚合邏輯一致,但以 rb 帶入)。
* 「審核時間」欄位:`approverTime` 之本地日期時間字串;無則退回盤點日 `date`。
*/
fun searchStockTakeVarianceReportV2(
stockTakeRoundId: Long,
itemCode: String?,
): List<Map<String, Any>> {
val countSql = """
SELECT COUNT(*) AS c FROM stocktakerecord s
WHERE s.deleted = 0
AND s.stockTakeRoundId = :stockTakeRoundId
AND s.status = 'completed'
""".trimIndent()
val cntRow = jdbcDao.queryForList(
countSql,
mapOf("stockTakeRoundId" to stockTakeRoundId)
).firstOrNull()
val cnt = (cntRow?.get("c") as? Number)?.toLong() ?: 0L
if (cnt == 0L) return emptyList()

val args = mutableMapOf<String, Any>()
args["stockTakeRoundId"] = stockTakeRoundId
val itemCodeSql = buildMultiValueLikeClause(
itemCode,
"it.code",
"itemCode",
args
)

val sql = """
WITH rb AS (
SELECT
COALESCE(MIN(s.date), CURRENT_DATE) AS fromDate,
COALESCE(MAX(s.date), CURRENT_DATE) AS toDate
FROM stocktakerecord s
WHERE s.deleted = 0
AND s.stockTakeRoundId = :stockTakeRoundId
AND s.status = 'completed'
),
latest_str AS (
SELECT
str.lotId,
str.warehouseId,
str.bookQty,
str.varianceQty,
str.approverStockTakeQty,
str.date AS strDate,
str.id,
str.approverTime
FROM stocktakerecord str
WHERE str.deleted = 0
AND str.stockTakeRoundId = :stockTakeRoundId
AND str.status = 'completed'
),
in_agg AS (
SELECT
ill.id AS inventoryLotLineId,
SUM(CASE WHEN DATE(sil.receiptDate) < rb.fromDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0) * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0))
ELSE COALESCE(sil.acceptedQty, 0)
END
ELSE 0 END) AS inBefore,
SUM(CASE WHEN DATE(sil.receiptDate) BETWEEN rb.fromDate AND rb.toDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0) * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0))
ELSE COALESCE(sil.acceptedQty, 0)
END
ELSE 0 END) AS inDuring,
MAX(CASE WHEN sil.receiptDate IS NOT NULL THEN DATE(sil.receiptDate) END) AS lastInDate
FROM inventory_lot_line ill
CROSS JOIN rb
INNER JOIN inventory_lot il
ON ill.inventoryLotId = il.id
AND il.deleted = 0
INNER JOIN items it
ON il.itemId = it.id
AND it.deleted = 0
LEFT JOIN stock_in_line sil
ON sil.inventoryLotLineId = ill.id
AND sil.deleted = 0
AND sil.status = 'completed'
LEFT JOIN item_uom iu_purchase
ON it.id = iu_purchase.itemId
AND iu_purchase.purchaseUnit = 1
AND iu_purchase.deleted = 0
LEFT JOIN item_uom iu_stock
ON it.id = iu_stock.itemId
AND iu_stock.stockUnit = 1
AND iu_stock.deleted = 0
WHERE ill.deleted = 0
GROUP BY ill.id
),
out_agg AS (
SELECT
ill.id AS inventoryLotLineId,
SUM(CASE WHEN DATE(sol.endTime) < rb.fromDate THEN COALESCE(sol.qty, 0) ELSE 0 END) AS outBefore,
SUM(CASE WHEN DATE(sol.endTime) BETWEEN rb.fromDate AND rb.toDate THEN COALESCE(sol.qty, 0) ELSE 0 END) AS outDuring,
MAX(CASE WHEN sol.endTime IS NOT NULL THEN DATE(sol.endTime) END) AS lastOutDate
FROM inventory_lot_line ill
CROSS JOIN rb
LEFT JOIN stock_out_line sol
ON sol.inventoryLotLineId = ill.id
AND sol.deleted = 0
AND sol.status = 'completed'
WHERE ill.deleted = 0
GROUP BY ill.id
),
in_out AS (
SELECT
i.inventoryLotLineId,
COALESCE(i.inBefore, 0) AS inBefore,
COALESCE(o.outBefore, 0) AS outBefore,
COALESCE(i.inDuring, 0) AS inDuring,
COALESCE(o.outDuring, 0) AS outDuring,
i.lastInDate,
o.lastOutDate
FROM in_agg i
LEFT JOIN out_agg o ON o.inventoryLotLineId = i.inventoryLotLineId
),
data AS (
SELECT
it.type AS stockSubCategory,
it.code AS itemNo,
it.name AS itemName,
uc.udfudesc AS unitOfMeasure,

il.lotNo AS lotNo,
COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
wh.code AS storeLocation,

(COALESCE(io.inBefore, 0) - COALESCE(io.outBefore, 0)) AS openingQty,
COALESCE(io.inDuring, 0) AS inQty,
COALESCE(io.outDuring, 0) AS outQty,
((COALESCE(io.inBefore, 0) - COALESCE(io.outBefore, 0)) + COALESCE(io.inDuring, 0) - COALESCE(io.outDuring, 0)) AS currentQty,

io.lastInDate AS lastInDateRaw,
io.lastOutDate AS lastOutDateRaw,

ls.bookQty AS stkBookQty,
ls.approverStockTakeQty AS stkApproverQty,
ls.varianceQty AS stkVarianceQty,
ls.strDate AS stockTakeDateRaw,
ls.approverTime AS approvalDateTimeRaw
FROM latest_str ls
INNER JOIN inventory_lot il
ON ls.lotId = il.id
AND il.deleted = 0
INNER JOIN inventory_lot_line ill
ON ill.inventoryLotId = il.id
AND ill.warehouseId = ls.warehouseId
AND ill.deleted = 0
INNER JOIN items it
ON il.itemId = it.id
AND it.deleted = 0
INNER JOIN warehouse wh
ON wh.id = ls.warehouseId
AND wh.deleted = 0

LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = 1
AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id

LEFT JOIN in_out io
ON io.inventoryLotLineId = ill.id

WHERE 1=1
$itemCodeSql
)

SELECT
stockSubCategory,
itemNo,
itemName,
unitOfMeasure,
lotNo,
expiryDate,
storeLocation,

CASE WHEN COALESCE(openingQty, 0) < 0 THEN CONCAT('(', FORMAT(-openingQty, 0), ')') ELSE FORMAT(COALESCE(openingQty, 0), 0) END AS openingBalance,
CASE WHEN COALESCE(inQty, 0) < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(COALESCE(inQty, 0), 0) END AS cumStockIn,
CASE WHEN COALESCE(outQty, 0) < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(COALESCE(outQty, 0), 0) END AS cumStockOut,
CASE WHEN COALESCE(stkBookQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkBookQty, 0), ')') ELSE FORMAT(COALESCE(stkBookQty, 0), 0) END AS currentBookBalance,

COALESCE(DATE_FORMAT(lastInDateRaw, '%Y-%m-%d'), '') AS lastInDate,
COALESCE(DATE_FORMAT(lastOutDateRaw, '%Y-%m-%d'), '') AS lastOutDate,
COALESCE(
DATE_FORMAT(approvalDateTimeRaw, '%Y-%m-%d %H:%i:%s'),
COALESCE(DATE_FORMAT(stockTakeDateRaw, '%Y-%m-%d'), '')
) AS stockTakeDate,

CASE
WHEN stkApproverQty IS NULL THEN '0'
WHEN COALESCE(stkApproverQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkApproverQty, 0), ')')
ELSE FORMAT(COALESCE(stkApproverQty, 0), 0)
END AS stockTakeQty,

CASE
WHEN stkVarianceQty IS NULL THEN '0'
WHEN COALESCE(stkVarianceQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkVarianceQty, 0), ')')
ELSE FORMAT(COALESCE(stkVarianceQty, 0), 0)
END AS variance,

CASE
WHEN stkVarianceQty IS NULL THEN '0%'
WHEN COALESCE(stkBookQty, 0) = 0 THEN '0%'
WHEN (COALESCE(stkVarianceQty, 0) / stkBookQty) * 100 < 0 THEN CONCAT('(', FORMAT(-(COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%)')
ELSE CONCAT(FORMAT((COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%')
END AS variancePercentage,

CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalOpeningBalance,
CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockIn,
CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockOut,
CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCurrentBalance

FROM data
ORDER BY
itemNo,
lotNo,
storeLocation
""".trimIndent()

return jdbcDao.queryForList(sql, args)
}

/** 報表表頭:盤點輪次說明(與 /report/stock-take-rounds 選項格式一致) */
fun getStockTakeRoundCaption(stockTakeRoundId: Long): String {
val sql = """
SELECT CONCAT(
'Round ',
CAST(st.stockTakeRoundId AS CHAR),
' (',
DATE_FORMAT(MIN(st.planStart), '%Y-%m-%d'),
')'
) AS cap
FROM stock_take st
WHERE st.deleted = 0
AND st.stockTakeRoundId = :stockTakeRoundId
GROUP BY st.stockTakeRoundId
""".trimIndent()
val row = jdbcDao.queryForList(
sql,
mapOf("stockTakeRoundId" to stockTakeRoundId)
).firstOrNull()
val cap = row?.get("cap") as? String
return if (!cap.isNullOrBlank()) cap else "Round $stockTakeRoundId"
}

/** LIKE 多值工具方法 */
private fun buildMultiValueLikeClause(
paramValue: String?,


+ 58
- 0
src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt Просмотреть файл

@@ -73,6 +73,64 @@ class StockTakeVarianceReportController(

parameters["stockTakeDate"] = stockTakeDateDisplay

parameters["stockTakeFilterCaption"] = ""

val pdfBytes = reportService.createPdfResponse(
"/jasper/StockTakeVarianceReport.jrxml",
parameters,
dbData
)

val headers = HttpHeaders().apply {
contentType = MediaType.APPLICATION_PDF
setContentDispositionFormData("attachment", "StockTakeVarianceReport.pdf")
set("filename", "StockTakeVarianceReport.pdf")
}
return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}

/**
* Stock Take Variance 報表 V2:依盤點輪次 + 可選物料編號;僅含已有已完成盤點紀錄之列;審核時間為 approver 之日期時間。
*/
@GetMapping("/print-stock-take-variance-v2")
fun generateStockTakeVarianceReportV2(
@RequestParam stockTakeRoundId: Long,
@RequestParam(required = false) itemCode: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()

parameters["stockCategory"] = "All"
parameters["stockSubCategory"] = "All"
parameters["itemNo"] = itemCode ?: "All"
parameters["year"] = LocalDate.now().year.toString()
parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))

parameters["storeLocation"] = ""
parameters["balanceFilterStart"] = ""
parameters["balanceFilterEnd"] = ""

parameters["stockTakeDateStart"] = ""
parameters["stockTakeDateEnd"] = ""
parameters["lastInDateEnd"] = ""
parameters["lastOutDateEnd"] = ""

parameters["stockTakeFilterCaption"] =
stockTakeVarianceReportService.getStockTakeRoundCaption(stockTakeRoundId)
parameters["stockTakeConditionLabel"] = "盤點輪次:"

val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2(
stockTakeRoundId = stockTakeRoundId,
itemCode = itemCode,
)
val stockTakeDateDisplay = dbData
.mapNotNull { it["stockTakeDate"] as? String }
.filter { it.isNotBlank() }
.maxOrNull()
?: ""

parameters["stockTakeDate"] = stockTakeDateDisplay

val pdfBytes = reportService.createPdfResponse(
"/jasper/StockTakeVarianceReport.jrxml",
parameters,


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/stock/entity/StockTake.kt Просмотреть файл

@@ -43,7 +43,7 @@ open class StockTake: BaseEntity<Long>() {
@Column(name = "stockTakeSection", length = 255)
open var stockTakeSection: String? = null

/** 同一輪盤點(多 section 多筆 stock_take)共用此 id,通常等於該輪第一筆 stock_take 的主鍵 */
/** 同一輪盤點(多 section 多筆 stock_take)共用此 id;由批次建立時依 MAX+1 遞增,不必等於任一筆主鍵 */
@Column(name = "stockTakeRoundId")
open var stockTakeRoundId: Long? = null
}

+ 6
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRepository.kt Просмотреть файл

@@ -15,4 +15,10 @@ interface StockTakeRepository : AbstractRepository<StockTake, Long> {
select st.code from StockTake st where st.code like :prefix% order by st.code desc limit 1
""")
fun findLatestCodeByPrefix(prefix: String): String?

/** 未刪除列中 stockTakeRoundId 的最大值;全為 null 或無資料時為 0 */
@Query(
"SELECT COALESCE(MAX(st.stockTakeRoundId), 0) FROM StockTake st WHERE st.deleted = false"
)
fun findMaxStockTakeRoundId(): Long
}

+ 3
- 49
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt Просмотреть файл

@@ -247,53 +247,12 @@ class StockTakeService(
.mapNotNull { it.stockTakeSection }
.distinct()
.filter { !it.isBlank() }
// 2. 获取所有 stock_take 记录(按 stockTakeSection 分组)
val allStockTakes = stockTakeRepository.findAll()
.filter { !it.deleted }
.groupBy { it.stockTakeSection }
/*
// 3. 为每个 stockTakeSection 检查并创建
distinctSections.forEach { section ->
val stockTakesForSection = allStockTakes[section] ?: emptyList()
// 检查:如果该 section 的所有记录都是 COMPLETED,才创建新的
val allCompleted = stockTakesForSection.isEmpty() ||
stockTakesForSection.all { it.status == StockTakeStatus.COMPLETED }
if (allCompleted) {
try {
val now = LocalDateTime.now()
val code = assignStockTakeNo()
val saveStockTakeReq = SaveStockTakeRequest(
code = code,
planStart = now,
planEnd = now.plusDays(1),
actualStart = null,
actualEnd = null,
status = StockTakeStatus.PENDING.value,
remarks = null,
stockTakeSection = section
)
val savedStockTake = saveStockTake(saveStockTakeReq)
result[section] = "Created: ${savedStockTake.code}"
logger.info("Created stock take for section $section: ${savedStockTake.code}")
} catch (e: Exception) {
result[section] = "Error: ${e.message}"
logger.error("Error creating stock take for section $section: ${e.message}")
}
} else {
result[section] = "Skipped: Has non-completed records"
logger.info("Skipped section $section: Has non-completed records")
}
}
*/

// 移除 null section 处理逻辑,因为 warehouse 表中没有 null 的 stockTakeSection
val batchPlanStart = LocalDateTime.now()
val batchPlanEnd = batchPlanStart.plusDays(1)
var roundId: Long? = null
// 輪次序號:每批次共用同一個遞增 roundId,與各筆 stock_take 主鍵脫鉤(避免第二輪變成 4,4,4)
val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1
distinctSections.forEach { section ->
try {
val code = assignStockTakeNo()
@@ -309,11 +268,6 @@ class StockTakeService(
stockTakeRoundId = roundId
)
val savedStockTake = saveStockTake(saveStockTakeReq)
if (roundId == null) {
roundId = savedStockTake.id
savedStockTake.stockTakeRoundId = roundId
stockTakeRepository.save(savedStockTake)
}
result[section] = "Created: ${savedStockTake.code}"
logger.info("Created stock take for section $section: ${savedStockTake.code}, roundId=$roundId")
} catch (e: Exception) {


+ 13
- 7
src/main/resources/jasper/StockTakeVarianceReport.jrxml Просмотреть файл

@@ -35,6 +35,12 @@
<parameter name="lastOutDateEnd" class="java.lang.String">
<parameterDescription><![CDATA["lastOutDateStart"]]></parameterDescription>
</parameter>
<parameter name="stockTakeFilterCaption" class="java.lang.String">
<defaultValueExpression><![CDATA[""]]></defaultValueExpression>
</parameter>
<parameter name="stockTakeConditionLabel" class="java.lang.String">
<defaultValueExpression><![CDATA["盤點日期:"]]></defaultValueExpression>
</parameter>
<queryString>
<![CDATA[]]>
</queryString>
@@ -164,7 +170,7 @@
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{stockTakeDateStart}+" 至 "+$P{stockTakeDateEnd}]]></textFieldExpression>
<textFieldExpression><![CDATA[($P{stockTakeFilterCaption} != null && !$P{stockTakeFilterCaption}.isEmpty()) ? $P{stockTakeFilterCaption} : ($P{stockTakeDateStart} + " 至 " + $P{stockTakeDateEnd})]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="747" y="0" width="21" height="23" uuid="5a1b4b58-b7b1-48c9-b229-7e96392c6425">
@@ -184,16 +190,16 @@
<textElement textAlignment="Center" verticalAlignment="Middle"/>
<textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="510" y="30" width="68" height="23" uuid="3741c716-b76a-442a-a4f8-0152853941d5">
<textField>
<reportElement x="510" y="30" width="80" height="23" uuid="3741c716-b76a-442a-a4f8-0152853941d5">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement>
<text><![CDATA[盤點日期:]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{stockTakeConditionLabel}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="10" y="30" width="90" height="23" uuid="0628b331-5736-4f93-bed3-2278011456aa">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
@@ -249,7 +255,7 @@
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10"/>
</textElement>
<text><![CDATA[現存存量]]></text>
<text><![CDATA[盤點前存量]]></text>
</staticText>
<staticText>
<reportElement isPrintRepeatedValues="false" x="228" y="1" width="110" height="18" isPrintInFirstWholeBand="true" uuid="e95a755d-4ecb-4900-ac9a-3a6e3b9b3470">
@@ -271,7 +277,7 @@
<textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10"/>
</textElement>
<text><![CDATA[最後出入倉日期]]></text>
<text><![CDATA[審核時間]]></text>
</staticText>
<staticText>
<reportElement isPrintRepeatedValues="false" x="558" y="1" width="100" height="18" isPrintInFirstWholeBand="true" uuid="921c16b3-172b-43b9-a090-24d2ee88a1b2">


Загрузка…
Отмена
Сохранить