This commit is contained in:
2026-03-09 08:54:42 +07:00
commit f634eaee1a
30 changed files with 2758 additions and 0 deletions

405
.gitignore vendored Normal file
View File

@@ -0,0 +1,405 @@
# Visual Studio and .NET Core
## Build results
.vs/
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/git/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/git/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
# JetBrains IDEs
.idea/
*.swp
*.swo
*~
.DS_Store
# AI assistant files
.qwen.md
.agent/
.qwen/
.opencode/
.claude/
.gemini/
.kilocode/
openspec/changes/
# Local output folder
output/

198
AGENTS.md Normal file
View File

@@ -0,0 +1,198 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-02-09
**Commit:** 107722278e9274073236e8ee4cba21f832b2ed86
**Branch:** main
## OVERVIEW
.NET 10.0 console application for extracting employee tax data from PostgreSQL multi-schema databases. Uses static service classes with synchronous operations.
## PROJECT STRUCTURE
```
./
├── excel_pajak/ # Main application
│ ├── Models/ # Data models (EmployeeInfo)
│ ├── Services/ # Static services (DatabaseService, ConfigurationService)
│ ├── Examples/ # Code examples
│ ├── appsettings.json # Configuration
│ └── excel_pajak.csproj # Main project
├── excel_pajak_test/ # MSTest project
└── openspec/ # OpenSpec change management artifacts
```
## COMMANDS
### Build
```bash
# Build entire solution
dotnet build
# Build specific project
dotnet build excel_pajak
dotnet build excel_pajak_test
```
### Run Tests
```bash
# Run all tests
dotnet test
# Run single test class
dotnet test --filter "FullyQualifiedName~DatabaseServiceTests"
# Run single test method
dotnet test --filter "FullyQualifiedName~DatabaseServiceTests.ExecuteScalar_NullConnectionString_ThrowsArgumentException"
# Run tests with specific logger
dotnet test --logger "console;verbosity=detailed"
# Run tests without building
dotnet test --no-build
```
### Run Application
```bash
# Run with default project
dotnet run --project excel_pajak
# Run with arguments
dotnet run --project excel_pajak -- --schema=company_2024 --year=2024
```
### Clean and Rebuild
```bash
# Clean solution
dotnet clean
# Clean and rebuild
dotnet clean && dotnet build -c Release
```
## CODE STYLE GUIDELINES
### General Principles
- Target .NET 10.0 with latest C# features
- Enable `ImplicitUsings` and `Nullable` reference types
- No comments unless required for complex logic
### Imports
- Use file-scoped namespaces (`namespace excel_pajak.Services;`)
- Group imports: System namespaces first, then third-party
- Order: `System.*``Microsoft.*``ThirdParty.*``Project.*`
```csharp
using Microsoft.Extensions.Configuration;
using Npgsql;
namespace excel_pajak.Services;
```
### Naming Conventions
- **Classes**: PascalCase (`DatabaseService`, `EmployeeInfo`)
- **Methods**: PascalCase (`ExecuteScalar`, `Initialize`)
- **Properties**: PascalCase (`EmployeeNumber`, `ConnectionString`)
- **Local variables**: camelCase (`connectionString`, `result`)
- **Constants**: UPPER_SNAKE_CASE for values, PascalCase for statics
- **Private fields**: camelCase with underscore prefix (`_connectionString`)
- **Parameters**: camelCase (`schema`, `year`)
### Types
- Use nullable reference types (`string?`, `int?`)
- Prefer `var` when type is obvious from context
- Use explicit types for public APIs and method returns
- Use `string?` instead of `string` with null annotations for nullable properties
### Error Handling
- Validate arguments with `ArgumentException` for null/empty checks
- Wrap database exceptions with `InvalidOperationException`
- Use result tuples for recoverable errors: `(bool success, string? result, Exception? error)`
- Re-throw exceptions with context using `throw new ExceptionType("message", inner)`
```csharp
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString));
try { /* ... */ }
catch (NpgsqlException ex)
{
throw new InvalidOperationException($"Database error: {ex.Message}", ex);
}
```
### Static Service Pattern
- Services use static classes with `Initialize()` pattern
- Lazy initialization for dependencies
- No async database operations (intentional - see ANTI-PATTERNS)
```csharp
public static class DatabaseService
{
private static string? _connectionString;
public static void Initialize(string connectionString)
{
_connectionString = connectionString;
}
}
```
### JSON Serialization
- Use `System.Text.Json` with `[JsonPropertyName]` attributes
- Snake_case for JSON property names
```csharp
public class EmployeeInfo
{
[JsonPropertyName("employee_number")]
public string? EmployeeNumber { get; set; }
}
```
### Configuration
- Use `appsettings.json` for all configuration
- Access via `Microsoft.Extensions.Configuration`
- Bind options with `ConfigurationBinder.Bind()`
### Validation
- Schema names: `^[a-zA-Z0-9_-]+$`
- Years: `^\d{4}$`
```csharp
private static readonly Regex SchemaValidation = new(@"^[a-zA-Z0-9_-]+$", RegexOptions.Compiled);
```
### Database Operations
- Synchronous only using Npgsql
- Use `json_agg(row_to_json(t))` for PostgreSQL JSON aggregation
- Placeholder replacement: `query.Replace("{schema}", schema)`
- Dispose resources with `using` statements or `using var`
### Testing
- Use MSTest with `[TestClass]` and `[TestMethod]`
- Use Shouldly for assertions: `Should.Throw<>`, `ShouldBe()`, `ShouldBeNull()`
- Use Moq for mocking
- Mark integration tests with `[Ignore]` if they require database connection
- Use region blocks (`#region`) for grouping related tests
## WHERE TO LOOK
| Task | Location |
|------|----------|
| Database operations | `excel_pajak/Services/DatabaseService.cs` |
| Configuration handling | `excel_pajak/Services/ConfigurationService.cs` |
| Data models | `excel_pajak/Models/EmployeeInfo.cs` |
| Tests | `excel_pajak_test/DatabaseServiceTests.cs` |
| Configuration | `excel_pajak/appsettings.json` |
## ANTI-PATTERNS TO AVOID
- No async database operations (intentional design, may need future migration)
- No connection pooling or disposal tracking in services
- Static state requires explicit `Initialize()` before use
## KEY DEPENDENCIES
- **Npgsql**: PostgreSQL driver (sync operations)
- **NPOI**: Excel file manipulation
- **Microsoft.Extensions.Configuration**: Configuration management
- **Shouldly**: Test assertions
- **Moq**: Mocking framework
- **MSTest**: Test framework
## NOTES
- All tests use MSTest framework
- Configuration requires valid connection string in `appsettings.json`
- Schema and Year parameters drive query execution
- Connection strings validated with regex for security

103
QWEN.md Normal file
View File

@@ -0,0 +1,103 @@
# Excel Pajak Application
## Project Overview
Excel Pajak is a .NET 10.0 console application that connects to a PostgreSQL database to handle tax-related data processing. The application is designed to execute database queries and process employee information, with a focus on connecting to specific database schemas containing employee data.
### Architecture
- **Main Application**: Console application in the `excel_pajak` directory
- **Test Project**: Unit and integration tests in the `excel_pajak_test` directory
- **Technology Stack**:
- .NET 10.0
- PostgreSQL database connectivity via Npgsql
- Microsoft.Extensions.Configuration for configuration management
- MSTest for unit testing
- Moq for mocking dependencies
- Shouldly for assertions
### Key Components
1. **DatabaseService** (`Services/DatabaseService.cs`): Handles PostgreSQL connections and executes scalar queries
2. **EmployeeInfo Model** (`Models/EmployeeInfo.cs`): Represents employee data structure with employee number and schema
3. **Configuration**: Managed through `appsettings.json` with connection strings and application settings
4. **Examples**: Demonstrates JSON deserialization of employee data
## Building and Running
### Prerequisites
- .NET 10.0 SDK
- PostgreSQL database server (version compatible with Npgsql 10.0.1)
- Access to the configured database (default connection points to localhost:55432)
### Build Commands
```bash
# Restore dependencies
dotnet restore
# Build the application
dotnet build
# Run the application
dotnet run --project excel_pajak/excel_pajak.csproj
# Run tests
dotnet test
```
### Configuration
The application uses `appsettings.json` for configuration:
- Connection string for PostgreSQL database
- Schema list (currently contains "_onx4pzkwkeortehfjthgyfkb7c")
- Year setting (currently "2025")
## Development Conventions
### Coding Standards
- Uses nullable reference types (`#nullable enable`)
- Follows .NET naming conventions
- Implements asynchronous programming patterns where appropriate
- Includes proper error handling with specific exception types
### Testing Approach
- Unit tests for business logic with mocked dependencies
- Integration tests that connect to actual PostgreSQL database
- Tests cover both positive and negative scenarios
- Uses Shouldly for readable assertions
### Error Handling
- Validates input parameters and throws appropriate exceptions
- Handles database connection errors gracefully
- Provides detailed error messages for debugging
## Project Structure
```
excel_pajak/
├── excel_pajak/ # Main application
│ ├── Models/ # Data models
│ │ └── EmployeeInfo.cs # Employee data structure
│ ├── Services/ # Business logic services
│ │ └── DatabaseService.cs # PostgreSQL database operations
│ ├── Examples/ # Usage examples
│ │ └── EmployeeJsonExample.cs # JSON processing example
│ ├── Program.cs # Application entry point
│ ├── appsettings.json # Configuration file
│ └── excel_pajak.csproj # Project file
├── excel_pajak_test/ # Test project
│ ├── DatabaseServiceTests.cs # Unit tests
│ ├── DatabaseServiceIntegrationTests.cs # Integration tests
│ └── excel_pajak_test.csproj # Test project file
└── openspec/ # OpenAPI specification directory
```
## Database Connection
The application connects to PostgreSQL using Npgsql with the connection string defined in `appsettings.json`. The default configuration connects to:
- Server: localhost
- Port: 55432
- Database: andal_kharisma
- User: postgres
- Password: Release@2024
## Testing
The project includes both unit tests (with mocked dependencies) and integration tests (requiring a live database connection). Unit tests validate the logic without external dependencies, while integration tests verify actual database connectivity and query execution.
To run integration tests, ensure that a PostgreSQL database is accessible with the configured connection string.

