refactor: move buildable .NET source into src/, update CI/doc paths
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m7s
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m7s
Groups the repo root into src (buildable source), docs (already existed), and everything else (db/, scripts/, tests/, deploy/ - deployment/ops/test assets that aren't compiled, already organized as their own folders). CI now only needs src/ to build: dotnet restore/build/test/publish all point at src/TaxBaik.sln, src/TaxBaik.Web/, src/TaxBaik.Proxy/. - git mv every project (Domain, Infrastructure, Application, Application.Tests, Web, Web.Client, Proxy) and TaxBaik.sln into src/ as a unit, so relative ProjectReference/.sln paths stay valid unchanged. - .gitea/workflows/deploy.yml: 6 dotnet restore/clean/build/test/publish invocations now point at src/. db/migrations and scripts/ stay at root (deploy_gb.sh and browser-e2e.yml only touch published output and the deployed URL, not source paths - verified, no changes needed there). - scripts/validate_admin_render.sh: admin render-mode file paths now src/TaxBaik.Web.Client/... - scripts/validate_kst_timestamps.sh: dropped deploy.sh from its target list - that script was removed in the prior cleanup commit (dead, no CI workflow referenced it) but this validator still expected it to exist. - CLAUDE.md, docs/ENGINEERING_HARNESS.md, docs/ADMIN_PATTERN_CRITIQUE_WBS.md: updated project-structure diagram, dotnet run/build commands, and grep targets to the new src/ paths (also fixed a pre-existing stale path in ADMIN_PATTERN_CRITIQUE_WBS.md that still said TaxBaik.Web/Components/Admin from before that ever moved to TaxBaik.Web.Client). - Added a Repo Root harness rule + Architecture Guardrail entries: new files belong under src/docs/tests/scripts/db/deploy, not loose at root; temp work stays outside the repo (or under a gitignored .scratch/) and is never committed. Verified locally: dotnet build/test src/TaxBaik.sln (26/26 tests), and all three scripts/validate_*.sh pass against the new layout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
namespace TaxBaik.Application.Tests;
|
||||
|
||||
using TaxBaik.Application.DTOs;
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Xunit;
|
||||
|
||||
public class BlogServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateAsync_WhenPublishedWithoutSeoTitle_ThrowsValidationException()
|
||||
{
|
||||
var service = new BlogService(new FakeBlogPostRepository(), new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await Assert.ThrowsAsync<ValidationException>(() => service.CreateAsync(new CreateBlogPostDto
|
||||
{
|
||||
Title = "테스트 포스트",
|
||||
Content = "본문",
|
||||
SeoDescription = "설명",
|
||||
IsPublished = true
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WhenTitleDuplicates_GeneratesUniqueSlug()
|
||||
{
|
||||
var repository = new FakeBlogPostRepository
|
||||
{
|
||||
Posts =
|
||||
[
|
||||
new BlogPost { Id = 1, Title = "같은 제목", Content = "본문", Slug = "같은-제목" }
|
||||
]
|
||||
};
|
||||
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
var post = await service.CreateAsync(new CreateBlogPostDto
|
||||
{
|
||||
Title = "같은 제목",
|
||||
Content = "본문"
|
||||
});
|
||||
|
||||
Assert.Equal("같은-제목-2", post.Slug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_SoftDeletesPost_AndExcludesFromSlugLookup()
|
||||
{
|
||||
var repository = new FakeBlogPostRepository
|
||||
{
|
||||
Posts =
|
||||
[
|
||||
new BlogPost { Id = 1, Title = "삭제 대상", Content = "본문", Slug = "delete-me", IsPublished = true }
|
||||
]
|
||||
};
|
||||
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await service.DeleteAsync(1);
|
||||
|
||||
Assert.NotNull(repository.Posts.Single().DeletedAt);
|
||||
Assert.Null(await service.GetBySlugAsync("delete-me"));
|
||||
Assert.Null(await service.GetByIdAsync(1));
|
||||
}
|
||||
|
||||
private sealed class FakeBlogPostRepository : IBlogPostRepository
|
||||
{
|
||||
public List<BlogPost> Posts { get; init; } = [];
|
||||
|
||||
public Task<BlogPost?> GetByIdAsync(int id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Posts.FirstOrDefault(x => x.Id == id && x.DeletedAt == null));
|
||||
|
||||
public Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Posts.FirstOrDefault(x => x.Slug == slug && x.IsPublished && x.DeletedAt == null));
|
||||
|
||||
public Task<(IEnumerable<BlogPost> Items, int Total)> GetPublishedPagedAsync(
|
||||
int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = Posts.Where(x => x.IsPublished).ToList();
|
||||
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
|
||||
}
|
||||
|
||||
public Task<IEnumerable<BlogPost>> GetByCategorySlugAsync(string categorySlug, int limit, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IEnumerable<BlogPost>>(Posts.Where(x => x.IsPublished).Take(limit).ToList());
|
||||
|
||||
public Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IEnumerable<BlogPost>>(Posts);
|
||||
|
||||
public Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
|
||||
int page, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = Posts.ToList();
|
||||
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
|
||||
}
|
||||
|
||||
public Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
|
||||
int page, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = Posts.Where(x => x.DeletedAt != null).ToList();
|
||||
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
|
||||
}
|
||||
|
||||
public Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
|
||||
{
|
||||
post.Id = Posts.Count + 1;
|
||||
Posts.Add(post);
|
||||
return Task.FromResult(post.Id);
|
||||
}
|
||||
|
||||
public Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var post = Posts.FirstOrDefault(x => x.Id == id);
|
||||
if (post != null)
|
||||
post.DeletedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ArchiveAsync(int id, CancellationToken cancellationToken = default) => DeleteAsync(id, cancellationToken);
|
||||
|
||||
public Task RestoreAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var post = Posts.FirstOrDefault(x => x.Id == id);
|
||||
if (post != null)
|
||||
post.DeletedAt = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace TaxBaik.Application.Tests;
|
||||
|
||||
using TaxBaik.Application.Utils;
|
||||
using Xunit;
|
||||
|
||||
public class BusinessDayCalculatorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(2026, 2, 14, 2026, 2, 19)]
|
||||
[InlineData(2026, 8, 15, 2026, 8, 18)]
|
||||
[InlineData(2026, 10, 3, 2026, 10, 6)]
|
||||
[InlineData(2026, 9, 24, 2026, 9, 28)]
|
||||
[InlineData(2027, 2, 6, 2027, 2, 10)]
|
||||
[InlineData(2027, 10, 9, 2027, 10, 12)]
|
||||
public void GetEffectiveDueDate_SkipsWeekendHolidayAndSubstituteHoliday(
|
||||
int dueYear, int dueMonth, int dueDay,
|
||||
int expectedYear, int expectedMonth, int expectedDay)
|
||||
{
|
||||
var effective = BusinessDayCalculator.GetEffectiveDueDate(new DateOnly(dueYear, dueMonth, dueDay));
|
||||
|
||||
Assert.Equal(new DateOnly(expectedYear, expectedMonth, expectedDay), effective);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2026, 2, 19, 0)]
|
||||
[InlineData(2026, 2, 20, -1)]
|
||||
[InlineData(2026, 2, 18, 1)]
|
||||
public void GetDday_UsesEffectiveDueDate(
|
||||
int refYear, int refMonth, int refDay,
|
||||
int expectedDays)
|
||||
{
|
||||
var dday = BusinessDayCalculator.GetDday(new DateOnly(2026, 2, 14), new DateOnly(refYear, refMonth, refDay));
|
||||
|
||||
Assert.Equal(expectedDays, dday);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace TaxBaik.Application.Tests;
|
||||
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
public class CommonCodeServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpsertAsync_TrimsAndRejectsWhitespaceInCodeValue()
|
||||
{
|
||||
var repository = new FakeCommonCodeRepository();
|
||||
var service = new CommonCodeService(repository);
|
||||
|
||||
await Assert.ThrowsAsync<ValidationException>(() => service.UpsertAsync(new CommonCode
|
||||
{
|
||||
CodeGroup = " CLIENT_STATUS ",
|
||||
CodeValue = "active code",
|
||||
CodeName = " 활성 "
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_TrimsAndPersistsNormalizedValues()
|
||||
{
|
||||
var repository = new FakeCommonCodeRepository();
|
||||
var service = new CommonCodeService(repository);
|
||||
|
||||
await service.UpsertAsync(new CommonCode
|
||||
{
|
||||
CodeGroup = " CLIENT_STATUS ",
|
||||
CodeValue = "active",
|
||||
CodeName = " 활성 ",
|
||||
SortOrder = 10
|
||||
});
|
||||
|
||||
var saved = Assert.Single(repository.SavedCodes);
|
||||
Assert.Equal("CLIENT_STATUS", saved.CodeGroup);
|
||||
Assert.Equal("active", saved.CodeValue);
|
||||
Assert.Equal("활성", saved.CodeName);
|
||||
}
|
||||
|
||||
private sealed class FakeCommonCodeRepository : ICommonCodeRepository
|
||||
{
|
||||
public List<CommonCode> SavedCodes { get; } = [];
|
||||
|
||||
public Task<IEnumerable<string>> GetAllGroupsAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IEnumerable<string>>([]);
|
||||
|
||||
public Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default) =>
|
||||
Task.FromResult<IEnumerable<CommonCode>>([]);
|
||||
|
||||
public Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IEnumerable<CommonCode>>([]);
|
||||
|
||||
public Task<CommonCode?> GetAsync(string codeGroup, string codeValue, CancellationToken ct = default) =>
|
||||
Task.FromResult<CommonCode?>(null);
|
||||
|
||||
public Task UpsertAsync(CommonCode code, CancellationToken ct = default)
|
||||
{
|
||||
SavedCodes.Add(new CommonCode
|
||||
{
|
||||
CodeGroup = code.CodeGroup,
|
||||
CodeValue = code.CodeValue,
|
||||
CodeName = code.CodeName,
|
||||
SortOrder = code.SortOrder,
|
||||
IsActive = code.IsActive
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
namespace TaxBaik.Application.Tests;
|
||||
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Xunit;
|
||||
|
||||
public class InquiryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_WhenStatusIsInvalid_ThrowsValidationException()
|
||||
{
|
||||
var service = new InquiryService(new FakeInquiryRepository(), new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await Assert.ThrowsAsync<ValidationException>(() => service.UpdateStatusAsync(1, "invalid"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitAsync_StoresEmailAndNewStatus()
|
||||
{
|
||||
var repository = new FakeInquiryRepository();
|
||||
var service = new InquiryService(repository, new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await service.SubmitAsync("홍길동", "010-1234-5678", "기장", "문의합니다.", "user@example.com");
|
||||
|
||||
Assert.Equal("user@example.com", repository.Inquiries.Single().Email);
|
||||
Assert.Equal("new", repository.Inquiries.Single().Status);
|
||||
}
|
||||
|
||||
private sealed class FakeInquiryRepository : IInquiryRepository
|
||||
{
|
||||
public List<Inquiry> Inquiries { get; } = [];
|
||||
|
||||
public Task<int> CreateAsync(Inquiry inquiry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
inquiry.Id = Inquiries.Count + 1;
|
||||
Inquiries.Add(inquiry);
|
||||
return Task.FromResult(inquiry.Id);
|
||||
}
|
||||
|
||||
public Task<Inquiry?> GetByIdAsync(int id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Inquiries.FirstOrDefault(x => x.Id == id));
|
||||
|
||||
public Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
|
||||
int page, int pageSize, string? status = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = status == null ? Inquiries : Inquiries.Where(x => x.Status == status).ToList();
|
||||
return Task.FromResult<(IEnumerable<Inquiry>, int)>((items, items.Count()));
|
||||
}
|
||||
|
||||
public Task<int> CountAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Inquiries.Count);
|
||||
|
||||
public Task<int> CountThisMonthAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Inquiries.Count);
|
||||
|
||||
public Task<int> CountByStatusAsync(string status, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Inquiries.Count(x => x.Status == status));
|
||||
|
||||
public Task<int> CountByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Inquiries.Count(x => x.CreatedAt >= startDate && x.CreatedAt <= endDate));
|
||||
|
||||
public Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Inquiries.Count(x => x.Status == status && x.CreatedAt >= startDate && x.CreatedAt <= endDate));
|
||||
|
||||
public Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var inquiry = Inquiries.FirstOrDefault(x => x.Id == id);
|
||||
if (inquiry != null)
|
||||
inquiry.Status = status;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var inquiry = Inquiries.FirstOrDefault(x => x.Id == id);
|
||||
if (inquiry != null)
|
||||
inquiry.AdminMemo = adminMemo;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAsync(Inquiry inquiry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = Inquiries.FirstOrDefault(x => x.Id == inquiry.Id);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Name = inquiry.Name;
|
||||
existing.Phone = inquiry.Phone;
|
||||
existing.Email = inquiry.Email;
|
||||
existing.ServiceType = inquiry.ServiceType;
|
||||
existing.Message = inquiry.Message;
|
||||
existing.Status = inquiry.Status;
|
||||
existing.AdminMemo = inquiry.AdminMemo;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var inquiry = Inquiries.FirstOrDefault(x => x.Id == inquiryId);
|
||||
if (inquiry != null)
|
||||
inquiry.ClientId = clientId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var inquiry = Inquiries.FirstOrDefault(x => x.Id == id);
|
||||
if (inquiry != null)
|
||||
Inquiries.Remove(inquiry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeInquiryNotificationService : IInquiryNotificationService
|
||||
{
|
||||
public Task NotifyCreatedAsync(int inquiryId, string name, string phone, string serviceType, string message, string? ipAddress, DateTime createdAtUtc, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task NotifyStatusChangedAsync(int inquiryId, string name, string phone, string serviceType, string previousStatus, string newStatus, string? changedBy = null, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace TaxBaik.Application.Tests;
|
||||
|
||||
using TaxBaik.Application.Seasonal;
|
||||
using TaxBaik.Application.Services;
|
||||
using Xunit;
|
||||
|
||||
public class SeasonalMarketingServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(2026, 1, 25, 2026, 1, 26)]
|
||||
[InlineData(2026, 2, 28, 2026, 3, 3)]
|
||||
[InlineData(2026, 3, 31, 2026, 3, 31)]
|
||||
[InlineData(2026, 5, 31, 2026, 6, 1)]
|
||||
[InlineData(2026, 7, 25, 2026, 7, 27)]
|
||||
[InlineData(2026, 11, 30, 2026, 11, 30)]
|
||||
[InlineData(2026, 12, 31, 2026, 12, 31)]
|
||||
public void SeasonalDeadlines_ApplyBusinessDayRollForward(
|
||||
int year, int month, int day,
|
||||
int expectedYear, int expectedMonth, int expectedDay)
|
||||
{
|
||||
var deadline = new DateOnly(year, month, day);
|
||||
var effective = BusinessDayCalculator.GetEffectiveBusinessDate(deadline);
|
||||
|
||||
Assert.Equal(new DateOnly(expectedYear, expectedMonth, expectedDay), effective);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2026, 7, 24, 2026, 7, 27, 3)]
|
||||
[InlineData(2026, 7, 25, 2026, 7, 27, 2)]
|
||||
[InlineData(2026, 7, 26, 2026, 7, 27, 1)]
|
||||
public void SeasonalDeadlines_UseBusinessDayDiff(
|
||||
int refYear, int refMonth, int refDay,
|
||||
int expectedYear, int expectedMonth, int expectedDay,
|
||||
int expectedDays)
|
||||
{
|
||||
var deadline = new DateOnly(2026, 7, 25);
|
||||
var reference = new DateOnly(refYear, refMonth, refDay);
|
||||
var days = BusinessDayCalculator.GetBusinessDayDiff(deadline, reference);
|
||||
|
||||
Assert.Equal(expectedDays, days);
|
||||
Assert.Equal(new DateOnly(expectedYear, expectedMonth, expectedDay), BusinessDayCalculator.GetEffectiveBusinessDate(deadline));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
|
||||
<ProjectReference Include="..\TaxBaik.Web\TaxBaik.Web.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1 @@
|
||||
namespace TaxBaik.Application { }
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TaxBaik.Application.DTOs;
|
||||
|
||||
public class AnnouncementDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Title { get; set; } = "";
|
||||
public string? Content { get; set; }
|
||||
public string DisplayType { get; set; } = "info";
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime? StartsAt { get; set; }
|
||||
public DateTime? EndsAt { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace TaxBaik.Application.DTOs;
|
||||
|
||||
public class ClientDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
public string? CompanyName { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? ServiceType { get; set; }
|
||||
public string? TaxType { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string? Source { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class CreateClientDto
|
||||
{
|
||||
public string Name { get; set; } = null!;
|
||||
public string? CompanyName { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? ServiceType { get; set; }
|
||||
public string? TaxType { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string? Source { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TaxBaik.Application.DTOs;
|
||||
|
||||
public class CreateBlogPostDto
|
||||
{
|
||||
public required string Title { get; set; }
|
||||
public required string Content { get; set; }
|
||||
public int? CategoryId { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
public string? SeoTitle { get; set; }
|
||||
public string? SeoDescription { get; set; }
|
||||
public string? ThumbnailUrl { get; set; }
|
||||
public bool IsPublished { get; set; }
|
||||
public int? AuthorId { get; set; }
|
||||
}
|
||||
|
||||
public class BlogPostResponseDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public int? CategoryId { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
public string? SeoTitle { get; set; }
|
||||
public string? SeoDescription { get; set; }
|
||||
public string? ThumbnailUrl { get; set; }
|
||||
public bool IsPublished { get; set; }
|
||||
public int? AuthorId { get; set; }
|
||||
public int ViewCount { get; set; }
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TaxBaik.Application.DTOs;
|
||||
|
||||
public class SubmitInquiryDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
public string? Email { get; set; }
|
||||
public string ServiceType { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public bool SuppressNotification { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TaxBaik.Application.DTOs;
|
||||
|
||||
public class UpdateInquiryDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
public string? Email { get; set; }
|
||||
public string ServiceType { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string? AdminMemo { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace TaxBaik.Application;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TaxBaik.Application.Services;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<BlogService>();
|
||||
services.AddScoped<InquiryService>();
|
||||
services.AddScoped<AdminDashboardService>();
|
||||
services.AddScoped<IInquiryNotificationService, NoopInquiryNotificationService>();
|
||||
services.AddScoped<SiteSettingService>();
|
||||
services.AddScoped<CategoryService>();
|
||||
services.AddScoped<AnnouncementService>();
|
||||
services.AddSingleton<SeasonalMarketingService>();
|
||||
services.AddScoped<ClientService>();
|
||||
services.AddScoped<FaqService>();
|
||||
services.AddScoped<ConsultationService>();
|
||||
services.AddScoped<TaxFilingService>();
|
||||
services.AddScoped<CompanyService>();
|
||||
services.AddScoped<TaxProfileService>();
|
||||
services.AddScoped<TaxFilingScheduleService>();
|
||||
services.AddScoped<ConsultingActivityService>();
|
||||
services.AddScoped<ContractService>();
|
||||
services.AddScoped<RevenueTrackingService>();
|
||||
services.AddScoped<TelegramReportService>();
|
||||
services.AddScoped<PortalUserService>();
|
||||
services.AddScoped<CommonCodeService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace TaxBaik.Application.Seasonal;
|
||||
|
||||
public static class BusinessDayCalculator
|
||||
{
|
||||
private static readonly HashSet<DateOnly> HolidayDates = new()
|
||||
{
|
||||
// 2026
|
||||
new DateOnly(2026, 1, 1),
|
||||
new DateOnly(2026, 2, 16),
|
||||
new DateOnly(2026, 2, 17),
|
||||
new DateOnly(2026, 2, 18),
|
||||
new DateOnly(2026, 3, 1),
|
||||
new DateOnly(2026, 3, 2),
|
||||
new DateOnly(2026, 5, 5),
|
||||
new DateOnly(2026, 5, 25),
|
||||
new DateOnly(2026, 6, 6),
|
||||
new DateOnly(2026, 8, 15),
|
||||
new DateOnly(2026, 8, 16),
|
||||
new DateOnly(2026, 8, 17),
|
||||
new DateOnly(2026, 9, 24),
|
||||
new DateOnly(2026, 9, 25),
|
||||
new DateOnly(2026, 9, 26),
|
||||
new DateOnly(2026, 10, 3),
|
||||
new DateOnly(2026, 10, 4),
|
||||
new DateOnly(2026, 10, 5),
|
||||
new DateOnly(2026, 10, 9),
|
||||
new DateOnly(2026, 12, 25),
|
||||
|
||||
// 2027
|
||||
new DateOnly(2027, 1, 1),
|
||||
new DateOnly(2027, 2, 6),
|
||||
new DateOnly(2027, 2, 7),
|
||||
new DateOnly(2027, 2, 8),
|
||||
new DateOnly(2027, 2, 9),
|
||||
new DateOnly(2027, 3, 1),
|
||||
new DateOnly(2027, 3, 2),
|
||||
new DateOnly(2027, 5, 5),
|
||||
new DateOnly(2027, 5, 13),
|
||||
new DateOnly(2027, 6, 6),
|
||||
new DateOnly(2027, 8, 15),
|
||||
new DateOnly(2027, 8, 16),
|
||||
new DateOnly(2027, 9, 14),
|
||||
new DateOnly(2027, 9, 15),
|
||||
new DateOnly(2027, 9, 16),
|
||||
new DateOnly(2027, 10, 3),
|
||||
new DateOnly(2027, 10, 4),
|
||||
new DateOnly(2027, 10, 9),
|
||||
new DateOnly(2027, 10, 10),
|
||||
new DateOnly(2027, 10, 11),
|
||||
new DateOnly(2027, 12, 25),
|
||||
new DateOnly(2027, 12, 26)
|
||||
};
|
||||
|
||||
public static DateOnly GetEffectiveBusinessDate(DateOnly date)
|
||||
{
|
||||
var effectiveDate = date;
|
||||
while (!IsBusinessDay(effectiveDate))
|
||||
{
|
||||
effectiveDate = effectiveDate.AddDays(1);
|
||||
}
|
||||
|
||||
return effectiveDate;
|
||||
}
|
||||
|
||||
public static int GetBusinessDayDiff(DateOnly date, DateOnly referenceDate)
|
||||
{
|
||||
var effectiveDate = GetEffectiveBusinessDate(date);
|
||||
return effectiveDate.DayNumber - referenceDate.DayNumber;
|
||||
}
|
||||
|
||||
private static bool IsBusinessDay(DateOnly date)
|
||||
=> date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday
|
||||
&& !HolidayDates.Contains(date);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TaxBaik.Application.Seasonal;
|
||||
|
||||
public record CurrentSeasonDto
|
||||
{
|
||||
public string Key { get; init; } = "";
|
||||
public string Name { get; init; } = "";
|
||||
public string HeroHeadline { get; init; } = "";
|
||||
public string HeroSubtext { get; init; } = "";
|
||||
public string UrgencyBadge { get; init; } = "";
|
||||
public string FocusService { get; init; } = "";
|
||||
public string RelatedCategorySlug { get; init; } = "";
|
||||
public string CtaText { get; init; } = "상담 신청하기";
|
||||
public int DaysUntilDeadline { get; init; }
|
||||
public DateTime Deadline { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace TaxBaik.Application.Seasonal;
|
||||
|
||||
public record TaxSeason
|
||||
{
|
||||
public string Key { get; init; } = "";
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public int StartMonth { get; init; }
|
||||
public int StartDay { get; init; }
|
||||
public int EndMonth { get; init; }
|
||||
public int EndDay { get; init; }
|
||||
|
||||
public string HeroHeadline { get; init; } = "";
|
||||
public string HeroSubtext { get; init; } = "";
|
||||
public string UrgencyBadge { get; init; } = "";
|
||||
public string FocusService { get; init; } = "";
|
||||
public string CtaText { get; init; } = "상담 신청하기";
|
||||
/// <summary>블로그 시즌 연동 시 우선 노출할 카테고리 slug (categories.slug 참조)</summary>
|
||||
public string RelatedCategorySlug { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
namespace TaxBaik.Application.Seasonal;
|
||||
|
||||
/// <summary>
|
||||
/// 한국 세무사 사무실 연간 시즌 캘린더.
|
||||
/// 각 시즌이 활성화되면 홈페이지 Hero가 해당 세무 이벤트에 맞게 전환된다.
|
||||
/// </summary>
|
||||
public static class TaxSeasonCalendar
|
||||
{
|
||||
public static readonly IReadOnlyList<TaxSeason> Seasons =
|
||||
[
|
||||
new TaxSeason
|
||||
{
|
||||
Key = "vat-2nd",
|
||||
Name = "부가가치세 2기 확정신고",
|
||||
StartMonth = 1, StartDay = 1,
|
||||
EndMonth = 1, EndDay = 25,
|
||||
HeroHeadline = "부가가치세 2기\n1월 25일 마감",
|
||||
HeroSubtext = "일반과세 사업자 확정신고 · 기한 내 신고로 가산세 방지",
|
||||
UrgencyBadge = "D-{n}일 | 부가세 마감",
|
||||
FocusService = "business-tax",
|
||||
CtaText = "부가세 신고 상담",
|
||||
RelatedCategorySlug = "vat"
|
||||
},
|
||||
new TaxSeason
|
||||
{
|
||||
Key = "year-end-settlement",
|
||||
Name = "연말정산",
|
||||
StartMonth = 1, StartDay = 15,
|
||||
EndMonth = 2, EndDay = 28,
|
||||
HeroHeadline = "연말정산\n지금 준비하세요",
|
||||
HeroSubtext = "직원이 있는 사업자 원천징수 신고 · 환급 최대화",
|
||||
UrgencyBadge = "연말정산 진행 중",
|
||||
FocusService = "business-tax",
|
||||
CtaText = "연말정산 상담",
|
||||
RelatedCategorySlug = "business-tax"
|
||||
},
|
||||
new TaxSeason
|
||||
{
|
||||
Key = "corporate-tax",
|
||||
Name = "법인세 신고",
|
||||
StartMonth = 3, StartDay = 1,
|
||||
EndMonth = 3, EndDay = 31,
|
||||
HeroHeadline = "법인세\n3월 31일 마감",
|
||||
HeroSubtext = "법인사업자 결산 · 세무조정 · 절세 전략 수립",
|
||||
UrgencyBadge = "D-{n}일 | 법인세 마감",
|
||||
FocusService = "business-tax",
|
||||
CtaText = "법인세 신고 상담",
|
||||
RelatedCategorySlug = "business-tax"
|
||||
},
|
||||
new TaxSeason
|
||||
{
|
||||
Key = "income-tax",
|
||||
Name = "종합소득세 신고",
|
||||
StartMonth = 5, StartDay = 1,
|
||||
EndMonth = 5, EndDay = 31,
|
||||
HeroHeadline = "종합소득세\n5월 31일 마감",
|
||||
HeroSubtext = "개인사업자 · 임대소득 · 프리랜서 · 기타소득 모두 해당",
|
||||
UrgencyBadge = "D-{n}일 | 종합소득세 마감",
|
||||
FocusService = "business-tax",
|
||||
CtaText = "종합소득세 상담",
|
||||
RelatedCategorySlug = "income-tax"
|
||||
},
|
||||
new TaxSeason
|
||||
{
|
||||
Key = "vat-1st",
|
||||
Name = "부가가치세 1기 확정신고",
|
||||
StartMonth = 7, StartDay = 1,
|
||||
EndMonth = 7, EndDay = 25,
|
||||
HeroHeadline = "부가가치세 1기\n7월 27일 마감",
|
||||
HeroSubtext = "일반과세 사업자 1기 확정신고 · 매입세액 공제 점검",
|
||||
UrgencyBadge = "D-{n}일 | 부가세 마감",
|
||||
FocusService = "business-tax",
|
||||
CtaText = "부가세 신고 상담",
|
||||
RelatedCategorySlug = "vat"
|
||||
},
|
||||
new TaxSeason
|
||||
{
|
||||
Key = "comprehensive-real-estate-tax",
|
||||
Name = "종합부동산세",
|
||||
StartMonth = 11, StartDay = 15,
|
||||
EndMonth = 11, EndDay = 30,
|
||||
HeroHeadline = "종합부동산세\n납부 시즌",
|
||||
HeroSubtext = "다주택자 · 임대사업자 세부담 분석 · 분납·합산배제 검토",
|
||||
UrgencyBadge = "D-{n}일 | 종부세 납부",
|
||||
FocusService = "real-estate-tax",
|
||||
CtaText = "종부세 절세 상담",
|
||||
RelatedCategorySlug = "real-estate-tax"
|
||||
},
|
||||
new TaxSeason
|
||||
{
|
||||
Key = "year-end-gift",
|
||||
Name = "연말 증여·절세 플래닝",
|
||||
StartMonth = 12, StartDay = 1,
|
||||
EndMonth = 12, EndDay = 31,
|
||||
HeroHeadline = "연말 절세 플래닝\n마지막 기회",
|
||||
HeroSubtext = "증여 공제 한도 · 자산 이전 · 법인전환 연간 마감",
|
||||
UrgencyBadge = "D-{n}일 | 연간 증여 한도 마감",
|
||||
FocusService = "family-asset",
|
||||
CtaText = "연말 절세 상담",
|
||||
RelatedCategorySlug = "family-asset"
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public record AdminDashboardSummary(
|
||||
int ThisMonthInquiries,
|
||||
int NewInquiries,
|
||||
int TotalPosts,
|
||||
int PublishedPosts,
|
||||
IReadOnlyList<Inquiry> RecentInquiries);
|
||||
|
||||
public class AdminDashboardService(
|
||||
InquiryService inquiryService,
|
||||
BlogService blogService,
|
||||
IMemoryCache memoryCache)
|
||||
{
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30);
|
||||
public const string CacheKey = "admin-dashboard-summary";
|
||||
|
||||
public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (memoryCache.TryGetValue(CacheKey, out AdminDashboardSummary? cached) && cached != null)
|
||||
return cached;
|
||||
|
||||
var recentTask = inquiryService.GetPagedAsync(1, 5, ct: ct);
|
||||
var thisMonthTask = inquiryService.CountThisMonthAsync(ct);
|
||||
var newTask = inquiryService.CountByStatusAsync("new", ct);
|
||||
var statsTask = blogService.GetStatsAsync(ct);
|
||||
|
||||
var (recentInquiries, _) = await recentTask;
|
||||
var stats = await statsTask;
|
||||
var summary = new AdminDashboardSummary(
|
||||
ThisMonthInquiries: await thisMonthTask,
|
||||
NewInquiries: await newTask,
|
||||
TotalPosts: stats.TotalPosts,
|
||||
PublishedPosts: stats.PublishedPosts,
|
||||
RecentInquiries: recentInquiries.OrderByDescending(x => x.CreatedAt).Take(5).ToList());
|
||||
|
||||
memoryCache.Set(CacheKey, summary, CacheDuration);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 최근 문의 조회
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<Inquiry>> GetRecentInquiriesAsync(int limit, CancellationToken ct = default)
|
||||
{
|
||||
var (inquiries, _) = await inquiryService.GetPagedAsync(1, limit, ct: ct);
|
||||
return inquiries.OrderByDescending(x => x.CreatedAt).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 월별 통계 (접수 건수, 진행 중, 완료)
|
||||
/// </summary>
|
||||
public async Task<object> GetMonthlyStatsAsync(string? month, CancellationToken ct = default)
|
||||
{
|
||||
var targetMonth = month != null && DateTime.TryParse($"{month}-01", out var dt)
|
||||
? dt
|
||||
: DateTime.Today;
|
||||
|
||||
var startDate = new DateTime(targetMonth.Year, targetMonth.Month, 1);
|
||||
var endDate = startDate.AddMonths(1).AddDays(-1);
|
||||
|
||||
// 캐시 시도 (일 단위)
|
||||
var cacheKey = $"admin-stats-{startDate:yyyy-MM}";
|
||||
if (memoryCache.TryGetValue(cacheKey, out object? cachedStats) && cachedStats != null)
|
||||
return cachedStats;
|
||||
|
||||
var total = await inquiryService.CountByDateRangeAsync(startDate, endDate, ct);
|
||||
var consulting = await inquiryService.CountByStatusAndDateAsync("consulting", startDate, endDate, ct);
|
||||
var completed = await inquiryService.CountByStatusAndDateAsync("contracted", startDate, endDate, ct);
|
||||
|
||||
var result = new
|
||||
{
|
||||
month = startDate.ToString("yyyy-MM"),
|
||||
totalInquiries = total,
|
||||
consultingCount = consulting,
|
||||
completedCount = completed,
|
||||
newCount = total - consulting - completed,
|
||||
completionRate = total > 0 ? (completed * 100.0 / total) : 0.0
|
||||
};
|
||||
|
||||
memoryCache.Set(cacheKey, result, TimeSpan.FromHours(1));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Application.DTOs;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class AnnouncementService(IAnnouncementRepository repository)
|
||||
{
|
||||
public Task<IEnumerable<Announcement>> GetActiveAsync(CancellationToken ct = default)
|
||||
=> repository.GetActiveAsync(ct);
|
||||
|
||||
public Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default)
|
||||
=> repository.GetAllAsync(ct);
|
||||
|
||||
public Task<Announcement?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
=> repository.GetByIdAsync(id, ct);
|
||||
|
||||
public Task<int> CreateAsync(AnnouncementDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var entity = MapToEntity(dto);
|
||||
return repository.CreateAsync(entity, ct);
|
||||
}
|
||||
|
||||
public Task UpdateAsync(AnnouncementDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var entity = MapToEntity(dto);
|
||||
return repository.UpdateAsync(entity, ct);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
=> repository.DeleteAsync(id, ct);
|
||||
|
||||
private static Announcement MapToEntity(AnnouncementDto dto) => new()
|
||||
{
|
||||
Id = dto.Id,
|
||||
Title = dto.Title.Trim(),
|
||||
Content = string.IsNullOrWhiteSpace(dto.Content) ? null : dto.Content.Trim(),
|
||||
DisplayType = dto.DisplayType,
|
||||
IsActive = dto.IsActive,
|
||||
StartsAt = dto.StartsAt,
|
||||
EndsAt = dto.EndsAt,
|
||||
SortOrder = dto.SortOrder
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using TaxBaik.Application.DTOs;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCache)
|
||||
{
|
||||
public async Task<BlogPost?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken ct = default) =>
|
||||
await repository.GetBySlugAsync(slug, ct);
|
||||
|
||||
/// <summary>카테고리 슬러그 기준 시즌 관련 글 조회. 부족 시 최신 글로 채워 total개 반환.</summary>
|
||||
public async Task<(IEnumerable<BlogPost> Seasonal, IEnumerable<BlogPost> Latest)> GetSeasonalPostsAsync(
|
||||
string categorySlug, int seasonalCount, int totalCount, CancellationToken ct = default)
|
||||
{
|
||||
var seasonal = (await repository.GetByCategorySlugAsync(categorySlug, seasonalCount, ct)).ToList();
|
||||
var seasonalIds = seasonal.Select(p => p.Id).ToHashSet();
|
||||
|
||||
var (latestAll, _) = await repository.GetPublishedPagedAsync(1, totalCount + seasonalCount, null, ct);
|
||||
var latest = latestAll.Where(p => !seasonalIds.Contains(p.Id)).Take(totalCount - seasonal.Count).ToList();
|
||||
|
||||
return (seasonal, latest);
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<BlogPost>, int)> GetPublishedPagedAsync(
|
||||
int page, int pageSize, int? categoryId = null, CancellationToken ct = default) =>
|
||||
await repository.GetPublishedPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), categoryId, ct);
|
||||
|
||||
public async Task<IEnumerable<BlogPost>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllForAdminAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllForAdminAsync(ct);
|
||||
|
||||
public async Task<(IEnumerable<BlogPost>, int)> GetAdminPagedAsync(
|
||||
int page, int pageSize, CancellationToken ct = default) =>
|
||||
await repository.GetAdminPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
|
||||
|
||||
public async Task<(IEnumerable<BlogPost>, int)> GetArchivedPagedAsync(
|
||||
int page, int pageSize, CancellationToken ct = default) =>
|
||||
await repository.GetArchivedPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
|
||||
|
||||
public async Task<int> CreateAsync(BlogPost post, CancellationToken ct = default)
|
||||
{
|
||||
ValidatePost(post);
|
||||
post.Title = post.Title.Trim();
|
||||
post.Content = post.Content.Trim();
|
||||
post.Slug = await GenerateUniqueSlugAsync(post.Title, ct: ct);
|
||||
post.PublishedAt = post.IsPublished ? DateTime.UtcNow : null;
|
||||
var result = await repository.CreateAsync(post, ct);
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<BlogPost> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var post = new BlogPost
|
||||
{
|
||||
Title = dto.Title,
|
||||
Content = dto.Content,
|
||||
CategoryId = dto.CategoryId,
|
||||
Tags = dto.Tags,
|
||||
SeoTitle = dto.SeoTitle,
|
||||
SeoDescription = dto.SeoDescription,
|
||||
ThumbnailUrl = dto.ThumbnailUrl,
|
||||
IsPublished = dto.IsPublished,
|
||||
AuthorId = dto.AuthorId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var id = await CreateAsync(post, ct);
|
||||
post.Id = id;
|
||||
return post;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(BlogPost post, CancellationToken ct = default)
|
||||
{
|
||||
await repository.UpdateAsync(post, ct);
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var post = await repository.GetByIdAsync(id, ct);
|
||||
if (post == null)
|
||||
return null;
|
||||
|
||||
post.Title = dto.Title;
|
||||
post.Content = dto.Content;
|
||||
post.CategoryId = dto.CategoryId;
|
||||
post.Tags = dto.Tags;
|
||||
post.SeoTitle = dto.SeoTitle;
|
||||
post.SeoDescription = dto.SeoDescription;
|
||||
post.ThumbnailUrl = dto.ThumbnailUrl;
|
||||
post.IsPublished = dto.IsPublished;
|
||||
post.PublishedAt = dto.IsPublished
|
||||
? post.PublishedAt ?? DateTime.UtcNow
|
||||
: null;
|
||||
ValidatePost(post);
|
||||
|
||||
await UpdateAsync(post, ct);
|
||||
return post;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
await repository.DeleteAsync(id, ct);
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
}
|
||||
|
||||
public async Task ArchiveAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
await repository.ArchiveAsync(id, ct);
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
}
|
||||
|
||||
public async Task RestoreAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
await repository.RestoreAsync(id, ct);
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
}
|
||||
|
||||
public async Task IncrementViewCountAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.IncrementViewCountAsync(id, ct);
|
||||
|
||||
private static string GenerateSlug(string title)
|
||||
{
|
||||
var slug = Regex.Replace(title.ToLowerInvariant(), @"[^\w\s-]", "");
|
||||
slug = Regex.Replace(slug, @"\s+", "-");
|
||||
slug = Regex.Replace(slug, @"-+", "-").Trim('-');
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
slug = $"post-{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
return slug.Length > 100 ? slug[..100] : slug;
|
||||
}
|
||||
|
||||
private async Task<string> GenerateUniqueSlugAsync(string title, int? existingPostId = null, CancellationToken ct = default)
|
||||
{
|
||||
var baseSlug = GenerateSlug(title);
|
||||
var slug = baseSlug;
|
||||
var suffix = 2;
|
||||
var allPosts = (await repository.GetAllForAdminAsync(ct)).ToList();
|
||||
|
||||
while (allPosts.Any(x => x.Id != existingPostId && string.Equals(x.Slug, slug, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var suffixText = $"-{suffix++}";
|
||||
var maxBaseLength = Math.Max(1, 100 - suffixText.Length);
|
||||
slug = $"{baseSlug[..Math.Min(baseSlug.Length, maxBaseLength)]}{suffixText}";
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
private static void ValidatePost(BlogPost post)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(post.Title))
|
||||
throw new ValidationException("제목을 입력하세요.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(post.Content))
|
||||
throw new ValidationException("본문을 입력하세요.");
|
||||
|
||||
if (post.IsPublished && string.IsNullOrWhiteSpace(post.SeoTitle))
|
||||
throw new ValidationException("발행하려면 SEO 제목을 입력하세요.");
|
||||
|
||||
if (post.IsPublished && string.IsNullOrWhiteSpace(post.SeoDescription))
|
||||
throw new ValidationException("발행하려면 SEO 설명을 입력하세요.");
|
||||
}
|
||||
|
||||
private static int NormalizePage(int page) => Math.Max(1, page);
|
||||
|
||||
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
public async Task<(int TotalPosts, int PublishedPosts)> GetStatsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var posts = (await repository.GetAllForAdminAsync(ct)).ToList();
|
||||
return (posts.Count, posts.Count(x => x.IsPublished));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class CategoryService(ICategoryRepository repository)
|
||||
{
|
||||
public async Task<IEnumerable<Category>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<Category?> GetBySlugAsync(string slug, CancellationToken ct = default) =>
|
||||
await repository.GetBySlugAsync(slug, ct);
|
||||
|
||||
public async Task<Category?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<Category> CreateAsync(string name, string? description, CancellationToken ct = default)
|
||||
{
|
||||
var slug = GenerateSlug(name);
|
||||
var category = new Category
|
||||
{
|
||||
Name = name.Trim(),
|
||||
Slug = slug,
|
||||
SortOrder = 0
|
||||
};
|
||||
|
||||
var id = await repository.CreateAsync(category, ct);
|
||||
return new Category { Id = id, Name = category.Name, Slug = category.Slug, SortOrder = category.SortOrder };
|
||||
}
|
||||
|
||||
public async Task<Category?> UpdateAsync(int id, string name, string? description, CancellationToken ct = default)
|
||||
{
|
||||
var category = await repository.GetByIdAsync(id, ct);
|
||||
if (category == null)
|
||||
return null;
|
||||
|
||||
category.Name = name.Trim();
|
||||
category.Slug = GenerateSlug(name);
|
||||
await repository.UpdateAsync(category, ct);
|
||||
return category;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.DeleteAsync(id, ct);
|
||||
|
||||
private static string GenerateSlug(string name)
|
||||
{
|
||||
var slug = Regex.Replace(name.ToLowerInvariant(), @"[^\w\s-]", "");
|
||||
slug = Regex.Replace(slug, @"\s+", "-");
|
||||
slug = Regex.Replace(slug, @"-+", "-").Trim('-');
|
||||
return slug.Length > 100 ? slug[..100] : slug;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Application.DTOs;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class ClientService(IClientRepository repository)
|
||||
{
|
||||
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
||||
int page, int pageSize, string? status = null, string? search = null, CancellationToken ct = default) =>
|
||||
await repository.GetPagedAsync(Math.Max(1, page), Math.Clamp(pageSize, 1, 100), status, search, ct);
|
||||
|
||||
public async Task<Client?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<Client?> GetByEmailAsync(string email, CancellationToken ct = default) =>
|
||||
await repository.GetByEmailAsync(email, ct);
|
||||
|
||||
public async Task<Client?> GetByPhoneAsync(string phone, CancellationToken ct = default) =>
|
||||
await repository.GetByPhoneAsync(phone, ct);
|
||||
|
||||
public async Task<int> CountCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default) =>
|
||||
await repository.CountByCreatedAtRangeAsync(startDateUtc, endDateUtc, ct);
|
||||
|
||||
public async Task<int> CreateAsync(CreateClientDto dto, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||
throw new ValidationException("고객명을 입력하세요.");
|
||||
|
||||
var client = new Client
|
||||
{
|
||||
Name = dto.Name.Trim(),
|
||||
CompanyName = dto.CompanyName?.Trim(),
|
||||
Phone = dto.Phone?.Trim(),
|
||||
Email = dto.Email?.Trim(),
|
||||
ServiceType = dto.ServiceType,
|
||||
TaxType = dto.TaxType,
|
||||
Status = dto.Status,
|
||||
Source = dto.Source,
|
||||
Memo = dto.Memo?.Trim()
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(client, ct);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(int id, CreateClientDto dto, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||
throw new ValidationException("고객명을 입력하세요.");
|
||||
|
||||
var client = await repository.GetByIdAsync(id, ct)
|
||||
?? throw new KeyNotFoundException($"고객 ID {id}를 찾을 수 없습니다.");
|
||||
|
||||
client.Name = dto.Name.Trim();
|
||||
client.CompanyName = dto.CompanyName?.Trim();
|
||||
client.Phone = dto.Phone?.Trim();
|
||||
client.Email = dto.Email?.Trim();
|
||||
client.ServiceType = dto.ServiceType;
|
||||
client.TaxType = dto.TaxType;
|
||||
client.Status = dto.Status;
|
||||
client.Source = dto.Source;
|
||||
client.Memo = dto.Memo?.Trim();
|
||||
|
||||
await repository.UpdateAsync(client, ct);
|
||||
}
|
||||
|
||||
public async Task<int> CreateFromInquiryAsync(string name, string? phone, string? serviceType, CancellationToken ct = default)
|
||||
{
|
||||
var client = new Client
|
||||
{
|
||||
Name = name.Trim(),
|
||||
Phone = phone?.Trim(),
|
||||
ServiceType = serviceType,
|
||||
Status = "active",
|
||||
Source = "홈페이지문의"
|
||||
};
|
||||
return await repository.CreateAsync(client, ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.DeleteAsync(id, ct);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
public class CommonCodeService(ICommonCodeRepository commonCodeRepository)
|
||||
{
|
||||
private const int MaxCodeGroupLength = 80;
|
||||
private const int MaxCodeValueLength = 120;
|
||||
private const int MaxCodeNameLength = 200;
|
||||
|
||||
public async Task<IEnumerable<string>> GetAllGroupsAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await commonCodeRepository.GetAllGroupsAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default)
|
||||
{
|
||||
return await commonCodeRepository.GetByGroupAsync(codeGroup, ct);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await commonCodeRepository.GetAllActiveAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<CommonCode?> GetAsync(string codeGroup, string codeValue, CancellationToken ct = default)
|
||||
{
|
||||
return await commonCodeRepository.GetAsync(codeGroup, codeValue, ct);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(CommonCode code, CancellationToken ct = default)
|
||||
{
|
||||
Normalize(code);
|
||||
await commonCodeRepository.UpsertAsync(code, ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default)
|
||||
{
|
||||
await commonCodeRepository.DeleteAsync(NormalizeToken(codeGroup, nameof(codeGroup), MaxCodeGroupLength), NormalizeToken(codeValue, nameof(codeValue), MaxCodeValueLength), ct);
|
||||
}
|
||||
|
||||
private static void Normalize(CommonCode code)
|
||||
{
|
||||
code.CodeGroup = NormalizeToken(code.CodeGroup, nameof(code.CodeGroup), MaxCodeGroupLength, disallowWhitespace: true);
|
||||
code.CodeValue = NormalizeToken(code.CodeValue, nameof(code.CodeValue), MaxCodeValueLength, disallowWhitespace: true);
|
||||
code.CodeName = NormalizeToken(code.CodeName, nameof(code.CodeName), MaxCodeNameLength);
|
||||
}
|
||||
|
||||
private static string NormalizeToken(string value, string fieldName, int maxLength, bool disallowWhitespace = false)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
throw new ValidationException($"{fieldName}은(는) 필수입니다.");
|
||||
|
||||
if (normalized.Length > maxLength)
|
||||
throw new ValidationException($"{fieldName}은(는) 최대 {maxLength}자까지 입력할 수 있습니다.");
|
||||
|
||||
if (disallowWhitespace && normalized.Any(char.IsWhiteSpace))
|
||||
throw new ValidationException($"{fieldName}에는 공백을 사용할 수 없습니다.");
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class CompanyService(ICompanyRepository repository)
|
||||
{
|
||||
public async Task<int> CreateAsync(string companyCode, string companyName, string? contactPerson = null,
|
||||
string? phone = null, string? email = null, string? memo = null, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(companyCode))
|
||||
throw new ValidationException("회사 코드를 입력하세요.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(companyName))
|
||||
throw new ValidationException("회사명을 입력하세요.");
|
||||
|
||||
var existing = await repository.GetByCodeAsync(companyCode.Trim(), ct);
|
||||
if (existing != null)
|
||||
throw new ValidationException("이미 존재하는 회사 코드입니다.");
|
||||
|
||||
var company = new Company
|
||||
{
|
||||
CompanyCode = companyCode.Trim(),
|
||||
CompanyName = companyName.Trim(),
|
||||
ContactPerson = string.IsNullOrWhiteSpace(contactPerson) ? null : contactPerson.Trim(),
|
||||
Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim(),
|
||||
Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
|
||||
Memo = string.IsNullOrWhiteSpace(memo) ? null : memo.Trim(),
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(company, ct);
|
||||
}
|
||||
|
||||
public async Task<Company?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<Company?> GetByCodeAsync(string code, CancellationToken ct = default) =>
|
||||
await repository.GetByCodeAsync(code, ct);
|
||||
|
||||
public async Task<IEnumerable<Company>> GetAllActiveAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllActiveAsync(ct);
|
||||
|
||||
public async Task<(IEnumerable<Company>, int)> GetPagedAsync(int page, int pageSize, CancellationToken ct = default)
|
||||
{
|
||||
var (items, total) = await repository.GetPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(int id, string companyCode, string companyName, string? contactPerson = null,
|
||||
string? phone = null, string? email = null, string? memo = null, bool isActive = true, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(companyCode))
|
||||
throw new ValidationException("회사 코드를 입력하세요.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(companyName))
|
||||
throw new ValidationException("회사명을 입력하세요.");
|
||||
|
||||
var company = await repository.GetByIdAsync(id, ct);
|
||||
if (company == null)
|
||||
throw new ValidationException("회사를 찾을 수 없습니다.");
|
||||
|
||||
var existing = await repository.GetByCodeAsync(companyCode.Trim(), ct);
|
||||
if (existing != null && existing.Id != id)
|
||||
throw new ValidationException("이미 존재하는 회사 코드입니다.");
|
||||
|
||||
company.CompanyCode = companyCode.Trim();
|
||||
company.CompanyName = companyName.Trim();
|
||||
company.ContactPerson = string.IsNullOrWhiteSpace(contactPerson) ? null : contactPerson.Trim();
|
||||
company.Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim();
|
||||
company.Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim();
|
||||
company.Memo = string.IsNullOrWhiteSpace(memo) ? null : memo.Trim();
|
||||
company.IsActive = isActive;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await repository.UpdateAsync(company, ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var company = await repository.GetByIdAsync(id, ct);
|
||||
if (company == null)
|
||||
throw new ValidationException("회사를 찾을 수 없습니다.");
|
||||
|
||||
if (company.CompanyCode == "DEFAULT")
|
||||
throw new ValidationException("기본 회사는 삭제할 수 없습니다.");
|
||||
|
||||
await repository.DeleteAsync(id, ct);
|
||||
}
|
||||
|
||||
private static int NormalizePage(int page) => Math.Max(1, page);
|
||||
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class ConsultationService(IConsultationRepository repository)
|
||||
{
|
||||
public static readonly string[] Results =
|
||||
["상담 중", "계약 완료", "보류", "거절", "완료"];
|
||||
|
||||
public async Task<IEnumerable<Consultation>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
public async Task<int> CreateAsync(Consultation consultation, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(consultation.Summary))
|
||||
throw new ValidationException("상담 내용을 입력하세요.");
|
||||
if (consultation.ClientId <= 0)
|
||||
throw new ValidationException("고객을 선택하세요.");
|
||||
return await repository.CreateAsync(consultation, ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.DeleteAsync(id, ct);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class ConsultingActivityService(IConsultingActivityRepository repository)
|
||||
{
|
||||
public async Task<int> CreateAsync(int clientId, string activityType, DateTime activityDate,
|
||||
string description, int? consultantId = null, DateTime? nextFollowupDate = null, CancellationToken ct = default)
|
||||
{
|
||||
if (clientId <= 0)
|
||||
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||
if (string.IsNullOrWhiteSpace(activityType))
|
||||
throw new ValidationException("활동 유형을 입력하세요.");
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
throw new ValidationException("활동 내용을 입력하세요.");
|
||||
|
||||
var activity = new ConsultingActivity
|
||||
{
|
||||
ClientId = clientId,
|
||||
ActivityType = activityType.Trim(),
|
||||
ActivityDate = activityDate,
|
||||
Description = description.Trim(),
|
||||
AssignedConsultantId = consultantId,
|
||||
NextFollowupDate = nextFollowupDate,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(activity, ct);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default) =>
|
||||
await repository.GetPendingFollowupsAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetConsultantActivityAsync(int consultantId, DateTime fromDate, CancellationToken ct = default) =>
|
||||
await repository.GetByConsultantAsync(consultantId, fromDate, ct);
|
||||
|
||||
public async Task UpdateAsync(int id, string? outcome, DateTime? nextFollowupDate, CancellationToken ct = default)
|
||||
{
|
||||
var activity = new ConsultingActivity { Id = id, Outcome = outcome, NextFollowupDate = nextFollowupDate, UpdatedAt = DateTime.UtcNow };
|
||||
await repository.UpdateAsync(activity, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class ContractService(IContractRepository repository)
|
||||
{
|
||||
public async Task<int> CreateAsync(int clientId, string contractNumber, string serviceType,
|
||||
DateTime startDate, decimal? monthlyFee = null, decimal? totalAmount = null, CancellationToken ct = default)
|
||||
{
|
||||
if (clientId <= 0)
|
||||
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||
if (string.IsNullOrWhiteSpace(contractNumber))
|
||||
throw new ValidationException("계약 번호를 입력하세요.");
|
||||
if (string.IsNullOrWhiteSpace(serviceType))
|
||||
throw new ValidationException("서비스 유형을 입력하세요.");
|
||||
|
||||
var contract = new Contract
|
||||
{
|
||||
ClientId = clientId,
|
||||
ContractNumber = contractNumber.Trim(),
|
||||
ServiceType = serviceType.Trim(),
|
||||
ContractDate = DateTime.Today,
|
||||
StartDate = startDate,
|
||||
MonthlyFee = monthlyFee,
|
||||
TotalAmount = totalAmount,
|
||||
Status = "active",
|
||||
PaymentStatus = "pending",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(contract, ct);
|
||||
}
|
||||
|
||||
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken ct = default) =>
|
||||
await repository.GetActiveContractsAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken ct = default) =>
|
||||
await repository.GetExpiringContractsAsync(daysAhead, ct);
|
||||
|
||||
public async Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken ct = default) =>
|
||||
await repository.GetMonthlyRecurringRevenueAsync(ct);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class FaqService(IFaqRepository repository)
|
||||
{
|
||||
public static readonly string[] Categories =
|
||||
["기장세금신고", "부동산", "증여상속", "기타"];
|
||||
|
||||
public async Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default) =>
|
||||
await repository.GetActiveAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<int> CreateAsync(Faq faq, CancellationToken ct = default)
|
||||
{
|
||||
Validate(faq);
|
||||
return await repository.CreateAsync(faq, ct);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Faq faq, CancellationToken ct = default)
|
||||
{
|
||||
Validate(faq);
|
||||
await repository.UpdateAsync(faq, ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.DeleteAsync(id, ct);
|
||||
|
||||
private static void Validate(Faq faq)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(faq.Question))
|
||||
throw new ValidationException("질문을 입력하세요.");
|
||||
if (string.IsNullOrWhiteSpace(faq.Answer))
|
||||
throw new ValidationException("답변을 입력하세요.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
public interface IInquiryNotificationService
|
||||
{
|
||||
Task NotifyCreatedAsync(int inquiryId, string name, string phone, string serviceType, string message, string? ipAddress, DateTime createdAtUtc, CancellationToken ct = default);
|
||||
Task NotifyStatusChangedAsync(int inquiryId, string name, string phone, string serviceType, string previousStatus, string newStatus, string? changedBy = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using TaxBaik.Application.DTOs;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Enums;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class InquiryService(
|
||||
IInquiryRepository repository,
|
||||
IInquiryNotificationService notificationService,
|
||||
IMemoryCache memoryCache)
|
||||
{
|
||||
private static readonly Regex PhoneRegex = new(@"^01[0-9]-\d{3,4}-\d{4}$");
|
||||
|
||||
public async Task<int> SubmitAsync(
|
||||
string name, string phone, string serviceType, string message,
|
||||
string? email = null, string? ipAddress = null, bool suppressNotification = false, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ValidationException("이름을 입력하세요.");
|
||||
|
||||
if (!PhoneRegex.IsMatch(phone))
|
||||
throw new ValidationException("올바른 전화번호를 입력하세요. (예: 010-1234-5678)");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
throw new ValidationException("문의 내용을 입력하세요.");
|
||||
|
||||
var inquiry = new Inquiry
|
||||
{
|
||||
Name = name.Trim(),
|
||||
Phone = phone.Trim(),
|
||||
Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
|
||||
ServiceType = serviceType ?? "기타",
|
||||
Message = message.Trim(),
|
||||
IpAddress = ipAddress,
|
||||
Status = InquiryStatusMapper.ToStorageValue(InquiryStatus.New),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var inquiryId = await repository.CreateAsync(inquiry, ct);
|
||||
if (!suppressNotification)
|
||||
{
|
||||
await notificationService.NotifyCreatedAsync(inquiryId, inquiry.Name, inquiry.Phone, inquiry.ServiceType, inquiry.Message, inquiry.IpAddress, inquiry.CreatedAt, ct);
|
||||
}
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
return inquiryId;
|
||||
}
|
||||
|
||||
public async Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<(IEnumerable<Inquiry>, int)> GetPagedAsync(
|
||||
int page, int pageSize, string? status = null, CancellationToken ct = default) =>
|
||||
await repository.GetPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), NormalizeOptionalStatus(status), ct);
|
||||
|
||||
public Task<int> CountAsync(CancellationToken ct = default)
|
||||
=> repository.CountAsync(ct);
|
||||
|
||||
public Task<int> CountThisMonthAsync(CancellationToken ct = default)
|
||||
=> repository.CountThisMonthAsync(ct);
|
||||
|
||||
public Task<int> CountByStatusAsync(string status, CancellationToken ct = default)
|
||||
=> repository.CountByStatusAsync(status, ct);
|
||||
|
||||
public Task<int> CountByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default)
|
||||
=> repository.CountByDateRangeAsync(startDate, endDate, ct);
|
||||
|
||||
public Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken ct = default)
|
||||
=> repository.CountByStatusAndDateAsync(status, startDate, endDate, ct);
|
||||
|
||||
public async Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken ct = default) =>
|
||||
await repository.UpdateAdminMemoAsync(id, adminMemo, ct);
|
||||
|
||||
public async Task<Inquiry?> UpdateAsync(int id, UpdateInquiryDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var inquiry = await repository.GetByIdAsync(id, ct);
|
||||
if (inquiry == null)
|
||||
return null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||
throw new ValidationException("이름을 입력하세요.");
|
||||
|
||||
if (!PhoneRegex.IsMatch(dto.Phone))
|
||||
throw new ValidationException("올바른 전화번호를 입력하세요. (예: 010-1234-5678)");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dto.Message))
|
||||
throw new ValidationException("문의 내용을 입력하세요.");
|
||||
|
||||
if (!InquiryStatusMapper.TryParse(dto.Status, out var parsedStatus))
|
||||
throw new ValidationException("지원하지 않는 문의 상태입니다.");
|
||||
|
||||
inquiry.Name = dto.Name.Trim();
|
||||
inquiry.Phone = dto.Phone.Trim();
|
||||
inquiry.Email = string.IsNullOrWhiteSpace(dto.Email) ? null : dto.Email.Trim();
|
||||
inquiry.ServiceType = string.IsNullOrWhiteSpace(dto.ServiceType) ? "기타" : dto.ServiceType.Trim();
|
||||
inquiry.Message = dto.Message.Trim();
|
||||
inquiry.Status = InquiryStatusMapper.ToStorageValue(parsedStatus);
|
||||
inquiry.AdminMemo = dto.AdminMemo;
|
||||
|
||||
await repository.UpdateAsync(inquiry, ct);
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
return inquiry;
|
||||
}
|
||||
|
||||
public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken ct = default) =>
|
||||
await repository.LinkClientAsync(inquiryId, clientId, ct);
|
||||
|
||||
public async Task UpdateStatusAsync(int id, string status, string? changedBy = null, CancellationToken ct = default)
|
||||
{
|
||||
if (!InquiryStatusMapper.TryParse(status, out var parsed))
|
||||
throw new ValidationException("지원하지 않는 문의 상태입니다.");
|
||||
|
||||
var inquiry = await repository.GetByIdAsync(id, ct);
|
||||
if (inquiry == null)
|
||||
return;
|
||||
|
||||
var previousStatus = inquiry.Status;
|
||||
var newStatus = InquiryStatusMapper.ToStorageValue(parsed);
|
||||
|
||||
await repository.UpdateStatusAsync(id, newStatus, ct);
|
||||
await notificationService.NotifyStatusChangedAsync(id, inquiry.Name, inquiry.Phone, inquiry.ServiceType, previousStatus, newStatus, changedBy, ct);
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
await repository.DeleteAsync(id, ct);
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
}
|
||||
|
||||
private static int NormalizePage(int page) => Math.Max(1, page);
|
||||
|
||||
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
private static string? NormalizeOptionalStatus(string? status)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(status))
|
||||
return null;
|
||||
|
||||
if (!InquiryStatusMapper.TryParse(status, out var parsed))
|
||||
throw new ValidationException("지원하지 않는 문의 상태입니다.");
|
||||
|
||||
return InquiryStatusMapper.ToStorageValue(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
public class ValidationException : Exception
|
||||
{
|
||||
public ValidationException(string message) : base(message) { }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Enums;
|
||||
|
||||
public static class InquiryStatusMapper
|
||||
{
|
||||
public static readonly Dictionary<string, string> Labels = new()
|
||||
{
|
||||
["new"] = "신규",
|
||||
["consulting"] = "상담중",
|
||||
["contracted"] = "계약완료",
|
||||
["rejected"] = "거절",
|
||||
["closed"] = "종결",
|
||||
};
|
||||
|
||||
public static string ToStorageValue(InquiryStatus status) => status switch
|
||||
{
|
||||
InquiryStatus.New => "new",
|
||||
InquiryStatus.Consulting => "consulting",
|
||||
InquiryStatus.Contracted => "contracted",
|
||||
InquiryStatus.Rejected => "rejected",
|
||||
InquiryStatus.Closed => "closed",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
|
||||
};
|
||||
|
||||
public static bool TryParse(string? value, out InquiryStatus status)
|
||||
{
|
||||
var key = value?.Trim().ToLowerInvariant();
|
||||
status = key switch
|
||||
{
|
||||
"new" => InquiryStatus.New,
|
||||
"consulting" => InquiryStatus.Consulting,
|
||||
"contracted" => InquiryStatus.Contracted,
|
||||
"rejected" => InquiryStatus.Rejected,
|
||||
"closed" => InquiryStatus.Closed,
|
||||
_ => default
|
||||
};
|
||||
return key is "new" or "consulting" or "contracted" or "rejected" or "closed";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
public sealed class NoopInquiryNotificationService : IInquiryNotificationService
|
||||
{
|
||||
public Task NotifyCreatedAsync(int inquiryId, string name, string phone, string serviceType, string message, string? ipAddress, DateTime createdAtUtc, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task NotifyStatusChangedAsync(int inquiryId, string name, string phone, string serviceType, string previousStatus, string newStatus, string? changedBy = null, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class PortalUserService(IPortalUserRepository repository)
|
||||
{
|
||||
public async Task<PortalUser?> GetByEmailAsync(string email, CancellationToken ct = default) =>
|
||||
await repository.GetByEmailAsync(email.Trim(), ct);
|
||||
|
||||
public async Task<PortalUser?> GetByProviderAsync(string provider, string providerId, CancellationToken ct = default) =>
|
||||
await repository.GetByProviderAsync(provider.Trim(), providerId.Trim(), ct);
|
||||
|
||||
public async Task<int> RegisterLocalAsync(string name, string email, string phone, string? passwordHash, int? clientId = null, CancellationToken ct = default) =>
|
||||
await RegisterAsync(name, email, phone, "local", null, passwordHash, clientId, ct);
|
||||
|
||||
public async Task<int> RegisterOAuthAsync(string name, string email, string? phone, string provider, string providerId, int? clientId = null, CancellationToken ct = default) =>
|
||||
await RegisterAsync(name, email, phone, provider, providerId, null, clientId, ct);
|
||||
|
||||
public async Task LinkOAuthAsync(PortalUser user, string provider, string providerId, string? displayName = null, string? email = null, CancellationToken ct = default)
|
||||
{
|
||||
user.Name = string.IsNullOrWhiteSpace(displayName) ? user.Name : displayName.Trim();
|
||||
user.Email = string.IsNullOrWhiteSpace(email) ? user.Email : email.Trim();
|
||||
if (string.IsNullOrWhiteSpace(user.PasswordHash))
|
||||
{
|
||||
user.Provider = provider.Trim();
|
||||
user.ProviderId = providerId.Trim();
|
||||
}
|
||||
await repository.UpdateAsync(user, ct);
|
||||
}
|
||||
|
||||
public async Task AttachClientAsync(PortalUser user, int clientId, CancellationToken ct = default)
|
||||
{
|
||||
user.ClientId = clientId;
|
||||
await repository.UpdateAsync(user, ct);
|
||||
}
|
||||
|
||||
private async Task<int> RegisterAsync(string name, string email, string? phone, string provider, string? providerId, string? passwordHash, int? clientId, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ValidationException("이름을 입력하세요.");
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
throw new ValidationException("이메일을 입력하세요.");
|
||||
|
||||
var user = new PortalUser
|
||||
{
|
||||
ClientId = clientId,
|
||||
Name = name.Trim(),
|
||||
Email = email.Trim(),
|
||||
Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim(),
|
||||
Provider = provider,
|
||||
ProviderId = providerId,
|
||||
PasswordHash = passwordHash,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(user, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class RevenueTrackingService(IRevenueTrackingRepository repository)
|
||||
{
|
||||
public async Task<int> CreateAsync(int clientId, string invoiceNumber, DateTime invoiceDate,
|
||||
decimal amount, string? serviceType = null, DateTime? dueDate = null, CancellationToken ct = default)
|
||||
{
|
||||
if (clientId <= 0)
|
||||
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||
if (string.IsNullOrWhiteSpace(invoiceNumber))
|
||||
throw new ValidationException("인보이스 번호를 입력하세요.");
|
||||
if (amount <= 0)
|
||||
throw new ValidationException("금액은 0보다 커야 합니다.");
|
||||
|
||||
var revenue = new RevenueTracking
|
||||
{
|
||||
ClientId = clientId,
|
||||
InvoiceNumber = invoiceNumber.Trim(),
|
||||
InvoiceDate = invoiceDate,
|
||||
Amount = amount,
|
||||
ServiceType = string.IsNullOrWhiteSpace(serviceType) ? null : serviceType.Trim(),
|
||||
DueDate = dueDate,
|
||||
PaymentStatus = "pending",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(revenue, ct);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default) =>
|
||||
await repository.GetPendingPaymentsAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetMonthlyRevenueAsync(DateTime month, CancellationToken ct = default)
|
||||
{
|
||||
var startDate = new DateTime(month.Year, month.Month, 1);
|
||||
var endDate = startDate.AddMonths(1).AddDays(-1);
|
||||
return await repository.GetByDateRangeAsync(startDate, endDate, ct);
|
||||
}
|
||||
|
||||
public async Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken ct = default) =>
|
||||
await repository.MarkPaidAsync(id, paymentDate, ct);
|
||||
|
||||
public async Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default) =>
|
||||
await repository.GetTotalRevenueAsync(startDate, endDate, ct);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Application.Seasonal;
|
||||
|
||||
public class SeasonalMarketingService
|
||||
{
|
||||
public CurrentSeasonDto? GetCurrentSeason()
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
|
||||
foreach (var season in TaxSeasonCalendar.Seasons)
|
||||
{
|
||||
var start = new DateTime(today.Year, season.StartMonth, season.StartDay);
|
||||
var end = new DateTime(today.Year, season.EndMonth, season.EndDay);
|
||||
|
||||
if (today >= start && today <= end)
|
||||
{
|
||||
var effectiveEnd = BusinessDayCalculator.GetEffectiveBusinessDate(DateOnly.FromDateTime(end)).ToDateTime(TimeOnly.MinValue);
|
||||
var days = BusinessDayCalculator.GetBusinessDayDiff(DateOnly.FromDateTime(end), DateOnly.FromDateTime(today));
|
||||
return new CurrentSeasonDto
|
||||
{
|
||||
Key = season.Key,
|
||||
Name = season.Name,
|
||||
HeroHeadline = season.HeroHeadline,
|
||||
HeroSubtext = season.HeroSubtext,
|
||||
UrgencyBadge = season.UrgencyBadge.Replace("{n}", days.ToString()),
|
||||
FocusService = season.FocusService,
|
||||
RelatedCategorySlug = season.RelatedCategorySlug,
|
||||
CtaText = season.CtaText,
|
||||
DaysUntilDeadline = days,
|
||||
Deadline = effectiveEnd
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<TaxSeason> GetFullCalendar() => TaxSeasonCalendar.Seasons;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
public class SiteSettingService(ISiteSettingRepository repository)
|
||||
{
|
||||
public Task<IReadOnlyDictionary<string, string>> GetAllAsync(CancellationToken ct = default)
|
||||
=> repository.GetAllAsync(ct);
|
||||
|
||||
public Task SaveAsync(string phone, string email, string kakaoUrl, string instagramUrl, CancellationToken ct = default)
|
||||
{
|
||||
var settings = new[]
|
||||
{
|
||||
new SiteSetting { Key = "PhoneNumber", Value = phone.Trim(), UpdatedAt = DateTime.UtcNow },
|
||||
new SiteSetting { Key = "EmailAddress", Value = email.Trim(), UpdatedAt = DateTime.UtcNow },
|
||||
new SiteSetting { Key = "KakaoChannelUrl", Value = kakaoUrl.Trim(), UpdatedAt = DateTime.UtcNow },
|
||||
new SiteSetting { Key = "InstagramUrl", Value = instagramUrl.Trim(), UpdatedAt = DateTime.UtcNow },
|
||||
};
|
||||
|
||||
return repository.UpsertAsync(settings, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class TaxFilingScheduleService(ITaxFilingScheduleRepository repository)
|
||||
{
|
||||
public async Task<int> CreateAsync(int clientId, string filingType, DateTime dueDate, int filingYear,
|
||||
int? assignedToId = null, CancellationToken ct = default)
|
||||
{
|
||||
if (clientId <= 0)
|
||||
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||
if (string.IsNullOrWhiteSpace(filingType))
|
||||
throw new ValidationException("신고 유형을 입력하세요.");
|
||||
if (dueDate < DateTime.Today)
|
||||
throw new ValidationException("마감일은 오늘 이후여야 합니다.");
|
||||
|
||||
var schedule = new TaxFilingSchedule
|
||||
{
|
||||
ClientId = clientId,
|
||||
FilingType = filingType.Trim(),
|
||||
DueDate = dueDate,
|
||||
FilingYear = filingYear,
|
||||
Status = "pending",
|
||||
AssignedToId = assignedToId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(schedule, ct);
|
||||
}
|
||||
|
||||
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken ct = default) =>
|
||||
await repository.GetUpcomingDuesAsync(daysAhead, ct);
|
||||
|
||||
public async Task MarkCompletedAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.MarkCompletedAsync(id, ct);
|
||||
|
||||
public async Task<int> GetPendingCountAsync(CancellationToken ct = default)
|
||||
{
|
||||
var pending = await repository.GetByStatusAsync("pending", ct);
|
||||
return pending.Count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class TaxFilingService(ITaxFilingRepository repository)
|
||||
{
|
||||
public static readonly string[] Statuses =
|
||||
["pending", "filed", "overdue"];
|
||||
|
||||
public static readonly Dictionary<string, string> StatusLabels = new()
|
||||
{
|
||||
["pending"] = "신고 예정",
|
||||
["filed"] = "신고 완료",
|
||||
["overdue"] = "기한 초과",
|
||||
};
|
||||
|
||||
public async Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default) =>
|
||||
await repository.GetUpcomingAsync(daysAhead, ct);
|
||||
|
||||
public async Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<int> CreateAsync(TaxFiling filing, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filing.FilingType))
|
||||
throw new ValidationException("신고 유형을 선택하세요.");
|
||||
if (filing.ClientId <= 0)
|
||||
throw new ValidationException("고객을 선택하세요.");
|
||||
if (filing.DueDate == default)
|
||||
throw new ValidationException("신고 기한을 입력하세요.");
|
||||
return await repository.CreateAsync(filing, ct);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(TaxFiling filing, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filing.FilingType))
|
||||
throw new ValidationException("신고 유형을 선택하세요.");
|
||||
await repository.UpdateAsync(filing, ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.DeleteAsync(id, ct);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class TaxProfileService(ITaxProfileRepository repository)
|
||||
{
|
||||
public async Task<int> CreateAsync(int clientId, string? businessType, string? businessRegistration = null,
|
||||
string? accountingMethod = null, DateTime? establishmentDate = null, CancellationToken ct = default)
|
||||
{
|
||||
if (clientId <= 0)
|
||||
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||
if (string.IsNullOrWhiteSpace(businessType))
|
||||
throw new ValidationException("사업 유형을 입력하세요.");
|
||||
|
||||
var profile = new TaxProfile
|
||||
{
|
||||
ClientId = clientId,
|
||||
BusinessType = businessType.Trim(),
|
||||
BusinessRegistration = string.IsNullOrWhiteSpace(businessRegistration) ? null : businessRegistration.Trim(),
|
||||
EstablishmentDate = establishmentDate,
|
||||
AccountingMethod = string.IsNullOrWhiteSpace(accountingMethod) ? null : accountingMethod.Trim(),
|
||||
TaxRiskLevel = "normal",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(profile, ct);
|
||||
}
|
||||
|
||||
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
|
||||
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
|
||||
{
|
||||
var profile = await repository.GetByIdAsync(profileId, ct);
|
||||
if (profile == null)
|
||||
throw new ValidationException("세무 프로필을 찾을 수 없습니다.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(businessType))
|
||||
profile.BusinessType = businessType.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(accountingMethod))
|
||||
profile.AccountingMethod = accountingMethod.Trim();
|
||||
profile.NextFilingDueDate = nextFilingDueDate;
|
||||
profile.TaxRiskLevel = taxRiskLevel;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await repository.UpdateAsync(profile, ct);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxProfile>> GetHighRiskProfilesAsync(CancellationToken ct = default) =>
|
||||
await repository.GetByRiskLevelAsync("high", ct);
|
||||
|
||||
public async Task<IEnumerable<TaxProfile>> GetUpcomingFilingDuesAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||
{
|
||||
var startDate = DateTime.Today;
|
||||
var endDate = startDate.AddDays(daysAhead);
|
||||
return await repository.GetUpcomingFilingDuesAsync(startDate, endDate, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
public record TelegramDailyReport(
|
||||
DateOnly Date,
|
||||
int NewInquiries,
|
||||
int PendingInquiries,
|
||||
int NewClients,
|
||||
int PendingTaxFilings,
|
||||
int PendingPayments);
|
||||
|
||||
public record TelegramWeeklyReport(
|
||||
DateOnly WeekStart,
|
||||
DateOnly WeekEnd,
|
||||
int NewInquiries,
|
||||
int NewClients,
|
||||
int UpcomingTaxFilings,
|
||||
decimal RevenueThisWeek);
|
||||
|
||||
public class TelegramReportService(
|
||||
InquiryService inquiryService,
|
||||
ClientService clientService,
|
||||
TaxFilingScheduleService taxFilingScheduleService,
|
||||
RevenueTrackingService revenueTrackingService)
|
||||
{
|
||||
public async Task<TelegramDailyReport> BuildDailyReportAsync(DateOnly date, CancellationToken ct = default)
|
||||
{
|
||||
var start = date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
var end = date.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
|
||||
|
||||
return new TelegramDailyReport(
|
||||
Date: date,
|
||||
NewInquiries: await inquiryService.CountByDateRangeAsync(start, end, ct),
|
||||
PendingInquiries: await inquiryService.CountByStatusAsync("new", ct),
|
||||
NewClients: await clientService.CountCreatedAtRangeAsync(start, end, ct),
|
||||
PendingTaxFilings: await taxFilingScheduleService.GetPendingCountAsync(ct),
|
||||
PendingPayments: (await revenueTrackingService.GetPendingPaymentsAsync(ct)).Count());
|
||||
}
|
||||
|
||||
public async Task<TelegramWeeklyReport> BuildWeeklyReportAsync(DateOnly weekStart, CancellationToken ct = default)
|
||||
{
|
||||
var weekEnd = weekStart.AddDays(6);
|
||||
var start = weekStart.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
var end = weekEnd.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
|
||||
var upcomingEnd = weekEnd.AddDays(7).ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
|
||||
|
||||
var revenue = await revenueTrackingService.GetTotalRevenueAsync(start, end, ct);
|
||||
|
||||
return new TelegramWeeklyReport(
|
||||
WeekStart: weekStart,
|
||||
WeekEnd: weekEnd,
|
||||
NewInquiries: await inquiryService.CountByDateRangeAsync(start, end, ct),
|
||||
NewClients: await clientService.CountCreatedAtRangeAsync(start, end, ct),
|
||||
UpcomingTaxFilings: (await taxFilingScheduleService.GetUpcomingDuesAsync(14, ct))
|
||||
.Count(x => x.DueDate >= start && x.DueDate <= upcomingEnd),
|
||||
RevenueThisWeek: revenue);
|
||||
}
|
||||
|
||||
public static string FormatDailyMessage(TelegramDailyReport report) =>
|
||||
$"<b>📊 일간 리포트</b>\n\n" +
|
||||
$"기준일: <code>{report.Date:yyyy-MM-dd}</code>\n" +
|
||||
$"신규 문의: <code>{report.NewInquiries}</code>\n" +
|
||||
$"처리 대기 문의: <code>{report.PendingInquiries}</code>\n" +
|
||||
$"신규 고객: <code>{report.NewClients}</code>\n" +
|
||||
$"신고 대기: <code>{report.PendingTaxFilings}</code>\n" +
|
||||
$"미수 청구: <code>{report.PendingPayments}</code>";
|
||||
|
||||
public static string FormatWeeklyMessage(TelegramWeeklyReport report) =>
|
||||
$"<b>📈 주간 리포트</b>\n\n" +
|
||||
$"기간: <code>{report.WeekStart:yyyy-MM-dd}</code> ~ <code>{report.WeekEnd:yyyy-MM-dd}</code>\n" +
|
||||
$"신규 문의: <code>{report.NewInquiries}</code>\n" +
|
||||
$"신규 고객: <code>{report.NewClients}</code>\n" +
|
||||
$"다가오는 신고: <code>{report.UpcomingTaxFilings}</code>\n" +
|
||||
$"주간 매출: <code>₩{report.RevenueThisWeek:N0}</code>";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TaxBaik.Domain\TaxBaik.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace TaxBaik.Application.Utils;
|
||||
|
||||
public static class BusinessDayCalculator
|
||||
{
|
||||
private static readonly HashSet<DateOnly> HolidayDates = new()
|
||||
{
|
||||
// 2026
|
||||
new DateOnly(2026, 1, 1),
|
||||
new DateOnly(2026, 2, 16),
|
||||
new DateOnly(2026, 2, 17),
|
||||
new DateOnly(2026, 2, 18),
|
||||
new DateOnly(2026, 3, 1),
|
||||
new DateOnly(2026, 3, 2),
|
||||
new DateOnly(2026, 5, 5),
|
||||
new DateOnly(2026, 5, 25),
|
||||
new DateOnly(2026, 6, 6),
|
||||
new DateOnly(2026, 8, 15),
|
||||
new DateOnly(2026, 8, 16),
|
||||
new DateOnly(2026, 8, 17),
|
||||
new DateOnly(2026, 9, 24),
|
||||
new DateOnly(2026, 9, 25),
|
||||
new DateOnly(2026, 9, 26),
|
||||
new DateOnly(2026, 10, 3),
|
||||
new DateOnly(2026, 10, 4),
|
||||
new DateOnly(2026, 10, 5),
|
||||
new DateOnly(2026, 10, 9),
|
||||
new DateOnly(2026, 12, 25),
|
||||
|
||||
// 2027
|
||||
new DateOnly(2027, 1, 1),
|
||||
new DateOnly(2027, 2, 6),
|
||||
new DateOnly(2027, 2, 7),
|
||||
new DateOnly(2027, 2, 8),
|
||||
new DateOnly(2027, 2, 9),
|
||||
new DateOnly(2027, 3, 1),
|
||||
new DateOnly(2027, 3, 2),
|
||||
new DateOnly(2027, 5, 5),
|
||||
new DateOnly(2027, 5, 13),
|
||||
new DateOnly(2027, 6, 6),
|
||||
new DateOnly(2027, 8, 15),
|
||||
new DateOnly(2027, 8, 16),
|
||||
new DateOnly(2027, 9, 14),
|
||||
new DateOnly(2027, 9, 15),
|
||||
new DateOnly(2027, 9, 16),
|
||||
new DateOnly(2027, 10, 3),
|
||||
new DateOnly(2027, 10, 4),
|
||||
new DateOnly(2027, 10, 9),
|
||||
new DateOnly(2027, 10, 10),
|
||||
new DateOnly(2027, 10, 11),
|
||||
new DateOnly(2027, 12, 25),
|
||||
new DateOnly(2027, 12, 26)
|
||||
};
|
||||
|
||||
public static DateOnly GetEffectiveDueDate(DateOnly dueDate)
|
||||
{
|
||||
var effectiveDate = dueDate;
|
||||
while (!IsBusinessDay(effectiveDate))
|
||||
{
|
||||
effectiveDate = effectiveDate.AddDays(1);
|
||||
}
|
||||
|
||||
return effectiveDate;
|
||||
}
|
||||
|
||||
public static int GetDday(DateOnly dueDate, DateOnly? referenceDate = null)
|
||||
{
|
||||
var today = referenceDate ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
var effectiveDueDate = GetEffectiveDueDate(dueDate);
|
||||
return effectiveDueDate.DayNumber - today.DayNumber;
|
||||
}
|
||||
|
||||
public static bool IsBusinessDay(DateOnly date)
|
||||
=> date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday
|
||||
&& !HolidayDates.Contains(date);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace TaxBaik.Application.Utils;
|
||||
|
||||
public class VersionInfo
|
||||
{
|
||||
public string Version { get; set; } = "unknown";
|
||||
public string Built { get; set; } = "unknown";
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
namespace TaxBaik.Domain { }
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class AdminUser
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Username { get; set; } = null!;
|
||||
public string PasswordHash { get; set; } = null!;
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class Announcement
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Title { get; set; } = null!;
|
||||
public string? Content { get; set; }
|
||||
public string DisplayType { get; set; } = "info";
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime? StartsAt { get; set; }
|
||||
public DateTime? EndsAt { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class BlogPost
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Title { get; set; } = null!;
|
||||
public string Content { get; set; } = null!;
|
||||
public string Slug { get; set; } = null!;
|
||||
public int? CategoryId { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
public int? AuthorId { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public int ViewCount { get; set; }
|
||||
public string? SeoTitle { get; set; }
|
||||
public string? SeoDescription { get; set; }
|
||||
public string? ThumbnailUrl { get; set; }
|
||||
public bool IsPublished { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
|
||||
// Navigation property (populated via LEFT JOIN, not stored in DB)
|
||||
public string? CategoryName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class Category
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
public string Slug { get; set; } = null!;
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class Client
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int? CompanyId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string? CompanyName { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? ContactPerson { get; set; }
|
||||
public string? ServiceType { get; set; }
|
||||
public string? TaxType { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string? Source { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
|
||||
// Tax-specific fields
|
||||
public string? BusinessRegistrationNumber { get; set; }
|
||||
public string? BusinessType { get; set; }
|
||||
public DateTime? EstablishmentDate { get; set; }
|
||||
public string? AnnualRevenueRange { get; set; }
|
||||
public int? EmployeeCount { get; set; }
|
||||
public DateTime? LastTaxFilingDate { get; set; }
|
||||
public string TaxRiskLevel { get; set; } = "normal";
|
||||
public DateTime? NextFilingDueDate { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class CommonCode
|
||||
{
|
||||
public string CodeGroup { get; set; } = string.Empty;
|
||||
public string CodeValue { get; set; } = string.Empty;
|
||||
public string CodeName { get; set; } = string.Empty;
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class Company
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string CompanyCode { get; set; } = "";
|
||||
public string CompanyName { get; set; } = "";
|
||||
public string? ContactPerson { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class Consultation
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public DateTime ConsultationDate { get; set; }
|
||||
public string? ServiceType { get; set; }
|
||||
public string Summary { get; set; } = null!;
|
||||
public string? Result { get; set; }
|
||||
public decimal? Fee { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class ConsultingActivity
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string ActivityType { get; set; } = "";
|
||||
public DateTime ActivityDate { get; set; }
|
||||
public TimeOnly? ActivityTime { get; set; }
|
||||
public int? AssignedConsultantId { get; set; }
|
||||
public string Description { get; set; } = "";
|
||||
public string? Outcome { get; set; }
|
||||
public DateTime? NextFollowupDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class Contract
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string ContractNumber { get; set; } = "";
|
||||
public string ServiceType { get; set; } = "";
|
||||
public DateTime ContractDate { get; set; }
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public decimal? MonthlyFee { get; set; }
|
||||
public decimal? TotalAmount { get; set; }
|
||||
public string PaymentStatus { get; set; } = "pending";
|
||||
public string Status { get; set; } = "active";
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class Faq
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Question { get; set; } = null!;
|
||||
public string Answer { get; set; } = null!;
|
||||
public string? Category { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class Inquiry
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
public string Phone { get; set; } = null!;
|
||||
public string? Email { get; set; }
|
||||
public string ServiceType { get; set; } = null!;
|
||||
public string Message { get; set; } = null!;
|
||||
public string Status { get; set; } = "new";
|
||||
public string? IpAddress { get; set; }
|
||||
public int? ClientId { get; set; }
|
||||
public string? AdminMemo { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class PortalUser
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int? ClientId { get; set; }
|
||||
public string Email { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string? Phone { get; set; }
|
||||
public string Provider { get; set; } = "local";
|
||||
public string? ProviderId { get; set; }
|
||||
public string? PasswordHash { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class RevenueTracking
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string InvoiceNumber { get; set; } = "";
|
||||
public DateTime InvoiceDate { get; set; }
|
||||
public string? ServiceType { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string PaymentStatus { get; set; } = "pending";
|
||||
public DateTime? PaymentDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class SiteSetting
|
||||
{
|
||||
public string Key { get; set; } = null!;
|
||||
public string Value { get; set; } = null!;
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class TaxFiling
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string FilingType { get; set; } = null!;
|
||||
public DateTime DueDate { get; set; }
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? Memo { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
// join
|
||||
public string? ClientName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class TaxFilingSchedule
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string FilingType { get; set; } = "";
|
||||
public DateTime DueDate { get; set; }
|
||||
public int FilingYear { get; set; }
|
||||
public string Status { get; set; } = "pending";
|
||||
public int? AssignedToId { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class TaxProfile
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string? BusinessRegistration { get; set; }
|
||||
public string? BusinessType { get; set; }
|
||||
public DateTime? EstablishmentDate { get; set; }
|
||||
public string? AnnualRevenueRange { get; set; }
|
||||
public int? EmployeeCount { get; set; }
|
||||
public string? AccountingMethod { get; set; }
|
||||
public string? FiscalYearEnd { get; set; }
|
||||
public DateTime? LastFilingDate { get; set; }
|
||||
public DateTime? NextFilingDueDate { get; set; }
|
||||
public string TaxRiskLevel { get; set; } = "normal";
|
||||
public bool PreviousAuditHistory { get; set; }
|
||||
public string? SpecialNotes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace TaxBaik.Domain.Enums;
|
||||
|
||||
public enum InquiryStatus
|
||||
{
|
||||
New = 0,
|
||||
Consulting = 1,
|
||||
Contracted = 2,
|
||||
Rejected = 3,
|
||||
Closed = 4
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TaxBaik.Domain.Enums;
|
||||
|
||||
public enum ServiceType
|
||||
{
|
||||
Bookkeeping = 0, // 기장
|
||||
IncomeTax = 1, // 종소세
|
||||
VatTax = 2, // 부가세
|
||||
CapitalGainsTax = 3, // 양도세
|
||||
GiftInheritanceTax = 4,// 증여·상속
|
||||
Other = 5 // 기타
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
public interface IAdminUserRepository
|
||||
{
|
||||
Task<Entities.AdminUser?> GetByUsernameAsync(string username);
|
||||
Task<Entities.AdminUser?> GetByIdAsync(int id);
|
||||
Task CreateAsync(Entities.AdminUser user);
|
||||
Task UpdatePasswordHashAsync(int id, string passwordHash);
|
||||
Task UpdateLastLoginAtAsync(int id);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IAnnouncementRepository
|
||||
{
|
||||
Task<IEnumerable<Announcement>> GetActiveAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<Announcement?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<int> CreateAsync(Announcement announcement, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Announcement announcement, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IBlogPostRepository
|
||||
{
|
||||
Task<BlogPost?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default);
|
||||
Task<(IEnumerable<BlogPost> Items, int Total)> GetPublishedPagedAsync(
|
||||
int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<BlogPost>> GetByCategorySlugAsync(string categorySlug, int limit, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
|
||||
int page, int pageSize, CancellationToken cancellationToken = default);
|
||||
Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
|
||||
int page, int pageSize, CancellationToken cancellationToken = default);
|
||||
Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task ArchiveAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task RestoreAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface ICategoryRepository
|
||||
{
|
||||
Task<IEnumerable<Category>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<Category?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default);
|
||||
Task<Category?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<int> CreateAsync(Category category, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Category category, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IClientRepository
|
||||
{
|
||||
Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
||||
int page, int pageSize, string? status = null, string? search = null,
|
||||
CancellationToken ct = default);
|
||||
Task<Client?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<Client?> GetByEmailAsync(string email, CancellationToken ct = default);
|
||||
Task<Client?> GetByPhoneAsync(string phone, CancellationToken ct = default);
|
||||
Task<int> CountByCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default);
|
||||
Task<int> CreateAsync(Client client, CancellationToken ct = default);
|
||||
Task UpdateAsync(Client client, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
public interface ICommonCodeRepository
|
||||
{
|
||||
Task<IEnumerable<string>> GetAllGroupsAsync(CancellationToken ct = default);
|
||||
Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default);
|
||||
Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default);
|
||||
Task<CommonCode?> GetAsync(string codeGroup, string codeValue, CancellationToken ct = default);
|
||||
Task UpsertAsync(CommonCode code, CancellationToken ct = default);
|
||||
Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface ICompanyRepository
|
||||
{
|
||||
Task<int> CreateAsync(Company company, CancellationToken cancellationToken = default);
|
||||
Task<Company?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<Company?> GetByCodeAsync(string code, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<Company>> GetAllActiveAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IEnumerable<Company> Items, int Total)> GetPagedAsync(int page, int pageSize, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Company company, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IConsultationRepository
|
||||
{
|
||||
Task<IEnumerable<Consultation>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||
Task<int> CreateAsync(Consultation consultation, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IConsultingActivityRepository
|
||||
{
|
||||
Task<int> CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IContractRepository
|
||||
{
|
||||
Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using System.Data;
|
||||
|
||||
public interface IDbConnectionFactory
|
||||
{
|
||||
IDbConnection CreateConnection();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IFaqRepository
|
||||
{
|
||||
Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default);
|
||||
Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<int> CreateAsync(Faq faq, CancellationToken ct = default);
|
||||
Task UpdateAsync(Faq faq, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IInquiryRepository
|
||||
{
|
||||
Task<int> CreateAsync(Inquiry inquiry, CancellationToken cancellationToken = default);
|
||||
Task<Inquiry?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
|
||||
int page, int pageSize, string? status = null, CancellationToken cancellationToken = default);
|
||||
Task<int> CountAsync(CancellationToken cancellationToken = default);
|
||||
Task<int> CountThisMonthAsync(CancellationToken cancellationToken = default);
|
||||
Task<int> CountByStatusAsync(string status, CancellationToken cancellationToken = default);
|
||||
Task<int> CountByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||
Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||
Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default);
|
||||
Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Inquiry inquiry, CancellationToken cancellationToken = default);
|
||||
Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IPortalUserRepository
|
||||
{
|
||||
Task<PortalUser?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<PortalUser?> GetByEmailAsync(string email, CancellationToken ct = default);
|
||||
Task<PortalUser?> GetByProviderAsync(string provider, string providerId, CancellationToken ct = default);
|
||||
Task<int> CreateAsync(PortalUser user, CancellationToken ct = default);
|
||||
Task UpdateAsync(PortalUser user, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IRevenueTrackingRepository
|
||||
{
|
||||
Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
|
||||
Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken cancellationToken = default);
|
||||
Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
public interface ISiteSettingRepository
|
||||
{
|
||||
Task<IReadOnlyDictionary<string, string>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task UpsertAsync(IEnumerable<SiteSetting> settings, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface ITaxFilingRepository
|
||||
{
|
||||
Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||
Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead, CancellationToken ct = default);
|
||||
Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<int> CreateAsync(TaxFiling filing, CancellationToken ct = default);
|
||||
Task UpdateAsync(TaxFiling filing, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface ITaxFilingScheduleRepository
|
||||
{
|
||||
Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxFilingSchedule>> GetByStatusAsync(string status, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
|
||||
Task MarkCompletedAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface ITaxProfileRepository
|
||||
{
|
||||
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
||||
Task<TaxProfile?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxProfile>> GetUpcomingFilingDuesAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1 @@
|
||||
namespace TaxBaik.Infrastructure { }
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace TaxBaik.Infrastructure.Data;
|
||||
|
||||
using System.Data;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Npgsql;
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public sealed class DbConnectionFactory : IDbConnectionFactory
|
||||
{
|
||||
static DbConnectionFactory()
|
||||
{
|
||||
// Keep PostgreSQL snake_case columns aligned with C# PascalCase properties.
|
||||
DefaultTypeMap.MatchNamesWithUnderscores = true;
|
||||
}
|
||||
|
||||
private readonly string _connectionString;
|
||||
|
||||
public DbConnectionFactory(IConfiguration configuration)
|
||||
{
|
||||
_connectionString = configuration.GetConnectionString("Default")
|
||||
?? throw new InvalidOperationException("Missing 'Default' connection string in configuration.");
|
||||
}
|
||||
|
||||
public IDbConnection CreateConnection() => new NpgsqlConnection(_connectionString);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Npgsql;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
namespace TaxBaik.Infrastructure.Data;
|
||||
|
||||
public class MigrationRunner
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
|
||||
public MigrationRunner(string connectionString, IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionString = connectionString;
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public async Task RunAsync()
|
||||
{
|
||||
await EnsureMigrationTableAsync();
|
||||
await ExecutePendingMigrationsAsync();
|
||||
}
|
||||
|
||||
private async Task EnsureMigrationTableAsync()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version VARCHAR(50) PRIMARY KEY,
|
||||
description VARCHAR(500),
|
||||
installed_on TIMESTAMPTZ DEFAULT NOW()
|
||||
);";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private async Task ExecutePendingMigrationsAsync()
|
||||
{
|
||||
var executedMigrations = await GetExecutedMigrationsAsync();
|
||||
var migrations = GetAvailableMigrations();
|
||||
|
||||
foreach (var migration in migrations.OrderBy(x => x.Version))
|
||||
{
|
||||
if (!executedMigrations.Contains(migration.Version))
|
||||
{
|
||||
await ExecuteMigrationAsync(migration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> GetExecutedMigrationsAsync()
|
||||
{
|
||||
var executed = new HashSet<string>();
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT version FROM schema_migrations ORDER BY version;";
|
||||
|
||||
using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
executed.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return executed;
|
||||
}
|
||||
|
||||
private List<Migration> GetAvailableMigrations()
|
||||
{
|
||||
var migrations = new List<Migration>();
|
||||
|
||||
// Try file system first (for deployment), then embedded resources
|
||||
var migrationDirs = new[]
|
||||
{
|
||||
"./migrations", // relative
|
||||
"/home/kjh2064/taxbaik_active/migrations" // deployment
|
||||
};
|
||||
|
||||
var migrationPath = migrationDirs.FirstOrDefault(Directory.Exists);
|
||||
|
||||
if (migrationPath != null && Directory.Exists(migrationPath))
|
||||
{
|
||||
var files = Directory.GetFiles(migrationPath, "V*.sql").OrderBy(x => x);
|
||||
foreach (var file in files)
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||
if (fileName.StartsWith("V"))
|
||||
{
|
||||
var version = fileName.Substring(1, fileName.IndexOf('_') - 1);
|
||||
var description = fileName.Substring(fileName.IndexOf('_') + 2);
|
||||
var sql = File.ReadAllText(file);
|
||||
|
||||
migrations.Add(new Migration { Version = version, Description = description, Sql = sql });
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceNames = assembly.GetManifestResourceNames()
|
||||
.Where(x => x.Contains(".Migrations.V") && x.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(x => x);
|
||||
|
||||
foreach (var resourceName in resourceNames)
|
||||
{
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream == null)
|
||||
continue;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var sql = reader.ReadToEnd();
|
||||
var fileName = Path.GetFileNameWithoutExtension(resourceName);
|
||||
var versionStart = fileName.IndexOf('V');
|
||||
var versionEnd = fileName.IndexOf('_', versionStart + 1);
|
||||
if (versionStart < 0 || versionEnd < 0)
|
||||
continue;
|
||||
|
||||
var version = fileName.Substring(versionStart + 1, versionEnd - versionStart - 1);
|
||||
var description = fileName.Substring(versionEnd + 1);
|
||||
|
||||
migrations.Add(new Migration { Version = version, Description = description, Sql = sql });
|
||||
}
|
||||
}
|
||||
|
||||
return migrations;
|
||||
}
|
||||
|
||||
private async Task ExecuteMigrationAsync(Migration migration)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = migration.Sql;
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
|
||||
using var insertCmd = conn.CreateCommand();
|
||||
insertCmd.CommandText =
|
||||
"INSERT INTO schema_migrations (version, description) VALUES (@version, @description);";
|
||||
insertCmd.Parameters.AddWithValue("@version", migration.Version);
|
||||
insertCmd.Parameters.AddWithValue("@description", migration.Description);
|
||||
await insertCmd.ExecuteNonQueryAsync();
|
||||
|
||||
Console.WriteLine($"✓ Migration {migration.Version} executed");
|
||||
}
|
||||
catch (Npgsql.PostgresException pgEx) when (pgEx.SqlState == "42P07") // relation already exists
|
||||
{
|
||||
// Already executed previously; mark as done
|
||||
Console.WriteLine($"ℹ Migration {migration.Version} already applied");
|
||||
using var insertCmd = conn.CreateCommand();
|
||||
insertCmd.CommandText =
|
||||
"INSERT INTO schema_migrations (version, description) VALUES (@version, @description) ON CONFLICT (version) DO NOTHING;";
|
||||
insertCmd.Parameters.AddWithValue("@version", migration.Version);
|
||||
insertCmd.Parameters.AddWithValue("@description", migration.Description);
|
||||
await insertCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Migration {migration.Version} failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private class Migration
|
||||
{
|
||||
public required string Version { get; set; }
|
||||
public required string Description { get; set; }
|
||||
public required string Sql { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace TaxBaik.Infrastructure;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
using TaxBaik.Infrastructure.Data;
|
||||
using TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDbConnectionFactory, DbConnectionFactory>();
|
||||
services.AddScoped<IAdminUserRepository, AdminUserRepository>();
|
||||
services.AddScoped<ICategoryRepository, CategoryRepository>();
|
||||
services.AddScoped<IBlogPostRepository, BlogPostRepository>();
|
||||
services.AddScoped<IInquiryRepository, InquiryRepository>();
|
||||
services.AddScoped<ISiteSettingRepository, SiteSettingRepository>();
|
||||
services.AddScoped<IAnnouncementRepository, AnnouncementRepository>();
|
||||
services.AddScoped<IClientRepository, ClientRepository>();
|
||||
services.AddScoped<IFaqRepository, FaqRepository>();
|
||||
services.AddScoped<IConsultationRepository, ConsultationRepository>();
|
||||
services.AddScoped<IPortalUserRepository, PortalUserRepository>();
|
||||
services.AddScoped<ITaxFilingRepository, TaxFilingRepository>();
|
||||
services.AddScoped<ICompanyRepository, CompanyRepository>();
|
||||
services.AddScoped<ITaxProfileRepository, TaxProfileRepository>();
|
||||
services.AddScoped<ITaxFilingScheduleRepository, TaxFilingScheduleRepository>();
|
||||
services.AddScoped<IConsultingActivityRepository, ConsultingActivityRepository>();
|
||||
services.AddScoped<IContractRepository, ContractRepository>();
|
||||
services.AddScoped<IRevenueTrackingRepository, RevenueTrackingRepository>();
|
||||
services.AddScoped<ICommonCodeRepository, CommonCodeRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
public class AdminUserRepository : BaseRepository, IAdminUserRepository
|
||||
{
|
||||
public AdminUserRepository(IDbConnectionFactory connectionFactory) : base(connectionFactory) { }
|
||||
|
||||
public async Task<AdminUser?> GetByUsernameAsync(string username)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QueryFirstOrDefaultAsync<AdminUser>(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
password_hash AS PasswordHash,
|
||||
last_login_at AS LastLoginAt,
|
||||
created_at AS CreatedAt
|
||||
FROM admin_users
|
||||
WHERE username = @username
|
||||
""",
|
||||
new { username });
|
||||
}
|
||||
|
||||
public async Task<AdminUser?> GetByIdAsync(int id)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QueryFirstOrDefaultAsync<AdminUser>(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
password_hash AS PasswordHash,
|
||||
last_login_at AS LastLoginAt,
|
||||
created_at AS CreatedAt
|
||||
FROM admin_users
|
||||
WHERE id = @id
|
||||
""",
|
||||
new { id });
|
||||
}
|
||||
|
||||
public async Task CreateAsync(AdminUser user)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(
|
||||
"INSERT INTO admin_users (username, password_hash, created_at) VALUES (@username, @passwordHash, NOW())",
|
||||
new { username = user.Username, passwordHash = user.PasswordHash });
|
||||
}
|
||||
|
||||
public async Task UpdatePasswordHashAsync(int id, string passwordHash)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE admin_users SET password_hash = @passwordHash WHERE id = @id",
|
||||
new { id, passwordHash });
|
||||
}
|
||||
|
||||
public async Task UpdateLastLoginAtAsync(int id)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE admin_users SET last_login_at = NOW() WHERE id = @id",
|
||||
new { id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class AnnouncementRepository(IDbConnectionFactory connectionFactory)
|
||||
: BaseRepository(connectionFactory), IAnnouncementRepository
|
||||
{
|
||||
private const string SelectColumns =
|
||||
"id, title, content, display_type, is_active, starts_at, ends_at, sort_order, created_at, updated_at";
|
||||
|
||||
public async Task<IEnumerable<Announcement>> GetActiveAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Announcement>(
|
||||
$@"SELECT {SelectColumns}
|
||||
FROM announcements
|
||||
WHERE is_active = TRUE
|
||||
AND (starts_at IS NULL OR starts_at <= NOW())
|
||||
AND (ends_at IS NULL OR ends_at >= NOW())
|
||||
ORDER BY sort_order DESC, created_at DESC");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Announcement>(
|
||||
$"SELECT {SelectColumns} FROM announcements ORDER BY sort_order DESC, created_at DESC");
|
||||
}
|
||||
|
||||
public async Task<Announcement?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Announcement>(
|
||||
$"SELECT {SelectColumns} FROM announcements WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(Announcement announcement, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO announcements
|
||||
(title, content, display_type, is_active, starts_at, ends_at, sort_order, created_at, updated_at)
|
||||
VALUES
|
||||
(@Title, @Content, @DisplayType, @IsActive, @StartsAt, @EndsAt, @SortOrder, NOW(), NOW())
|
||||
RETURNING id",
|
||||
announcement);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Announcement announcement, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE announcements
|
||||
SET title = @Title,
|
||||
content = @Content,
|
||||
display_type = @DisplayType,
|
||||
is_active = @IsActive,
|
||||
starts_at = @StartsAt,
|
||||
ends_at = @EndsAt,
|
||||
sort_order = @SortOrder,
|
||||
updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
announcement);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync("DELETE FROM announcements WHERE id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using System.Data;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public abstract class BaseRepository(IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
protected readonly IDbConnectionFactory _connectionFactory = connectionFactory;
|
||||
|
||||
protected IDbConnection Conn() => _connectionFactory.CreateConnection();
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IBlogPostRepository
|
||||
{
|
||||
public async Task<BlogPost?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
|
||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||
FROM blog_posts bp
|
||||
LEFT JOIN categories c ON bp.category_id = c.id
|
||||
WHERE bp.id = @Id AND bp.deleted_at IS NULL",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
|
||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||
FROM blog_posts bp
|
||||
LEFT JOIN categories c ON bp.category_id = c.id
|
||||
WHERE bp.slug = @Slug AND bp.is_published = TRUE AND bp.deleted_at IS NULL",
|
||||
new { Slug = slug });
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<BlogPost> Items, int Total)> GetPublishedPagedAsync(
|
||||
int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
using var reader = await conn.QueryMultipleAsync(
|
||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||
FROM blog_posts bp
|
||||
LEFT JOIN categories c ON bp.category_id = c.id
|
||||
WHERE bp.is_published = TRUE AND bp.deleted_at IS NULL AND (@CategoryId::int IS NULL OR bp.category_id = @CategoryId)
|
||||
ORDER BY bp.published_at DESC
|
||||
LIMIT @PageSize OFFSET @Offset;
|
||||
|
||||
SELECT COUNT(*) FROM blog_posts
|
||||
WHERE is_published = TRUE AND deleted_at IS NULL AND (@CategoryId::int IS NULL OR category_id = @CategoryId);",
|
||||
new { CategoryId = categoryId, PageSize = pageSize, Offset = offset });
|
||||
|
||||
var items = (await reader.ReadAsync<BlogPost>()).ToList();
|
||||
var total = await reader.ReadFirstAsync<int>();
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BlogPost>> GetByCategorySlugAsync(string categorySlug, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<BlogPost>(
|
||||
@"SELECT bp.id, bp.title, bp.slug, bp.category_id, bp.tags,
|
||||
bp.published_at, bp.view_count, bp.seo_description, bp.thumbnail_url,
|
||||
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||
FROM blog_posts bp
|
||||
LEFT JOIN categories c ON bp.category_id = c.id
|
||||
WHERE bp.is_published = TRUE AND bp.deleted_at IS NULL AND c.slug = @CategorySlug
|
||||
ORDER BY bp.published_at DESC
|
||||
LIMIT @Limit",
|
||||
new { CategorySlug = categorySlug, Limit = limit });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<BlogPost>(
|
||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
||||
FROM blog_posts bp
|
||||
LEFT JOIN categories c ON bp.category_id = c.id
|
||||
WHERE bp.deleted_at IS NULL
|
||||
ORDER BY bp.created_at DESC");
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
|
||||
int page, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
using var reader = await conn.QueryMultipleAsync(
|
||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||
FROM blog_posts bp
|
||||
LEFT JOIN categories c ON bp.category_id = c.id
|
||||
WHERE bp.deleted_at IS NULL
|
||||
ORDER BY bp.created_at DESC
|
||||
LIMIT @PageSize OFFSET @Offset;
|
||||
|
||||
SELECT COUNT(*) FROM blog_posts WHERE deleted_at IS NULL;",
|
||||
new { PageSize = pageSize, Offset = offset });
|
||||
|
||||
var items = (await reader.ReadAsync<BlogPost>()).ToList();
|
||||
var total = await reader.ReadFirstAsync<int>();
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
|
||||
int page, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
using var reader = await conn.QueryMultipleAsync(
|
||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||
FROM blog_posts bp
|
||||
LEFT JOIN categories c ON bp.category_id = c.id
|
||||
WHERE bp.deleted_at IS NOT NULL
|
||||
ORDER BY bp.deleted_at DESC
|
||||
LIMIT @PageSize OFFSET @Offset;
|
||||
|
||||
SELECT COUNT(*) FROM blog_posts WHERE deleted_at IS NOT NULL;",
|
||||
new { PageSize = pageSize, Offset = offset });
|
||||
|
||||
var items = (await reader.ReadAsync<BlogPost>()).ToList();
|
||||
var total = await reader.ReadFirstAsync<int>();
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO blog_posts (title, content, slug, category_id, tags, author_id, published_at,
|
||||
seo_title, seo_description, thumbnail_url, is_published, created_at, updated_at)
|
||||
VALUES (@Title, @Content, @Slug, @CategoryId, @Tags, @AuthorId, @PublishedAt,
|
||||
@SeoTitle, @SeoDescription, @ThumbnailUrl, @IsPublished, NOW(), NOW())
|
||||
RETURNING id",
|
||||
post);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE blog_posts
|
||||
SET title = @Title, content = @Content, slug = @Slug, category_id = @CategoryId,
|
||||
tags = @Tags, author_id = @AuthorId, published_at = @PublishedAt,
|
||||
seo_title = @SeoTitle, seo_description = @SeoDescription,
|
||||
thumbnail_url = @ThumbnailUrl, is_published = @IsPublished, updated_at = NOW()
|
||||
WHERE id = @Id AND deleted_at IS NULL",
|
||||
post);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ArchiveAsync(id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ArchiveAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE blog_posts SET deleted_at = NOW(), updated_at = NOW() WHERE id = @Id AND deleted_at IS NULL",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task RestoreAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE blog_posts SET deleted_at = NULL, updated_at = NOW() WHERE id = @Id AND deleted_at IS NOT NULL",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync("UPDATE blog_posts SET view_count = view_count + 1 WHERE id = @Id AND deleted_at IS NULL", new { Id = id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class CategoryRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ICategoryRepository
|
||||
{
|
||||
public async Task<IEnumerable<Category>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Category>(
|
||||
"SELECT id, name, slug, sort_order FROM categories ORDER BY sort_order");
|
||||
}
|
||||
|
||||
public async Task<Category?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Category>(
|
||||
"SELECT id, name, slug, sort_order FROM categories WHERE slug = @Slug",
|
||||
new { Slug = slug });
|
||||
}
|
||||
|
||||
public async Task<Category?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Category>(
|
||||
"SELECT id, name, slug, sort_order FROM categories WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(Category category, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO categories (name, slug, sort_order)
|
||||
VALUES (@Name, @Slug, @SortOrder)
|
||||
RETURNING id",
|
||||
category);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Category category, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE categories SET name = @Name, slug = @Slug, sort_order = @SortOrder WHERE id = @Id",
|
||||
category);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync("DELETE FROM categories WHERE id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class ClientRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IClientRepository
|
||||
{
|
||||
private const string SelectColumns =
|
||||
"id, name, company_name, phone, email, service_type, tax_type, status, source, memo, created_at, updated_at";
|
||||
|
||||
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
||||
int page, int pageSize, string? status = null, string? search = null, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
using var reader = await conn.QueryMultipleAsync(
|
||||
$@"SELECT {SelectColumns} FROM clients
|
||||
WHERE (@Status::text IS NULL OR status = @Status)
|
||||
AND (@Search::text IS NULL OR name ILIKE @SearchLike OR phone ILIKE @SearchLike OR company_name ILIKE @SearchLike)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @PageSize OFFSET @Offset;
|
||||
|
||||
SELECT COUNT(*) FROM clients
|
||||
WHERE (@Status::text IS NULL OR status = @Status)
|
||||
AND (@Search::text IS NULL OR name ILIKE @SearchLike OR phone ILIKE @SearchLike OR company_name ILIKE @SearchLike);",
|
||||
new { Status = status, Search = search, SearchLike = string.IsNullOrEmpty(search) ? null : $"%{search}%", PageSize = pageSize, Offset = offset });
|
||||
|
||||
var items = (await reader.ReadAsync<Client>()).ToList();
|
||||
var total = await reader.ReadFirstAsync<int>();
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task<Client?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Client>(
|
||||
$"SELECT {SelectColumns} FROM clients WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<Client?> GetByEmailAsync(string email, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Client>(
|
||||
$"SELECT {SelectColumns} FROM clients WHERE email = @Email",
|
||||
new { Email = email });
|
||||
}
|
||||
|
||||
public async Task<Client?> GetByPhoneAsync(string phone, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Client>(
|
||||
$"SELECT {SelectColumns} FROM clients WHERE phone = @Phone",
|
||||
new { Phone = phone });
|
||||
}
|
||||
|
||||
public async Task<int> CountByCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.ExecuteScalarAsync<int>(
|
||||
@"SELECT COUNT(*)
|
||||
FROM clients
|
||||
WHERE created_at >= @StartDateUtc
|
||||
AND created_at <= @EndDateUtc",
|
||||
new { StartDateUtc = startDateUtc, EndDateUtc = endDateUtc });
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(Client client, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO clients (name, company_name, phone, email, service_type, tax_type, status, source, memo, created_at, updated_at)
|
||||
VALUES (@Name, @CompanyName, @Phone, @Email, @ServiceType, @TaxType, @Status, @Source, @Memo, NOW(), NOW())
|
||||
RETURNING id",
|
||||
client);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Client client, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE clients
|
||||
SET name = @Name, company_name = @CompanyName, phone = @Phone, email = @Email,
|
||||
service_type = @ServiceType, tax_type = @TaxType, status = @Status,
|
||||
source = @Source, memo = @Memo, updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
client);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync("DELETE FROM clients WHERE id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
public class CommonCodeRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ICommonCodeRepository
|
||||
{
|
||||
public async Task<IEnumerable<string>> GetAllGroupsAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<string>(
|
||||
"SELECT DISTINCT code_group FROM common_codes WHERE is_active = TRUE ORDER BY code_group");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<CommonCode>(
|
||||
@"SELECT code_group as CodeGroup, code_value as CodeValue, code_name as CodeName, sort_order as SortOrder, is_active as IsActive
|
||||
FROM common_codes
|
||||
WHERE code_group = @CodeGroup AND is_active = TRUE
|
||||
ORDER BY sort_order",
|
||||
new { CodeGroup = codeGroup });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<CommonCode>(
|
||||
@"SELECT code_group as CodeGroup, code_value as CodeValue, code_name as CodeName, sort_order as SortOrder, is_active as IsActive
|
||||
FROM common_codes
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY code_group, sort_order");
|
||||
}
|
||||
|
||||
public async Task<CommonCode?> GetAsync(string codeGroup, string codeValue, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QuerySingleOrDefaultAsync<CommonCode>(
|
||||
@"SELECT code_group as CodeGroup, code_value as CodeValue, code_name as CodeName, sort_order as SortOrder, is_active as IsActive
|
||||
FROM common_codes
|
||||
WHERE code_group = @CodeGroup AND code_value = @CodeValue",
|
||||
new { CodeGroup = codeGroup, CodeValue = codeValue });
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(CommonCode code, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"INSERT INTO common_codes (code_group, code_value, code_name, sort_order, is_active)
|
||||
VALUES (@CodeGroup, @CodeValue, @CodeName, @SortOrder, @IsActive)
|
||||
ON CONFLICT (code_group, code_value) DO UPDATE
|
||||
SET code_name = EXCLUDED.code_name,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
is_active = EXCLUDED.is_active",
|
||||
code);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"DELETE FROM common_codes
|
||||
WHERE code_group = @CodeGroup AND code_value = @CodeValue",
|
||||
new { CodeGroup = codeGroup, CodeValue = codeValue });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class CompanyRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ICompanyRepository
|
||||
{
|
||||
public async Task<int> CreateAsync(Company company, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO companies (company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at)
|
||||
VALUES (@CompanyCode, @CompanyName, @ContactPerson, @Phone, @Email, @Memo, @IsActive, NOW(), NOW())
|
||||
RETURNING id",
|
||||
company);
|
||||
}
|
||||
|
||||
public async Task<Company?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Company>(
|
||||
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||
FROM companies WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<Company?> GetByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Company>(
|
||||
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||
FROM companies WHERE company_code = @Code",
|
||||
new { Code = code });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Company>> GetAllActiveAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Company>(
|
||||
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||
FROM companies WHERE is_active = TRUE ORDER BY company_name");
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<Company> Items, int Total)> GetPagedAsync(int page, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
using var reader = await conn.QueryMultipleAsync(
|
||||
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||
FROM companies
|
||||
ORDER BY company_name
|
||||
LIMIT @PageSize OFFSET @Offset;
|
||||
|
||||
SELECT COUNT(*) FROM companies;",
|
||||
new { PageSize = pageSize, Offset = offset });
|
||||
|
||||
var items = (await reader.ReadAsync<Company>()).ToList();
|
||||
var total = await reader.ReadFirstAsync<int>();
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Company company, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE companies
|
||||
SET company_code = @CompanyCode, company_name = @CompanyName,
|
||||
contact_person = @ContactPerson, phone = @Phone, email = @Email,
|
||||
memo = @Memo, is_active = @IsActive, updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
company);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync("DELETE FROM companies WHERE id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class ConsultationRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IConsultationRepository
|
||||
{
|
||||
public async Task<IEnumerable<Consultation>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Consultation>(
|
||||
@"SELECT id, client_id, consultation_date, service_type, summary, result, fee, created_at
|
||||
FROM consultations
|
||||
WHERE client_id = @ClientId
|
||||
ORDER BY consultation_date DESC, id DESC",
|
||||
new { ClientId = clientId });
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(Consultation consultation, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO consultations (client_id, consultation_date, service_type, summary, result, fee, created_at)
|
||||
VALUES (@ClientId, @ConsultationDate, @ServiceType, @Summary, @Result, @Fee, NOW())
|
||||
RETURNING id",
|
||||
consultation);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync("DELETE FROM consultations WHERE id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class ConsultingActivityRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IConsultingActivityRepository
|
||||
{
|
||||
public async Task<int> CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO consulting_activities (client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at)
|
||||
VALUES (@ClientId, @ActivityType, @ActivityDate, @ActivityTime, @AssignedConsultantId, @Description, @Outcome, @NextFollowupDate, @Notes, NOW(), NOW())
|
||||
RETURNING id",
|
||||
activity);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<ConsultingActivity>(
|
||||
@"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at
|
||||
FROM consulting_activities ORDER BY activity_date DESC");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<ConsultingActivity>(
|
||||
@"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at
|
||||
FROM consulting_activities WHERE client_id = @ClientId ORDER BY activity_date DESC",
|
||||
new { ClientId = clientId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<ConsultingActivity>(
|
||||
@"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at
|
||||
FROM consulting_activities WHERE next_followup_date IS NOT NULL AND next_followup_date <= CURRENT_DATE
|
||||
ORDER BY next_followup_date ASC");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<ConsultingActivity>(
|
||||
@"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at
|
||||
FROM consulting_activities WHERE assigned_consultant = @ConsultantId AND activity_date >= @FromDate
|
||||
ORDER BY activity_date DESC",
|
||||
new { ConsultantId = consultantId, FromDate = fromDate });
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE consulting_activities SET activity_type = @ActivityType, activity_date = @ActivityDate,
|
||||
activity_time = @ActivityTime, assigned_consultant = @AssignedConsultantId, description = @Description,
|
||||
outcome = @Outcome, next_followup_date = @NextFollowupDate, notes = @Notes, updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
activity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class ContractRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IContractRepository
|
||||
{
|
||||
public async Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO contracts (client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at)
|
||||
VALUES (@ClientId, @ContractNumber, @ServiceType, @ContractDate, @StartDate, @EndDate, @MonthlyFee, @TotalAmount, @PaymentStatus, @Status, @Notes, NOW(), NOW())
|
||||
RETURNING id",
|
||||
contract);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Contract>(
|
||||
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
|
||||
FROM contracts ORDER BY contract_date DESC");
|
||||
}
|
||||
|
||||
public async Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Contract>(
|
||||
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
|
||||
FROM contracts WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Contract>(
|
||||
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
|
||||
FROM contracts WHERE client_id = @ClientId ORDER BY contract_date DESC",
|
||||
new { ClientId = clientId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Contract>(
|
||||
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
|
||||
FROM contracts WHERE status = 'active' ORDER BY client_id");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Contract>(
|
||||
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
|
||||
FROM contracts
|
||||
WHERE status = 'active' AND end_date IS NOT NULL AND end_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '1 day' * @DaysAhead
|
||||
ORDER BY end_date ASC",
|
||||
new { DaysAhead = daysAhead });
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE contracts SET contract_number = @ContractNumber, service_type = @ServiceType, contract_date = @ContractDate,
|
||||
start_date = @StartDate, end_date = @EndDate, monthly_fee = @MonthlyFee, total_amount = @TotalAmount,
|
||||
payment_status = @PaymentStatus, status = @Status, notes = @Notes, updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
contract);
|
||||
}
|
||||
|
||||
public async Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
var result = await conn.QueryFirstAsync<decimal>(
|
||||
@"SELECT COALESCE(SUM(monthly_fee), 0) FROM contracts WHERE status = 'active' AND monthly_fee IS NOT NULL");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class FaqRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IFaqRepository
|
||||
{
|
||||
private const string SelectColumns =
|
||||
"id, question, answer, category, sort_order, is_active, created_at, updated_at";
|
||||
|
||||
public async Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Faq>(
|
||||
$"SELECT {SelectColumns} FROM faqs WHERE is_active = TRUE ORDER BY sort_order, id");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Faq>(
|
||||
$"SELECT {SelectColumns} FROM faqs ORDER BY sort_order, id");
|
||||
}
|
||||
|
||||
public async Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Faq>(
|
||||
$"SELECT {SelectColumns} FROM faqs WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(Faq faq, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO faqs (question, answer, category, sort_order, is_active, created_at, updated_at)
|
||||
VALUES (@Question, @Answer, @Category, @SortOrder, @IsActive, NOW(), NOW())
|
||||
RETURNING id",
|
||||
faq);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Faq faq, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE faqs
|
||||
SET question = @Question, answer = @Answer, category = @Category,
|
||||
sort_order = @SortOrder, is_active = @IsActive, updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
faq);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync("DELETE FROM faqs WHERE id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user