Просмотр исходного кода

[Feat] Restyle excel doc

fg_report_rework
Harry Groves 11 часов назад
Родитель
Сommit
768efee770
5 измененных файлов: 231 добавлений и 76 удалений
  1. +29
    -5
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt
  2. +52
    -29
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayout.kt
  3. +63
    -37
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbook.kt
  4. +2
    -2
      src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayoutTest.kt
  5. +85
    -3
      src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbookTest.kt

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

@@ -102,12 +102,13 @@ class SemiFGProductionAnalysisReportService(
SELECT SELECT
COALESCE(it.code, '') AS itemNo, COALESCE(it.code, '') AS itemNo,
COALESCE(it.name, '') AS itemName, COALESCE(it.name, '') AS itemName,
COALESCE(uc.udfudesc, '') AS unitOfMeasure,
COALESCE(NULLIF(lot_uom.unitOfMeasure, ''), item_stock.unitOfMeasure, '') AS unitOfMeasure,
DATE($prodDateExpr) AS prodDate, DATE($prodDateExpr) AS prodDate,
COALESCE(jo.code, '') AS jobOrderCode, COALESCE(jo.code, '') AS jobOrderCode,
COALESCE(si.productLotNo, '') AS productLotNo, COALESCE(si.productLotNo, '') AS productLotNo,
si.id AS stockInLineId, si.id AS stockInLineId,
CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0 CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0
WHEN COALESCE(lot_uom.uomCount, 0) > 1 THEN COALESCE(lot_uom.inQty, 0)
ELSE COALESCE(si.acceptedQty, 0) ELSE COALESCE(si.acceptedQty, 0)
END AS linePutAwayQty, END AS linePutAwayQty,
COALESCE(qr_agg.qcFailed, 0) AS qcFailed COALESCE(qr_agg.qcFailed, 0) AS qcFailed
@@ -125,8 +126,31 @@ class SemiFGProductionAnalysisReportService(
) qr_agg ON qr_agg.stockInLineId = si.id ) qr_agg ON qr_agg.stockInLineId = si.id
LEFT JOIN job_order jo ON jo.id = si.jobOrderId AND jo.deleted = false LEFT JOIN job_order jo ON jo.id = si.jobOrderId AND jo.deleted = false
LEFT JOIN item_category ic ON it.categoryId = ic.id LEFT JOIN item_category ic ON it.categoryId = ic.id
LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
LEFT JOIN (
SELECT
grouped.inventoryLotId AS inventoryLotId,
grouped.unitOfMeasure AS unitOfMeasure,
grouped.inQty AS inQty,
COUNT(*) OVER (PARTITION BY grouped.inventoryLotId) AS uomCount
FROM (
SELECT
ill.inventoryLotId AS inventoryLotId,
COALESCE(uc.udfudesc, '') AS unitOfMeasure,
SUM(COALESCE(ill.inQty, 0)) AS inQty
FROM inventory_lot_line ill
LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId AND iu.deleted = 0
LEFT JOIN uom_conversion uc ON uc.id = iu.uomId
WHERE ill.deleted = 0
GROUP BY ill.inventoryLotId, COALESCE(uc.udfudesc, '')
) grouped
) lot_uom ON lot_uom.inventoryLotId = il.id
LEFT JOIN (
SELECT iu.itemId AS itemId, MAX(uc.udfudesc) AS unitOfMeasure
FROM item_uom iu
INNER JOIN uom_conversion uc ON uc.id = iu.uomId
WHERE iu.stockUnit = 1 AND iu.deleted = 0
GROUP BY iu.itemId
) item_stock ON item_stock.itemId = it.id
WHERE si.deleted = false WHERE si.deleted = false
AND si.productLotNo IS NOT NULL AND si.productLotNo IS NOT NULL
AND TRIM(si.productLotNo) <> '' AND TRIM(si.productLotNo) <> ''
@@ -139,8 +163,8 @@ class SemiFGProductionAnalysisReportService(
$stockSubCategorySql $stockSubCategorySql
$itemCodeSql $itemCodeSql
) base ) base
GROUP BY base.stockInLineId
ORDER BY itemNo, prodDate, jobOrderCode, productLotNo
GROUP BY base.stockInLineId, base.unitOfMeasure
ORDER BY itemNo, unitOfMeasure, prodDate, jobOrderCode, productLotNo
""".trimIndent() """.trimIndent()


val lines = jdbcDao.queryForList(sql, args).mapNotNull { row -> toLine(row) } val lines = jdbcDao.queryForList(sql, args).mapNotNull { row -> toLine(row) }


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