164
README.md Normal file
View File

@@ -0,0 +1,164 @@
# Excel Pajak
Employee tax data extraction and Excel report generation tool for Indonesian payroll processing.
## Overview
.NET 10.0 console application that extracts employee tax data from PostgreSQL multi-schema databases and generates Excel reports using NPOI library.
## Prerequisites
- .NET 10.0 SDK or later
- PostgreSQL database
- Valid connection string in configuration
## Project Structure
```
excel_pajak/
├── Models/ # Data models (EmployeeInfo, EmployeePayrollInfo)
├── Services/ # Static services (DatabaseService, ExcelService)
├── Examples/ # Code examples
├── appsettings.json # Application configuration
└── excel_pajak.csproj # Main project
excel_pajak_test/ # MSTest unit tests
```
## Configuration
Edit [`excel_pajak/appsettings.json`](excel_pajak/appsettings.json) to configure:
```json
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=payroll;Username=your_user;Password=your_password"
},
"ExcelSettings": {
"TemplatePath": "simulasi_perhitungan_ak.xlsx",
"OutputDirectory": "Output"
}
}
```
## Building
```bash
# Build entire solution
dotnet build
# Build specific project
dotnet build excel_pajak
dotnet build excel_pajak_test
```
## Running
```bash
# Run with default settings
dotnet run --project excel_pajak
# Run with command-line arguments
dotnet run --project excel_pajak -- --schema=company_2024 --year=2024
# Run specific schema and year
dotnet run --project excel_pajak -- --schema=company_2025 --year=2025
```
### Command-Line Arguments
| Argument | Description | Required |
|----------|-------------|----------|
| `--schema` | Database schema name | Yes |
| `--year` | Tax year (4 digits) | Yes |
| `--help` | Show help information | No |
## Testing
```bash
# Run all tests
dotnet test
# Run specific test class
dotnet test --filter "FullyQualifiedName~DatabaseServiceTests"
# Run specific test method
dotnet test --filter "FullyQualifiedName~DatabaseServiceTests.ExecuteScalar_NullConnectionString_ThrowsArgumentException"
# Run with detailed output
dotnet test --logger "console;verbosity=detailed"
```
## Workflow
### 1. Configuration Phase
- Load connection string from `appsettings.json`
- Validate schema name and year parameters
- Initialize DatabaseService with connection string
### 2. Data Extraction Phase
- Connect to PostgreSQL database
- Execute query to extract employee tax data
- Query uses `json_agg(row_to_json(t))` for JSON aggregation
- Placeholder replacement: `{schema}` in query template
### 3. Excel Generation Phase
- Load template Excel file (`simulasi_perhitungan_ak.xlsx`)
- Write employee data to specific cells
- Optionally refresh formulas
- Save to Output directory with naming convention: `{EmployeeNumber}_{Year}.xlsx`
### 4. Output Phase
- Generate Excel files in `Output/` directory
- Each file named after employee number and year
- Example: `AA001_2025.xlsx`
## Key Services
### DatabaseService
- Static service for PostgreSQL operations
- Synchronous database operations using Npgsql
- Query execution with schema placeholders
### ExcelService
- Excel file manipulation using NPOI
- Template loading and data writing
- Formula refresh support
- File overwrite control
### ConfigurationService
- Manages application settings
- Loads configuration from appsettings.json
## Architecture
- **Pattern**: Static service classes with Initialize()
- **Database**: Synchronous operations (no async)
- **Serialization**: System.Text.Json with snake_case
- **Validation**: Regex-based schema/year validation
## Anti-Patterns Avoided
- No async database operations (synchronous by design)
- No connection pooling in services
- Static state requires explicit Initialize() before use
## Troubleshooting
### Connection Issues
- Verify PostgreSQL server is running
- Check connection string in appsettings.json
- Ensure database user has appropriate permissions
### Schema Not Found
- Verify schema name exists in database
- Schema names should match pattern: `^[a-zA-Z0-9_-]+$`
### Excel Output Issues
- Ensure Output directory exists
- Check write permissions
- Verify template file exists
## License
Internal use only.

4
excel_pajak.slnx Normal file
View File

@@ -0,0 +1,4 @@
<Solution>
<Project Path="excel_pajak/excel_pajak.csproj" />
<Project Path="excel_pajak_test/excel_pajak_test.csproj" />
</Solution>

View File

@@ -0,0 +1,78 @@
using System.Text.Json;
using excel_pajak.Models;
namespace excel_pajak.Examples;
public static class EmployeeJsonExample
{
public static void RunExample()
{
// Sample JSON from PostgreSQL query
var json = @"[
{""employee_number"":""PS2MA001"",""schema"":""_onx4pzkwkeortehfjthgyfkb7c""},
{""employee_number"":""PS2MA002"",""schema"":""_onx4pzkwkeortehfjthgyfkb7c""},
{""employee_number"":""PS2MA003"",""schema"":""_onx4pzkwkeortehfjthgyfkb7c""}
]";
// Configure options
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
try
{
// Deserialize JSON array to EmployeeInfo[]
var employees = JsonSerializer.Deserialize<EmployeeInfo[]>(json, options);
if (employees != null)
{
Console.WriteLine($"Deserialized {employees.Length} employees:");
foreach (var employee in employees)
{
Console.WriteLine($" - {employee.EmployeeNumber} (schema: {employee.Schema})");
}
}
}
catch (JsonException ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"JSON deserialization error: {ex.Message}");
Console.ResetColor();
}
}
public static void TestEmptyArray()
{
// Test empty array
var emptyJson = "[]";
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
var employees = JsonSerializer.Deserialize<EmployeeInfo[]>(emptyJson, options);
Console.WriteLine($"Empty array test: {(employees?.Length == 0 ? "PASS" : "FAIL")}");
}
public static void TestMalformedJson()
{
// Test malformed JSON
var malformedJson = "[{invalid json}]";
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
try
{
var employees = JsonSerializer.Deserialize<EmployeeInfo[]>(malformedJson, options);
Console.WriteLine("Malformed JSON test: FAIL - should have thrown exception");
}
catch (JsonException)
{
Console.WriteLine("Malformed JSON test: PASS - exception thrown as expected");
}
}
}

View File

