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