diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExport.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExport.kt index 7adf2ac2c5..f618af22df 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExport.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExport.kt @@ -64,6 +64,7 @@ private val log = KotlinLogging.logger {} */ @Service open class ForecastExport { // open needed by Wicket. + @Autowired private lateinit var accessChecker: AccessChecker @@ -88,12 +89,20 @@ open class ForecastExport { // open needed by Wicket. @Autowired private lateinit var applicationContext: ApplicationContext + /** + * Export the forecast sheet. + * @param origFilter The filter for the orders to export. + * @param planningDate If given, the monthly forecast will be calculated with the specified date and inserted as plan data. + * @param snapshotDate Today (null) or, the day of the snapshot, if the orderList is loaded from order book snapshots. + * @param fillUnitCol The function to get the unit of the order to show in the unit column. + */ @JvmOverloads @Throws(IOException::class) open fun xlsExport( origFilter: AuftragFilter, planningDate: LocalDate? = null, - snapshotDate: LocalDate? = null + snapshotDate: LocalDate? = null, + fillUnitCol: ((orderInfo: OrderInfo) -> String)? = null, ): ByteArray? { val startDateParam = origFilter.periodOfPerformanceStartDate val startDate = if (startDateParam != null) PFDay.from(startDateParam).beginOfMonth else PFDay.now().beginOfYear @@ -145,7 +154,8 @@ open class ForecastExport { // open needed by Wicket. snapshotDate = closestSnapshotDate, showAll = showAll, auftragFilter = filter, - scriptLogger = scriptLogger + scriptLogger = scriptLogger, + fillUnitCol = fillUnitCol, ) } catch (ex: Exception) { log.error(ex) { "Error exporting forecast: $ex" } @@ -216,6 +226,7 @@ open class ForecastExport { // open needed by Wicket. snapshotDate: LocalDate?, auftragFilter: AuftragFilter, scriptLogger: ScriptLogger?, + fillUnitCol: ((orderInfo: OrderInfo) -> String)?, ): ByteArray? { if (orderList.isEmpty()) { val msg = "No orders found for export." @@ -280,6 +291,7 @@ open class ForecastExport { // open needed by Wicket. baseDate = PFDay.fromOrNow(snapshotDate), planningDate = planningDate, snapshot = snapshotDate != null, + fillUnitCol = fillUnitCol, ) ctx.showAll = showAll @@ -290,7 +302,13 @@ open class ForecastExport { // open needed by Wicket. log.debug { "info sheet: $infoSheet" } val orderPositionsFound = - fillOrderPositions(orderList, ctx, ctx.forecastSheet, baseDate = snapshotDate, useAuftragsCache) + fillOrderPositions( + orderList, + ctx, + ctx.forecastSheet, + baseDate = snapshotDate, + useAuftragsCache, + ) if (!orderPositionsFound) { val msg = "No orders positions found for export." scriptLogger?.info { msg } ?: log.info { msg } // scriptLogger does also log.info @@ -303,10 +321,14 @@ open class ForecastExport { // open needed by Wicket. replaceMonthDatesInHeaderRow(invoicesSheet, startDate) replaceMonthDatesInHeaderRow(invoicesPrevYearSheet, prevYearBaseDate) replaceMonthDatesInHeaderRow(planningInvoicesSheet, startDate) - ExcelUtils.setAutoFilter(forecastSheet, FORECAST_HEAD_ROW, 0, FORECAST_NUMBER_OF_COLS) + if (!ctx.hasUnitColEntries) { + ExcelUtils.setColumnHidden(forecastSheet, ForecastCol.UNIT.header, true) + ExcelUtils.setColumnHidden(planningSheet, ForecastCol.UNIT.header, true) + } + ExcelUtils.setAutoFilter(forecastSheet, FORECAST_HEAD_ROW, 0, FORECAST_NUMBER_OF_COLS_AUTOFILTER) invoicesSheet.setAutoFilter() invoicesPrevYearSheet.setAutoFilter() - ExcelUtils.setAutoFilter(planningSheet, FORECAST_HEAD_ROW, 0, FORECAST_NUMBER_OF_COLS) + ExcelUtils.setAutoFilter(planningSheet, FORECAST_HEAD_ROW, 0, FORECAST_NUMBER_OF_COLS_AUTOFILTER) planningInvoicesSheet.setAutoFilter() fillPlanningForecast(planningDate, auftragFilter, ctx) @@ -372,10 +394,16 @@ open class ForecastExport { // open needed by Wicket. return orderPositionFound } - private fun fillPlanningForecast(planningDate: LocalDate?, auftragFilter: AuftragFilter, ctx: Context) { + private fun fillPlanningForecast(planningDate: LocalDate?, auftragFilter: AuftragFilter, ctx: Context, ) { planningDate ?: return - val orderList = readSnapshot(planningDate, auftragFilter) ?: return - fillOrderPositions(orderList, ctx, ctx.planningSheet, baseDate = planningDate, useAuftragsCache = false) + val orderList = readSnapshot(planningDate, auftragFilter) + fillOrderPositions( + orderList, + ctx, + ctx.planningSheet, + baseDate = planningDate, + useAuftragsCache = false, + ) } private fun replaceMonthDatesInHeaderRow( @@ -413,8 +441,34 @@ open class ForecastExport { // open needed by Wicket. baseDate: LocalDate?, useAuftragsCache: Boolean, ) { + val isPlanningSheet = ctx.planningSheet == sheet sheet.setIntValue(row, ForecastCol.ORDER_NR.header, order.nummer) sheet.setStringValue(row, ForecastCol.POS_NR.header, "#${pos.number}") + ExcelUtils.setLongValue(sheet, row, ForecastCol.PROJECT_ID.header, order.projektId) + val excelRow = row + 1 // Excel row number for formulas, 1-based. + if (isPlanningSheet) { + // Planning sheet: visible column is true, if the project is visible in the forecast sheet. + val visibleProjectIdCol = + ctx.forecastSheet.getColumnDef(ForecastCol.VISIBLE_PROJECT_ID.header)?.columnNumberAsLetters + val projectIdCol = ctx.forecastSheet.getColumnDef(ForecastCol.PROJECT_ID.header)?.columnNumberAsLetters + ExcelUtils.setCellFormula( + sheet, + row, + ForecastCol.VISIBLE.header, + "COUNTIF(Forecast_Data!$visibleProjectIdCol$11:$visibleProjectIdCol$100000, $projectIdCol$excelRow) > 0" + ) + } else { + // Visible cell is 1, if row is visible (by filter), otherwise, 0. + ExcelUtils.setCellFormula(sheet, row, ForecastCol.VISIBLE.header, "SUBTOTAL(3, A$excelRow)") + val visibleCol = ctx.forecastSheet.getColumnDef(ForecastCol.VISIBLE.header)?.columnNumberAsLetters + val projectIdCol = ctx.forecastSheet.getColumnDef(ForecastCol.PROJECT_ID.header)?.columnNumberAsLetters + ExcelUtils.setCellFormula( + sheet, + row, + ForecastCol.VISIBLE_PROJECT_ID.header, + "IF($visibleCol$excelRow=1, $projectIdCol$excelRow, \"\")" + ) + } order.angebotsDatum?.let { sheet.setDateValue(row, ForecastCol.DATE_OF_OFFER.header, PFDay(it).localDate, ctx.excelDateFormat) } @@ -424,6 +478,12 @@ open class ForecastExport { // open needed by Wicket. order.entscheidungsDatum?.let { sheet.setDateValue(row, ForecastCol.DATE_OF_DECISION.header, PFDay(it).localDate, ctx.excelDateFormat) } + ctx.fillUnitCol?.invoke(order)?.let { + if (it.isNotBlank()) { + ctx.hasUnitColEntries = true + } + sheet.setStringValue(row, ForecastCol.UNIT.header, it) + } sheet.setStringValue(row, ForecastCol.CUSTOMER.header, order.kundeAsString) sheet.setStringValue(row, ForecastCol.PROJECT.header, order.projektAsString) sheet.setStringValue(row, ForecastCol.TITEL.header, order.titel) @@ -595,7 +655,10 @@ open class ForecastExport { // open needed by Wicket. private const val FORECAST_HEAD_ROW = 9 private const val FORECAST_FISRT_ORDER_ROW = FORECAST_HEAD_ROW + 1 - private const val FORECAST_NUMBER_OF_COLS = 45 + private const val FORECAST_NUMBER_OF_COLS_AUTOFILTER = 47 + + // Two more technical cols: ProjectID, visible and visibleID + private const val FORECAST_NUMBER_OF_COLS = FORECAST_NUMBER_OF_COLS_AUTOFILTER + 3 fun formatMonthHeader(date: PFDay): String { return date.format(formatter) diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportContext.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportContext.kt index 2c1500ea4f..f29a541789 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportContext.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportContext.kt @@ -30,8 +30,6 @@ import org.projectforge.business.excel.ExcelDateFormats import org.projectforge.business.excel.XlsContentProvider import org.projectforge.common.DateFormatType import org.projectforge.excel.ExcelUtils -import org.projectforge.framework.i18n.translate -import org.projectforge.framework.i18n.translateMsg import org.projectforge.framework.persistence.user.api.ThreadLocalUserContext import org.projectforge.framework.time.DateFormats import org.projectforge.framework.time.PFDay @@ -54,6 +52,7 @@ internal class ForecastExportContext( val baseDate: PFDay = PFDay.now(), val planningDate: LocalDate? = null, val snapshot: Boolean = false, + val fillUnitCol: ((orderInfo: OrderInfo) -> String)? = null, ) { enum class Sheet(val title: String) { FORECAST("Forecast_Data"), @@ -66,7 +65,7 @@ internal class ForecastExportContext( enum class ForecastCol(val header: String) { ORDER_NR("Nr."), POS_NR("Pos."), DATE_OF_OFFER("Angebotsdatum"), DATE("Erfassungsdatum"), - DATE_OF_DECISION("Entscheidungsdatum"), CUSTOMER("Kunde"), PROJECT("Projekt"), + DATE_OF_DECISION("Entscheidungsdatum"), UNIT("Unit"), CUSTOMER("Kunde"), PROJECT("Projekt"), TITEL("Titel"), POS_TITLE("Pos.-Titel"), ART("Art"), ABRECHNUNGSART("Abrechnungsart"), AUFTRAG_STATUS("Auftrag Status"), POSITION_STATUS("Position Status"), PT("PT"), NETTOSUMME("Nettosumme"), FAKTURIERT("fakturiert"), @@ -75,13 +74,15 @@ internal class ForecastExportContext( EINTRITTSWAHRSCHEINLICHKEIT("Eintrittswahrsch. in %"), ANSPRECHPARTNER("Ansprechpartner"), STRUKTUR_ELEMENT("Strukturelement"), BEMERKUNG("Bemerkung"), PROBABILITY_NETSUM("gewichtete Nettosumme"), ANZAHL_MONATE("Anzahl Monate"), FORECAST_TYPE("Forecasttyp"), PAYMENT_SCHEDULE("Zahlplan"), - REMAINING("Rest"), DIFFERENCE("Abweichung"), WARNING("Warnung") + REMAINING("Rest"), DIFFERENCE("Abweichung"), WARNING("Warnung"), + PROJECT_ID("ProjectID"), VISIBLE("visible"), VISIBLE_PROJECT_ID("visibleID") } enum class InvoicesCol(val header: String) { INVOICE_NR("Nr."), POS_NR("Pos."), DATE("Datum"), CUSTOMER("Kunde"), PROJECT("Projekt"), SUBJECT("Betreff"), POS_TEXT("Positionstext"), DATE_OF_PAYMENT("Bezahldatum"), - LEISTUNGSZEITRAUM("Leistungszeitraum"), ORDER("Auftrag"), NETSUM("Netto") + LEISTUNGSZEITRAUM("Leistungszeitraum"), ORDER("Auftrag"), NETSUM("Netto"), + PROJECT_ID("ProjectID"), VISIBLE("visible") } enum class MonthCol(val header: String) { @@ -144,4 +145,6 @@ internal class ForecastExportContext( false // showAll is true, if no filter is given and for financial and controlling staff only. val orderPositionMap = mutableMapOf() val orderMapByPositionId = mutableMapOf() + + var hasUnitColEntries = false } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportInvoices.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportInvoices.kt index a8fcbc7dc2..fc73c9c202 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportInvoices.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportInvoices.kt @@ -25,7 +25,9 @@ package org.projectforge.business.fibu import de.micromata.merlin.excel.ExcelSheet import mu.KotlinLogging +import org.projectforge.business.fibu.ForecastExportContext.ForecastCol import org.projectforge.business.fibu.kost.ProjektCache +import org.projectforge.excel.ExcelUtils import org.projectforge.framework.time.PFDay import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service @@ -99,7 +101,16 @@ internal class ForecastExportInvoices { // open needed by Wicket. } } else { monthIndex += 12 - insertIntoSheet(ctx, ctx.invoicesPrevYearSheet, invoice, pos, order, orderPosId, firstMonthCol, monthIndex) + insertIntoSheet( + ctx, + ctx.invoicesPrevYearSheet, + invoice, + pos, + order, + orderPosId, + firstMonthCol, + monthIndex + ) } } } @@ -110,8 +121,24 @@ internal class ForecastExportInvoices { // open needed by Wicket. order: OrderInfo?, orderPosId: Long?, firstMonthCol: Int, monthIndex: Int, ) { val rowNumber = sheet.createRow().rowNum + val excelRowNumber = rowNumber + 1 // Excel row numbers start with 1. sheet.setIntValue(rowNumber, ForecastExportContext.InvoicesCol.INVOICE_NR.header, invoice.nummer) + ExcelUtils.setLongValue( + sheet, + rowNumber, + ForecastExportContext.InvoicesCol.PROJECT_ID.header, + invoice.projekt?.id + ) sheet.setStringValue(rowNumber, ForecastExportContext.InvoicesCol.POS_NR.header, "#${pos.number}") + val visibleProjectIdCol = + ctx.forecastSheet.getColumnDef(ForecastCol.VISIBLE_PROJECT_ID.header)?.columnNumberAsLetters + val projectIdCol = ctx.invoicesSheet.getColumnDef(ForecastExportContext.InvoicesCol.PROJECT_ID.header)?.columnNumberAsLetters + ExcelUtils.setCellFormula( + sheet, + rowNumber, + ForecastExportContext.InvoicesCol.VISIBLE.header, + "COUNTIF(Forecast_Data!$visibleProjectIdCol$11:$visibleProjectIdCol$100000, $projectIdCol$excelRowNumber) > 0" + ) sheet.setDateValue( rowNumber, ForecastExportContext.InvoicesCol.DATE.header, diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderConverterService.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderConverterService.kt index faafdfc083..16c6986e37 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderConverterService.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderConverterService.kt @@ -113,6 +113,7 @@ internal class OrderConverterService { } info.calculateInvoicedSum(info.infoPositions) info.kundeAsString = kundeAsString + info.projektId = projekt?.id info.projektAsString = projekt?.name info.updatePaymentScheduleEntries(paymentSchedules) } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/excel/ExcelUtils.kt b/projectforge-business/src/main/kotlin/org/projectforge/excel/ExcelUtils.kt index 0fe131a3a1..2e0d960e1e 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/excel/ExcelUtils.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/excel/ExcelUtils.kt @@ -51,6 +51,7 @@ private val log = KotlinLogging.logger {} */ object ExcelUtils { /** + * Should be part of Merlin in the next release. * Sets the active sheet and deselects all other sheets. */ @JvmStatic @@ -62,6 +63,7 @@ object ExcelUtils { } /** + * Should be part of Merlin in the next release. * Sets the head row of the sheet. * @param sheet the sheet. * @param rowNum the row number of the head row (0-based). @@ -70,11 +72,15 @@ object ExcelUtils { setHeadRow(sheet, sheet.getRow(rowNum)); } + /** + * Should be part of Merlin in the next release. + */ fun setHeadRow(sheet: ExcelSheet, row: ExcelRow) { ClassUtils.setPrivateField(sheet, "_headRow", row); } /** + * Should be part of Merlin in the next release. * Sets the auto filter for the given row. * @param sheet the sheet. * @param rowNum the row number of the row to set the auto filter. @@ -88,6 +94,10 @@ object ExcelUtils { sheet.poiSheet.setAutoFilter(range) } + /** + * Should be part of Merlin in the next release. + * Clears all cells of the given row. + */ fun clearCells(row: ExcelRow, fromColIndex: Int = 0, toColIndex: Int? = null) { val lastCol = toColIndex ?: row.lastCellNum.toInt() for (i in fromColIndex until lastCol) { @@ -96,6 +106,89 @@ object ExcelUtils { } /** + * Should be part of Merlin in the next release. + * Sets the column width of the given column. + * @param sheet the sheet. + * @param columnHeader the column by header. + */ + fun setColumnHidden(sheet: ExcelSheet, columnHeader: String, hidden: Boolean) { + setColumnHidden(sheet, sheet.getColumnDef(columnHeader)!!, hidden) + } + + /** + * Should be part of Merlin in the next release. + * Sets the column width of the given column. + * @param sheet the sheet. + * @param columnDef the column by header. + */ + fun setColumnHidden(sheet: ExcelSheet, columnDef: ExcelColumnDef, hidden: Boolean) { + setColumnHidden(sheet, columnDef.columnNumber, hidden) + } + + /** + * Should be part of Merlin in the next release. + * Sets the column width of the given column. + * @param sheet the sheet. + * @param col the column number (0-based). + */ + fun setColumnHidden(sheet: ExcelSheet, col: Int, hidden: Boolean) { + sheet.poiSheet.setColumnHidden(col, hidden) + } + + + /** + * Should be part of Merlin in the next release. + */ + fun setLongValue(sheet: ExcelSheet, row: Int, columnHeader: String, value: Long?): Cell { + return setLongValue(sheet, row, sheet.getColumnDef(columnHeader)!!, value) + } + + /** + * Should be part of Merlin in the next release. + */ + fun setLongValue(sheet: ExcelSheet, row: Int, col: ExcelColumnDef, value: Long?): Cell { + return setLongValue(sheet, row, col.columnNumber, value) + } + + /** + * Should be part of Merlin in the next release. + */ + fun setLongValue(sheet: ExcelSheet, row: Int, col: Int, value: Long?): Cell { + val cell = sheet.getCell(row, col, true)!! + if (value == null) cell.setBlank() else { + cell.setCellValue(value.toDouble()) + cell.cellStyle = sheet.excelWorkbook.ensureCellStyle(ExcelCellStandardFormat.INT) + } + return cell + } + + /** + * Should be part of Merlin in the next release. + */ + fun setCellFormula(sheet: ExcelSheet, row: Int, columnHeader: String, formula: String?): Cell { + return setCellFormula(sheet, row, sheet.getColumnDef(columnHeader)!!, formula) + } + + /** + * Should be part of Merlin in the next release. + */ + fun setCellFormula(sheet: ExcelSheet, row: Int, col: ExcelColumnDef, formula: String?): Cell { + return setCellFormula(sheet, row, col.columnNumber, formula) + } + + /** + * Should be part of Merlin in the next release. + */ + fun setCellFormula(sheet: ExcelSheet, row: Int, col: Int, formula: String?): Cell { + val cell = sheet.getCell(row, col, true)!! + if (formula == null) cell.setBlank() else { + cell.cellFormula = formula + } + return cell + } + + /** + * Should be part of Merlin in the next release. * Moves a row to another position. * @param sheet the sheet. * @param fromRowIndex the row index to move. @@ -166,7 +259,7 @@ object ExcelUtils { } /** - * Registers an excel column by using the translated i18n-key of the given property as column head and the + * Registers an Excel column by using the translated i18n-key of the given property as column head and the * property name as alias (for referring and [de.micromata.merlin.excel.ExcelRow.autoFillFromObject]. * @param size approx no of characters */ @@ -253,6 +346,7 @@ object ExcelUtils { } /** + * Should be part of Merlin in the next release. * Clones the font and returns the new font. * @param bold if null, the original font's bold is used. * @param heightInPoints if null, the original font's heightInPoints is used. @@ -282,6 +376,7 @@ object ExcelUtils { } /** + * Should be part of Merlin in the next release. * Clones the cell style and returns the new cell style. * @param font if null, the original cell style's font is used. * @param alignment if null, the original cell style's alignment is used. diff --git a/projectforge-business/src/main/resources/officeTemplates/ForecastTemplate.xlsx b/projectforge-business/src/main/resources/officeTemplates/ForecastTemplate.xlsx index 3fe5945fa8..3403dcd0e7 100644 Binary files a/projectforge-business/src/main/resources/officeTemplates/ForecastTemplate.xlsx and b/projectforge-business/src/main/resources/officeTemplates/ForecastTemplate.xlsx differ