@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace excel_pajak.Models;
public class AppSettings
{
public List<string> SchemaList { get; set; } = new List<string>();
[RegularExpression(@"^\d{4}$", ErrorMessage = "Year must be a 4-digit number")]
public string Year { get; set; } = string.Empty;
public ExcelSettings? ExcelSettings { get; set; }
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace excel_pajak.Models;
public class EmployeeInfo
{
[JsonPropertyName("employee_number")]
public string? EmployeeNumber { get; set; }
[JsonPropertyName("schema")]
public string? Schema { get; set; }
}

View File

@@ -0,0 +1,106 @@
using System.Text.Json.Serialization;
namespace excel_pajak.Models;
/// <summary>
/// Represents employee payroll and tax data for a specific payment period.
/// Used for extracting tax-related information from PostgreSQL databases.
/// </summary>
public class EmployeePayrollInfo
{
[JsonPropertyName("employee_number")]
public string? EmployeeNumber { get; set; }
[JsonPropertyName("employee_name")]
public string? EmployeeName { get; set; }
[JsonPropertyName("employee_npwp")]
public string? EmployeeNpwp { get; set; }
[JsonPropertyName("tax_marital_status")]
public string? TaxMaritalStatus { get; set; }
[JsonPropertyName("employee_tax_status")]
public int EmployeeTaxStatus { get; set; }
[JsonPropertyName("payment_period")]
public string? PaymentPeriod { get; set; }
[JsonPropertyName("npwp_pemotong")]
public string? NpwpPemotong { get; set; }
[JsonPropertyName("tax_type")]
public string? TaxType { get; set; }
[JsonPropertyName("basic_salary")]
public decimal BasicSalary { get; set; }
[JsonPropertyName("overtime")]
public decimal Overtime { get; set; }
[JsonPropertyName("allowance")]
public decimal Allowance { get; set; }
[JsonPropertyName("deduction")]
public decimal Deduction { get; set; }
[JsonPropertyName("benefit_in_kind")]
public decimal BenefitInKind { get; set; }
[JsonPropertyName("severance")]
public decimal Severance { get; set; }
[JsonPropertyName("jkk")]
public decimal Jkk { get; set; }
[JsonPropertyName("jkm")]
public decimal Jkm { get; set; }
[JsonPropertyName("bpjs_kesehatan_company")]
public decimal BpjsKesehatanCompany { get; set; }
[JsonPropertyName("jht_emp")]
public decimal JhtEmp { get; set; }
[JsonPropertyName("jp_emp")]
public decimal JpEmp { get; set; }
[JsonPropertyName("pension_emp")]
public decimal PensionEmp { get; set; }
[JsonPropertyName("employee_condition")]
public int EmployeeCondition { get; set; }
[JsonPropertyName("resign_type")]
public string? ResignType { get; set; }
[JsonPropertyName("allowance_tax_regular")]
public decimal AllowanceTaxRegular { get; set; }
[JsonPropertyName("tax_regular")]
public decimal TaxRegular { get; set; }
[JsonPropertyName("allowance_irregular")]
public decimal AllowanceIrregular { get; set; }
[JsonPropertyName("benefit_in_kind_irregular")]
public decimal BenefitInKindIrregular { get; set; }
[JsonPropertyName("allowance_tax_irregular")]
public decimal AllowanceTaxIrregular { get; set; }
[JsonPropertyName("tax_irregular")]
public decimal TaxIrregular { get; set; }
[JsonPropertyName("severance_tax")]
public decimal SeveranceTax { get; set; }
[JsonPropertyName("allowance_severance_tax")]
public decimal AllowanceSeveranceTax { get; set; }
[JsonPropertyName("beginning_salary_netto")]
public decimal BeginningSalaryNetto { get; set; }
[JsonPropertyName("beginning_pph21")]
public decimal BeginningPph21 { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace excel_pajak.Models;
public class ExcelSettings
{
public string TemplatePath { get; set; } = string.Empty;
public string OutputFolder { get; set; } = string.Empty;
}

203
excel_pajak/Program.cs Normal file
View File

@@ -0,0 +1,203 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using excel_pajak.Services;
using excel_pajak.Models;
using System.Text.Json;
namespace excel_pajak;
class Program
{
static void Main(string[] args)
{
// Initialize configuration
var builder = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
IConfigurationRoot configuration = builder.Build();
var schemaList = configuration.GetSection("SchemaList").Get<List<string>>() ?? [];
var year = configuration.GetValue<string>("Year") ?? string.Empty;
var connectionString = configuration.GetConnectionString("DefaultConnection") ?? string.Empty;
var outputFolder = configuration.GetValue<string>("ExcelSettings:OutputFolder") ?? string.Empty;
var templatePath = configuration.GetValue<string>("ExcelSettings:TemplatePath") ?? string.Empty;
// Process each schema in the configuration
var allEmployeeInfos = new List<EmployeeInfo>();
foreach (var schema in schemaList)
{
Console.WriteLine($"\nProcessing schema: {schema}");
string query =
$"""
select json_agg(row_to_json(t)) from (
select distinct e."NIK" as employee_number , '{schema}' as schema
from {schema}."EmployeeReportPeriodics" erp
inner join {schema}."Employees" e on erp."EmployeeId" =e."Id"
inner join {schema}."EmployeeTaxStatuses" ets on e."EmployeeTaxStatusRefId" =ets."Id"
where erp."PaymentPeriod" between '{year}01' and '{year}12'
and ets."EmployeeTaxStatusCode" =1
and erp."TaxCalculationType" <>1 AND (
e."LastWorkingDate" IS NULL
OR e."LastWorkingDate" >= extract(epoch from DATE '{year}-01-01')*1000
)
) t
""";
var result = DatabaseService.ExecuteScalar(query, connectionString);
if (result != null)
{
try
{
// Deserialize the JSON result to a list of EmployeeInfo objects
var employeeInfos = JsonSerializer.Deserialize<List<EmployeeInfo>>(result);
if (employeeInfos != null)
{
// Add the retrieved employees to the overall list
allEmployeeInfos.AddRange(employeeInfos);
Console.WriteLine($"Retrieved {employeeInfos.Count} employee records from schema '{schema}'");
}
}
catch (JsonException ex)
{
Console.WriteLine($"Error deserializing JSON from schema '{schema}': {ex.Message}");
}
}
else
{
Console.WriteLine($"No data returned from schema '{schema}'");
}
}
// Output the aggregated employee information to the console
Console.WriteLine($"\nAggregated {allEmployeeInfos.Count} employee records from all schemas:");
foreach (var employeeInfo in allEmployeeInfos)
{
Console.WriteLine($"Employee Number: {employeeInfo.EmployeeNumber}, Schema: {employeeInfo.Schema}");
ExcelService.InitExcel($"{employeeInfo.EmployeeNumber}_{year}.xlsx", templatePath, outputFolder);
string queryEmployeeData = $"""
select json_agg(row_to_json(t)) from (
select e."NIK" as employee_number, e."Name" as employee_name,e."NPWP" as employee_npwp,
(case
when erp."TaxStatus"=1 then 'T0'
when erp."TaxStatus"=2 then 'T1'
when erp."TaxStatus"=3 then 'T2'
when erp."TaxStatus"=4 then 'T3'
when erp."TaxStatus"=5 then 'K0'
when erp."TaxStatus"=6 then 'K1'
when erp."TaxStatus"=7 then 'K2'
when erp."TaxStatus"=8 then 'K3'
end
) as tax_marital_status,
ets."EmployeeTaxStatusCode" as employee_tax_status,
erp."PaymentPeriod" as payment_period,
erp."NPWPPemotong" as npwp_pemotong,
(case erp."TaxCalculationType" when 2 then 'PaidByEmployee' when 3 then 'PaidAsAllowance' else 'PaidByCompany' end) as tax_type,
erp."BasicSalary" as basic_salary,
erp."Overtime" as overtime,
erp."AllowanceRegular" as allowance,
erp."Deductions" as deduction,
erp."Natura" as benefit_in_kind,
erp."Severance" as severance,
erp."JKKCompany" as jkk,
erp."JKMCompany" as jkm,
erp."BPJSKesCompany" +erp."OtherInsuranceCompany" as bpjs_kesehatan_company,
erp."JHTEmployee" as jht_emp,
erp."JPEmployee" as jp_emp,
0 as pension_emp,
erp."EmployeeCondition" as employee_condition,
erp."ResignType" as resign_type,
erp."AllowanceTaxRegular" as allowance_tax_regular,
erp."TaxRegular" as tax_regular ,
erp."AllowanceIrregular" as allowance_irregular,
0 as benefit_in_kind_irregular,
erp."AllowanceTaxIrregular" as allowance_tax_irregular,
erp."TaxIrregular" as tax_irregular,
0 as severance_tax,
0 as allowance_severance_tax,
erp."BegSalaryNetto" as beginning_salary_netto,
erp."BegPPh21" as beginning_pph21
from {employeeInfo.Schema}."EmployeeReportPeriodics" erp
inner join {employeeInfo.Schema}."Employees" e on erp."EmployeeId" = e."Id"
inner join {employeeInfo.Schema}."EmployeeTaxStatuses" ets on e."EmployeeTaxStatusRefId" =ets."Id"
where erp."TaxCalculationType" <>1
and erp."PaymentPeriod" between '{year}01' and '{year}12'
and e."NIK" ='{employeeInfo.EmployeeNumber}'
) t
""";
string resultPayroll = DatabaseService.ExecuteScalar(queryEmployeeData, connectionString) ?? string.Empty;
if (resultPayroll != "")
{
try
{
// Deserialize the JSON result to a list of EmployeeInfo objects
var employeeInfos = JsonSerializer.Deserialize<List<EmployeePayrollInfo>>(resultPayroll);
if (employeeInfos != null)
{
string taxTypeBasal = employeeInfos.LastOrDefault(x => x.BasicSalary > 0)?.TaxType ?? "PaidByEmployee";
string taxMaritalStatus = employeeInfos.FirstOrDefault()?.TaxMaritalStatus ?? "T0";
string periodStart = employeeInfos.OrderBy(x => x.PaymentPeriod).FirstOrDefault()?.PaymentPeriod ?? "";
string periodEnd = employeeInfos.OrderBy(x => x.PaymentPeriod).LastOrDefault()?.PaymentPeriod ?? "";
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", taxMaritalStatus, 3, 3);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", int.Parse(periodStart.Substring(4, 2)), 2, 7);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", int.Parse(periodEnd.Substring(4, 2)), 3, 7);
List<string?> periodList = employeeInfos.Select(x => x.PaymentPeriod).Distinct().ToList() ?? new();
foreach (string period in periodList)
{
var employeeInfoDetail = employeeInfos.FirstOrDefault(x => x.PaymentPeriod == period && x.TaxType == taxTypeBasal);
int month = int.Parse(period?.Substring(4, 2) ?? "01");
if (employeeInfoDetail != null)
{
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.BasicSalary, 6, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Overtime, 8, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Allowance, 7, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", Math.Abs(employeeInfoDetail.Deduction) * -1, 10, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.BenefitInKind, 9, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Jkk, 11, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Jkm, 12, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.BpjsKesehatanCompany, 13, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.JhtEmp, 18, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.JpEmp, 19, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.PensionEmp, 20, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.AllowanceTaxRegular, 14, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.AllowanceIrregular + employeeInfoDetail.BenefitInKindIrregular, 36, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.AllowanceTaxIrregular, 37, month + 1);
}
employeeInfoDetail = employeeInfos.FirstOrDefault(x => x.PaymentPeriod == period && x.TaxType != taxTypeBasal);
if (employeeInfoDetail != null)
{
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.BasicSalary, 52, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Overtime, 54, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Allowance, 53, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", Math.Abs(employeeInfoDetail.Deduction) * -1, 56, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.BenefitInKind, 55, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Jkk, 57, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Jkm, 58, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.BpjsKesehatanCompany, 59, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.AllowanceTaxRegular, 60, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.AllowanceIrregular + employeeInfoDetail.BenefitInKindIrregular, 85, month + 1);
ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.AllowanceTaxIrregular, 86, month + 1);
}
}
ExcelService.RefreshFormulas($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx");
}
}
catch (JsonException ex)
{
Console.WriteLine($"Error deserializing JSON from schema '{employeeInfo.Schema}': {ex.Message}");
}
}
else
{
Console.WriteLine($"No data returned from schema '{employeeInfo.Schema}'");
}
}
}
}

View File

@@ -0,0 +1,39 @@
# SERVICES DIRECTORY
**Generated:** 2026-02-06
**Path:** excel_pajak/Services/
## OVERVIEW
Static service classes for database and configuration operations.
## FILES
| File | Purpose |
|------|---------|
| `DatabaseService.cs` | PostgreSQL query execution with sync operations |
| `ConfigurationService.cs` | Configuration access with lazy initialization |
## CONVENTIONS
- Static classes with `Initialize()` pattern for dependency injection
- Lazy initialization in ConfigurationService: creates default config on first access
- Error handling via result tuples: `(bool success, string? result, Exception? error)`
- Schema name validation regex: `^[a-zA-Z0-9_-]+$`
- Year validation regex: `^\d{4}$`
## ERROR HANDLING
```csharp
// Pattern: TryExecute pattern for schema iteration
var (success, result, error) = DatabaseService.ExecuteEmployeeTaxQueryWithErrorHandling(schema, year);
if (success) { /* use result */ }
else { /* handle error, continue processing */ }
```
## INITIALIZATION
```csharp
// Before using services:
ConfigurationService.Initialize(configuration);
DatabaseService.Initialize(connectionString);
```
## QUERY PATTERNS
- Use `json_agg(row_to_json(t))` for PostgreSQL JSON aggregation
- Placeholder replacement for schema/year: `query.Replace("{schema}", schema)`

View File

@@ -0,0 +1,54 @@
using Microsoft.Extensions.Configuration;
using Npgsql;
namespace excel_pajak.Services;
public static class DatabaseService
{
public static string? ExecuteScalar(string query, string connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString));
}
if (string.IsNullOrWhiteSpace(query))
{
throw new ArgumentException("Query cannot be null or empty.", nameof(query));
}
try
{
using var connection = new NpgsqlConnection(connectionString);
connection.Open();
using var command = new NpgsqlCommand(query, connection);
var result = command.ExecuteScalar();
connection.Close();
// Handle empty result sets (no rows)
if (result == null || result == DBNull.Value)
{
return null;
}
return result.ToString();
}
catch (Npgsql.PostgresException ex)
{
// Re-throw PostgreSQL exceptions with context
throw new InvalidOperationException(
$"PostgreSQL error executing query: {ex.MessageText ?? ex.Message}",
ex);
}
catch (NpgsqlException ex)
{
// Re-throw Npgsql exceptions with context
throw new InvalidOperationException(
$"Database error executing query: {ex.Message}",
ex);
}
}
}