@@ -187,41 +187,50 @@ object SemiFgProductionAnalysisLayout {
private fun datesBetween(start: LocalDate, end: LocalDate): List<LocalDate> = private fun datesBetween(start: LocalDate, end: LocalDate): List<LocalDate> =
generateSequence(start) { prev -> prev.plusDays(1).takeIf { !it.isAfter(end) } }.toList() generateSequence(start) { prev -> prev.plusDays(1).takeIf { !it.isAfter(end) } }.toList()


/** Same item code with a different stock unit is a separate group. */
fun itemKey(itemNo: String, unitOfMeasure: String): String =
"${itemNo.trim()}\u001F${unitOfMeasure.trim()}"

fun summaryItems(lines: List<SemiFgLine>, qtyDesc: Boolean = false): List<SemiFgItem> { fun summaryItems(lines: List<SemiFgLine>, qtyDesc: Boolean = false): List<SemiFgItem> {
val grouped = linkedMapOf<String, SemiFgItem>() val grouped = linkedMapOf<String, SemiFgItem>()
val totals = linkedMapOf<String, BigDecimal>() val totals = linkedMapOf<String, BigDecimal>()
for (line in lines) { for (line in lines) {
if (line.itemNo.isBlank()) continue if (line.itemNo.isBlank()) continue
val existing = grouped[line.itemNo]
val key = itemKey(line.itemNo, line.unitOfMeasure)
val existing = grouped[key]
if (existing == null) { if (existing == null) {
grouped[line.itemNo] = SemiFgItem(line.itemNo, line.itemName, line.unitOfMeasure)
grouped[key] = SemiFgItem(line.itemNo, line.itemName, line.unitOfMeasure.trim())
} else if (existing.itemName.isBlank() && line.itemName.isNotBlank()) { } else if (existing.itemName.isBlank() && line.itemName.isNotBlank()) {
grouped[line.itemNo] = existing.copy(itemName = line.itemName, unitOfMeasure = line.unitOfMeasure.ifBlank { existing.unitOfMeasure })
grouped[key] = existing.copy(itemName = line.itemName)
} }
totals[line.itemNo] = (totals[line.itemNo] ?: BigDecimal.ZERO) + line.qty
totals[key] = (totals[key] ?: BigDecimal.ZERO) + line.qty
} }
val items = grouped.values.toList() val items = grouped.values.toList()
return if (qtyDesc) { return if (qtyDesc) {
items.sortedWith(compareByDescending<SemiFgItem> { totals[it.itemNo] }.thenBy { it.itemNo })
items.sortedWith(
compareByDescending<SemiFgItem> { totals[itemKey(it.itemNo, it.unitOfMeasure)] }
.thenBy { it.itemNo }
.thenBy { it.unitOfMeasure },
)
} else { } else {
items.sortedBy { it.itemNo }
items.sortedWith(compareBy({ it.itemNo }, { it.unitOfMeasure }))
} }
} }


fun dailyQty(lines: List<SemiFgLine>): Map<String, Map<LocalDate, BigDecimal>> { fun dailyQty(lines: List<SemiFgLine>): Map<String, Map<LocalDate, BigDecimal>> {
val out = linkedMapOf<String, MutableMap<LocalDate, BigDecimal>>() val out = linkedMapOf<String, MutableMap<LocalDate, BigDecimal>>()
for (line in lines) { for (line in lines) {
val byDate = out.getOrPut(line.itemNo) { linkedMapOf() }
val byDate = out.getOrPut(itemKey(line.itemNo, line.unitOfMeasure)) { linkedMapOf() }
byDate[line.prodDate] = (byDate[line.prodDate] ?: BigDecimal.ZERO) + line.qty byDate[line.prodDate] = (byDate[line.prodDate] ?: BigDecimal.ZERO) + line.qty
} }
return out return out
} }


fun anchorKey(view: SemiFgView, itemNo: String, date: LocalDate): String =
"$itemNo|${filterPeriod(view, date)}"
fun anchorKey(view: SemiFgView, itemNo: String, unitOfMeasure: String, date: LocalDate): String =
"$itemNo|${unitOfMeasure.trim()}|${filterPeriod(view, date)}"


fun anchorKey(window: SemiFgWindow, itemNo: String, date: LocalDate): String =
"$itemNo|${filterPeriod(window, date)}"
fun anchorKey(window: SemiFgWindow, itemNo: String, unitOfMeasure: String, date: LocalDate): String =
"$itemNo|${unitOfMeasure.trim()}|${filterPeriod(window, date)}"


fun filterPeriod(view: SemiFgView, date: LocalDate): String = when (view) { fun filterPeriod(view: SemiFgView, date: LocalDate): String = when (view) {
SemiFgView.DAY, SemiFgView.WEEK, SemiFgView.MONTH -> date.format(isoDate) SemiFgView.DAY, SemiFgView.WEEK, SemiFgView.MONTH -> date.format(isoDate)
@@ -252,15 +261,19 @@ object SemiFgProductionAnalysisLayout {
fun roundQty(value: BigDecimal): Long = fun roundQty(value: BigDecimal): Long =
value.setScale(0, RoundingMode.HALF_UP).longValueExact() value.setScale(0, RoundingMode.HALF_UP).longValueExact()


fun jobOrderCount(lines: List<SemiFgLine>, itemNo: String): Int {
fun jobOrderCount(lines: List<SemiFgLine>, itemNo: String, unitOfMeasure: String): Int {
val uom = unitOfMeasure.trim()
val matching = { line: SemiFgLine ->
line.itemNo == itemNo && line.unitOfMeasure.trim() == uom && line.qty > BigDecimal.ZERO
}
val codes = lines.asSequence() val codes = lines.asSequence()
.filter { it.itemNo == itemNo && it.qty > BigDecimal.ZERO }
.filter(matching)
.map { it.jobOrderCode.trim() } .map { it.jobOrderCode.trim() }
.filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
.distinct() .distinct()
.toList() .toList()
if (codes.isNotEmpty()) return codes.size if (codes.isNotEmpty()) return codes.size
return lines.count { it.itemNo == itemNo && it.qty > BigDecimal.ZERO }
return lines.count(matching)
} }


fun qtyOnDay(daily: Map<String, Map<LocalDate, BigDecimal>>, itemNo: String, day: LocalDate): BigDecimal = fun qtyOnDay(daily: Map<String, Map<LocalDate, BigDecimal>>, itemNo: String, day: LocalDate): BigDecimal =
@@ -277,7 +290,7 @@ object SemiFgProductionAnalysisLayout {
val daily = dailyQty(data.lines) val daily = dailyQty(data.lines)
return summaryItems(data.lines).map { item -> return summaryItems(data.lines).map { item ->
val months = (1..12).map { month -> val months = (1..12).map { month ->
qtyInMonth(daily, item.itemNo, YearMonth.of(year, month))
qtyInMonth(daily, itemKey(item.itemNo, item.unitOfMeasure), YearMonth.of(year, month))
} }
val total = months.fold(BigDecimal.ZERO, BigDecimal::add) val total = months.fold(BigDecimal.ZERO, BigDecimal::add)
mapOf( mapOf(
@@ -312,7 +325,7 @@ object SemiFgProductionAnalysisLayout {
itemNo = item.itemNo, itemNo = item.itemNo,
itemName = item.itemName, itemName = item.itemName,
unitOfMeasure = item.unitOfMeasure, unitOfMeasure = item.unitOfMeasure,
qty = qtyOnDay(daily, item.itemNo, day),
qty = qtyOnDay(daily, itemKey(item.itemNo, item.unitOfMeasure), day),
) )
} }
} }
@@ -323,7 +336,7 @@ object SemiFgProductionAnalysisLayout {
val end = month.atEndOfMonth() val end = month.atEndOfMonth()
while (!cursor.isAfter(end)) { while (!cursor.isAfter(end)) {
for (item in summaryItems(data.lines)) { for (item in summaryItems(data.lines)) {
val qty = qtyOnDay(daily, item.itemNo, cursor)
val qty = qtyOnDay(daily, itemKey(item.itemNo, item.unitOfMeasure), cursor)
if (qty > BigDecimal.ZERO) { if (qty > BigDecimal.ZERO) {
rows += SemiFgListRow(cursor.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty) rows += SemiFgListRow(cursor.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty)
} }
@@ -347,7 +360,7 @@ object SemiFgProductionAnalysisLayout {
if (data.window.dayColumns.isNotEmpty()) { if (data.window.dayColumns.isNotEmpty()) {
for (date in data.window.dayColumns) { for (date in data.window.dayColumns) {
for (item in items) { for (item in items) {
val qty = qtyOnDay(daily, item.itemNo, date)
val qty = qtyOnDay(daily, itemKey(item.itemNo, item.unitOfMeasure), date)
if (qty > BigDecimal.ZERO) { if (qty > BigDecimal.ZERO) {
rows += SemiFgListRow(date.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty) rows += SemiFgListRow(date.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty)
} }
@@ -358,7 +371,7 @@ object SemiFgProductionAnalysisLayout {
} }
for (period in data.window.periods) { for (period in data.window.periods) {
for (item in items) { for (item in items) {
val qty = qtyInMonthWithin(daily, item.itemNo, period, data.window.start, data.window.endInclusive)
val qty = qtyInMonthWithin(daily, itemKey(item.itemNo, item.unitOfMeasure), period, data.window.start, data.window.endInclusive)
if (qty > BigDecimal.ZERO) { if (qty > BigDecimal.ZERO) {
rows += SemiFgListRow(period.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty) rows += SemiFgListRow(period.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty)
} }
@@ -369,16 +382,16 @@ object SemiFgProductionAnalysisLayout {
} }


private fun appendItemsWithNoQty(rows: MutableList<SemiFgListRow>, items: List<SemiFgItem>, period: String) { private fun appendItemsWithNoQty(rows: MutableList<SemiFgListRow>, items: List<SemiFgItem>, period: String) {
val seen = rows.map { it.itemNo }.toSet()
val seen = rows.map { itemKey(it.itemNo, it.unitOfMeasure) }.toSet()
for (item in items) { for (item in items) {
if (item.itemNo in seen) continue
if (itemKey(item.itemNo, item.unitOfMeasure) in seen) continue
rows += SemiFgListRow(period, item.itemNo, item.itemName, item.unitOfMeasure, BigDecimal.ZERO) rows += SemiFgListRow(period, item.itemNo, item.itemName, item.unitOfMeasure, BigDecimal.ZERO)
} }
} }


fun chartCategories(data: SemiFgReportData, items: List<SemiFgItem>): List<String> { fun chartCategories(data: SemiFgReportData, items: List<SemiFgItem>): List<String> {
return when (data.view) { return when (data.view) {
SemiFgView.DAY -> items.map { it.itemNo }
SemiFgView.DAY -> items.map { chartItemLabel(it, items) }
SemiFgView.MONTH -> { SemiFgView.MONTH -> {
val month = data.window.month ?: return emptyList() val month = data.window.month ?: return emptyList()
(1..month.lengthOfMonth()).map { it.toString() } (1..month.lengthOfMonth()).map { it.toString() }
@@ -406,15 +419,25 @@ object SemiFgProductionAnalysisLayout {
} }
} }


fun chartItemLabel(item: SemiFgItem, items: List<SemiFgItem>): String {
val uom = item.unitOfMeasure.trim()
val sharedCode = items.count { it.itemNo == item.itemNo } > 1
return if (sharedCode && uom.isNotEmpty()) "${item.itemNo} ($uom)" else item.itemNo
}

fun chartQty( fun chartQty(
data: SemiFgReportData, data: SemiFgReportData,
daily: Map<String, Map<LocalDate, BigDecimal>>, daily: Map<String, Map<LocalDate, BigDecimal>>,
itemNo: String, itemNo: String,
unitOfMeasure: String,
category: String, category: String,
): BigDecimal { ): BigDecimal {
val key = itemKey(itemNo, unitOfMeasure)
val uom = unitOfMeasure.trim()
val withUom = if (uom.isEmpty()) itemNo else "$itemNo ($uom)"
return when (data.view) { return when (data.view) {
SemiFgView.DAY -> if (itemNo == category) {
daily[itemNo].orEmpty().values.fold(BigDecimal.ZERO, BigDecimal::add)
SemiFgView.DAY -> if (category == itemNo || category == withUom) {
daily[key].orEmpty().values.fold(BigDecimal.ZERO, BigDecimal::add)
} else { } else {
BigDecimal.ZERO BigDecimal.ZERO
} }
@@ -422,24 +445,24 @@ object SemiFgProductionAnalysisLayout {
val month = data.window.month ?: return BigDecimal.ZERO val month = data.window.month ?: return BigDecimal.ZERO
val day = category.toIntOrNull() ?: return BigDecimal.ZERO val day = category.toIntOrNull() ?: return BigDecimal.ZERO
if (day !in 1..month.lengthOfMonth()) BigDecimal.ZERO if (day !in 1..month.lengthOfMonth()) BigDecimal.ZERO
else qtyOnDay(daily, itemNo, month.atDay(day))
else qtyOnDay(daily, key, month.atDay(day))
} }
SemiFgView.YEAR -> { SemiFgView.YEAR -> {
val index = MONTH_LABELS.indexOf(category) val index = MONTH_LABELS.indexOf(category)
val year = data.window.year ?: return BigDecimal.ZERO val year = data.window.year ?: return BigDecimal.ZERO
if (index < 0) BigDecimal.ZERO else qtyInMonth(daily, itemNo, YearMonth.of(year, index + 1))
if (index < 0) BigDecimal.ZERO else qtyInMonth(daily, key, YearMonth.of(year, index + 1))
} }
SemiFgView.WEEK -> { SemiFgView.WEEK -> {
val day = runCatching { LocalDate.parse(category) }.getOrNull() ?: return BigDecimal.ZERO val day = runCatching { LocalDate.parse(category) }.getOrNull() ?: return BigDecimal.ZERO
qtyOnDay(daily, itemNo, day)
qtyOnDay(daily, key, day)
} }
SemiFgView.RANGE -> { SemiFgView.RANGE -> {
if (data.window.dayColumns.isNotEmpty()) { if (data.window.dayColumns.isNotEmpty()) {
val day = runCatching { LocalDate.parse(category) }.getOrNull() ?: return BigDecimal.ZERO val day = runCatching { LocalDate.parse(category) }.getOrNull() ?: return BigDecimal.ZERO
qtyOnDay(daily, itemNo, day)
qtyOnDay(daily, key, day)
} else { } else {
val period = runCatching { YearMonth.parse(category) }.getOrNull() ?: return BigDecimal.ZERO val period = runCatching { YearMonth.parse(category) }.getOrNull() ?: return BigDecimal.ZERO
qtyInMonthWithin(daily, itemNo, period, data.window.start, data.window.endInclusive)
qtyInMonthWithin(daily, key, period, data.window.start, data.window.endInclusive)
} }
} }
} }


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

@@ -14,6 +14,7 @@ import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.CellRangeAddressList import org.apache.poi.ss.util.CellRangeAddressList
import org.apache.poi.ss.util.CellReference import org.apache.poi.ss.util.CellReference
import org.apache.poi.ss.util.WorkbookUtil import org.apache.poi.ss.util.WorkbookUtil
import org.apache.poi.xddf.usermodel.chart.AxisCrossBetween
import org.apache.poi.xddf.usermodel.chart.AxisPosition import org.apache.poi.xddf.usermodel.chart.AxisPosition
import org.apache.poi.xddf.usermodel.chart.BarDirection import org.apache.poi.xddf.usermodel.chart.BarDirection
import org.apache.poi.xddf.usermodel.chart.ChartTypes import org.apache.poi.xddf.usermodel.chart.ChartTypes
@@ -36,7 +37,7 @@ import java.time.format.DateTimeFormatter
*/ */
class SemiFgProductionAnalysisWorkbook { class SemiFgProductionAnalysisWorkbook {
private companion object { private companion object {
const val DETAIL_LAST_COL = 7
const val DETAIL_LAST_COL = 4
} }
fun build(data: SemiFgReportData, lang: String? = null): ByteArray { fun build(data: SemiFgReportData, lang: String? = null): ByteArray {
val text = SemiFgReportText.of(lang) val text = SemiFgReportText.of(lang)
@@ -155,14 +156,15 @@ class SemiFgProductionAnalysisWorkbook {
} }
var total = BigDecimal.ZERO var total = BigDecimal.ZERO
for (item in items) { for (item in items) {
val qty = SemiFgProductionAnalysisLayout.qtyOnDay(daily, item.itemNo, day)
val key = SemiFgProductionAnalysisLayout.itemKey(item.itemNo, item.unitOfMeasure)
val qty = SemiFgProductionAnalysisLayout.qtyOnDay(daily, key, day)
total += qty total += qty
val anchor = anchors[SemiFgProductionAnalysisLayout.anchorKey(SemiFgView.DAY, item.itemNo, day)]
val anchor = anchors[SemiFgProductionAnalysisLayout.anchorKey(SemiFgView.DAY, item.itemNo, item.unitOfMeasure, day)]
val row = sheet.createRow(rowIndex++) val row = sheet.createRow(rowIndex++)
setText(row, 0, item.itemNo, styles, detailSheetName, anchor, link = true) setText(row, 0, item.itemNo, styles, detailSheetName, anchor, link = true)
setText(row, 1, item.itemName.ifBlank { "-" }, styles) setText(row, 1, item.itemName.ifBlank { "-" }, styles)
setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles) setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles)
setWholeNumber(row, 3, SemiFgProductionAnalysisLayout.jobOrderCount(data.lines, item.itemNo).toBigDecimal(), styles)
setWholeNumber(row, 3, SemiFgProductionAnalysisLayout.jobOrderCount(data.lines, item.itemNo, item.unitOfMeasure).toBigDecimal(), styles)
setWholeNumber(row, 4, qty, styles) setWholeNumber(row, 4, qty, styles)
} }
writeTotalRow(sheet, rowIndex++, text.total, 4, total, styles, leadingBlanks = 3) writeTotalRow(sheet, rowIndex++, text.total, 4, total, styles, leadingBlanks = 3)
@@ -193,14 +195,15 @@ class SemiFgProductionAnalysisWorkbook {
var grand = BigDecimal.ZERO var grand = BigDecimal.ZERO
for (item in items) { for (item in items) {
val row = sheet.createRow(rowIndex++) val row = sheet.createRow(rowIndex++)
val firstAnchor = firstAnchor(anchors, item.itemNo)
val firstAnchor = firstAnchor(anchors, item.itemNo, item.unitOfMeasure)
setText(row, 0, item.itemNo, styles, detailSheetName, firstAnchor, link = true) setText(row, 0, item.itemNo, styles, detailSheetName, firstAnchor, link = true)
setText(row, 1, item.itemName.ifBlank { "-" }, styles) setText(row, 1, item.itemName.ifBlank { "-" }, styles)
setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles) setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles)
var rowTotal = BigDecimal.ZERO var rowTotal = BigDecimal.ZERO
val key = SemiFgProductionAnalysisLayout.itemKey(item.itemNo, item.unitOfMeasure)
for (day in 1..days) { for (day in 1..days) {
val date = month.atDay(day) val date = month.atDay(day)
val qty = SemiFgProductionAnalysisLayout.qtyOnDay(daily, item.itemNo, date)
val qty = SemiFgProductionAnalysisLayout.qtyOnDay(daily, key, date)
rowTotal += qty rowTotal += qty
dayTotals[day - 1] += qty dayTotals[day - 1] += qty
setWholeNumber(row, 2 + day, qty, styles) setWholeNumber(row, 2 + day, qty, styles)
@@ -234,15 +237,15 @@ class SemiFgProductionAnalysisWorkbook {
val sameMonth = data.window.dayColumns.map { YearMonth.from(it) }.distinct().size == 1 val sameMonth = data.window.dayColumns.map { YearMonth.from(it) }.distinct().size == 1
data.window.dayColumns.map { date -> data.window.dayColumns.map { date ->
val heading = if (sameMonth) date.dayOfMonth.toString() else "%d/%d".format(date.dayOfMonth, date.monthValue) val heading = if (sameMonth) date.dayOfMonth.toString() else "%d/%d".format(date.dayOfMonth, date.monthValue)
heading to { itemNo: String ->
SemiFgProductionAnalysisLayout.qtyOnDay(daily, itemNo, date)
heading to { itemKey: String ->
SemiFgProductionAnalysisLayout.qtyOnDay(daily, itemKey, date)
} }
} }
} else { } else {
data.window.periods.map { period -> data.window.periods.map { period ->
period.toString() to { itemNo: String ->
period.toString() to { itemKey: String ->
SemiFgProductionAnalysisLayout.qtyInMonthWithin( SemiFgProductionAnalysisLayout.qtyInMonthWithin(
daily, itemNo, period, data.window.start, data.window.endInclusive,
daily, itemKey, period, data.window.start, data.window.endInclusive,
) )
} }
} }
@@ -258,13 +261,14 @@ class SemiFgProductionAnalysisWorkbook {
var grand = BigDecimal.ZERO var grand = BigDecimal.ZERO
for (item in items) { for (item in items) {
val row = sheet.createRow(rowIndex++) val row = sheet.createRow(rowIndex++)
val firstAnchor = firstAnchor(anchors, item.itemNo)
val firstAnchor = firstAnchor(anchors, item.itemNo, item.unitOfMeasure)
setText(row, 0, item.itemNo, styles, detailSheetName, firstAnchor, link = true) setText(row, 0, item.itemNo, styles, detailSheetName, firstAnchor, link = true)
setText(row, 1, item.itemName.ifBlank { "-" }, styles) setText(row, 1, item.itemName.ifBlank { "-" }, styles)
setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles) setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles)
var rowTotal = BigDecimal.ZERO var rowTotal = BigDecimal.ZERO
val key = SemiFgProductionAnalysisLayout.itemKey(item.itemNo, item.unitOfMeasure)
columns.forEachIndexed { index, (_, qtyOf) -> columns.forEachIndexed { index, (_, qtyOf) ->
val qty = qtyOf(item.itemNo)
val qty = qtyOf(key)
rowTotal += qty rowTotal += qty
columnTotals[index] += qty columnTotals[index] += qty
setWholeNumber(row, 3 + index, qty, styles) setWholeNumber(row, 3 + index, qty, styles)
@@ -306,14 +310,15 @@ class SemiFgProductionAnalysisWorkbook {
var grand = BigDecimal.ZERO var grand = BigDecimal.ZERO
for (item in items) { for (item in items) {
val row = sheet.createRow(rowIndex++) val row = sheet.createRow(rowIndex++)
val firstAnchor = firstAnchor(anchors, item.itemNo)
val firstAnchor = firstAnchor(anchors, item.itemNo, item.unitOfMeasure)
setText(row, 0, item.itemNo, styles, detailSheetName, firstAnchor, link = true) setText(row, 0, item.itemNo, styles, detailSheetName, firstAnchor, link = true)
setText(row, 1, item.itemName.ifBlank { "-" }, styles) setText(row, 1, item.itemName.ifBlank { "-" }, styles)
setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles) setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles)
var rowTotal = BigDecimal.ZERO var rowTotal = BigDecimal.ZERO
val key = SemiFgProductionAnalysisLayout.itemKey(item.itemNo, item.unitOfMeasure)
for (month in 1..12) { for (month in 1..12) {
val period = YearMonth.of(year, month) val period = YearMonth.of(year, month)
val qty = SemiFgProductionAnalysisLayout.qtyInMonth(daily, item.itemNo, period)
val qty = SemiFgProductionAnalysisLayout.qtyInMonth(daily, key, period)
rowTotal += qty rowTotal += qty
monthTotals[month - 1] += qty monthTotals[month - 1] += qty
setWholeNumber(row, 2 + month, qty, styles) setWholeNumber(row, 2 + month, qty, styles)
@@ -333,52 +338,63 @@ class SemiFgProductionAnalysisWorkbook {
} }


private fun writeDetail(sheet: XSSFSheet, data: SemiFgReportData, styles: Styles, text: SemiFgReportText): Map<String, DetailSpan> { private fun writeDetail(sheet: XSSFSheet, data: SemiFgReportData, styles: Styles, text: SemiFgReportText): Map<String, DetailSpan> {
val headers = listOf(text.date, text.itemNo, text.itemName, text.uom, text.jobOrderNo, text.lotNo, text.putAwayQty, text.qc)
val headers = listOf(text.date, text.jobOrderNo, text.lotNo, text.putAwayQty, text.qc)
writeHeader(sheet, 0, headers, styles) writeHeader(sheet, 0, headers, styles)
sheet.createFreezePane(0, 1)
val anchors = linkedMapOf<String, DetailSpan>() val anchors = linkedMapOf<String, DetailSpan>()
val grouped = data.lines val grouped = data.lines
.sortedWith(compareBy({ it.itemNo }, { it.prodDate }, { it.jobOrderCode }, { it.productLotNo }))
.groupBy { it.itemNo }
.sortedWith(compareBy({ it.itemNo }, { it.unitOfMeasure }, { it.prodDate }, { it.jobOrderCode }, { it.productLotNo }))
.groupBy { SemiFgProductionAnalysisLayout.itemKey(it.itemNo, it.unitOfMeasure) }
var rowIndex = 1 var rowIndex = 1
if (grouped.isEmpty()) { if (grouped.isEmpty()) {
writeEmptyRow(sheet, rowIndex, headers.size, styles) writeEmptyRow(sheet, rowIndex, headers.size, styles)
return anchors return anchors
} }
for ((itemNo, lines) in grouped) {
for ((_, lines) in grouped) {
val itemNo = lines.first().itemNo
val firstRow = rowIndex val firstRow = rowIndex
val header = sheet.createRow(rowIndex) val header = sheet.createRow(rowIndex)
header.heightInPoints = 22f header.heightInPoints = 22f
header.createCell(0).cellStyle = styles.sectionEdge
header.createCell(1).apply {
header.createCell(0).apply {
setCellValue(itemNo) setCellValue(itemNo)
cellStyle = styles.sectionCode cellStyle = styles.sectionCode
} }
header.createCell(2).apply {
header.createCell(1).apply {
setCellValue(lines.first().itemName.ifBlank { "" }) setCellValue(lines.first().itemName.ifBlank { "" })
cellStyle = styles.sectionName cellStyle = styles.sectionName
} }
header.createCell(2).apply {
setCellValue(lines.first().unitOfMeasure.ifBlank { "-" })
cellStyle = styles.sectionName
}
for (col in 3 until headers.size) { for (col in 3 until headers.size) {
header.createCell(col).cellStyle = styles.sectionEdge header.createCell(col).cellStyle = styles.sectionEdge
} }
rowIndex++ rowIndex++
var previous: List<String>? = null
for (line in lines) { for (line in lines) {
val fields = listOf(
line.prodDate.toString(),
line.jobOrderCode.ifBlank { "-" },
line.productLotNo.ifBlank { "-" },
)
val row = sheet.createRow(rowIndex++) val row = sheet.createRow(rowIndex++)
setText(row, 0, line.prodDate.toString(), styles)
setText(row, 1, line.itemNo, styles)
setText(row, 2, line.itemName.ifBlank { "-" }, styles)
setText(row, 3, line.unitOfMeasure.ifBlank { "-" }, styles)
setText(row, 4, line.jobOrderCode.ifBlank { "-" }, styles)
setText(row, 5, line.productLotNo.ifBlank { "-" }, styles)
setWholeNumber(row, 6, line.qty, styles)
setText(row, 7, if (line.qcFailed) text.qcFail else text.qcPass, styles)
val prior = previous
fields.forEachIndexed { index, value ->
val shown = if (prior != null && prior[index] == value) "" else value
setText(row, index, shown, styles)
}
setWholeNumber(row, 3, line.qty, styles)
setText(row, 4, if (line.qcFailed) text.qcFail else text.qcPass, styles)
previous = fields
} }
val span = DetailSpan(firstRow, rowIndex - 1) val span = DetailSpan(firstRow, rowIndex - 1)
anchors[itemNo] = span
anchors[SemiFgProductionAnalysisLayout.itemKey(itemNo, lines.first().unitOfMeasure)] = span
for (line in lines) { for (line in lines) {
anchors[SemiFgProductionAnalysisLayout.anchorKey(data.window, line.itemNo, line.prodDate)] = span
anchors[SemiFgProductionAnalysisLayout.anchorKey(data.window, line.itemNo, line.unitOfMeasure, line.prodDate)] = span
} }
} }
val widths = intArrayOf(22, 18, 28, 12, 18, 18, 12, 10)
val widths = intArrayOf(22, 28, 18, 12, 10)
widths.forEachIndexed { idx, width -> sheet.setColumnWidth(idx, width * 256) } widths.forEachIndexed { idx, width -> sheet.setColumnWidth(idx, width * 256) }
return anchors return anchors
} }
@@ -411,16 +427,22 @@ class SemiFgProductionAnalysisWorkbook {
category category
} }
val row = chartData.createRow(dataRow++) val row = chartData.createRow(dataRow++)
row.createCell(0).setCellValue(item.itemNo)
row.createCell(0).setCellValue(SemiFgProductionAnalysisLayout.chartItemLabel(item, items))
row.createCell(1).setCellValue(category) row.createCell(1).setCellValue(category)
row.createCell(2).setCellValue( row.createCell(2).setCellValue(
SemiFgProductionAnalysisLayout.roundQty( SemiFgProductionAnalysisLayout.roundQty(
SemiFgProductionAnalysisLayout.chartQty(data, daily, item.itemNo, qtyCategory),
SemiFgProductionAnalysisLayout.chartQty(
data,
daily,
item.itemNo,
item.unitOfMeasure,
qtyCategory,
),
).toDouble(), ).toDouble(),
) )
} }
} }
val itemList = listOf(text.all) + items.map { it.itemNo }
val itemList = listOf(text.all) + items.map { SemiFgProductionAnalysisLayout.chartItemLabel(it, items) }
val listCol = 40 val listCol = 40
itemList.forEachIndexed { index, value -> itemList.forEachIndexed { index, value ->
val row = chartData.getRow(index) ?: chartData.createRow(index) val row = chartData.getRow(index) ?: chartData.createRow(index)
@@ -505,6 +527,8 @@ class SemiFgProductionAnalysisWorkbook {
val series = barData.addSeries(categoriesSource, valuesSource) val series = barData.addSeries(categoriesSource, valuesSource)
series.setTitle(text.producedQty, null) series.setTitle(text.producedQty, null)
chart.plot(barData) chart.plot(barData)
// midCat draws the value axis through the middle of the first and last categories, so those bars are clipped to half width.
leftAxis.crossBetween = AxisCrossBetween.BETWEEN
} }


val note = chartSheet.createRow(19) val note = chartSheet.createRow(19)
@@ -603,8 +627,10 @@ class SemiFgProductionAnalysisWorkbook {
setWholeNumber(row, qtyColumn, qty, styles) setWholeNumber(row, qtyColumn, qty, styles)
} }


private fun firstAnchor(anchors: Map<String, DetailSpan>, itemNo: String): DetailSpan? =
anchors.keys.filter { it.startsWith("$itemNo|") }.minOrNull()?.let { anchors[it] }
private fun firstAnchor(anchors: Map<String, DetailSpan>, itemNo: String, unitOfMeasure: String): DetailSpan? {
val prefix = "$itemNo|${unitOfMeasure.trim()}|"
return anchors.keys.filter { it.startsWith(prefix) }.minOrNull()?.let { anchors[it] }
}


private fun setText( private fun setText(
row: org.apache.poi.ss.usermodel.Row, row: org.apache.poi.ss.usermodel.Row,


+ 2
- 2
src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayoutTest.kt Просмотреть файл

@@ -132,8 +132,8 @@ class SemiFgProductionAnalysisLayoutTest {
@Test @Test
fun `anchor period is a day for a month view and a year-month for a year view`() { fun `anchor period is a day for a month view and a year-month for a year view`() {
val date = LocalDate.of(2026, 3, 15) val date = LocalDate.of(2026, 3, 15)
assertEquals("FG1|2026-03-15", SemiFgProductionAnalysisLayout.anchorKey(SemiFgView.MONTH, "FG1", date))
assertEquals("FG1|2026-03", SemiFgProductionAnalysisLayout.anchorKey(SemiFgView.YEAR, "FG1", date))
assertEquals("FG1|箱|2026-03-15", SemiFgProductionAnalysisLayout.anchorKey(SemiFgView.MONTH, "FG1", "箱", date))
assertEquals("FG1|箱|2026-03", SemiFgProductionAnalysisLayout.anchorKey(SemiFgView.YEAR, "FG1", "箱", date))
} }


private fun line(date: LocalDate, item: String, qty: String) = SemiFgLine( private fun line(date: LocalDate, item: String, qty: String) = SemiFgLine(


+ 85
- 3
src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbookTest.kt Просмотреть файл

@@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.report.service


import org.apache.poi.ss.usermodel.CellType import org.apache.poi.ss.usermodel.CellType
import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -41,15 +42,16 @@ class SemiFgProductionAnalysisWorkbookTest {
(0 until row.lastCellNum).mapNotNull { col -> row.getCell(col)?.hyperlink } (0 until row.lastCellNum).mapNotNull { col -> row.getCell(col)?.hyperlink }
} }
assertTrue(linked.isNotEmpty()) assertTrue(linked.isNotEmpty())
assertTrue(linked.any { it.address.contains("明細") && it.address.contains(":H") })
assertTrue(linked.any { it.address.contains("明細") && it.address.contains(":E") })
val detail = workbook.getSheet("明細") val detail = workbook.getSheet("明細")
assertEquals(1, detail.paneInformation.horizontalSplitPosition)
val heading = (0..detail.lastRowNum).any { rowIndex -> val heading = (0..detail.lastRowNum).any { rowIndex ->
val row = detail.getRow(rowIndex) ?: return@any false val row = detail.getRow(rowIndex) ?: return@any false
row.getCell(1)?.stringCellValue == "FG1" && row.getCell(2)?.stringCellValue == "Cake"
row.getCell(0)?.stringCellValue == "FG1" && row.getCell(1)?.stringCellValue == "Cake"
} }
val line = (0..detail.lastRowNum).any { rowIndex -> val line = (0..detail.lastRowNum).any { rowIndex ->
val row = detail.getRow(rowIndex) ?: return@any false val row = detail.getRow(rowIndex) ?: return@any false
row.getCell(0)?.stringCellValue == "2026-03-15" && row.getCell(1)?.stringCellValue == "FG1"
row.getCell(0)?.stringCellValue == "2026-03-15" && row.getCell(4)?.stringCellValue == "合格"
} }
assertTrue(heading) assertTrue(heading)
assertTrue(line) assertTrue(line)
@@ -71,4 +73,84 @@ class SemiFgProductionAnalysisWorkbookTest {
assertTrue(cached.all { it.cachedFormulaResultType == CellType.NUMERIC }) assertTrue(cached.all { it.cachedFormulaResultType == CellType.NUMERIC })
} }
} }

@Test
fun `detail splits the same item code by stock unit and blanks repeated values`() {
val window = SemiFgProductionAnalysisLayout.resolveWindow(
SemiFgReportFilter(view = "month", reportMonth = "2026-03"),
LocalDate.of(2026, 9, 23),
)
val data = SemiFgReportData(
window = window,
lines = listOf(
line(LocalDate.of(2026, 3, 15), "FG1", "箱", "JO-1", "LOT-1", "12"),
line(LocalDate.of(2026, 3, 16), "FG1", "箱", "JO-1", "LOT-2", "4"),
line(LocalDate.of(2026, 3, 15), "FG1", "包", "JO-2", "LOT-3", "3"),
),
)
XSSFWorkbook(ByteArrayInputStream(SemiFgProductionAnalysisWorkbook().build(data))).use { workbook ->
val detail = workbook.getSheet("明細")
val rows = (0..detail.lastRowNum).map { index ->
val row = detail.getRow(index)
(0..7).map { col -> cellText(row?.getCell(col)) }
}
val headers = rows.filter { it[0] == "FG1" }
assertEquals(setOf("箱", "包"), headers.map { it[2] }.toSet())
val boxHeader = rows.indexOfFirst { it[0] == "FG1" && it[2] == "箱" }
val boxLines = listOf(rows[boxHeader + 1], rows[boxHeader + 2])
assertEquals("2026-03-15", boxLines[0][0])
assertEquals("JO-1", boxLines[0][1])
assertEquals("LOT-1", boxLines[0][2])
assertEquals("2026-03-16", boxLines[1][0])
assertEquals("", boxLines[1][1])
assertEquals("LOT-2", boxLines[1][2])
assertEquals("合格", boxLines[0][4])
assertEquals("合格", boxLines[1][4])
val summary = workbook.getSheet("彙總")
val summaryCodes = (0..summary.lastRowNum).mapNotNull { summary.getRow(it)?.getCell(0)?.stringCellValue }
assertEquals(2, summaryCodes.count { it == "FG1" })
}
}

@Test
fun `chart value axis crosses between categories so end bars keep full width`() {
val window = SemiFgProductionAnalysisLayout.resolveWindow(
SemiFgReportFilter(view = "month", reportMonth = "2026-03"),
LocalDate.of(2026, 9, 23),
)
val bytes = SemiFgProductionAnalysisWorkbook().build(
SemiFgReportData(window, listOf(line(LocalDate.of(2026, 3, 15), "FG1", "箱", "JO-1", "LOT-1", "12"))),
)
val chartXml = java.util.zip.ZipInputStream(ByteArrayInputStream(bytes)).use { zip ->
generateSequence { zip.nextEntry }.first { it.name.startsWith("xl/charts/") }.let { zip.readBytes().decodeToString() }
}
assertTrue(chartXml.contains("val=\"between\""), chartXml)
assertTrue(!chartXml.contains("midCat"), chartXml)
}

private fun line(
date: LocalDate,
item: String,
uom: String,
job: String,
lot: String,
qty: String,
) = SemiFgLine(
prodDate = date,
itemNo = item,
itemName = "Cake",
unitOfMeasure = uom,
jobOrderCode = job,
productLotNo = lot,
qty = BigDecimal(qty),
qcFailed = false,
)

private fun cellText(cell: org.apache.poi.ss.usermodel.Cell?): String = when {
cell == null -> ""
cell.cellType == CellType.STRING -> cell.stringCellValue
cell.cellType == CellType.NUMERIC -> cell.numericCellValue.toString()
cell.cellType == CellType.BLANK -> ""
else -> ""
}
} }

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