View File

@@ -0,0 +1,206 @@
using System.IO;
using NPOI.SS.UserModel;
using NPOI.XSSF.UserModel;
using NPOI.XSSF.UserModel.Helpers;
using excel_pajak.Models;
namespace excel_pajak.Services;
public static class ExcelService
{
/// <summary>
/// Initializes an Excel workbook from a template file.
/// </summary>
/// <param name="outputName">The name of the output Excel file (without .xlsx extension).</param>
/// <param name="templatePath">The relative path to the template file from the application base directory.</param>
/// <param name="outputFolder">The folder where the output file will be created.</param>
/// <param name="overwrite">If true, will overwrite an existing file. If false, will throw IOException if file exists.</param>
/// <returns>A tuple containing success status, output file path on success, or exception on failure.</returns>
public static (bool success, string? result, Exception? error) InitExcel(string outputName, string templatePath, string outputFolder, bool overwrite = true)
{
if (string.IsNullOrWhiteSpace(outputName))
{
return (false, null, new ArgumentException("File name cannot be null or empty"));
}
try
{
var templateFullPath = Path.IsPathRooted(templatePath)
? templatePath
: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, templatePath);
if (!File.Exists(templateFullPath))
{
return (false, null, new FileNotFoundException($"Template file not found: {templateFullPath}"));
}
if (!Directory.Exists(outputFolder))
{
Directory.CreateDirectory(outputFolder);
}
var outputFileName = outputName.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase)
? outputName
: outputName + ".xlsx";
var outputFullPath = Path.Combine(outputFolder, outputFileName);
if (!overwrite && File.Exists(outputFullPath))
{
return (false, null, new IOException($"File already exists: {outputFullPath}. Set overwrite=true to replace it."));
}
using (var sourceStream = new FileStream(templateFullPath, FileMode.Open, FileAccess.Read))
{
var workbook = new XSSFWorkbook(sourceStream);
using (var destinationStream = new FileStream(outputFullPath, overwrite ? FileMode.Create : FileMode.CreateNew, FileAccess.Write))
{
workbook.Write(destinationStream, false);
}
workbook.Close();
}
return (true, outputFullPath, null);
}
catch (Exception ex)
{
return (false, null, ex);
}
}
public static bool WriteExcel(string fileName, string value, int row, int column)
{
return InternalWriteExcel(fileName, value, row, column);
}
public static bool WriteExcel(string fileName, long value, int row, int column)
{
return InternalWriteExcel(fileName, value, row, column);
}
public static bool WriteExcel(string fileName, decimal value, int row, int column)
{
return InternalWriteExcel(fileName, value, row, column);
}
private static bool InternalWriteExcel(string fileName, object value, int row, int column)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return false;
}
if (row < 1)
{
return false;
}
if (column < 1)
{
return false;
}
if (!File.Exists(fileName))
{
return false;
}
try
{
XSSFWorkbook workbook;
using (var fileStream = new FileStream(fileName, FileMode.Open, FileAccess.Read))
{
workbook = new XSSFWorkbook(fileStream);
}
ISheet sheet = workbook.NumberOfSheets > 0 ? workbook.GetSheetAt(0) : workbook.CreateSheet();
IRow dataRow = sheet.GetRow(row - 1) ?? sheet.CreateRow(row - 1);
ICell cell = dataRow.GetCell(column - 1) ?? dataRow.CreateCell(column - 1);
if (value is string strValue)
{
if (string.IsNullOrEmpty(strValue))
{
cell.SetCellValue(string.Empty);
}
else if (double.TryParse(strValue, out double numericValue))
{
cell.SetCellValue(numericValue);
}
else
{
cell.SetCellValue(strValue);
}
}
else if (value is long longValue)
{
cell.SetCellValue((double)longValue);
}
else if (value is decimal decimalValue)
{
cell.SetCellValue((double)decimalValue);
}
else if (value is double doubleValue)
{
cell.SetCellValue(doubleValue);
}
using (var fileStream = new FileStream(fileName, FileMode.Create, FileAccess.Write))
{
workbook.Write(fileStream, false);
}
workbook.Close();
return true;
}
catch (Exception)
{
return false;
}
}
public static bool RefreshFormulas(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return false;
}
if (!File.Exists(fileName))
{
return false;
}
try
{
XSSFWorkbook workbook;
using (var fileStream = new FileStream(fileName, FileMode.Open, FileAccess.Read))
{
workbook = new XSSFWorkbook(fileStream);
}
for (int i = 0; i < workbook.NumberOfSheets; i++)
{
ISheet sheet = workbook.GetSheetAt(i);
sheet.ForceFormulaRecalculation = true;
}
XSSFFormulaEvaluator.EvaluateAllFormulaCells(workbook);
using (var fileStream = new FileStream(fileName, FileMode.Create, FileAccess.Write))
{
workbook.Write(fileStream, false);
}
workbook.Close();
return true;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,20 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Port=55432;Database=andal_kharisma;User Id=postgres;Password=Release@2024;Timeout=60;CommandTimeout=120;ServerCompatibilityMode=NoTypeLoading;"
},
"SchemaList": [
"_fpjs5fybbsar9cxyttn9ui5ndw"
],
"Year": "2025",
"ExcelSettings": {
"TemplatePath": "simulasi_perhitungan_ak.xlsx",
"OutputFolder": "Output"
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.2" />
<PackageReference Include="Npgsql" Version="10.0.1" />
<PackageReference Include="NPOI" Version="2.7.5" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="simulasi_perhitungan_ak.xlsx">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

Binary file not shown.

View File

@@ -0,0 +1,147 @@
using Microsoft.Extensions.Configuration;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using excel_pajak.Services;
using Shouldly;
using System;
namespace excel_pajak_test;
[TestClass]
public sealed class DatabaseServiceIntegrationTests
{
private static string? _connectionString;
[ClassInitialize]
public static void ClassInitialize(TestContext context)
{
// Load configuration from appsettings.json
var builder = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false);
var configuration = builder.Build();
_connectionString = configuration["ConnectionStrings:DefaultConnection"];
if (string.IsNullOrWhiteSpace(_connectionString))
{
throw new InvalidOperationException("Connection string is missing in test appsettings.json");
}
}
[TestMethod]
public void Integration_ValidQuery_ReturnsResult()
{
// Act
var result = DatabaseService.ExecuteScalar("SELECT 42", _connectionString!);
// Assert
result.ShouldBe("42");
}
[TestMethod]
public void Integration_QueryReturnsNull_ReturnsNull()
{
// Act
var result = DatabaseService.ExecuteScalar("SELECT NULL::text", _connectionString!);
// Assert
result.ShouldBeNull();
}
[TestMethod]
public void Integration_EmptyResult_ReturnsNull()
{
// Act
var result = DatabaseService.ExecuteScalar("SELECT 1 WHERE 1=0", _connectionString!);
// Assert
result.ShouldBeNull();
}
[TestMethod]
public void Integration_QueryReturnsString_ReturnsString()
{
// Act
var result = DatabaseService.ExecuteScalar("SELECT 'test value'", _connectionString!);
// Assert
result.ShouldBe("test value");
}
[TestMethod]
public void Integration_QueryReturnsInteger_ReturnsString()
{
// Act
var result = DatabaseService.ExecuteScalar("SELECT 12345", _connectionString!);
// Assert
result.ShouldBe("12345");
}
[TestMethod]
public void Integration_QueryReturnsTimestamp_ReturnsString()
{
// Act
var result = DatabaseService.ExecuteScalar("SELECT NOW()", _connectionString!);
// Assert
result.ShouldNotBeNull();
result.ShouldBeOfType<string>();
Console.WriteLine($"Database timestamp: {result}");
}
[TestMethod]
public void Integration_QueryVersion_ReturnsVersionString()
{
// Act
var result = DatabaseService.ExecuteScalar("SELECT version()", _connectionString!);
// Assert
result.ShouldNotBeNull();
result.ShouldStartWith("PostgreSQL");
Console.WriteLine($"PostgreSQL version: {result}");
}
[TestMethod]
public void Integration_InvalidSQL_ThrowsInvalidOperationException()
{
// Act & Assert
var exception = Should.Throw<InvalidOperationException>(() => DatabaseService.ExecuteScalar("INVALID SQL SYNTAX", _connectionString!));
exception.Message.ShouldMatch(".*(PostgreSQL error|Database error).*");
Console.WriteLine($"Expected error caught: {exception.Message}");
}
[TestMethod]
public void Integration_QueryToNonExistentTable_ThrowsInvalidOperationException()
{
// Act & Assert
var exception = Should.Throw<InvalidOperationException>(() => DatabaseService.ExecuteScalar("SELECT * FROM nonexistent_table_xyz LIMIT 1", _connectionString!));
exception.Message.ShouldMatch(".*(Database error|PostgreSQL error).*");
exception.InnerException.ShouldNotBeNull();
Console.WriteLine($"Expected error caught: {exception.Message}");
}
[TestMethod]
public void Integration_QueryCurrentDatabase_ReturnsDatabaseName()
{
// Act
var result = DatabaseService.ExecuteScalar("SELECT current_database()", _connectionString!);
// Assert
result.ShouldNotBeNull();
Console.WriteLine($"Current database: {result}");
}
[TestMethod]
public void Integration_QueryCurrentUser_ReturnsUserName()
{
// Act
var result = DatabaseService.ExecuteScalar("SELECT current_user", _connectionString!);
// Assert
result.ShouldNotBeNull();
Console.WriteLine($"Current user: {result}");
}
}

View File

@@ -0,0 +1,147 @@
using Microsoft.Extensions.Configuration;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using excel_pajak.Services;
using Shouldly;
using System;
namespace excel_pajak_test;
[TestClass]
public sealed class DatabaseServiceTests
{
private const string ValidConnectionString = "Server=localhost;Port=5432;Database=testdb;User Id=testuser;Password=testpass;";
#region Connection String Validation Tests
[TestMethod]
public void ExecuteScalar_NullConnectionString_ThrowsArgumentException()
{
// Act & Assert
Should.Throw<ArgumentException>(() => DatabaseService.ExecuteScalar("SELECT 1", null!));
}
[TestMethod]
public void ExecuteScalar_EmptyConnectionString_ThrowsArgumentException()
{
// Act & Assert
Should.Throw<ArgumentException>(() => DatabaseService.ExecuteScalar("SELECT 1", string.Empty));
}
#endregion
#region Query Validation Tests
[TestMethod]
public void ExecuteScalar_NullQuery_ThrowsArgumentException()
{
// Act & Assert
Should.Throw<ArgumentException>(() => DatabaseService.ExecuteScalar(null!, ValidConnectionString));
}
[TestMethod]
public void ExecuteScalar_EmptyQuery_ThrowsArgumentException()
{
// Act & Assert
Should.Throw<ArgumentException>(() => DatabaseService.ExecuteScalar(string.Empty, ValidConnectionString));
}
[TestMethod]
public void ExecuteScalar_WhitespaceQuery_ThrowsArgumentException()
{
// Act & Assert
Should.Throw<ArgumentException>(() => DatabaseService.ExecuteScalar(" ", ValidConnectionString));
}
#endregion
#region Database Connection Tests
[TestMethod]
public void ExecuteScalar_InvalidConnection_ThrowsInvalidOperationException()
{
// Arrange
var invalidConnection = "Server=invalidhost;Port=9999;Database=nonexistent;User Id=invalid;Password=invalid;";
// Act & Assert
var exception = Should.Throw<InvalidOperationException>(() => DatabaseService.ExecuteScalar("SELECT 1", invalidConnection));
exception.Message.ShouldContain("Database error");
exception.InnerException.ShouldNotBeNull();
}
#endregion
#region Query Execution Tests (Integration - requires PostgreSQL)
[TestMethod]
[Ignore] // Requires database connection
public void ExecuteScalar_ValidQuery_ReturnsResult()
{
// Act
var result = DatabaseService.ExecuteScalar("SELECT 42", ValidConnectionString);
// Assert
result.ShouldBe("42");
}
[TestMethod]
[Ignore] // Requires database connection
public void ExecuteScalar_QueryReturnsNull_ReturnsNull()
{
// Act
var result = DatabaseService.ExecuteScalar("SELECT NULL::text", ValidConnectionString);
// Assert
result.ShouldBeNull();
}
[TestMethod]
[Ignore] // Requires database connection
public void ExecuteScalar_EmptyResult_ReturnsNull()
{
// Act
var result = DatabaseService.ExecuteScalar("SELECT 1 WHERE 1=0", ValidConnectionString);
// Assert
result.ShouldBeNull();
}
[TestMethod]
[Ignore] // Requires database connection
public void ExecuteScalar_QueryReturnsString_ReturnsString()
{
// Act
var result = DatabaseService.ExecuteScalar("SELECT 'test value'", ValidConnectionString);
// Assert
result.ShouldBe("test value");
}
[TestMethod]
[Ignore] // Requires database connection
public void ExecuteScalar_QueryReturnsInteger_ReturnsString()
{
// Act
var result = DatabaseService.ExecuteScalar("SELECT 12345", ValidConnectionString);
// Assert
result.ShouldBe("12345");
}
#endregion
#region Error Handling Tests
[TestMethod]
[Ignore] // Requires database connection
public void ExecuteScalar_InvalidSQL_ThrowsInvalidOperationException()
{
// Act & Assert
var exception = Should.Throw<InvalidOperationException>(() => DatabaseService.ExecuteScalar("INVALID SQL SYNTAX", ValidConnectionString));
exception.Message.ShouldMatch(".*(PostgreSQL error|Database error).*");
}
#endregion
}

View File

@@ -0,0 +1,372 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using excel_pajak.Services;
using Shouldly;
using System;
using System.IO;
namespace excel_pajak_test;
[TestClass]
public sealed class ExcelServiceTests
{
#region WriteExcel Parameter Validation Tests
[TestMethod]
public void WriteExcel_EmptyFileName_ReturnsFailure()
{
// Act
var result = ExcelService.WriteExcel("", "value", 1, 1);
// Assert
result.ShouldBeFalse();
}
[TestMethod]
public void WriteExcel_NullFileName_ReturnsFailure()
{
// Act
var result = ExcelService.WriteExcel(null!, "value", 1, 1);
// Assert
result.ShouldBeFalse();
}
[TestMethod]
public void WriteExcel_NonExistentFile_ReturnsFailure()
{
// Act
var result = ExcelService.WriteExcel("nonexistent.xlsx", "value", 1, 1);
// Assert
result.ShouldBeFalse();
}
[TestMethod]
public void WriteExcel_InvalidRowZero_ReturnsFailure()
{
// Act
var result = ExcelService.WriteExcel("test.xlsx", "value", 0, 1);
// Assert
result.ShouldBeFalse();
}
[TestMethod]
public void WriteExcel_InvalidRowNegative_ReturnsFailure()
{
// Act
var result = ExcelService.WriteExcel("test.xlsx", "value", -1, 1);
// Assert
result.ShouldBeFalse();
}
[TestMethod]
public void WriteExcel_InvalidColumnZero_ReturnsFailure()
{
// Act
var result = ExcelService.WriteExcel("test.xlsx", "value", 1, 0);
// Assert
result.ShouldBeFalse();
}
[TestMethod]
public void WriteExcel_InvalidColumnNegative_ReturnsFailure()
{
// Act
var result = ExcelService.WriteExcel("test.xlsx", "value", 1, -1);
// Assert
result.ShouldBeFalse();
}
#endregion
#region InitExcel Parameter Validation Tests
[TestMethod]
public void InitExcel_EmptyOutputName_ReturnsFailure()
{
// Act - This test validates the empty string check happens before initialization check
// by using a file that doesn't exist (which would fail if initialization check was first)
var result = ExcelService.InitExcel("", "template.xlsx", "output");
// Assert - Should fail due to empty output name
result.success.ShouldBeFalse();
result.error.ShouldNotBeNull();
}
[TestMethod]
public void InitExcel_NullOutputName_ReturnsFailure()
{
// Act
var result = ExcelService.InitExcel(null!, null!, null!);
// Assert
result.success.ShouldBeFalse();
result.error.ShouldNotBeNull();
}
#endregion
#region InitExcel Overwrite Control Tests
[TestMethod]
public void InitExcel_OverwriteFalse_ExistingFile_ThrowsIOException()
{
// Arrange
var tempDir = Path.Combine(Path.GetTempPath(), "excel_pajak_test_" + Guid.NewGuid());
Directory.CreateDirectory(tempDir);
var templatePath = Path.Combine(tempDir, "template.xlsx");
var outputPath = Path.Combine(tempDir, "output.xlsx");
// Create a minimal template file
var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook();
workbook.CreateSheet("Test");
using (var fs = new FileStream(templatePath, FileMode.Create)) { workbook.Write(fs, false); }
workbook.Close();
// Create existing output file
File.WriteAllText(outputPath, "existing content");
try
{
// Act
var result = ExcelService.InitExcel("output", templatePath, tempDir, overwrite: false);
// Assert
result.success.ShouldBeFalse();
result.error.ShouldBeOfType<IOException>();
result.error!.Message.ShouldContain("already exists");
}
finally
{
// Cleanup
Directory.Delete(tempDir, true);
}
}
[TestMethod]
public void InitExcel_OverwriteTrue_ExistingFile_Succeeds()
{
// Arrange
var tempDir = Path.Combine(Path.GetTempPath(), "excel_pajak_test_" + Guid.NewGuid());
Directory.CreateDirectory(tempDir);
var templatePath = Path.Combine(tempDir, "template.xlsx");
var outputPath = Path.Combine(tempDir, "output.xlsx");
// Create a minimal template file
var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook();
workbook.CreateSheet("Test");
using (var fs = new FileStream(templatePath, FileMode.Create)) { workbook.Write(fs, false); }
workbook.Close();
// Create existing output file
File.WriteAllText(outputPath, "existing content");
try
{
// Act
var result = ExcelService.InitExcel("output.xlsx", templatePath, tempDir, overwrite: true);
// Assert
result.success.ShouldBeTrue();
result.result.ShouldNotBeNull();
File.Exists(result.result!).ShouldBeTrue();
}
finally
{
// Cleanup
Directory.Delete(tempDir, true);
}
}
[TestMethod]
public void InitExcel_OverwriteFalse_NewFile_Succeeds()
{
// Arrange
var tempDir = Path.Combine(Path.GetTempPath(), "excel_pajak_test_" + Guid.NewGuid());
Directory.CreateDirectory(tempDir);
var templatePath = Path.Combine(tempDir, "template.xlsx");
// Create a minimal template file
var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook();
workbook.CreateSheet("Test");
using (var fs = new FileStream(templatePath, FileMode.Create)) { workbook.Write(fs, false); }
workbook.Close();
try
{
// Act
var result = ExcelService.InitExcel("output.xlsx", templatePath, tempDir, overwrite: false);
// Assert
result.success.ShouldBeTrue();
result.result.ShouldNotBeNull();
File.Exists(result.result!).ShouldBeTrue();
}
finally
{
// Cleanup
Directory.Delete(tempDir, true);
}
}
[TestMethod]
public void InitExcel_DefaultOverwrite_ExistingFile_Overwrites()
{
// Arrange
var tempDir = Path.Combine(Path.GetTempPath(), "excel_pajak_test_" + Guid.NewGuid());
Directory.CreateDirectory(tempDir);
var templatePath = Path.Combine(tempDir, "template.xlsx");
var outputPath = Path.Combine(tempDir, "output.xlsx");
// Create a minimal template file
var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook();
workbook.CreateSheet("Test");
using (var fs = new FileStream(templatePath, FileMode.Create)) { workbook.Write(fs, false); }
workbook.Close();
// Create existing output file
File.WriteAllText(outputPath, "existing content");
try
{
// Act - Default behavior should overwrite
var result = ExcelService.InitExcel("output.xlsx", templatePath, tempDir);
// Assert
result.success.ShouldBeTrue();
result.result.ShouldNotBeNull();
File.Exists(result.result!).ShouldBeTrue();
}
finally
{
// Cleanup
Directory.Delete(tempDir, true);
}
}
#endregion
#region Integration Tests (manual execution)
[TestMethod]
[Ignore] // Requires proper initialization sequence - run manually
public void IntegrationTest_InitializeAndInitExcel()
{
// This test requires manual setup - skip in CI
Assert.Inconclusive("Requires manual configuration setup");
}
[TestMethod]
[Ignore] // File locking issues in parallel tests
public void WriteExcel_WithValidFile_WritesValue_Integration()
{
// Arrange - Create a simple test Excel file using absolute path
var testFilePath = Path.Combine(Path.GetTempPath(), "test_excel_integration.xlsx");
// Create a minimal workbook using NPOI
var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook();
var sheet = workbook.CreateSheet("Test");
var row = sheet.CreateRow(0);
var cell = row.CreateCell(0);
cell.SetCellValue((string?)"Initial");
using (var fs = new FileStream(testFilePath, FileMode.Create))
{
workbook.Write(fs, false);
}
workbook.Close();
try
{
// Act - Write to cell
var result = ExcelService.WriteExcel(testFilePath, "NewValue", 1, 1);
// Assert
result.ShouldBeTrue();
}
finally
{
// Clean up
if (File.Exists(testFilePath))
{
try { File.Delete(testFilePath); } catch { }
}
}
}
[TestMethod]
[Ignore] // File locking issues in parallel tests
public void WriteExcel_LongOverload_WritesNumericValue()
{
// Arrange
var testFilePath = Path.Combine(Path.GetTempPath(), "test_excel_long.xlsx");
var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook();
workbook.CreateSheet("Test").CreateRow(0).CreateCell(0).SetCellValue(0);
using (var fs = new FileStream(testFilePath, FileMode.Create)) { workbook.Write(fs, false); }
workbook.Close();
try
{
// Act
long testValue = 1234567890L;
var result = ExcelService.WriteExcel(testFilePath, testValue, 1, 1);
// Assert
result.ShouldBeTrue();
// Verify
using (var fs = new FileStream(testFilePath, FileMode.Open, FileAccess.Read))
{
var wb = new NPOI.XSSF.UserModel.XSSFWorkbook(fs);
var cellValue = wb.GetSheetAt(0).GetRow(0).GetCell(0).NumericCellValue;
cellValue.ShouldBe(1234567890.0);
wb.Close();
}
}
finally
{
if (File.Exists(testFilePath)) { try { File.Delete(testFilePath); } catch { } }
}
}
[TestMethod]
[Ignore] // File locking issues in parallel tests
public void WriteExcel_LargeLongValue_MaintainsPrecision()
{
// Arrange
var testFilePath = Path.Combine(Path.GetTempPath(), "test_excel_large_long.xlsx");
var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook();
workbook.CreateSheet("Test").CreateRow(0).CreateCell(0).SetCellValue(0);
using (var fs = new FileStream(testFilePath, FileMode.Create)) { workbook.Write(fs, false); }
workbook.Close();
try
{
// Act - A large number that still fits within double's exact integer representation (53 bits)
long testValue = 9007199254740991L; // 2^53 - 1
var result = ExcelService.WriteExcel(testFilePath, testValue, 1, 1);
// Assert
result.ShouldBeTrue();
// Verify
using (var fs = new FileStream(testFilePath, FileMode.Open, FileAccess.Read))
{
var wb = new NPOI.XSSF.UserModel.XSSFWorkbook(fs);
var cellValue = wb.GetSheetAt(0).GetRow(0).GetCell(0).NumericCellValue;
cellValue.ShouldBe((double)testValue);
wb.Close();
}
}
finally
{
if (File.Exists(testFilePath)) { try { File.Delete(testFilePath); } catch { } }
}
}
#endregion
}

View File

@@ -0,0 +1 @@
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]

11
excel_pajak_test/Test1.cs Normal file
View File

@@ -0,0 +1,11 @@
namespace excel_pajak_test
{
[TestClass]
public sealed class Test1
{
[TestMethod]
public void TestMethod1()
{
}
}
}

View File

@@ -0,0 +1,15 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Port=55432;Database=andal_kharisma;User Id=postgres;Password=Release@2024;Timeout=60;CommandTimeout=120;ServerCompatibilityMode=NoTypeLoading;"
},
"SchemaList": ["_onx4pzkwkeortehfjthgyfkb7c"],
"Year": "2025"
}

View File

@@ -0,0 +1,34 @@
<Project Sdk="MSTest.Sdk/4.0.1">
<ItemGroup>
<ProjectReference Include="..\excel_pajak\excel_pajak.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NPOI" Version="2.7.5" />
<PackageReference Include="Shouldly" Version="4.3.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseVSTest>true</UseVSTest>
</PropertyGroup>
<ItemGroup>
<PackageReference Update="Microsoft.NET.Test.Sdk" Version="18.0.1" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="MSTest.TestAdapter" Version="4.1.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="MSTest.TestFramework" Version="4.1.0" />
</ItemGroup>
</Project>

20
openspec/config.yaml Normal file
View File

@@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours

View File

@@ -0,0 +1,10 @@
## ADDED Requirements
### Requirement: ExcelService shall support writing long values
The `ExcelService` class SHALL provide a `WriteExcel` overload that accepts a `long` value.
#### Scenario: WriteExcel with valid long value
- **WHEN** `WriteExcel(string fileName, long value, int row, int column)` is called with valid parameters
- **THEN** the system SHALL write the numeric `long` value to the specified cell
- **AND** the cell type in Excel SHALL be numeric
- **AND** the file SHALL be saved successfully

View File

@@ -0,0 +1,39 @@
# file-overwrite-control Specification
## Purpose
TBD - created by archiving change add-file-overwrite-control. Update Purpose after archive.
## Requirements
### Requirement: ExcelService provides file overwrite control
The ExcelService SHALL provide explicit control over whether existing files should be overwritten during initialization.
#### Scenario: Create new file when file does not exist
- **WHEN** `InitExcel` is called with `overwrite=true` and file does not exist
- **THEN** method creates new file and returns success with file path
#### Scenario: Overwrite existing file when overwrite is true
- **WHEN** `InitExcel` is called with `overwrite=true` and file exists
- **THEN** method overwrites existing file and returns success with file path
#### Scenario: Throw exception when file exists and overwrite is false
- **WHEN** `InitExcel` is called with `overwrite=false` and file exists
- **THEN** method throws `IOException` with message indicating file already exists
#### Scenario: Create file when overwrite is false and file does not exist
- **WHEN** `InitExcel` is called with `overwrite=false` and file does not exist
- **THEN** method creates new file and returns success with file path
#### Scenario: Preserve existing file behavior when overwrite parameter is not provided
- **WHEN** `InitExcel` is called without the `overwrite` parameter
- **THEN** method behaves as current implementation (overwrites file)
### Requirement: ExcelService maintains backward compatibility
The ExcelService SHALL maintain existing behavior for all current method signatures and parameters.
#### Scenario: Existing code continues to work without changes
- **WHEN** existing code calls `InitExcel` without the `overwrite` parameter
- **THEN** method behaves identically to current implementation (overwrites file)
#### Scenario: Existing error handling patterns remain valid
- **WHEN** existing code checks the result tuple for success/failure
- **THEN** method returns same tuple structure and error types as current implementation

View File

@@ -0,0 +1,56 @@
# Capability: Formula Refresh
## Purpose
The Formula Refresh capability allows the system to recalculate all formulas in an Excel workbook and update their cached values. This is essential when data has been written to the workbook externally and the calculated results need to be persisted within the file itself.
## Requirements
### Requirement: Refresh all formulas in workbook
The ExcelService SHALL provide a public method `RefreshFormulas` that evaluates and recalculates all formulas in an Excel workbook.
#### Scenario: Successful formula refresh
- **WHEN** a valid Excel file containing formulas is provided to `RefreshFormulas`
- **THEN** all formulas in the workbook are evaluated
- **AND** cached formula values are updated with recalculated results
- **AND** the workbook is saved back to the file
- **AND** the method returns `(success: true, result: <filePath>, error: null)`
#### Scenario: File not found
- **WHEN** `RefreshFormulas` is called with a file path that does not exist
- **THEN** the method returns `(success: false, result: null, error: FileNotFoundException)`
#### Scenario: Invalid or corrupted workbook
- **WHEN** `RefreshFormulas` is called with a file that is not a valid Excel workbook
- **THEN** the method returns `(success: false, result: null, error: <exception>)`
- **AND** the original file remains unmodified
#### Scenario: Empty file path
- **WHEN** `RefreshFormulas` is called with null or empty file path
- **THEN** the method returns `(success: false, result: null, error: ArgumentException)`
### Requirement: Formula evaluation completeness
The `RefreshFormulas` method SHALL evaluate all formula types across all sheets in the workbook.
#### Scenario: Multiple sheets with formulas
- **WHEN** a workbook contains multiple sheets with formulas
- **THEN** formulas in all sheets are evaluated
- **AND** cross-sheet references are properly resolved
#### Scenario: Different formula types
- **WHEN** a workbook contains various formula types (SUM, AVERAGE, IF, VLOOKUP, etc.)
- **THEN** all formula types are evaluated correctly
- **AND** formulas dependent on other formulas are evaluated in correct order
### Requirement: Resource management
The `RefreshFormulas` method SHALL properly dispose of all resources including file streams and workbook objects.
#### Scenario: Proper disposal after successful refresh
- **WHEN** formula refresh completes successfully
- **THEN** all file streams are closed and disposed
- **AND** the workbook object is disposed
- **AND** no file locks remain on the Excel file
#### Scenario: Proper disposal after error
- **WHEN** an exception occurs during formula refresh
- **THEN** all allocated resources are still disposed
- **AND** no file locks remain on the Excel file

View File

@@ -0,0 +1,89 @@
## ADDED Requirements
### Requirement: Configuration loading from appsettings.json
The system SHALL load the PostgreSQL connection string from `appsettings.json` using the `ConnectionStrings:DefaultConnection` configuration key.
#### Scenario: Successful configuration loading
- **WHEN** the application starts and `appsettings.json` exists with a valid `ConnectionStrings:DefaultConnection` entry
- **THEN** the system shall successfully load the connection string
- **AND** the connection string shall be available for database connections
#### Scenario: Missing connection string
- **WHEN** the application starts and `appsettings.json` is missing the `ConnectionStrings:DefaultConnection` entry
- **THEN** the system shall throw an `InvalidOperationException` with a clear error message indicating the missing configuration
#### Scenario: Invalid appsettings.json format
- **WHEN** the application starts and `appsettings.json` contains invalid JSON
- **THEN** the system shall throw a configuration exception indicating the JSON parsing error
### Requirement: Database query execution
The system SHALL provide a function that accepts a SQL query string and returns the first column of the first row as a string value.
#### Scenario: Successful query execution with single result
- **WHEN** a valid SQL query is executed that returns a single row with a single column
- **THEN** the system shall return the value as a string
- **AND** the database connection shall be properly opened and closed
#### Scenario: Query returning NULL value
- **WHEN** a SQL query is executed that returns a NULL value in the first column of the first row
- **THEN** the system shall return `null` instead of throwing an exception
#### Scenario: Query returning no rows
- **WHEN** a SQL query is executed that returns zero rows
- **THEN** the system shall return `null`
#### Scenario: Invalid SQL syntax
- **WHEN** a SQL query with invalid syntax is executed
- **THEN** the system shall throw a `PostgresException` with the PostgreSQL error details
#### Scenario: Database connection failure
- **WHEN** the database server is unreachable or credentials are invalid
- **THEN** the system shall throw a `PostgresException` or `NpgsqlException` with connection error details
### Requirement: Connection management
The system SHALL properly manage database connections using proper disposal patterns to prevent connection leaks.
#### Scenario: Connection properly disposed after query
- **WHEN** a query is executed successfully
- **THEN** the database connection shall be disposed after the query completes
- **AND** the connection shall be returned to the connection pool
#### Scenario: Connection disposed on exception
- **WHEN** a query execution throws an exception
- **THEN** the database connection shall still be properly disposed
- **AND** no connection leak shall occur
### Requirement: Static Service Initialization
The system SHALL provide a static method to initialize the database service with a connection string.
#### Scenario: Successful static initialization
- **WHEN** the `Initialize` method is called with a valid connection string
- **THEN** the static internal state shall be updated
- **AND** subsequent query calls shall use this connection string
### Requirement: Synchronous Database Query Execution
The system SHALL provide static functions that accept SQL query strings and return results synchronously.
#### Scenario: Successful synchronous query execution
- **WHEN** a valid SQL query is executed via a static method
- **THEN** the system shall return the result string synchronously
- **AND** the database connection shall be properly opened and closed synchronously
### Requirement: Static Configuration Access
The system SHALL provide static access to configuration properties through a dedicated service.
#### Scenario: Accessing connection string via static method
- **WHEN** the static `GetConnectionString()` method is called
- **THEN** it shall return the connection string from the internal static configuration
- **AND** it shall throw an `InvalidOperationException` if the configuration is missing the required key
### Requirement: Static Configuration Initialization
The system SHALL provide a way to initialize the static configuration state.
#### Scenario: Default static initialization
- **WHEN** the configuration service is first accessed without explicit initialization
- **THEN** it shall automatically load configuration from `appsettings.json`
#### Scenario: Explicit static initialization
- **WHEN** the `Initialize` method is called with a custom `IConfiguration` object
- **THEN** the static internal state shall be updated with the provided configuration

176
query_employee_data.sql Normal file
View File

@@ -0,0 +1,176 @@
with months as (
select '202501' as payment_period
union all select '202502'
union all select '202503'
union all select '202504'
union all select '202505'
union all select '202506'
union all select '202507'
union all select '202508'
union all select '202509'
union all select '202510'
union all select '202511'
union all select '202512'
),
tax_calculation_basal as (
select erp."TaxCalculationType"
from _onx4pzkwkeortehfjthgyfkb7c."EmployeeReportPeriodics" erp
inner join _onx4pzkwkeortehfjthgyfkb7c."Employees" e on erp."EmployeeId" = e."Id"
where erp."PaymentPeriod" between '202501' and '202512'
and e."NIK" = 'PS2MA001'
and erp."BasicSalary" > 0
order by erp."PaymentPeriod" desc
limit 1
),
matched_records as (
select e."NIK" as employee_number,
e."Name" as employee_name,
e."NPWP" as employee_npwp,
(case
when erp."TaxStatus"=1 then 'TK0'
when erp."TaxStatus"=2 then 'TK1'
when erp."TaxStatus"=3 then 'TK2'
when erp."TaxStatus"=4 then 'TK3'
when erp."TaxStatus"=5 then 'K0'
when erp."TaxStatus"=6 then 'K1'
when erp."TaxStatus"=7 then 'K2'
when erp."TaxStatus"=8 then 'K3'
end) as tax_marital_status,
ets."EmployeeTaxStatusCode" as employee_tax_status,
m.payment_period,
erp."NPWPPemotong" as npwp_pemotong,
(case erp."TaxCalculationType" when 2 then 'PaidByEmployee' when 3 then 'PaidAsAllowance' else 'PaidByCompany' end) as tax_type,
erp."BasicSalary" as basic_salary,
erp."Overtime" as overtime,
erp."AllowanceRegular" as allowance,
erp."Deductions" as deduction,
erp."Natura" as benefit_in_kind,
erp."Severance" as severance,
erp."JKKCompany" as jkk,
erp."JKMCompany" as jkm,
erp."BPJSKesCompany" + erp."OtherInsuranceCompany" as bpjs_kesehatan_company,
erp."JHTEmployee" as jht_emp,
erp."JPEmployee" as jp_emp,
0 as pension_emp,
erp."EmployeeCondition" as employee_condition,
erp."ResignType" as resign_type,
erp."AllowanceTaxRegular" as allowance_tax_regular,
erp."TaxRegular" as tax_regular,
erp."AllowanceIrregular" as allowance_irregular,
0 as benefit_in_kind_irregular,
erp."AllowanceTaxIrregular" as allowance_tax_irregular,
erp."TaxIrregular" as tax_irregular,
0 as severance_tax,
0 as allowance_severance_tax,
erp."BegSalaryNetto" as beginning_salary_netto,
erp."BegPPh21" as beginning_pph21
from months m
left join _onx4pzkwkeortehfjthgyfkb7c."EmployeeReportPeriodics" erp
on m.payment_period = erp."PaymentPeriod"
left join _onx4pzkwkeortehfjthgyfkb7c."Employees" e
on erp."EmployeeId" = e."Id"
and e."NIK" = 'PS2MA001'
left join _onx4pzkwkeortehfjthgyfkb7c."EmployeeTaxStatuses" ets
on e."EmployeeTaxStatusRefId" = ets."Id"
where erp."TaxCalculationType" = (select "TaxCalculationType" from tax_calculation_basal)
),
unmatched_records as (
select e."NIK" as employee_number,
e."Name" as employee_name,
e."NPWP" as employee_npwp,
(case
when erp."TaxStatus"=1 then 'TK0'
when erp."TaxStatus"=2 then 'TK1'
when erp."TaxStatus"=3 then 'TK2'
when erp."TaxStatus"=4 then 'TK3'
when erp."TaxStatus"=5 then 'K0'
when erp."TaxStatus"=6 then 'K1'
when erp."TaxStatus"=7 then 'K2'
when erp."TaxStatus"=8 then 'K3'
end) as tax_marital_status,
ets."EmployeeTaxStatusCode" as employee_tax_status,
m.payment_period,
erp."NPWPPemotong" as npwp_pemotong,
(case erp."TaxCalculationType" when 2 then 'PaidByEmployee' when 3 then 'PaidAsAllowance' else 'PaidByCompany' end) as tax_type,
erp."BasicSalary" as basic_salary,
erp."Overtime" as overtime,
erp."AllowanceRegular" as allowance,
erp."Deductions" as deduction,
erp."Natura" as benefit_in_kind,
erp."Severance" as severance,
erp."JKKCompany" as jkk,
erp."JKMCompany" as jkm,
erp."BPJSKesCompany" + erp."OtherInsuranceCompany" as bpjs_kesehatan_company,
erp."JHTEmployee" as jht_emp,
erp."JPEmployee" as jp_emp,
0 as pension_emp,
erp."EmployeeCondition" as employee_condition,
erp."ResignType" as resign_type,
erp."AllowanceTaxRegular" as allowance_tax_regular,
erp."TaxRegular" as tax_regular,
erp."AllowanceIrregular" as allowance_irregular,
0 as benefit_in_kind_irregular,
erp."AllowanceTaxIrregular" as allowance_tax_irregular,
erp."TaxIrregular" as tax_irregular,
0 as severance_tax,
0 as allowance_severance_tax,
erp."BegSalaryNetto" as beginning_salary_netto,
erp."BegPPh21" as beginning_pph21
from months m
left join _onx4pzkwkeortehfjthgyfkb7c."EmployeeReportPeriodics" erp
on m.payment_period = erp."PaymentPeriod"
left join _onx4pzkwkeortehfjthgyfkb7c."Employees" e
on erp."EmployeeId" = e."Id"
and e."NIK" = 'PS2MA001'
left join _onx4pzkwkeortehfjthgyfkb7c."EmployeeTaxStatuses" ets
on e."EmployeeTaxStatusRefId" = ets."Id"
where erp."TaxCalculationType" <> (select "TaxCalculationType" from tax_calculation_basal)
and erp."TaxCalculationType" = 0
),
empty_records as (
select null::text as employee_number,
''::text as employee_name,
''::text as employee_npwp,
null::text as tax_marital_status,
null::integer as employee_tax_status,
m.payment_period,
''::text as npwp_pemotong,
''::text as tax_type,
0::numeric as basic_salary,
0::numeric as overtime,
0::numeric as allowance,
0::numeric as deduction,
0::numeric as benefit_in_kind,
0::numeric as severance,
0::numeric as jkk,
0::numeric as jkm,
0::numeric as bpjs_kesehatan_company,
0::numeric as jht_emp,
0::numeric as jp_emp,
0::numeric as pension_emp,
null::integer as employee_condition,
null::integer as resign_type,
0::numeric as allowance_tax_regular,
0::numeric as tax_regular,
0::numeric as allowance_irregular,
0::numeric as benefit_in_kind_irregular,
0::numeric as allowance_tax_irregular,
0::numeric as tax_irregular,
0::numeric as severance_tax,
0::numeric as allowance_severance_tax,
0::numeric as beginning_salary_netto,
0::numeric as beginning_pph21
from months m
where not exists (
select 1 from matched_records mr where mr.payment_period = m.payment_period
)
and not exists (
select 1 from unmatched_records ur where ur.payment_period = m.payment_period
)
)
select * from matched_records
union all
select * from unmatched_records
union all
select * from empty_records
order by payment_period