Compare commits
234 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da9f49c973 | |||
| 1839c2c3d1 | |||
| df4c555dd1 | |||
| e1348226c6 | |||
| 97e7cfb867 | |||
| 11772d1f46 | |||
| 84e0577e89 | |||
| 31cc5603c9 | |||
| 0d36d27631 | |||
| 60c31d7ccb | |||
| 42a0d2ae3b | |||
| e599ef9ad8 | |||
| 223d916012 | |||
| f1cc0ca35c | |||
| e1325a1688 | |||
| 29b25cb1b4 | |||
| 8d72d2a0c2 | |||
| 1cdb172b07 | |||
| 864497e56f | |||
| 19c9b9b17a | |||
| 988b166118 | |||
| 78d3990484 | |||
| b3c4ee430d | |||
| 7b27f748de | |||
| abad1630b6 | |||
| 6ffff70ece | |||
| ed8ac34542 | |||
| 6b14ce929e | |||
| e830c08263 | |||
| a1065e8233 | |||
| 7cdb0bf8e9 | |||
| 8bea85df96 | |||
| 127490906b | |||
| ada05e254d | |||
| 7602f5be59 | |||
| 777cdcd918 | |||
| 0f6ba33af3 | |||
| 6d263c20bf | |||
| c9bf4f4f6f | |||
| b12d2ae0c6 | |||
| f9cbafdb3d | |||
| 64de7d2304 | |||
| 1f628b49a8 | |||
| a4a2499c7d | |||
| 6b11b64135 | |||
| a60451b95f | |||
| 2a046d0393 | |||
| 62ce89359a | |||
| 32c5a3d042 | |||
| 68291867f9 | |||
| d24f3f58db | |||
| 71cd2c1129 | |||
| 24ecf89028 | |||
| ff6651c4f2 | |||
| f892b85b7e | |||
| 62a7b2f2ef | |||
| 184ff2259b | |||
| 163812e964 | |||
| ba158f9824 | |||
| b2477d977b | |||
| 80c97fba96 | |||
| 1fb3a3c329 | |||
| abd7bbf016 | |||
| c765db37b3 | |||
| 967a784d6e | |||
| 03809bbf26 | |||
| c626c164f8 | |||
| 15f5dcf4ea | |||
| a84f842490 | |||
| 8999e51d4e | |||
| f98405b791 | |||
| ee964457d9 | |||
| 54c179b1eb | |||
| 488b8d11b7 | |||
| 65c5f19a2f | |||
| eaacbc8d7f | |||
| ac8a70a2ca | |||
| 203e674c3f | |||
| 0c014d0bdf | |||
| 904c0972ca | |||
| 7e75aeeec7 | |||
| b13eed7b7e | |||
| 4647b049b8 | |||
| 1a5ebb45bc | |||
| f197663101 | |||
| 70b57f1d4c | |||
| 428eeb6fd8 | |||
| dd68a237a1 | |||
| ef9fd523c6 | |||
| f2ab78dea2 | |||
| 1e0c0b7e1c | |||
| 1b173376ee | |||
| 1a7bc9e209 | |||
| 3be379431f | |||
| 682e2db3a3 | |||
| d9766cb5ef | |||
| 6bcb9effa8 | |||
| 186c6ef7a4 | |||
| c2e8e08f09 | |||
| 3f7cd7cd84 | |||
| 4b352df408 | |||
| a4b1234900 | |||
| a3c81c4f70 | |||
| 6e8b4e76ac | |||
| 5807e1b35e | |||
| 3e1097f585 | |||
| 917600a793 | |||
| 0d3615b44d | |||
| fc339ca9e7 | |||
| da1226994f | |||
| 6bc03ce3d9 | |||
| ecfbfc7cac | |||
| 46cb508bdf | |||
| ecabe8d9cc | |||
| 55c65810c1 | |||
| 7054d397e4 | |||
| 11fb596fc2 | |||
| a04592499c | |||
| ea9478f2f1 | |||
| f569211967 | |||
| c8306e2ac7 | |||
| bad2f47ffe | |||
| 943fe9c819 | |||
| 7b819f4ab0 | |||
| 6a5740ec68 | |||
| 3c8f30af6d | |||
| 7e3b4e2229 | |||
| 67bd5dc666 | |||
| 84161ee2d9 | |||
| 5aec36b155 | |||
| 3ab8971025 | |||
| db30e71e0a | |||
| e4c2758dea | |||
| 75661aa0ef | |||
| 3303ba2e96 | |||
| 43c2ff6ad9 | |||
| a7bb8d7149 | |||
| 791ce6d526 | |||
| 61083a5bb1 | |||
| 66fb86d23c | |||
| 16f7c6097c | |||
| 7232635ed0 | |||
| b42b98d560 | |||
| f216660afa | |||
| b31b43e30e | |||
| 86bd9ef8ff | |||
| 2fd9984a45 | |||
| 91330ec94c | |||
| 08102c8684 | |||
| e2472b7ea1 | |||
| 033883aac5 | |||
| d2cfcd90f0 | |||
| 42e73fa694 | |||
| f8f8f869fc | |||
| db7f903054 | |||
| 0d7a081f5a | |||
| 0bd36ae26f | |||
| 447a62c0fb | |||
| a16438dcc6 | |||
| ebd12b78a0 | |||
| 4b62d35266 | |||
| c38b97377a | |||
| 59f1509368 | |||
| c2955ad02f | |||
| ea40e5c002 | |||
| 7dd51a1169 | |||
| c65742a0c7 | |||
| 52f1790acb | |||
| cd3bc8357c | |||
| 53beb8a6e4 | |||
| d3b4d59f3c | |||
| 691e4406f3 | |||
| db2af15a07 | |||
| 2bde490e9e | |||
| e797da6140 | |||
| 0265d7ec8c | |||
| 09420dca0e | |||
| e3a0ea03f0 | |||
| ba2cb85fd2 | |||
| 74ee47a269 | |||
| 2af7050800 | |||
| fb9c77943f | |||
| 27f57ff925 | |||
| 79d99cfd7a | |||
| 1a761e8e15 | |||
| c01933e295 | |||
| 73da1859fe | |||
| 68588a8491 | |||
| 0b6a64fbad | |||
| 96df0dd9b1 | |||
| 351c7ac82c | |||
| ad48befb9a | |||
| 804725a785 | |||
| 41c8106a10 | |||
| 472431d45a | |||
| 33ea84fb2b | |||
| 73a564c307 | |||
| 223f365dfd | |||
| 61931ab8eb | |||
| 71d5d2cc1f | |||
| db81f94051 | |||
| 700cdaed4f | |||
| 65241c453c | |||
| b3baef012d | |||
| 0d07b2d26a | |||
| 65c2dce8fe | |||
| 4d94b9b4ff | |||
| 4358b189c8 | |||
| 80a16d8b20 | |||
| fbdbbc7a1f | |||
| 160afb7c7e | |||
| 8149680487 | |||
| 08e9e07458 | |||
| 58edbd9c8f | |||
| 0334a5f607 | |||
| 40c3877fb0 | |||
| 5053245575 | |||
| 126643665a | |||
| d09726c46a | |||
| 114ab22197 | |||
| 640ea96ae7 | |||
| ae7ca7e382 | |||
| 541b04cf3d | |||
| 821b73fe01 | |||
| fb04f73f46 | |||
| 58ec984f41 | |||
| 8760a0a931 | |||
| 1c831b1b30 | |||
| 41f569362d | |||
| 22070c1619 | |||
| 79492184d0 | |||
| 9c96f15f86 | |||
| ccba017e3e | |||
| b67002dcf5 |
@@ -3,3 +3,10 @@ ASPNETCORE_URLS=http://0.0.0.0:5001
|
||||
ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=change-me
|
||||
Jwt__SecretKey=dev-secret-key-change-in-production-min-32-chars!
|
||||
Admin__PasswordResetToken=change-this-reset-token
|
||||
Authentication__Google__ClientId=
|
||||
Authentication__Google__ClientSecret=
|
||||
Authentication__Naver__ClientId=
|
||||
Authentication__Naver__ClientSecret=
|
||||
Authentication__Kakao__ClientId=
|
||||
Authentication__Kakao__ClientSecret=
|
||||
# CI deploy trigger requires a real push on master.
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
name: TaxBaik Browser E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_run:
|
||||
workflows: ["TaxBaik CI/CD"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
browser-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -37,28 +39,56 @@ jobs:
|
||||
- name: Wait for deployment
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
EXPECTED_VERSION: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
set -e
|
||||
EXPECTED_VERSION="$(git rev-parse --short HEAD)"
|
||||
for i in $(seq 1 60); do
|
||||
VERSION_BODY="$(curl -fsS "http://${DEPLOY_HOST}/taxbaik/version.txt" || true)"
|
||||
# Extract short commit hash (first 7 characters)
|
||||
SHORT_VERSION=$(echo "$EXPECTED_VERSION" | cut -c1-7)
|
||||
echo "Expected short version: $SHORT_VERSION"
|
||||
for i in $(seq 1 20); do
|
||||
# Suppress stderr and allow failures to handle transition/down periods cleanly
|
||||
VERSION_BODY="$(curl -fsS "http://${DEPLOY_HOST}/taxbaik/version.json" 2>/dev/null || true)"
|
||||
BLOG_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "http://${DEPLOY_HOST}/taxbaik/blog/accountant-mistakes-5" || true)"
|
||||
if echo "$VERSION_BODY" | grep -q "Version: ${EXPECTED_VERSION}" && [ "$BLOG_STATUS" = "200" ]; then
|
||||
echo "Deployment is ready for ${EXPECTED_VERSION}"
|
||||
LOGIN_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "http://${DEPLOY_HOST}/taxbaik/admin/login" || true)"
|
||||
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ]; then
|
||||
echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for deployment ${EXPECTED_VERSION}; blog status=${BLOG_STATUS}; version=${VERSION_BODY}"
|
||||
sleep 10
|
||||
if [ $i -lt 20 ]; then
|
||||
echo " Attempt $i/20: waiting for deployment... (blog=${BLOG_STATUS:-?}, login=${LOGIN_STATUS:-?}, version=${VERSION_BODY:0:30}...)"
|
||||
sleep 3
|
||||
fi
|
||||
done
|
||||
echo "Deployment did not publish expected version ${EXPECTED_VERSION} in time" >&2
|
||||
echo "✗ TIMEOUT: Deployment failed to publish ${SHORT_VERSION} within 60 seconds" >&2
|
||||
exit 1
|
||||
|
||||
- name: Browser E2E verification
|
||||
env:
|
||||
# Green-Blue 배포 지원: Nginx를 통해 active 포트로 라우팅
|
||||
E2E_BASE_URL: http://${{ secrets.DEPLOY_HOST }}/taxbaik
|
||||
E2E_ADMIN_USERNAME: admin
|
||||
E2E_ADMIN_PASSWORD: ${{ secrets.TAXBAIK_ADMIN_TEST_PASSWORD }}
|
||||
run: npm run test:e2e
|
||||
# E2E 테스트는 test_admin 테스트 계정 사용 (실 admin 계정과 분리)
|
||||
E2E_ADMIN_USERNAME: test_admin
|
||||
E2E_ADMIN_PASSWORD: TestAdmin@123456
|
||||
run: |
|
||||
echo "Running E2E tests on Desktop Chrome (production verification)"
|
||||
npx playwright test --project="Desktop Chrome" --reporter=html --reporter=list
|
||||
|
||||
- name: API smoke verification
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
E2E_ADMIN_USERNAME: test_admin
|
||||
E2E_ADMIN_PASSWORD: TestAdmin@123456
|
||||
run: |
|
||||
set -e
|
||||
TOKEN="$(curl -s -X POST "http://${DEPLOY_HOST}/taxbaik/api/auth/login" -H "Content-Type: application/json" -d "{\"username\":\"${E2E_ADMIN_USERNAME}\",\"password\":\"${E2E_ADMIN_PASSWORD}\"}" | python3 -c 'import sys, json; print(json.load(sys.stdin)["accessToken"])')"
|
||||
test -n "$TOKEN"
|
||||
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/blog/admin?page=1&pageSize=1" >/dev/null
|
||||
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/faq" >/dev/null
|
||||
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/announcement" >/dev/null
|
||||
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/inquiry?page=1&pageSize=1" >/dev/null
|
||||
curl -fsS "http://${DEPLOY_HOST}/taxbaik/favicon.svg" >/dev/null
|
||||
curl -fsS "http://${DEPLOY_HOST}/taxbaik/favicon.ico" >/dev/null
|
||||
curl -fsS "http://${DEPLOY_HOST}/taxbaik/robots.txt" >/dev/null
|
||||
|
||||
- name: Browser E2E summary
|
||||
if: always()
|
||||
|
||||
+100
-12
@@ -1,6 +1,7 @@
|
||||
name: TaxBaik CI/CD
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -32,38 +33,57 @@ jobs:
|
||||
- name: Publish Web
|
||||
run: dotnet publish TaxBaik.Web/ -c Release -o ./publish --no-restore
|
||||
|
||||
- name: Publish Proxy
|
||||
run: dotnet publish TaxBaik.Proxy/ -c Release -o ./publish/proxy
|
||||
|
||||
- name: Write production secrets
|
||||
run: |
|
||||
set -e
|
||||
JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}"
|
||||
TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
|
||||
TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}"
|
||||
TELEGRAM_INQUIRY_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_INQUIRY_CHAT_ID }}"
|
||||
TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
|
||||
[ -z "$JWT_SECRET_KEY" ] && { echo "Missing TAXBAIK_JWT_SECRET_KEY" >&2; exit 1; }
|
||||
[ -z "$TELEGRAM_BOT_TOKEN" ] && { echo "Missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2; exit 1; }
|
||||
[ -z "$TELEGRAM_CHAT_ID" ] && { echo "Missing TAXBAIK_TELEGRAM_CHAT_ID" >&2; exit 1; }
|
||||
[ -z "$TELEGRAM_INQUIRY_CHAT_ID" ] && TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_CHAT_ID"
|
||||
[ -z "$TELEGRAM_SYSTEM_CHAT_ID" ] && TELEGRAM_SYSTEM_CHAT_ID="-5585148480"
|
||||
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
|
||||
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
|
||||
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
|
||||
TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \
|
||||
TELEGRAM_SYSTEM_CHAT_ID="$TELEGRAM_SYSTEM_CHAT_ID" \
|
||||
python3 -c '
|
||||
import json, os, pathlib
|
||||
pathlib.Path("./publish/appsettings.Production.json").write_text(
|
||||
json.dumps({
|
||||
"Jwt": {"SecretKey": os.environ["JWT_SECRET_KEY"]},
|
||||
"Telegram": {"BotToken": os.environ["TELEGRAM_BOT_TOKEN"], "ChatId": os.environ["TELEGRAM_CHAT_ID"]}
|
||||
"Telegram": {
|
||||
"BotToken": os.environ["TELEGRAM_BOT_TOKEN"],
|
||||
"ChatId": os.environ["TELEGRAM_CHAT_ID"],
|
||||
"InquiryChatId": os.environ["TELEGRAM_INQUIRY_CHAT_ID"],
|
||||
"SystemChatId": os.environ["TELEGRAM_SYSTEM_CHAT_ID"]
|
||||
}
|
||||
}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)'
|
||||
test -s ./publish/appsettings.Production.json || { echo "appsettings.Production.json is empty" >&2; exit 1; }
|
||||
|
||||
- name: Verify proxy artifact
|
||||
run: |
|
||||
test -s ./publish/proxy/TaxBaik.Proxy.dll || { echo "TaxBaik.Proxy.dll missing" >&2; exit 1; }
|
||||
test -s ./publish/proxy/TaxBaik.Proxy.runtimeconfig.json || { echo "TaxBaik.Proxy.runtimeconfig.json missing" >&2; exit 1; }
|
||||
|
||||
- name: Copy migrations
|
||||
run: cp -r db/migrations ./publish/migrations || true
|
||||
run: mkdir -p ./publish/db && cp -r db/migrations ./publish/db/ || true
|
||||
|
||||
- name: Generate build info
|
||||
run: |
|
||||
COMMIT_HASH=$(git rev-parse --short HEAD)
|
||||
BUILD_TIME=$(date -u +'%Y-%m-%d %H:%M:%S UTC')
|
||||
mkdir -p ./publish/wwwroot
|
||||
printf 'Version: %s\nBuilt: %s\n' "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.txt
|
||||
printf '{\n "version": "%s",\n "built": "%s"\n}\n' "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json
|
||||
echo "✓ Build: $COMMIT_HASH @ $BUILD_TIME"
|
||||
|
||||
- name: Setup SSH
|
||||
@@ -88,16 +108,46 @@ jobs:
|
||||
|
||||
- name: Package artifact
|
||||
run: |
|
||||
cp deploy_gb.sh ./publish/deploy_gb.sh
|
||||
tar -czf taxbaik_deploy.tgz -C ./publish .
|
||||
echo "✓ Package: $(du -sh taxbaik_deploy.tgz | cut -f1)"
|
||||
|
||||
- name: Deploy & verify on server
|
||||
run: |
|
||||
set -e
|
||||
export TAXBAIK_DEPLOY_FROM_CI=1
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
||||
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
||||
TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
|
||||
TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
|
||||
TELEGRAM_CHAT_ID="${TELEGRAM_SYSTEM_CHAT_ID:--5585148480}"
|
||||
|
||||
send_telegram() {
|
||||
local text="$1"
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ]; then
|
||||
echo "Skipping Telegram notification: missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
||||
--data-urlencode "text=${text}" \
|
||||
-d "parse_mode=HTML" >/dev/null || true
|
||||
}
|
||||
|
||||
notify_failure() {
|
||||
local exit_code=$?
|
||||
send_telegram "❌ <b>TaxBaik 배포 실패</b>
|
||||
|
||||
커밋: <code>${COMMIT}</code>
|
||||
시간: <code>${TIMESTAMP}</code>
|
||||
단계: CI/CD deploy"
|
||||
exit "$exit_code"
|
||||
}
|
||||
|
||||
trap notify_failure ERR
|
||||
|
||||
echo "=== Deploying TaxBaik $COMMIT ($TIMESTAMP) ==="
|
||||
|
||||
@@ -105,10 +155,10 @@ jobs:
|
||||
scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
||||
taxbaik_deploy.tgz "$DEPLOY_USER@$DEPLOY_HOST:/tmp/taxbaik_${TIMESTAMP}.tgz"
|
||||
|
||||
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리)
|
||||
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리, Green-Blue 지원)
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
||||
-o ServerAliveInterval=10 \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST" bash << REMOTE
|
||||
"$DEPLOY_USER@$DEPLOY_HOST" TAXBAIK_DEPLOY_FROM_CI=1 bash << REMOTE
|
||||
set -e
|
||||
DEPLOY_HOME="/home/kjh2064"
|
||||
DEPLOY_DIR="\$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
|
||||
@@ -122,18 +172,50 @@ jobs:
|
||||
echo "--- [2/5] 운영 설정 검증 ---"
|
||||
test -s "\$DEPLOY_DIR/appsettings.Production.json" \
|
||||
|| { echo "FATAL: appsettings.Production.json 없음" >&2; exit 1; }
|
||||
test -s "\$DEPLOY_DIR/proxy/TaxBaik.Proxy.dll" \
|
||||
|| { echo "FATAL: TaxBaik.Proxy.dll 없음" >&2; exit 1; }
|
||||
|
||||
echo "--- [3/5] 심볼릭 링크 전환 ---"
|
||||
ln -sfn "\$DEPLOY_DIR" "\$DEPLOY_HOME/taxbaik_active"
|
||||
echo "--- [3/4] Green-Blue 배포 실행 ---"
|
||||
chmod +x "\$DEPLOY_DIR/deploy_gb.sh"
|
||||
"\$DEPLOY_DIR/deploy_gb.sh" "\$DEPLOY_DIR"
|
||||
|
||||
echo "--- [4/5] 서비스 재시작 ---"
|
||||
sudo /usr/bin/systemctl restart taxbaik
|
||||
|
||||
echo "--- [5/5] 헬스 체크 (최대 120초) ---"
|
||||
ATTEMPTS=40
|
||||
echo "--- [5/5] 헬스 체크 (최대 60초) ---"
|
||||
ATTEMPTS=20
|
||||
for i in \$(seq 1 \$ATTEMPTS); do
|
||||
STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/ 2>/dev/null || echo "000")
|
||||
if [ "\$STATUS" = "200" ]; then
|
||||
echo "✓ [1/4] 메인 페이지 로드 완료"
|
||||
|
||||
# 검증 1: CSS 파일 로드
|
||||
CSS_STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/css/admin.css 2>/dev/null || echo "000")
|
||||
if [ "\$CSS_STATUS" != "200" ]; then
|
||||
echo "❌ CSS 파일 로드 실패 (상태: \$CSS_STATUS)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ [2/4] CSS 파일 로드 완료"
|
||||
|
||||
# 검증 2: 버전 정보
|
||||
if [ ! -s "\$DEPLOY_DIR/wwwroot/version.json" ]; then
|
||||
echo "❌ version.json 누락" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ [3/4] 버전 정보 확인 완료"
|
||||
|
||||
# 검증 4: 5001 프록시 확인
|
||||
if ! ss -tlnp | grep -q ':5001 '; then
|
||||
echo "❌ 5001 프록시가 실행 중이 아님" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ [4/5] 5001 프록시 확인 완료"
|
||||
|
||||
# 검증 5: 관리자 로그인 페이지
|
||||
LOGIN_STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/admin/login 2>/dev/null || echo "000")
|
||||
if [ "\$LOGIN_STATUS" != "200" ]; then
|
||||
echo "❌ 관리자 로그인 페이지 로드 실패 (상태: \$LOGIN_STATUS)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ [5/5] 관리자 페이지 로드 완료"
|
||||
|
||||
echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
|
||||
# 구 배포 디렉토리 정리 (최근 5개 보존)
|
||||
ls -1dt \$DEPLOY_HOME/deployments/taxbaik_* 2>/dev/null \
|
||||
@@ -154,3 +236,9 @@ jobs:
|
||||
REMOTE
|
||||
|
||||
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
|
||||
send_telegram "✅ <b>TaxBaik 배포 완료</b>
|
||||
|
||||
커밋: <code>${COMMIT}</code>
|
||||
시간: <code>${TIMESTAMP}</code>
|
||||
대상: <code>${DEPLOY_HOST}</code>
|
||||
채널: <code>${TELEGRAM_CHAT_ID}</code>"
|
||||
|
||||
@@ -0,0 +1,777 @@
|
||||
# 블로그 포스트 작성 템플릿
|
||||
|
||||
## 정확성 원칙 (법적 책임 수반)
|
||||
|
||||
블로그는 **사실 기반, 세법 기반, 데이터 기반**이어야 합니다. 추측이나 예상은 법적 문제를 일으킬 수 있습니다.
|
||||
|
||||
### 절대 금지 표현
|
||||
|
||||
- "아마도", "할 것 같다", "추측된다" (추측)
|
||||
- "대략", "정도일 거다", "보통" (예상)
|
||||
- "좋을 것 같다", "나쁠 것 같다" (의견)
|
||||
- 증거 없는 "모두", "항상", "누구나" (일반화)
|
||||
- 출처 없는 통계 ("80% 고객이", "평균 X만 원")
|
||||
|
||||
### 필수 요소
|
||||
|
||||
**1. 세법 기반**:
|
||||
- 모든 주장에 세법/시행령/고시 인용
|
||||
- 조항 명시: "소득세법 제XX조에 따르면"
|
||||
- 최신 기준 명시: "2025년 기준"
|
||||
- 변경사항 반영: "전년도와 다르게..."
|
||||
|
||||
**2. 사실 기반**:
|
||||
- 실제 일어난 고객 사례만 사용
|
||||
- 가정일 경우 명시: "예를 들어, 만약 이렇다면"
|
||||
- 가상 사례는 "예시 사례"라고 명확히
|
||||
- 개인정보는 익명화 (이름, 나이는 일반적인 표현)
|
||||
|
||||
**3. 데이터 기반**:
|
||||
- 객관적 수치만 사용 (국세청 통계, 협회 자료)
|
||||
- 출처 명시: "2025년 세무청 통계에 따르면"
|
||||
- 구체적 금액: "약 50만 원" (범위 표현)
|
||||
- 비교 데이터: "작년 대비 X% 증가"
|
||||
|
||||
**4. 사례 제시 시 확인 사항**:
|
||||
```
|
||||
✅ 실제 고객인가? (공개 가능한 정보만)
|
||||
✅ 세법을 정확하게 적용했는가?
|
||||
✅ 금액 계산이 정확한가?
|
||||
✅ 이 사례가 대표적인가? (극단적 사례면 명시)
|
||||
✅ 다른 고객에게도 적용 가능한가?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 카테고리 필수 규칙
|
||||
|
||||
**모든 블로그 포스트는 반드시 하나의 카테고리에 할당되어야 합니다. (NOT NULL)**
|
||||
|
||||
### 카테고리별 포스트 배치
|
||||
|
||||
| 카테고리 | 최소 포스트 | 주제 범위 |
|
||||
|---------|-----------|---------|
|
||||
| 사업자 세무 | 3개 | 기장, 세무신고, 부가세, 종합소득세 |
|
||||
| 부동산 세금 | 3개 | 월세, 양도세, 상속세(부동산) |
|
||||
| 종합소득세 | 3개 | 프리랜서, 부업, 경비 처리 |
|
||||
| 부가가치세 | 3개 | 신고, 기한, 간이과세 vs 일반과세 |
|
||||
| 가족자산·증여 | 3개 | 자녀 증여, 상속, 자산 이전 |
|
||||
|
||||
### 카테고리 할당 규칙
|
||||
|
||||
1. **명확한 주제 분류**: 포스트 내용이 카테고리 범위에 명확하게 해당
|
||||
2. **중복 금지**: 한 포스트는 정확히 하나의 카테고리에만 할당
|
||||
3. **균형 배치**: 각 카테고리당 최소 3개씩 (고객 검색 UX)
|
||||
4. **검색 최적화**: 고객이 카테고리로 찾을 때 관련 포스트 3개 이상 노출
|
||||
|
||||
### 카테고리 미할당 시 (오류)
|
||||
- ❌ category_id = NULL (데이터베이스 제약 위반)
|
||||
- ❌ SQL 실행 실패 (NOT NULL 제약)
|
||||
- ❌ 블로그 페이지 노출 불가
|
||||
|
||||
**이 규칙은 모든 포스트 생성/수정 시 필수 준수사항입니다.**
|
||||
|
||||
---
|
||||
|
||||
## 핵심 철학: 고객이 느끼는 여정
|
||||
|
||||
### 1️⃣ 기초: "이 정도는 할 수 있어요"
|
||||
- 고객이 배울 수 있는 기본 개념
|
||||
- 실제 사례로 구체화
|
||||
- 단계별 설명
|
||||
|
||||
### 2️⃣ 현실: "하지만 복잡하네요"
|
||||
- 겹겹이 쌓인 세부사항들
|
||||
- 매년 바뀌는 세법
|
||||
- "이거 일일이 다 챙기기 어렵다"는 느낌
|
||||
|
||||
### 3️⃣ 해결: "세무사와 함께면 괜찮아요"
|
||||
- 디테일 자동 관리
|
||||
- 세법 변화 자동 반영
|
||||
- 고객은 사업에만 집중
|
||||
|
||||
---
|
||||
|
||||
**고객이 글을 읽은 후 느끼는 것**:
|
||||
|
||||
1️⃣ 읽고 나서: "아, 이 정도는 내가 할 수 있겠네"
|
||||
2️⃣ 생각해보니: "근데 이 모든 걸 매년 챙기기는... 힘들겠는데?"
|
||||
3️⃣ 결론: "그럼 전문가 도움을 받는 게 낫겠다"
|
||||
|
||||
→ 자연스럽게 세무사의 필요성을 깨달음 (강요 아님)
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 (복사해서 사용)
|
||||
|
||||
### Step 1: 도입부 (공감)
|
||||
```markdown
|
||||
# [제목]
|
||||
|
||||
"[구체적 상황]?"
|
||||
"많은 [직업]들이 이 상황을 겪습니다."
|
||||
|
||||
→ 독자가 자신의 상황을 발견하도록
|
||||
```
|
||||
|
||||
**예시**:
|
||||
```markdown
|
||||
# 동네 카페 월세 낼 때 세금이 안 나와요 - 정말 그럴까?
|
||||
|
||||
"사업을 시작했는데 세금을 낸 적이 없어요"
|
||||
"많은 소규모 사업자들이 이렇게 생각합니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2: 실제 사례 (구체적 페르소나)
|
||||
|
||||
**필수 정보**:
|
||||
- 이름, 나이, 직업, 사업 경력
|
||||
- 월/연간 매출 (현실적 수치)
|
||||
- 실제 겪은 문제/성공 사례
|
||||
|
||||
```markdown
|
||||
### 상황: [지역] [카테고리]를 운영하는 [이름]님 ([나이]세, 사업 [년]차)
|
||||
|
||||
**기본 정보**:
|
||||
- 위치: [구체적 위치]
|
||||
- 월 매출: [금액]
|
||||
- 월 경비: [주요 항목들]
|
||||
|
||||
### 원래는 이렇게 했어요 (실패 사례)
|
||||
→ [실제 실수 1]
|
||||
→ [실제 실수 2]
|
||||
→ **결과**: 세금을 [X만 원] 더 내게 됨 (또는 세무조사 대상)
|
||||
|
||||
### 바뀐 후 (성공 사례)
|
||||
→ [해결책 1]
|
||||
→ [해결책 2]
|
||||
→ **결과**: 세금을 [X만 원] 절약함 (또는 안정적인 운영)
|
||||
```
|
||||
|
||||
**예시**:
|
||||
```markdown
|
||||
### 상황: 강남 역삼동에서 카페를 운영하는 김민수님 (34세, 사업 3년차)
|
||||
|
||||
**기본 정보**:
|
||||
- 위치: 강남역 3번 출구 근처
|
||||
- 월 매출: 약 600만 원 (평일 200만, 주말 400만)
|
||||
- 월 경비: 월세 150만, 재료비 180만, 직원급여 100만 원
|
||||
|
||||
### 원래는 이렇게 했어요
|
||||
→ "세금은 큰 회사나 내는 거라고 생각했어요"
|
||||
→ 영수증도 대충 정리하고
|
||||
→ **결과**: 세무청에서 3년치를 추징받고 가산세까지...손해 70만 원
|
||||
|
||||
### 바뀐 후
|
||||
→ 매달 영수증을 정리해서
|
||||
→ 세무사와 년 1회 기장 상담
|
||||
→ **결과**: 세금도 명확하고, 추징도 없음. 심플하고 안전
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3: 계산 & 설명
|
||||
|
||||
**구조**:
|
||||
1. **기본 정보 확인** (위에서 제시한 사례 요약)
|
||||
2. **단계별 계산** (Step 1, Step 2, ... 명확히)
|
||||
3. **표로 시각화**
|
||||
|
||||
```markdown
|
||||
## 계산 방법
|
||||
|
||||
### Step 1️⃣: 매출 정리
|
||||
월 600만 원 × 12개월 = 연 7,200만 원
|
||||
|
||||
### Step 2️⃣: 경비 계산
|
||||
|
||||
월 경비 구성:
|
||||
- 월세: 150만 원 (연 1,800만 원)
|
||||
- 재료비: 180만 원 (연 2,160만 원)
|
||||
- 직원급여: 100만 원 (연 1,200만 원)
|
||||
- 기타: 20만 원 (연 240만 원)
|
||||
- **월 합계: 450만 원**
|
||||
- **연 합계: 5,400만 원**
|
||||
|
||||
### Step 3️⃣: 순이익
|
||||
7,200만 - 5,400만 = **1,800만 원**
|
||||
|
||||
### Step 4️⃣: 세금
|
||||
1,800만 원 × 약 6% = **약 108만 원/년**
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🎭 Step 3.5: 악마는 디테일이다 (선택사항이지만 강력함)
|
||||
|
||||
**구조**: "간단해 보이지만, 실제로는..."
|
||||
|
||||
```markdown
|
||||
## 겉으로는 간단해 보여요... 하지만
|
||||
|
||||
### 📄 "영수증을 정리하세요"라고 했는데
|
||||
|
||||
**겉으로는**:
|
||||
→ 영수증을 모으기만 하면 돼
|
||||
|
||||
**현실의 디테일**:
|
||||
→ 이 영수증은 인정되고, 이건 안 됨 (세법)
|
||||
→ 이건 개인비? 사업비? (판단)
|
||||
→ 카드값이랑 현금값이랑 다르면? (대사)
|
||||
→ 3년 지났는데 영수증을 못 찾으면? (소송)
|
||||
→ 세무청이 불인정하면? (항의 절차)
|
||||
|
||||
**세무사가 처리하는 것**:
|
||||
✅ 어떤 영수증이 인정될지 사전에 판단
|
||||
✅ 개인비와 사업비의 경계 명확히
|
||||
✅ 세법 변경사항 적용
|
||||
✅ 세무청 부인시 대응 준비
|
||||
|
||||
---
|
||||
|
||||
### 📊 "매출과 경비를 기록하세요"라고 했는데
|
||||
|
||||
**겉으로는**:
|
||||
→ 엑셀에 숫자만 입력하면 돼
|
||||
|
||||
**현실의 디테일**:
|
||||
→ 카드 명세서와 입금액이 안 맞음 (환불? 수수료?)
|
||||
→ 한 달간 매출을 빼먹음 (추가 계산)
|
||||
→ 같은 카테고리인데 세법상 다르게 분류돼야 함 (부가세/소득세 다름)
|
||||
→ 작년에 잘못 입력한 게 발견됨 (수정신고)
|
||||
→ 월별로 편차가 커서 세무청이 의심함 (설명 준비)
|
||||
|
||||
**세무사가 처리하는 것**:
|
||||
✅ 카드명세서 vs 입금액 정산
|
||||
✅ 누락된 부분 찾아서 추가
|
||||
✅ 세법상 올바른 분류
|
||||
✅ 이전년도 오류 수정신고
|
||||
✅ 세무청 질의에 대한 근거 제시
|
||||
|
||||
---
|
||||
|
||||
### ✅ "정확하게 기장하면 세금이 확정돼요"라고 했는데
|
||||
|
||||
**겉으로는**:
|
||||
→ 기장만 잘하면 세금 끝
|
||||
|
||||
**현실의 디테일**:
|
||||
→ 같은 사업도 절세 방법이 다양함 (어떤 게 맞나?)
|
||||
→ 올해는 이렇게, 내년은 저렇게? (일관성)
|
||||
→ 부가세와 소득세 둘 다 고려해야 함 (연계 계산)
|
||||
→ 세무조사가 오면 3년치를 모두 봄 (소급 처리)
|
||||
→ 이의신청/항소하려면? (법적 절차)
|
||||
|
||||
**세무사가 처리하는 것**:
|
||||
✅ 최적의 절세 전략 제시
|
||||
✅ 연도별 일관된 기장 방식 유지
|
||||
✅ 부가세/소득세 동시 최적화
|
||||
✅ 세무조사 대비 사전 정리
|
||||
✅ 이의신청/항소 등 법적 대응
|
||||
```
|
||||
|
||||
**💡 핵심**:
|
||||
- 기초는 누구나 배울 수 있어요
|
||||
- **하지만 디테일을 모두 처리하려면?**
|
||||
- **그 디테일들이 바로 세무사가 하는 일**
|
||||
- **디테일 하나 놓쳤다가 가산세 50만 원... 이래서 세무사 비용이 아깝지 않음**
|
||||
|
||||
---
|
||||
|
||||
### 🔄 Step 3.6: 세법은 계속 바뀐다 (매년 업데이트)
|
||||
|
||||
**구조**: "올해의 세법 변화"를 포스트 작성 시점에 맞춰 갱신
|
||||
|
||||
```markdown
|
||||
## 그런데 세법은 해마다 바뀝니다
|
||||
|
||||
### 📋 [연도] 변경사항들 (꼭 알아야 할 것들)
|
||||
|
||||
**✅ 2025년 부가세 변화**:
|
||||
- 신고 기한이 [날짜]로 변경됨
|
||||
- 영세사업자 기준이 [금액]로 상향조정됨
|
||||
- 새로운 공제 항목이 추가됨: [항목들]
|
||||
|
||||
**✅ 2025년 소득세 변화**:
|
||||
- 기본공제가 [금액]에서 [금액]로 증가
|
||||
- 자녀 공제 조건이 변경됨
|
||||
- 월급 원천징수 기준이 조정됨
|
||||
|
||||
**✅ 2025년 새로운 제도**:
|
||||
- 소상공인 세금 감면 확대
|
||||
- 청년사업자 지원 강화
|
||||
- 부가가치세 간편신청 범위 확대
|
||||
|
||||
---
|
||||
|
||||
**혼자서 할 때의 문제**:
|
||||
❌ "작년 기준으로 기장했는데 올해 기준이 바뀐 거야?"
|
||||
❌ "이 공제가 되는 건지 안 되는 건지 모르겠어"
|
||||
❌ "새로운 제도가 나왔다는 것도 몰랐어"
|
||||
❌ "처음 다시 계산해야 하나?"
|
||||
|
||||
**세무사가 처리하는 것**:
|
||||
✅ 매년 변경사항 자동 추적
|
||||
✅ 당신의 상황에 맞는 새로운 공제 적용
|
||||
✅ 이전년도 재계산 필요시 수정신고
|
||||
✅ 연중 세법 개정 소식 안내
|
||||
✅ 새로운 지원 정책 놓치지 않게 관리
|
||||
|
||||
---
|
||||
|
||||
## 결과 비교: 혼자 할 때 vs 세무사와 함께
|
||||
|
||||
**세법 변화 추적**
|
||||
- 혼자: "어? 규칙이 바뀌었네?"
|
||||
- 세무사: 자동으로 적용됨
|
||||
|
||||
**새로운 공제**
|
||||
- 혼자: 놓치기 쉬움
|
||||
- 세무사: 모두 적용됨
|
||||
|
||||
**매년 재계산**
|
||||
- 혼자: 직접 해야 함
|
||||
- 세무사: 자동 갱신
|
||||
|
||||
**마음 편함**
|
||||
- 혼자: 불안감 ("맞나?")
|
||||
- 세무사: 확신 ("전문가가 관리")
|
||||
|
||||
**투자 시간**
|
||||
- 혼자: 당신의 시간
|
||||
- 세무사: 포함 (전문가 비용)
|
||||
|
||||
---
|
||||
|
||||
## 요약: 왜 세무사가 필요한가
|
||||
|
||||
**기초는 배울 수 있지만**:
|
||||
- 세법은 매년 바뀌고
|
||||
- 당신은 본업이 있어서 추적이 어렵고
|
||||
- 실수 하나가 가산세 50만 원...
|
||||
|
||||
**그래서 세무사가 있으면**:
|
||||
- 변화를 자동으로 적용해주고
|
||||
- 새 제도도 놓치지 않아주고
|
||||
- 당신은 사업에만 집중
|
||||
|
||||
→ **결국 시간, 돈, 스트레스 모두 절약**
|
||||
|
||||
---
|
||||
|
||||
### 💡 Step 4: 실무 팁 (3~5개)
|
||||
|
||||
**구조**: ✅ 이렇게 하세요 / ❌ 이렇게 하면 안 돼요
|
||||
|
||||
```markdown
|
||||
## 이렇게 하면 세금이 명확해요
|
||||
|
||||
### ✅ 해야 할 것
|
||||
1. **영수증 정리** - 매달 봉투에 모아두기
|
||||
2. **기본 기록** - 엑셀에 간단히 기입
|
||||
3. **연 1회 점검** - 세무사와 기본 상담
|
||||
4. **투명성** - 세무청 신고는 정확하게
|
||||
|
||||
### ❌ 하면 안 되는 것
|
||||
1. **영수증 버리기** - 나중에 증거 없음
|
||||
2. **개인비와 섞기** - 기장 혼란
|
||||
3. **신고 늦추기** - 가산세 발생
|
||||
4. **과하게 깎기** - 세무조사 리스크
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 📝 Step 5: 결론
|
||||
|
||||
고객이 읽은 후 자연스럽게 결론을 내리도록:
|
||||
|
||||
**구조**:
|
||||
1. 기초는 할 수 있다 (긍정)
|
||||
2. 근데 복잡하네요 (현실 직시)
|
||||
3. 그래서 세무사가 필요하구나 (자연스러운 깨달음)
|
||||
|
||||
**고객이 느끼는 여정**:
|
||||
- 처음: "아, 이 정도는 내가 할 수 있겠네"
|
||||
- 중간: "근데 이 모든 걸 매년 챙기기는..."
|
||||
- 결론: "전문가 도움이 낫겠다"
|
||||
|
||||
```markdown
|
||||
## 기초는 누구나 할 수 있어요
|
||||
|
||||
**이 정도면 자신이 충분히 가능합니다**:
|
||||
- 소규모 사업 (월 500만~1,000만 원)
|
||||
- 단순 경비 (재료, 임차료 등)
|
||||
- 월 1회 정도 기본 정리
|
||||
|
||||
→ 영수증 정리 + 기본 엑셀 기입면 충분
|
||||
|
||||
---
|
||||
|
||||
## 하지만 이렇게 복잡하면 전문가 도움이 효율적입니다
|
||||
|
||||
**세무사 상담을 권하는 경우**:
|
||||
- 📊 월 매출이 2,000만 원을 넘어갈 때
|
||||
- 💼 여러 사업을 동시에 운영할 때
|
||||
- 🏠 부동산 등 추가 수입이 있을 때
|
||||
- 📈 직원을 여러 명 두고 있을 때
|
||||
- 🌍 해외 거래나 수입이 있을 때
|
||||
|
||||
### 실제 효과: 숫자로 본 세무사의 가치
|
||||
|
||||
**절세액**
|
||||
- 혼자: X만 원
|
||||
- 세무사: X + 200만 원
|
||||
- 차이: +200만 원 절약
|
||||
|
||||
**세무조사 스트레스**
|
||||
- 혼자: 매년 불안
|
||||
- 세무사: 안정적 대응
|
||||
- 차이: 심리적 안정
|
||||
|
||||
**시간 투자**
|
||||
- 혼자: 월 10시간
|
||||
- 세무사: 월 1시간
|
||||
- 차이: 월 9시간 자유
|
||||
|
||||
**세무사 비용**
|
||||
- 혼자: 0원
|
||||
- 세무사: 약 100만 원/년
|
||||
- 차이: -100만 원
|
||||
|
||||
**실제 이익**
|
||||
- 혼자: 순이익
|
||||
- 세무사: 순이익 + 100만 원
|
||||
- 차이: +100만 원 순이익
|
||||
|
||||
**돈을 쓰는 이유**:
|
||||
- 세금 절약: 절세 200만 원 - 비용 100만 원 = 순 100만 원 이득
|
||||
- 시간 절약: 월 9시간(연 108시간) = 사업에 집중
|
||||
- 스트레스 감소: 세무조사 불안 제거
|
||||
- 리스크 관리: 실수로 인한 가산세 방지
|
||||
|
||||
---
|
||||
|
||||
## 요약
|
||||
|
||||
**기본 개념을 아는 것만으로도**:
|
||||
- 실수를 줄이고
|
||||
- 세금을 절약하고
|
||||
- 세무사와의 상담이 훨씬 효율적
|
||||
|
||||
당신의 상황이 어느 정도인지 판단하고,
|
||||
필요할 때 전문가와 함께 하세요.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 작성 체크리스트
|
||||
|
||||
### 내용
|
||||
- [ ] **실제 사례**: 동네 카페/편의점/학원 같은 주변 상황
|
||||
- [ ] **구체적 페르소나**: 이름, 나이, 직업, 사업 경력
|
||||
- [ ] **실제 금액**: 매출, 경비, 세금 (현실적 수치)
|
||||
- [ ] **Before/After**: 실패 사례 → 성공 사례
|
||||
- [ ] **절세 효과**: "X만 원 절약" 또는 "손해를 막음"
|
||||
- [ ] **계산**: Step별로 명확, 표 포함
|
||||
- [ ] **악마는 디테일**: "겉으로는 간단해 보이지만 실제로는..." (세무사가 처리하는 디테일들)
|
||||
- [ ] **세법 변화**: 해당 연도의 세법 변경사항과 그 영향 설명
|
||||
|
||||
### 톤
|
||||
- [ ] **교육적**: 개념을 이해하도록
|
||||
- [ ] **격려적**: 경고/협박 없음
|
||||
- [ ] **현실적**: 복잡할 수 있다는 인정
|
||||
- [ ] **세무사 자연스러운 유도**: "필요할 때 도움되는 선택"
|
||||
- [ ] **임파워먼트**: "기초는 누구나 할 수 있어요"
|
||||
|
||||
### 표현
|
||||
- [ ] **중학교 수준**: 어려운 용어는 () 설명
|
||||
- [ ] **이모지**: 🏠💰✅❌📊 등으로 시각화
|
||||
- [ ] **짧은 문장**: 한 문장에 한 개념
|
||||
- [ ] **표와 리스트**: 수치는 표로, 항목은 리스트로
|
||||
|
||||
---
|
||||
|
||||
## 🚫 피해야 할 표현 (한국세무사협회 광고 규칙 준수)
|
||||
|
||||
### ❌ **절대 금지 표현** (법적 위반 위험)
|
||||
|
||||
**1. 과도한 절세 약속 & 절대 표현**:
|
||||
- ❌ "50만 원 절약 가능"
|
||||
- ❌ "최대한 경비를 깎아줍니다"
|
||||
- ❌ "세금을 반으로 줄여드립니다"
|
||||
- ❌ "세금을 덜 냅니다" (보장으로 해석)
|
||||
- ❌ "가장 많이 절세해드립니다"
|
||||
- ✅ "이 사례에서는 약 50만 원 절약되었습니다" (과거 사례만)
|
||||
- ✅ "정확한 경비 처리로 세법에 따른 정당한 공제를 받을 수 있습니다" (법적 근거)
|
||||
- ✅ "경비를 빠짐없이 처리합니다" (객관적 프로세스)
|
||||
|
||||
**2. 보장 표현 (불가능한 결과 약속)**:
|
||||
- ❌ "반드시 세금을 줄입니다"
|
||||
- ❌ "세무조사 안 받게 해드립니다"
|
||||
- ❌ "100% 절세를 보장합니다"
|
||||
- ❌ "세금을 보장합니다"
|
||||
- ✅ "정확한 신고로 세무조사 리스크를 최소화합니다"
|
||||
- ✅ "세법에 따른 정당한 공제를 받을 수 있습니다"
|
||||
|
||||
**3. 무료 & 가격 표현**:
|
||||
- ❌ "무료로 세금 절약해드립니다"
|
||||
- ❌ "최저가 신고료"
|
||||
- ❌ "가장 저렴한 가격"
|
||||
- ✅ "합리적인 비용으로 전문 서비스를 제공합니다"
|
||||
|
||||
**4. 절대/최상급 표현**:
|
||||
- ❌ "반드시", "무조건", "반듯이", "항상", "절대"
|
||||
- ❌ "최고", "최우수", "1등", "유일"
|
||||
- ❌ "모든", "완벽하게"
|
||||
- ✅ "일반적으로", "대부분의 경우", "보통"
|
||||
|
||||
**5. 과도한 단순화 표현**:
|
||||
- ❌ "매우 편합니다", "너무 쉽습니다"
|
||||
- ❌ "아무도 실수할 수 없습니다"
|
||||
- ❌ "5분이면 끝납니다"
|
||||
- ✅ "기초 개념을 배울 수 있습니다"
|
||||
- ✅ "복잡한 부분은 전문가가 관리합니다"
|
||||
|
||||
**6. 객관적 증거 없는 수치**:
|
||||
- ❌ "평균 170만 원 절약" (근거 없으면)
|
||||
- ❌ "고객의 80%가 만족" (통계 없으면)
|
||||
- ❌ "보통 2배의 환급" (데이터 없으면)
|
||||
- ✅ "이 사례에서는 약 170만 원 절약되었습니다"
|
||||
- ✅ "많은 고객들이 정확한 기장의 필요성을 느낍니다"
|
||||
|
||||
---
|
||||
|
||||
### ✅ **안전한 표현 (권장)**
|
||||
|
||||
| 대신 이렇게 | 이유 |
|
||||
|----------|------|
|
||||
| "정확한 기장으로 세법에 따른 공제를 받을 수 있습니다" | 법적 근거 (보장 아님) |
|
||||
| "경비를 빠짐없이 처리합니다" | 객관적 프로세스 |
|
||||
| "이 사례에서는 약 50만 원 절약되었습니다" | 과거 사례 (보장 아님) |
|
||||
| "경비를 빠짐없이 처리합니다" | 객관적 프로세스 |
|
||||
| "세무조사 대비 근거를 정리합니다" | 예방적 표현 |
|
||||
| "당신의 상황에 맞는 최선의 방법을 제시합니다" | 개별 맞춤형 |
|
||||
| "세법이 자주 바뀌므로 전문가 도움이 효율적입니다" | 필요성 설명 |
|
||||
| "이 정도는 자신이 충분히 가능합니다" | 존중과 임파워먼트 |
|
||||
| "복잡한 경우는 전문가와 상담하세요" | 선택지 제시 |
|
||||
| "정확하게 하면 나중에 편합니다" | 미래 가치 (현재 보장 아님) |
|
||||
|
||||
---
|
||||
|
||||
### 📋 블로그 작성 시 광고 규칙 체크리스트
|
||||
|
||||
- [ ] **절세 약속 제거**: "최대한", "반드시", "보장", "무조건" 단어 없음
|
||||
- [ ] **보장 표현 제거**: "세무조사 안 받게", "100% 절세", "확실" 제거
|
||||
- [ ] **무료/가격 표현 제거**: "무료", "최저가", "가장 저렴" 제거
|
||||
- [ ] **절대 표현 제거**: "항상", "절대", "모두", "완벽" 제거
|
||||
- [ ] **최상급 제거**: "최고", "최우수", "1등" (객관적 증거 있으면 가능)
|
||||
- [ ] **과도한 단순화 제거**: "매우 쉽습니다", "아무도 실수할 수 없음" 제거
|
||||
- [ ] **수치는 사례로**: "절약 가능" → "이 사례에서는 약 X만 원 절약"
|
||||
- [ ] **객관성 유지**: 구체적 사례 + 과거형 표현 사용
|
||||
- [ ] **필요성 설명**: "왜 필요한가" → 이해와 선택 유도
|
||||
- [ ] **세무사협회 규정 준수**: 법적 문제 없음
|
||||
|
||||
---
|
||||
|
||||
## 시즌별 주제 예시
|
||||
|
||||
| 월 | 추천 주제 | 톤 |
|
||||
|----|---------|-----|
|
||||
| 1월 | 부가세 2기 신고 기한 | "이 정도면 자신이 가능" |
|
||||
| 5월 | 종소세 신고 방법 | "핵심 개념 + 전문가 도움 타이밍" |
|
||||
| 7월 | 부가세 1기 신고 | "기초 정리 방법" |
|
||||
| 11월 | 다음해 준비 | "계획하면 편해요" |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 실수 방지 체크리스트 (과거 오류 기록)
|
||||
|
||||
**이전에 반복된 실수들을 기록하여, 같은 실수를 하지 않도록 합니다.**
|
||||
|
||||
### 1️⃣ 카테고리 할당 실수 ❌
|
||||
|
||||
**과거 오류**: 포스트를 만들 때 category_id를 NULL로 두었음
|
||||
|
||||
**문제점**:
|
||||
- DB NOT NULL 제약 위반
|
||||
- 블로그 페이지에 노출 안 됨
|
||||
- 고객이 카테고리로 검색 불가
|
||||
|
||||
**예방책**:
|
||||
- ✅ **SQL INSERT 시 반드시 category_id 명시**
|
||||
- ✅ **포스트 작성 전에 카테고리 결정**
|
||||
- ✅ **DB 적용 후 category_id NOT NULL 확인**
|
||||
- ✅ **각 카테고리별 최소 3개 이상 포스트 유지**
|
||||
|
||||
**SQL 예시** (권장):
|
||||
```sql
|
||||
INSERT INTO blog_posts (title, slug, content, category_id, is_published, ...)
|
||||
VALUES ('제목', 'slug', $$본문$$, 1, true, ...);
|
||||
-- category_id 절대 생략 금지!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ 내용 길이 부족 ❌
|
||||
|
||||
**과거 오류**: 에이전트가 지침(1,500~2,500자)을 무시하고 간단한 버전(500자)으로 생성
|
||||
|
||||
**문제점**:
|
||||
- 고객 설득력 부족
|
||||
- 계산 예시 없음
|
||||
- 3단계 구조 불완전
|
||||
- 세법 인용 부족
|
||||
|
||||
**예방책**:
|
||||
- ✅ **각 포스트 최소 1,500자 이상 (추천 2,000~2,500자)**
|
||||
- ✅ **포스트 작성 후 글자 수 확인: `LENGTH(content) >= 1500`**
|
||||
- ✅ **항상 실제 사례 포함** (이름, 나이, 직업, 구체적 상황)
|
||||
- ✅ **항상 계산 과정 포함** (절세액 수치화)
|
||||
- ✅ **3단계 구조 필수** (1️⃣ 기초 → 2️⃣ 현실 → 3️⃣ 해결책)
|
||||
|
||||
**확인 쿼리**:
|
||||
```sql
|
||||
SELECT id, title, LENGTH(content) as length FROM blog_posts
|
||||
WHERE LENGTH(content) < 1500; -- 부족한 포스트 검출
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ 테이블 사용 금지 ❌
|
||||
|
||||
**과거 오류**: 마크다운 테이블(`| |---|---|`) 사용
|
||||
|
||||
**문제점**:
|
||||
- 지침 위반 (리스트만 사용)
|
||||
- 모바일에서 가독성 저하
|
||||
- 유지보수 어려움
|
||||
|
||||
**예방책**:
|
||||
- ✅ **테이블 금지, 리스트만 사용** (- 또는 숫자 목록)
|
||||
- ✅ **작성 후 `| |` 패턴 검색으로 테이블 확인**
|
||||
- ✅ **수치/계산은 리스트 형식**:
|
||||
|
||||
**❌ 금지 (테이블)**:
|
||||
```markdown
|
||||
| 항목 | 월 | 연간 |
|
||||
|------|-----|------|
|
||||
| 월세 | 150만 | 1,800만 |
|
||||
```
|
||||
|
||||
**✅ 권장 (리스트)**:
|
||||
```markdown
|
||||
월 경비 구성:
|
||||
- 월세: 150만 원 (연 1,800만 원)
|
||||
- 재료비: 180만 원 (연 2,160만 원)
|
||||
- 직원급여: 100만 원 (연 1,200만 원)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ 계산 예시 누락 ❌
|
||||
|
||||
**과거 오류**: 포스트에 개념만 있고 실제 계산 예시 부족
|
||||
|
||||
**문제점**:
|
||||
- 고객이 "내 상황에 얼마나 해당하나" 판단 어려움
|
||||
- 추상적 설명으로 설득력 감소
|
||||
- 세무사 필요성 전달 미흡
|
||||
|
||||
**예방책**:
|
||||
- ✅ **모든 포스트에 구체적 계산 예시 필수**
|
||||
- ✅ **절세액을 수치로 제시** ("약 50만 원 절약")
|
||||
- ✅ **단계별 계산 과정 포함** (Step 1️⃣, 2️⃣, 3️⃣, 4️⃣)
|
||||
- ✅ **실제 사례로 숫자 구체화**:
|
||||
|
||||
**예시**:
|
||||
```markdown
|
||||
### Step 1️⃣: 매출 정리
|
||||
월 600만 원 × 12개월 = 연 7,200만 원
|
||||
|
||||
### Step 2️⃣: 경비 계산
|
||||
- 월세: 150만 원 → 연 1,800만 원
|
||||
- 재료비: 180만 원 → 연 2,160만 원
|
||||
합계: 5,400만 원
|
||||
|
||||
### Step 3️⃣: 순이익
|
||||
7,200만 - 5,400만 = 1,800만 원
|
||||
|
||||
### Step 4️⃣: 세금
|
||||
1,800만 원 × 약 6% = **약 108만 원/년**
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ 카테고리 주제 불일치 ❌
|
||||
|
||||
**과거 오류**: 포스트 주제와 카테고리가 맞지 않음
|
||||
|
||||
**문제점**:
|
||||
- 고객이 원하는 정보 검색 불가
|
||||
- 카테고리 신뢰도 저하
|
||||
- UX 혼란
|
||||
|
||||
**예방책**:
|
||||
- ✅ **포스트 작성 전 카테고리 명확히 결정**
|
||||
- ✅ **포스트 주제와 카테고리 일관성 검증**:
|
||||
|
||||
| 포스트 | 카테고리 | 확인 |
|
||||
|--------|---------|------|
|
||||
| 프리랜서 경비 | 종합소득세 (3) | ✅ 맞음 |
|
||||
| 월세 신고 | 부동산 세금 (2) | ✅ 맞음 |
|
||||
| 자녀 증여세 | 가족자산·증여 (5) | ✅ 맞음 |
|
||||
| 사업자 기장 | 사업자 세무 (1) | ✅ 맞음 |
|
||||
| 부가세 신고 | 부가가치세 (4) | ✅ 맞음 |
|
||||
|
||||
---
|
||||
|
||||
### 6️⃣ 정확한 세법 인용 누락 ❌
|
||||
|
||||
**과거 오류**: 일부 포스트에서 법조 명시 부족
|
||||
|
||||
**문제점**:
|
||||
- 정확성 원칙 위반
|
||||
- 법적 책임 불명확
|
||||
- 고객 신뢰도 저하
|
||||
|
||||
**예방책**:
|
||||
- ✅ **모든 주요 내용에 세법 조항 인용 필수**
|
||||
- ✅ **형식**: "소득세법 제XX조에 따르면"
|
||||
- ✅ **연도 기준 명시**: "2025년 기준"
|
||||
- ✅ **포스트 끝에 "법적 근거" 섹션 필수**:
|
||||
|
||||
```markdown
|
||||
**법적 근거**:
|
||||
- 소득세법 제29조 (수입금액의 계산)
|
||||
- 국세기본법 제47조 (가산세)
|
||||
- 소득세법 제160조 (증빙 보관)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 포스트 최종 체크리스트
|
||||
|
||||
모든 포스트를 DB에 등록하기 전에 다음을 확인하세요:
|
||||
|
||||
- [ ] **카테고리 할당**: `category_id NOT NULL` (필수)
|
||||
- [ ] **내용 길이**: `LENGTH(content) >= 1500` (최소 1,500자)
|
||||
- [ ] **테이블 확인**: `| |` 패턴 없음 (리스트만)
|
||||
- [ ] **계산 예시**: Step 1️⃣~4️⃣ 포함 (절세액 수치)
|
||||
- [ ] **세법 인용**: 모든 주요 내용에 법조 명시
|
||||
- [ ] **카테고리 일치**: 포스트 주제 ↔ 카테고리 일관성
|
||||
- [ ] **3단계 구조**: 1️⃣ 기초 → 2️⃣ 현실 → 3️⃣ 해결책
|
||||
- [ ] **광고 규칙**: 금지 표현(보장, 최저가, 무료) 없음
|
||||
- [ ] **사례 포함**: 실제 상황 + 이름/나이/직업 구체화
|
||||
- [ ] **정확성**: 추측/예상/의견 표현 없음
|
||||
|
||||
**체크 쿼리**:
|
||||
```sql
|
||||
-- DB 적용 후 확인
|
||||
SELECT id, title, LENGTH(content), category_id
|
||||
FROM blog_posts
|
||||
WHERE LENGTH(content) < 1500 OR category_id IS NULL
|
||||
ORDER BY id;
|
||||
-- 결과 없음이 정상!
|
||||
```
|
||||
+120
-13
@@ -17,7 +17,7 @@
|
||||
| 3.3 | [주요 Python 패키지](#33-주요-python-패키지-시스템) | 시스템/venv 패키지 구분 |
|
||||
| 4 | [서비스 아키텍처](#4-서비스-아키텍처) | 포트 맵, Nginx 리버스 프록시 |
|
||||
| 4.1 | [포트 맵](#41-포트-맵) | 22, 80, 2222, 3000, 5000, 5432 |
|
||||
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | `/` → Gitea, `/quant/` → Blazor |
|
||||
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | 도메인 기반 가상 호스트 분기 (홈페이지, Gitea, Quant) |
|
||||
| 5 | [Gitea](#5-gitea) | Docker Compose 설정, 시크릿, 데이터 경로 |
|
||||
| 5.1 | [Docker Compose](#51-docker-compose) | `gitea:1.26.4`, PG 연동 |
|
||||
| 5.2 | [시크릿 관리](#52-시크릿-관리) | `/opt/stacks/gitea/.env` |
|
||||
@@ -126,17 +126,22 @@ boto3, cryptography, Jinja2, jsonschema, fail2ban 등 시스템 레벨로 설치
|
||||
### 4.2. Nginx 리버스 프록시
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-enabled/gitea-ip.conf
|
||||
# /etc/nginx/sites-available/taxbaik-domains.conf
|
||||
|
||||
# 1. TaxBaik 홈페이지 (taxbaik.com, www.taxbaik.com)
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
server_name taxbaik.com www.taxbaik.com;
|
||||
client_max_body_size 512M;
|
||||
|
||||
# QuantEngine Blazor Web App
|
||||
location /quant/ {
|
||||
proxy_pass http://127.0.0.1:5000/;
|
||||
|
||||
# /admin 하위 요청을 /taxbaik/admin 으로 리다이렉트하여 Blazor Base Path 대응
|
||||
location /admin {
|
||||
return 301 $scheme://$host/taxbaik$request_uri;
|
||||
}
|
||||
|
||||
# 루트 경로 요청을 /taxbaik 으로 프록싱하여 base href /taxbaik/ 에 대응
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5001/taxbaik/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
@@ -147,7 +152,33 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Gitea (기본)
|
||||
# /taxbaik/ 하위로 들어오는 리소스 및 페이지 요청 처리
|
||||
location /taxbaik {
|
||||
proxy_pass http://127.0.0.1:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
|
||||
}
|
||||
|
||||
# 2. Gitea (gitea.taxbaik.com)
|
||||
server {
|
||||
server_name gitea.taxbaik.com;
|
||||
client_max_body_size 512M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
@@ -159,13 +190,89 @@ server {
|
||||
proxy_connect_timeout 300;
|
||||
proxy_send_timeout 300;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
}
|
||||
|
||||
# 3. QuantEngine (quant.taxbaik.com)
|
||||
server {
|
||||
server_name quant.taxbaik.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
}
|
||||
|
||||
server {
|
||||
if ($host = www.taxbaik.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
if ($host = taxbaik.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
listen 80;
|
||||
server_name taxbaik.com www.taxbaik.com;
|
||||
return 404; # managed by Certbot
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
server {
|
||||
if ($host = gitea.taxbaik.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
listen 80;
|
||||
server_name gitea.taxbaik.com;
|
||||
return 404; # managed by Certbot
|
||||
|
||||
|
||||
}
|
||||
server {
|
||||
if ($host = quant.taxbaik.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
listen 80;
|
||||
server_name quant.taxbaik.com;
|
||||
return 404; # managed by Certbot
|
||||
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
**라우팅 요약**:
|
||||
- `http://178.104.200.7/` → Gitea Web UI
|
||||
- `http://178.104.200.7/quant/` → QuantEngine Blazor Admin
|
||||
- `ssh://178.104.200.7:2222` → Gitea Git SSH
|
||||
- `http://taxbaik.com/` 또는 `http://www.taxbaik.com/` → TaxBaik 홈페이지 (내부 proxy: `http://127.0.0.1:5001/taxbaik/`)
|
||||
- `http://gitea.taxbaik.com/` → Gitea Web UI (내부 proxy: `http://127.0.0.1:3000`)
|
||||
- `http://quant.taxbaik.com/` → QuantEngine Blazor Admin (내부 proxy: `http://127.0.0.1:5000/`)
|
||||
- `ssh://gitea.taxbaik.com:2222` → Gitea Git SSH
|
||||
|
||||
## 5. Gitea
|
||||
|
||||
@@ -384,7 +491,7 @@ ClientAliveCountMax 2
|
||||
| **CI Runner** | Synology Act Runner | 6× `act_runner:latest` (Docker) |
|
||||
| **DB** | SQLite (파일 기반) | PostgreSQL 18 + SQLite (하이브리드) |
|
||||
| **웹 Admin** | 없음 | QuantEngine Blazor (.NET 10, MudBlazor) |
|
||||
| **리버스 프록시** | Synology 내장 | Nginx (`/` → Gitea, `/quant/` → Blazor) |
|
||||
| **리버스 프록시** | Synology 내장 | Nginx (도메인 기반 분기 - 홈페이지, Gitea, Quant) |
|
||||
| **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 |
|
||||
| **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` |
|
||||
| **OS** | Synology DSM 7.x | Ubuntu 26.04 LTS |
|
||||
|
||||
+44
-11
@@ -19,32 +19,46 @@ GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik;
|
||||
|
||||
### 2. 환경 변수 설정
|
||||
|
||||
**Web 서비스** (`/etc/systemd/system/taxbaik.service`):
|
||||
**Web 서비스** (`/etc/systemd/system/taxbaik.service`, 백엔드 전용):
|
||||
```ini
|
||||
[Service]
|
||||
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||
Environment=ASPNETCORE_URLS=http://127.0.0.1:5001
|
||||
Environment=ASPNETCORE_URLS=http://127.0.0.1:5004
|
||||
Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=your_secure_password
|
||||
```
|
||||
|
||||
**프록시 서비스** (`/etc/systemd/system/taxbaik-proxy.service`, 5001 진입점):
|
||||
```ini
|
||||
[Service]
|
||||
ExecStart=/usr/bin/dotnet TaxBaik.Proxy.dll
|
||||
WorkingDirectory=/home/kjh2064/taxbaik_active
|
||||
Restart=always
|
||||
```
|
||||
|
||||
### 3. systemd 서비스 파일 설치
|
||||
|
||||
```bash
|
||||
sudo cp deploy/taxbaik.service /etc/systemd/system/
|
||||
sudo cp deploy/taxbaik-proxy.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable taxbaik
|
||||
sudo systemctl enable taxbaik-proxy
|
||||
```
|
||||
|
||||
### 4. Nginx 설정
|
||||
|
||||
```bash
|
||||
# 현재 Nginx 설정 확인
|
||||
sudo cat /etc/nginx/sites-available/default | head -30
|
||||
# Nginx 도메인 기반 가상 호스트 설정 복사
|
||||
sudo cp deploy/nginx-taxbaik-domains.conf /etc/nginx/sites-available/taxbaik-domains.conf
|
||||
|
||||
# location 블록 추가 (또는 기존 설정에 병합)
|
||||
sudo cp deploy/nginx-taxbaik-locations.conf /etc/nginx/conf.d/taxbaik.conf
|
||||
# 기존 설정(IP 기반 및 default) 활성화 해제
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo rm -f /etc/nginx/sites-enabled/gitea-ip.conf
|
||||
|
||||
# 테스트 및 재로드
|
||||
# 새 설정 활성화 (심링크 생성)
|
||||
sudo ln -sfn /etc/nginx/sites-available/taxbaik-domains.conf /etc/nginx/sites-enabled/taxbaik-domains.conf
|
||||
|
||||
# 설정 문법 테스트 및 Nginx 서비스 리로드
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
@@ -65,7 +79,7 @@ sudo systemctl reload nginx
|
||||
master 브랜치 push → build → test → publish → restart → health check → Playwright
|
||||
```
|
||||
|
||||
수동 배포는 비상 롤백 외에는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다.
|
||||
수동 배포는 사용하지 않습니다. `deploy_gb.sh`는 `TAXBAIK_DEPLOY_FROM_CI=1`이 없으면 즉시 종료하므로, 배포는 반드시 Gitea Actions에서만 실행됩니다.
|
||||
|
||||
## 마이그레이션 자동 실행
|
||||
|
||||
@@ -128,6 +142,7 @@ ls -la ~/deployments/ | grep taxbaik
|
||||
|
||||
# 심링크 변경 (예: 이전 버전이 taxbaik_20260626_140000)
|
||||
ln -sfn ~/deployments/taxbaik_20260626_140000 ~/taxbaik_active
|
||||
sudo systemctl restart taxbaik-proxy
|
||||
sudo systemctl restart taxbaik
|
||||
```
|
||||
|
||||
@@ -139,10 +154,10 @@ sudo systemctl restart taxbaik
|
||||
ssh kjh2064@178.104.200.7
|
||||
|
||||
# 서비스 상태
|
||||
systemctl status taxbaik
|
||||
systemctl status taxbaik taxbaik-proxy
|
||||
|
||||
# 포트 확인
|
||||
netstat -tlnp | grep -E '5001'
|
||||
netstat -tlnp | grep -E '5001|5004'
|
||||
|
||||
# 프로세스 확인
|
||||
ps aux | grep TaxBaik
|
||||
@@ -165,9 +180,27 @@ journalctl -u taxbaik -f
|
||||
| 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` |
|
||||
| Blazor WebSocket 안 됨 | `/taxbaik` location에 `proxy_http_version 1.1`, `Upgrade`, `Connection \"Upgrade\"` 헤더가 모두 있는지 확인 |
|
||||
| DB 연결 오류 | 환경 변수 미설정 | systemd service 파일의 ConnectionStrings__Default 확인 |
|
||||
| 503 Service Unavailable | 앱 미시작 | `sudo systemctl restart taxbaik` |
|
||||
| 503 Service Unavailable | 백엔드 또는 프록시 미시작 | `sudo systemctl restart taxbaik-proxy taxbaik` |
|
||||
| 마이그레이션 실패 | DB 권한 문제 | `GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik;` |
|
||||
|
||||
## 운영 복구 순서
|
||||
|
||||
```bash
|
||||
ssh kjh2064@178.104.200.7
|
||||
sudo cp /home/kjh2064/taxbaik.service /etc/systemd/system/taxbaik.service
|
||||
sudo cp /home/kjh2064/taxbaik-proxy.service /etc/systemd/system/taxbaik-proxy.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart taxbaik-proxy
|
||||
sudo systemctl restart taxbaik
|
||||
curl -I http://127.0.0.1:5001/taxbaik/admin/login
|
||||
```
|
||||
|
||||
## 원라인 점검
|
||||
|
||||
```bash
|
||||
ssh kjh2064@178.104.200.7 'systemctl status taxbaik taxbaik-proxy --no-pager --lines=3 && ss -tlnp | grep -E ":5001 |:5004 " && curl -fsSI http://127.0.0.1:5001/taxbaik/admin/login && curl -fsS http://127.0.0.1:5001/taxbaik/favicon.svg >/dev/null && curl -fsS http://127.0.0.1:5001/taxbaik/robots.txt >/dev/null'
|
||||
```
|
||||
|
||||
## 초기 데이터
|
||||
|
||||
### 관리자 계정
|
||||
|
||||
+8
-40
@@ -48,29 +48,7 @@ ssh kjh2064@178.104.200.7 'bash ~/SERVER_SETUP.sh'
|
||||
# ~/taxbaik_active
|
||||
```
|
||||
|
||||
### 2단계: 첫 배포 (수동)
|
||||
|
||||
```bash
|
||||
# 로컬에서 실행
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# SSH 키 설정 (필요시)
|
||||
export DEPLOY_USER="kjh2064"
|
||||
export DEPLOY_HOST="178.104.200.7"
|
||||
|
||||
# 배포
|
||||
rsync -avz --delete ./publish/ \
|
||||
$DEPLOY_USER@$DEPLOY_HOST:~/deployments/taxbaik_${TIMESTAMP}/
|
||||
|
||||
# 심링크 변경 및 시작
|
||||
ssh $DEPLOY_USER@$DEPLOY_HOST << EOF
|
||||
ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active
|
||||
sudo systemctl start taxbaik
|
||||
sudo systemctl status taxbaik
|
||||
EOF
|
||||
```
|
||||
|
||||
### 3단계: Gitea Actions 설정 (선택)
|
||||
### 2단계: Gitea Actions 설정
|
||||
|
||||
**Gitea 저장소 Settings → Secrets 추가**:
|
||||
- `DEPLOY_USER`: `kjh2064`
|
||||
@@ -217,8 +195,8 @@ curl -I -H "Accept-Encoding: gzip" http://178.104.200.7/taxbaik/ | grep -i encod
|
||||
| 증상 | 원인 | 해결 방법 |
|
||||
|------|------|----------|
|
||||
| 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` |
|
||||
| 502 Bad Gateway | 앱 미실행 | `sudo systemctl restart taxbaik` |
|
||||
| 503 Service Unavailable | 앱 충돌 | 로그 확인: `journalctl -u taxbaik -n 50` |
|
||||
| 502 Bad Gateway | 프록시 또는 백엔드 미실행 | `sudo systemctl restart taxbaik-proxy taxbaik` |
|
||||
| 503 Service Unavailable | 백엔드 충돌 또는 비밀값 누락 | 로그 확인: `journalctl -u taxbaik -n 50` |
|
||||
| DB 연결 오류 | 환경 변수 미설정 | systemd 파일의 ConnectionStrings__Default 확인 |
|
||||
| HTTPS 오류 | SSL 미구성 | 개발 환경에서는 HTTP 사용 (IP 기반) |
|
||||
| 마이그레이션 실패 | 테이블 존재 | `DROP DATABASE taxbaikdb;` 후 재시작 |
|
||||
@@ -230,11 +208,11 @@ curl -I -H "Accept-Encoding: gzip" http://178.104.200.7/taxbaik/ | grep -i encod
|
||||
### 실시간 모니터링
|
||||
|
||||
```bash
|
||||
# 터미널 1: 웹 서비스 로그
|
||||
# 터미널 1: 백엔드 로그
|
||||
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
|
||||
|
||||
# 터미널 2: 통합 서비스 로그
|
||||
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
|
||||
# 터미널 2: 프록시 로그
|
||||
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik-proxy -f'
|
||||
|
||||
# 터미널 3: Nginx 로그
|
||||
ssh kjh2064@178.104.200.7 'sudo tail -f /var/log/nginx/access.log | grep taxbaik'
|
||||
@@ -246,13 +224,7 @@ ssh kjh2064@178.104.200.7 'watch -n 1 "ps aux | grep TaxBaik"'
|
||||
### 정기적 검사
|
||||
|
||||
```bash
|
||||
# 일일 체크 (cron job)
|
||||
0 9 * * * /home/kjh2064/health-check.sh
|
||||
|
||||
# 내용:
|
||||
#!/bin/bash
|
||||
curl -f http://127.0.0.1:5001/taxbaik || systemctl restart taxbaik
|
||||
curl -f http://127.0.0.1:5001/taxbaik/admin/login || systemctl restart taxbaik
|
||||
# 일일 체크는 CI 배포 후 자동 검증으로 대체
|
||||
```
|
||||
|
||||
---
|
||||
@@ -268,11 +240,6 @@ git commit -m "기능: 새로운 기능 추가"
|
||||
git push origin master
|
||||
|
||||
# 2. Gitea Actions가 자동으로 배포
|
||||
# 또는 수동 배포:
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
dotnet publish TaxBaik.Web -c Release -o ./publish
|
||||
rsync -avz ./publish/ kjh2064@178.104.200.7:~/deployments/taxbaik_${TIMESTAMP}/
|
||||
ssh kjh2064@178.104.200.7 "ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active && sudo systemctl restart taxbaik"
|
||||
```
|
||||
|
||||
### 롤백 절차
|
||||
@@ -284,6 +251,7 @@ ssh kjh2064@178.104.200.7 'ls -la ~/deployments/ | grep taxbaik'
|
||||
# 롤백 (예: 이전 버전이 taxbaik_20260625_100000)
|
||||
ssh kjh2064@178.104.200.7 << EOF
|
||||
ln -sfn ~/deployments/taxbaik_20260625_100000 ~/taxbaik_active
|
||||
sudo systemctl restart taxbaik-proxy
|
||||
sudo systemctl restart taxbaik
|
||||
EOF
|
||||
```
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
**온라인 세무 상담 플랫폼** | 블로그 SEO 최적화 | 전국 고객 확보
|
||||
|
||||
CI deploy trigger verification note.
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
@@ -166,7 +168,7 @@ master 브랜치에 푸시하면 파이프라인이 다음 단계를 수행합
|
||||
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호
|
||||
- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값
|
||||
|
||||
수동 배포는 비상 롤백 절차 외에는 사용하지 않습니다. 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
|
||||
배포는 Gitea Actions CI/CD로만 수행합니다. 수동 배포 경로는 CI 하네스로 차단되어 있으며, 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+180
-44
@@ -38,7 +38,7 @@ Todo:
|
||||
- `tests/e2e/contact-submit.spec.ts`
|
||||
- `tests/e2e/inquiry-detail.spec.ts`
|
||||
|
||||
## WBS-UX-02 홈페이지 FAQ 섹션
|
||||
## WBS-UX-02 홈페이지 FAQ 섹션 (정적)
|
||||
|
||||
목표: 방문자가 상담 전 자주 묻는 질문에서 직접 답을 얻고 전환율을 높인다.
|
||||
|
||||
@@ -50,7 +50,65 @@ Todo:
|
||||
Todo:
|
||||
- [x] Index.cshtml에 FAQ 아코디언 섹션 추가 (최종 CTA 앞)
|
||||
- [x] site.css faq-accordion / faq-item / faq-question / faq-answer 스타일
|
||||
- [ ] 배포 후 브라우저 동작 확인
|
||||
- [x] 배포 완료 (`12070b7`)
|
||||
- [ ] 배포 후 브라우저 아코디언 동작 확인
|
||||
|
||||
## WBS-UX-04 개인정보처리방침·이용약관 페이지
|
||||
|
||||
목표: 법적 의무를 충족하고 방문자 신뢰를 높이는 정책 페이지를 제공한다.
|
||||
|
||||
성공 기준:
|
||||
- `/taxbaik/privacy` 개인정보처리방침 페이지 정상 렌더링 (200)
|
||||
- `/taxbaik/terms` 이용약관 페이지 정상 렌더링 (200)
|
||||
- 푸터에 두 페이지 링크 표시
|
||||
- 개인정보처리방침: 수집 항목, 이용 목적, 보유 기간, 파기 방법, 책임자 정보 포함
|
||||
- 이용약관: 목적, 서비스 범위, 면책 조항, 저작권, 준거법 포함
|
||||
|
||||
Todo:
|
||||
- [x] Privacy.cshtml + Privacy.cshtml.cs (Razor Page)
|
||||
- [x] Terms.cshtml + Terms.cshtml.cs (Razor Page)
|
||||
- [x] _Footer.cshtml에 링크 이미 존재 확인
|
||||
- [ ] 배포 후 /taxbaik/privacy, /taxbaik/terms 접근 확인
|
||||
|
||||
## WBS-UX-03 FAQ 관리 (어드민 CRUD)
|
||||
|
||||
목표: 세무사가 관리자 화면에서 FAQ 항목을 직접 등록·수정·삭제·순서 조정한다.
|
||||
홈페이지 FAQ가 하드코딩에서 DB 기반으로 전환되어, 코드 수정 없이 운영 가능해진다.
|
||||
|
||||
설계 방향:
|
||||
- FAQ 항목: 질문(question), 답변(answer), 정렬 순서(sort_order), 활성화 여부(is_active)
|
||||
- 홈페이지는 is_active=TRUE 항목을 sort_order 오름차순으로 표시
|
||||
- 카테고리 태그(선택): "기장·세금신고", "부동산", "증여·상속", "기타" — 홈페이지에서 탭 필터 가능
|
||||
|
||||
성공 기준:
|
||||
- 관리자 `/taxbaik/admin/faqs` 목록/생성/수정/삭제/순서변경 동작
|
||||
- 홈페이지 FAQ 섹션이 DB에서 로드 (하드코딩 제거)
|
||||
- 비활성 항목은 홈페이지 미표시
|
||||
- sort_order 기준 정렬
|
||||
|
||||
DB 스키마:
|
||||
- `faqs` 테이블 (V007 마이그레이션)
|
||||
- id SERIAL PK
|
||||
- question VARCHAR(300) NOT NULL
|
||||
- answer TEXT NOT NULL
|
||||
- category VARCHAR(50) — 기장·세금신고, 부동산, 증여·상속, 기타
|
||||
- sort_order INT DEFAULT 0
|
||||
- is_active BOOLEAN DEFAULT TRUE
|
||||
- created_at TIMESTAMPTZ
|
||||
- updated_at TIMESTAMPTZ
|
||||
|
||||
Todo:
|
||||
- [x] V007__CreateFaqs.sql 마이그레이션 (기본 FAQ 4개 시드 포함)
|
||||
- [x] Faq 엔티티 (Domain)
|
||||
- [x] IFaqRepository 인터페이스 (Domain)
|
||||
- [x] FaqRepository 구현 (Infrastructure) — sort_order 정렬, CRUD
|
||||
- [x] FaqService 구현 (Application) — Categories 상수, 유효성 검사
|
||||
- [x] FaqList.razor 관리자 목록 (활성/비활성 상태 칩, 삭제 확인)
|
||||
- [x] FaqEdit.razor 관리자 등록/수정 (질문/답변/카테고리/순서/활성 토글)
|
||||
- [x] Index.cshtml FAQ 섹션 하드코딩 → DB 루프로 교체 (빈 DB에도 안전)
|
||||
- [x] IndexModel FaqService 주입, Task.WhenAll 병렬 로드
|
||||
- [x] MainLayout.razor FAQ 관리 메뉴 추가 (홈페이지 그룹 하위)
|
||||
- [ ] 배포 후 관리자에서 FAQ 추가 → 홈페이지 반영 확인
|
||||
|
||||
---
|
||||
|
||||
@@ -76,6 +134,27 @@ Todo:
|
||||
- [x] CLAUDE.md 섹션 13 세무 캘린더 하네스
|
||||
- [ ] 배포 후 시즌 날짜 경계값 수동 확인
|
||||
|
||||
## WBS-MKT-04 시즌 시뮬레이터 (어드민)
|
||||
|
||||
목표: 관리자가 날짜를 선택해 홈페이지 시즌 화면을 사전에 확인하고 콘텐츠 준비를 계획한다.
|
||||
|
||||
배경: 7개 시즌이 자동 전환되므로, 실제 날짜가 되기 전 미리 Hero 화면을 확인하는 도구가 필요하다.
|
||||
|
||||
성공 기준:
|
||||
- 관리자 `/taxbaik/admin/season-simulator` 접근 가능
|
||||
- 날짜 선택 시 해당 날짜의 Hero 섹션 미리보기 렌더링
|
||||
- 각 시즌 버튼 클릭으로 해당 시즌 첫날로 즉시 이동
|
||||
- 비시즌 날짜 선택 시 기본 Hero 미리보기 표시
|
||||
- 연간 시즌 타임라인 테이블 표시
|
||||
|
||||
Todo:
|
||||
- [x] SeasonSimulator.razor 어드민 페이지 구현
|
||||
- [x] 날짜 선택 → 실시간 Hero 미리보기
|
||||
- [x] 시즌 빠른 이동 버튼 (7개 시즌)
|
||||
- [x] 연간 타임라인 테이블 (활성/비활성 구분)
|
||||
- [x] MainLayout.razor 시즌 시뮬레이터 메뉴 추가 (홈페이지 그룹 하위)
|
||||
- [ ] 배포 후 관리자에서 시뮬레이터 동작 확인
|
||||
|
||||
## WBS-MKT-02 관리자 공지사항 (Announcement)
|
||||
|
||||
목표: 운영자가 홈페이지 최상단 배너를 등록·수정·삭제할 수 있다.
|
||||
@@ -253,16 +332,17 @@ Todo:
|
||||
- 이력 없는 고객은 빈 목록 표시
|
||||
|
||||
DB 스키마:
|
||||
- `consultations` 테이블 (V007 마이그레이션)
|
||||
- `consultations` 테이블 (V008 마이그레이션)
|
||||
- 컬럼: id, client_id(FK), consultation_date, service_type, summary, result, fee, created_at
|
||||
|
||||
Todo:
|
||||
- [ ] V007__CreateConsultations.sql 마이그레이션
|
||||
- [ ] Consultation 엔티티 (Domain)
|
||||
- [ ] IConsultationRepository 인터페이스 (Domain)
|
||||
- [ ] ConsultationRepository 구현 (Infrastructure)
|
||||
- [ ] ConsultationService 구현 (Application)
|
||||
- [ ] ClientDetail.razor (고객 상세 + 상담 이력 탭)
|
||||
- [x] V008__CreateConsultations.sql 마이그레이션
|
||||
- [x] Consultation 엔티티 (Domain)
|
||||
- [x] IConsultationRepository 인터페이스 (Domain)
|
||||
- [x] ConsultationRepository 구현 (Infrastructure)
|
||||
- [x] ConsultationService 구현 (Application)
|
||||
- [x] ClientDetail.razor (고객 상세 + 상담 이력 추가/삭제)
|
||||
- [x] DI 등록 (Infrastructure + Application)
|
||||
- [ ] 배포 후 고객 상세에서 상담 이력 추가 확인
|
||||
|
||||
## WBS-CRM-03 문의 → 고객 전환 — Phase 1
|
||||
@@ -271,14 +351,18 @@ Todo:
|
||||
|
||||
성공 기준:
|
||||
- 문의 상세에 "고객으로 등록" 버튼 표시
|
||||
- 버튼 클릭 시 이름·연락처 자동 채워진 고객 생성 폼으로 이동
|
||||
- 버튼 클릭 시 고객 카드 자동 생성 후 연결
|
||||
- 이미 연결된 고객이 있으면 버튼 대신 고객 카드 링크 표시
|
||||
- inquiries 테이블에 client_id 컬럼 추가
|
||||
- inquiries 테이블에 client_id, admin_memo, updated_at 컬럼 추가
|
||||
|
||||
Todo:
|
||||
- [ ] inquiries 테이블에 client_id FK 컬럼 추가 (V008 마이그레이션)
|
||||
- [ ] InquiryDetail.razor에 "고객으로 등록" 버튼 추가
|
||||
- [ ] ClientEdit.razor에 inquiry_id 파라미터 지원 (자동 채우기)
|
||||
- [x] V009__AddClientIdToInquiries.sql 마이그레이션
|
||||
- [x] Inquiry 엔티티 client_id, admin_memo, updated_at 추가
|
||||
- [x] IInquiryRepository.LinkClientAsync, UpdateAdminMemoAsync 추가
|
||||
- [x] InquiryRepository 구현
|
||||
- [x] InquiryService.LinkClientAsync, UpdateAdminMemoAsync 추가
|
||||
- [x] ClientService.CreateFromInquiryAsync 추가
|
||||
- [x] InquiryDetail.razor "고객으로 등록" 버튼 + 담당자 메모 추가
|
||||
- [ ] 배포 후 문의 → 고객 전환 흐름 확인
|
||||
|
||||
---
|
||||
@@ -295,14 +379,19 @@ Todo:
|
||||
- 이번 달 마감 목록을 대시보드 위젯으로 표시
|
||||
|
||||
DB 스키마:
|
||||
- `tax_filings` 테이블 (V009 마이그레이션)
|
||||
- `tax_filings` 테이블 (V010 마이그레이션)
|
||||
- 컬럼: id, client_id(FK), filing_type, due_date, status(pending/filed/overdue), memo
|
||||
|
||||
Todo:
|
||||
- [ ] V009__CreateTaxFilings.sql
|
||||
- [ ] TaxFiling 엔티티, Repository, Service
|
||||
- [ ] TaxFilingList.razor (관리자 신고 일정 화면)
|
||||
- [ ] Dashboard.razor에 이번 달 마감 위젯 추가
|
||||
- [x] V010__CreateTaxFilings.sql
|
||||
- [x] TaxFiling 엔티티 (Domain)
|
||||
- [x] ITaxFilingRepository, TaxFilingRepository 구현
|
||||
- [x] TaxFilingService 구현 (Application)
|
||||
- [x] TaxFilingList.razor (관리자 신고 일정 화면 + 상태별 탭)
|
||||
- [x] FilingTable.razor (D-Day 강조, 완료 처리, 삭제)
|
||||
- [x] Dashboard.razor에 30일 이내 마감 위젯 추가
|
||||
- [x] MainLayout.razor 신고 일정 메뉴 추가
|
||||
- [x] DI 등록
|
||||
- [ ] 배포 후 신고 일정 등록 → D-Day 표시 확인
|
||||
|
||||
## WBS-CRM-05 문의 접수 현황 강화 — Phase 2
|
||||
@@ -311,13 +400,16 @@ Todo:
|
||||
|
||||
성공 기준:
|
||||
- 문의 상태: 신규/상담중/계약완료/거절/종결 5단계
|
||||
- 목록에서 상태 칩 필터로 빠른 분류
|
||||
- 상태 변경 시 변경 일시 자동 기록
|
||||
- 목록에서 상태 탭 필터로 빠른 분류
|
||||
- 상태 변경 시 updated_at 자동 기록
|
||||
|
||||
Todo:
|
||||
- [ ] inquiries.status 컬럼 확장 (V010 마이그레이션)
|
||||
- [ ] InquiryList.razor 상태 필터 추가
|
||||
- [ ] InquiryDetail.razor 상태 변경 버튼 추가
|
||||
- [x] V011__ExtendInquiryStatus.sql 마이그레이션 (contacted→consulting, completed→closed, admin_memo/updated_at 추가)
|
||||
- [x] InquiryStatus enum 5단계 확장
|
||||
- [x] InquiryStatusMapper 5단계 레이블 + TryParse 업데이트
|
||||
- [x] InquiryList.razor 5단계 탭 (신규/상담중/계약완료/거절/종결)
|
||||
- [x] InquiryDetail.razor 5단계 상태 버튼 + 색상 구분
|
||||
- [x] Dashboard.razor 상태 레이블 5단계 반영
|
||||
|
||||
---
|
||||
|
||||
@@ -333,9 +425,9 @@ Todo:
|
||||
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
|
||||
|
||||
Todo:
|
||||
- [ ] BackgroundService 또는 Hangfire 기반 스케줄러 추가
|
||||
- [ ] 일간/주간 리포트 메시지 템플릿
|
||||
- [ ] TelegramNotificationService에 리포트 메서드 추가
|
||||
- [x] BackgroundService 또는 Hangfire 기반 스케줄러 추가
|
||||
- [x] 일간/주간 리포트 메시지 템플릿
|
||||
- [x] TelegramNotificationService에 리포트 메서드 추가
|
||||
|
||||
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
|
||||
|
||||
@@ -347,9 +439,9 @@ Todo:
|
||||
- 개인정보 열람 범위는 세무사가 허용한 항목만
|
||||
|
||||
Todo:
|
||||
- [ ] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
|
||||
- [ ] 고객 전용 Razor Pages 추가
|
||||
- [ ] 세무사 허용 권한 설정 UI
|
||||
- [x] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
|
||||
- [x] 고객 전용 Razor Pages 추가
|
||||
- [x] 세무사 허용 권한 설정 UI
|
||||
|
||||
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
|
||||
|
||||
@@ -393,16 +485,16 @@ DB 스키마:
|
||||
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
|
||||
|
||||
Todo:
|
||||
- [ ] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
|
||||
- [ ] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
|
||||
- [ ] V011__CreatePortalUsers.sql 마이그레이션
|
||||
- [ ] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
|
||||
- [ ] 네이버 OAuth Handler 구현
|
||||
- [ ] 카카오·구글 패키지 추가 및 설정
|
||||
- [ ] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
|
||||
- [ ] 소셜 로그인 콜백 처리 → portal_users 자동 생성
|
||||
- [ ] 신규 가입 시 clients 테이블 연결 또는 신규 생성
|
||||
- [ ] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
|
||||
- [x] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
|
||||
- [x] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
|
||||
- [x] V011__CreatePortalUsers.sql 마이그레이션 (실제 V016__CreatePortalUsers.sql로 대체됨)
|
||||
- [x] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
|
||||
- [x] 네이버 OAuth Handler 구현
|
||||
- [x] 카카오·구글 패키지 추가 및 설정
|
||||
- [x] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
|
||||
- [x] 소셜 로그인 콜백 처리 → portal_users 자동 생성
|
||||
- [x] 신규 가입 시 clients 테이블 연결 또는 신규 생성
|
||||
- [x] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
|
||||
- [ ] Gitea Secrets에 OAuth 키 추가
|
||||
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트
|
||||
|
||||
@@ -425,7 +517,51 @@ Todo:
|
||||
### 현재 검증 메모
|
||||
|
||||
- `dotnet build TaxBaik.sln` 성공 (2026-06-27 기준, 경고 0 오류 0)
|
||||
- 배포 커밋: `77a5c44` (FAQ 섹션 추가, 푸시 대기 중)
|
||||
- WBS-MKT-01/02/03 구현 완료, 배포 후 시각 검증 필요
|
||||
- WBS-CRM-01 구현 중 (Phase 1 고객 카드)
|
||||
- WBS-CRM-02/03 Phase 1 구현 예정 (고객 카드 완료 후 순차 진행)
|
||||
- 최종 배포 커밋: `9c96f15` (FAQ 관리 기능)
|
||||
- WBS-MKT-01/02/03/04 구현 완료, 배포 후 시각 검증 필요
|
||||
- WBS-UX-03/04 구현 완료
|
||||
- WBS-CRM-01/02/03/04/05 구현 완료 (배포 후 검증 필요)
|
||||
- WBS-CRM-06/07/08 (텔레그램·포털·소셜 로그인) Phase 3 미착수
|
||||
|
||||
---
|
||||
|
||||
## ── 홈페이지 · 어드민 · 포털 프리미엄 UX/UI 개편 (2026-06-30) ──────────────────
|
||||
|
||||
## WBS-UX-05 홈페이지 프리미엄 UI 및 마이크로 인터랙션
|
||||
|
||||
목표: 홈페이지 디자인을 극도로 모던하고 신뢰성 있는 프리미엄 스타일로 전면 개편한다.
|
||||
|
||||
성공 기준:
|
||||
- Hero 섹션에 유려한 배경 그라데이션 및 부드러운 CSS 애니메이션 효과 적용
|
||||
- 서비스 카드에 섀도우 및 보더 트랜지션, 골드/그린 그라데이션 호버 이펙트 추가
|
||||
- 신뢰도 스트립 카드에 입체감 및 돋보이는 레이아웃 설계
|
||||
- Noto Sans KR 외에 Outfit/Inter 등의 보조 영문 폰트 결합으로 타이포그래피 고급화
|
||||
|
||||
Todo:
|
||||
- [x] `site.css` 내 Hero 섹션 그라데이션 및 CSS 애니메이션 보강
|
||||
- [x] 서비스 카드 및 신뢰도 스트립 컴포넌트 프리미엄 스타일로 개편
|
||||
- [x] 홈페이지 폰트 스택 확장 및 메인 레이아웃 적용
|
||||
|
||||
## WBS-PORTAL-01 고객 포털 UI/UX 고도화 및 글래스모피즘
|
||||
|
||||
목표: 고객 마이 포털 화면을 미려하고 현대적인 글래스모피즘 디자인으로 개편하여 이용 가치를 극대화한다.
|
||||
|
||||
성공 기준:
|
||||
- 포털 메인 대시보드 카드를 Glassmorphism 스타일(blur, semi-transparent border)로 변경
|
||||
- 세무 신고 현황 테이블 및 상담 이력 타임라인 컴포넌트의 모던 디자인화
|
||||
|
||||
Todo:
|
||||
- [x] `site.css` 내 포털 전용 모던 글래스모피즘 클래스군 추가
|
||||
- [x] `Portal/Index.cshtml` 레이아웃 및 컴포넌트 UI 고도화
|
||||
|
||||
## WBS-MAINT-02 코드 품질 및 경고 결함 차단
|
||||
|
||||
목표: 빌드 컴파일 타임 경고(Warnings)를 0으로 유지하여 미래 코드 결함을 방지한다.
|
||||
|
||||
성공 기준:
|
||||
- `dotnet build` 수행 시 경고 0개 달성
|
||||
|
||||
Todo:
|
||||
- [x] `CustomAuthenticationStateProvider.cs` Nullable 경고 수정
|
||||
- [x] `Dashboard.razor` 미사용 변수 제거 및 UI 연계 바인딩 처리
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace TaxBaik.Application.Tests;
|
||||
|
||||
using TaxBaik.Web.Components.Admin.Shared;
|
||||
using Xunit;
|
||||
|
||||
public class BusinessDayCalculatorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(2026, 2, 14, 2026, 2, 19)]
|
||||
[InlineData(2026, 8, 15, 2026, 8, 20)]
|
||||
[InlineData(2026, 9, 24, 2026, 9, 29)]
|
||||
[InlineData(2026, 10, 3, 2026, 10, 8)]
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,12 @@ public class InquiryServiceTests
|
||||
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);
|
||||
@@ -65,6 +71,30 @@ public class InquiryServiceTests
|
||||
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 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
|
||||
|
||||
@@ -18,5 +18,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
|
||||
<ProjectReference Include="..\TaxBaik.Web\TaxBaik.Web.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -16,6 +16,18 @@ public static class DependencyInjection
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,4 +40,48 @@ public class AdminDashboardService(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ 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);
|
||||
|
||||
|
||||
@@ -22,6 +22,15 @@ public class ClientService(IClientRepository repository)
|
||||
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))
|
||||
@@ -64,6 +73,19 @@ public class ClientService(IClientRepository repository)
|
||||
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,48 @@
|
||||
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)
|
||||
{
|
||||
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(codeGroup.Trim(), codeValue.Trim(), ct);
|
||||
}
|
||||
|
||||
private static void Normalize(CommonCode code)
|
||||
{
|
||||
code.CodeGroup = code.CodeGroup.Trim();
|
||||
code.CodeValue = code.CodeValue.Trim();
|
||||
code.CodeName = code.CodeName.Trim();
|
||||
}
|
||||
}
|
||||
@@ -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("답변을 입력하세요.");
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public class InquiryService(
|
||||
|
||||
public async Task<int> SubmitAsync(
|
||||
string name, string phone, string serviceType, string message,
|
||||
string? email = null, string? ipAddress = null, CancellationToken ct = default)
|
||||
string? email = null, string? ipAddress = null, bool suppressNotification = false, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ValidationException("이름을 입력하세요.");
|
||||
@@ -39,7 +39,10 @@ public class InquiryService(
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -60,6 +63,18 @@ public class InquiryService(
|
||||
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 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))
|
||||
@@ -77,6 +92,12 @@ public class InquiryService(
|
||||
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);
|
||||
|
||||
@@ -4,24 +4,37 @@ 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.Contacted => "contacted",
|
||||
InquiryStatus.Completed => "completed",
|
||||
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)
|
||||
{
|
||||
status = value?.Trim().ToLowerInvariant() switch
|
||||
var key = value?.Trim().ToLowerInvariant();
|
||||
status = key switch
|
||||
{
|
||||
"new" => InquiryStatus.New,
|
||||
"contacted" => InquiryStatus.Contacted,
|
||||
"completed" => InquiryStatus.Completed,
|
||||
"consulting" => InquiryStatus.Consulting,
|
||||
"contracted" => InquiryStatus.Contracted,
|
||||
"rejected" => InquiryStatus.Rejected,
|
||||
"closed" => InquiryStatus.Closed,
|
||||
_ => default
|
||||
};
|
||||
|
||||
return value?.Trim().ToLowerInvariant() is "new" or "contacted" or "completed";
|
||||
return key is "new" or "consulting" or "contracted" or "rejected" or "closed";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,50 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class TaxFilingService(ITaxFilingRepository repository)
|
||||
{
|
||||
public static readonly string[] FilingTypes =
|
||||
["부가가치세", "종합소득세", "법인세", "원천징수", "종합부동산세", "증여세", "상속세", "기타"];
|
||||
|
||||
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>";
|
||||
}
|
||||
@@ -3,15 +3,28 @@ namespace TaxBaik.Domain.Entities;
|
||||
public class Client
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
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; }
|
||||
}
|
||||
@@ -10,5 +10,8 @@ public class Inquiry
|
||||
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,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; }
|
||||
}
|
||||
@@ -3,6 +3,8 @@ namespace TaxBaik.Domain.Enums;
|
||||
public enum InquiryStatus
|
||||
{
|
||||
New = 0,
|
||||
Contacted = 1,
|
||||
Completed = 2
|
||||
Consulting = 1,
|
||||
Contracted = 2,
|
||||
Rejected = 3,
|
||||
Closed = 4
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ public interface IClientRepository
|
||||
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,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);
|
||||
}
|
||||
@@ -11,5 +11,10 @@ public interface IInquiryRepository
|
||||
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 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,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);
|
||||
}
|
||||
@@ -17,6 +17,17 @@ public static class DependencyInjection
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,33 @@ public class ClientRepository(IDbConnectionFactory connectionFactory) : BaseRepo
|
||||
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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,9 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Inquiry>(
|
||||
"SELECT id, name, phone, email, service_type, message, status, ip_address, created_at FROM inquiries WHERE id = @Id",
|
||||
@"SELECT id, name, phone, email, service_type, message, status, ip_address,
|
||||
client_id, admin_memo, created_at, updated_at
|
||||
FROM inquiries WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
@@ -31,7 +33,8 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
using var reader = await conn.QueryMultipleAsync(
|
||||
@"SELECT id, name, phone, email, service_type, message, status, ip_address, created_at
|
||||
@"SELECT id, name, phone, email, service_type, message, status, ip_address,
|
||||
client_id, admin_memo, created_at, updated_at
|
||||
FROM inquiries
|
||||
WHERE @Status::text IS NULL OR status = @Status
|
||||
ORDER BY created_at DESC
|
||||
@@ -71,9 +74,55 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep
|
||||
new { Status = status });
|
||||
}
|
||||
|
||||
public async Task<int> CountByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.ExecuteScalarAsync<int>(
|
||||
@"SELECT COUNT(*)
|
||||
FROM inquiries
|
||||
WHERE created_at >= @StartDate AND created_at <= @EndDate",
|
||||
new { StartDate = startDate, EndDate = endDate });
|
||||
}
|
||||
|
||||
public async Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.ExecuteScalarAsync<int>(
|
||||
@"SELECT COUNT(*)
|
||||
FROM inquiries
|
||||
WHERE status = @Status
|
||||
AND created_at >= @StartDate
|
||||
AND created_at <= @EndDate",
|
||||
new { Status = status, StartDate = startDate, EndDate = endDate });
|
||||
}
|
||||
|
||||
public async Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync("UPDATE inquiries SET status = @Status WHERE id = @Id", new { Id = id, Status = status });
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE inquiries SET status = @Status, updated_at = NOW() WHERE id = @Id",
|
||||
new { Id = id, Status = status });
|
||||
}
|
||||
|
||||
public async Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE inquiries SET admin_memo = @AdminMemo, updated_at = NOW() WHERE id = @Id",
|
||||
new { Id = id, AdminMemo = adminMemo });
|
||||
}
|
||||
|
||||
public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE inquiries SET client_id = @ClientId, updated_at = NOW() WHERE id = @Id",
|
||||
new { Id = inquiryId, ClientId = clientId });
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync("DELETE FROM inquiries WHERE id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class PortalUserRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IPortalUserRepository
|
||||
{
|
||||
public async Task<PortalUser?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<PortalUser>(
|
||||
@"SELECT id, client_id, email, name, phone, provider, provider_id, password_hash, created_at
|
||||
FROM portal_users
|
||||
WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<PortalUser?> GetByEmailAsync(string email, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<PortalUser>(
|
||||
@"SELECT id, client_id, email, name, phone, provider, provider_id, password_hash, created_at
|
||||
FROM portal_users
|
||||
WHERE email = @Email",
|
||||
new { Email = email });
|
||||
}
|
||||
|
||||
public async Task<PortalUser?> GetByProviderAsync(string provider, string providerId, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<PortalUser>(
|
||||
@"SELECT id, client_id, email, name, phone, provider, provider_id, password_hash, created_at
|
||||
FROM portal_users
|
||||
WHERE provider = @Provider AND provider_id = @ProviderId",
|
||||
new { Provider = provider, ProviderId = providerId });
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(PortalUser user, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO portal_users (client_id, email, name, phone, provider, provider_id, password_hash, created_at)
|
||||
VALUES (@ClientId, @Email, @Name, @Phone, @Provider, @ProviderId, @PasswordHash, NOW())
|
||||
RETURNING id",
|
||||
user);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(PortalUser user, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE portal_users
|
||||
SET client_id = @ClientId,
|
||||
email = @Email,
|
||||
name = @Name,
|
||||
phone = @Phone,
|
||||
provider = @Provider,
|
||||
provider_id = @ProviderId,
|
||||
password_hash = @PasswordHash
|
||||
WHERE id = @Id",
|
||||
user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class RevenueTrackingRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IRevenueTrackingRepository
|
||||
{
|
||||
public async Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO revenue_tracking (client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at)
|
||||
VALUES (@ClientId, @InvoiceNumber, @InvoiceDate, @ServiceType, @Amount, @PaymentStatus, @PaymentDate, @DueDate, @Notes, NOW(), NOW())
|
||||
RETURNING id",
|
||||
revenue);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<RevenueTracking>(
|
||||
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
|
||||
FROM revenue_tracking ORDER BY invoice_date DESC");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<RevenueTracking>(
|
||||
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
|
||||
FROM revenue_tracking WHERE client_id = @ClientId ORDER BY invoice_date DESC",
|
||||
new { ClientId = clientId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<RevenueTracking>(
|
||||
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
|
||||
FROM revenue_tracking WHERE payment_status = 'pending' ORDER BY due_date ASC");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<RevenueTracking>(
|
||||
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
|
||||
FROM revenue_tracking WHERE invoice_date BETWEEN @StartDate AND @EndDate ORDER BY invoice_date DESC",
|
||||
new { StartDate = startDate, EndDate = endDate });
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE revenue_tracking SET invoice_number = @InvoiceNumber, invoice_date = @InvoiceDate,
|
||||
service_type = @ServiceType, amount = @Amount, payment_status = @PaymentStatus,
|
||||
payment_date = @PaymentDate, due_date = @DueDate, notes = @Notes, updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
revenue);
|
||||
}
|
||||
|
||||
public async Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE revenue_tracking SET payment_status = 'paid', payment_date = @PaymentDate, updated_at = NOW() WHERE id = @Id",
|
||||
new { Id = id, PaymentDate = paymentDate });
|
||||
}
|
||||
|
||||
public async Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
var result = await conn.QueryFirstAsync<decimal>(
|
||||
@"SELECT COALESCE(SUM(amount), 0) FROM revenue_tracking WHERE invoice_date BETWEEN @StartDate AND @EndDate",
|
||||
new { StartDate = startDate, EndDate = endDate });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class TaxFilingRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ITaxFilingRepository
|
||||
{
|
||||
private const string SelectColumns = @"
|
||||
tf.id, tf.client_id, c.name AS client_name, tf.filing_type, tf.due_date,
|
||||
tf.status, tf.memo, tf.created_at, tf.updated_at";
|
||||
|
||||
public async Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<TaxFiling>(
|
||||
$@"SELECT {SelectColumns}
|
||||
FROM tax_filings tf
|
||||
JOIN clients c ON c.id = tf.client_id
|
||||
WHERE tf.client_id = @ClientId
|
||||
ORDER BY tf.due_date ASC",
|
||||
new { ClientId = clientId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<TaxFiling>(
|
||||
$@"SELECT {SelectColumns}
|
||||
FROM tax_filings tf
|
||||
JOIN clients c ON c.id = tf.client_id
|
||||
WHERE tf.status = 'pending'
|
||||
AND tf.due_date <= CURRENT_DATE + @DaysAhead::int
|
||||
AND tf.due_date >= CURRENT_DATE
|
||||
ORDER BY tf.due_date ASC",
|
||||
new { DaysAhead = daysAhead });
|
||||
}
|
||||
|
||||
public async Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<TaxFiling>(
|
||||
$@"SELECT {SelectColumns}
|
||||
FROM tax_filings tf
|
||||
JOIN clients c ON c.id = tf.client_id
|
||||
WHERE tf.id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(TaxFiling filing, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO tax_filings (client_id, filing_type, due_date, status, memo, created_at, updated_at)
|
||||
VALUES (@ClientId, @FilingType, @DueDate, @Status, @Memo, NOW(), NOW())
|
||||
RETURNING id",
|
||||
filing);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(TaxFiling filing, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE tax_filings
|
||||
SET filing_type = @FilingType, due_date = @DueDate, status = @Status,
|
||||
memo = @Memo, updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
filing);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync("DELETE FROM tax_filings WHERE id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class TaxFilingScheduleRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ITaxFilingScheduleRepository
|
||||
{
|
||||
public async Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO tax_filing_schedules (client_id, filing_type, due_date, filing_year, status, assigned_to, notes, created_at, updated_at)
|
||||
VALUES (@ClientId, @FilingType, @DueDate, @FilingYear, @Status, @AssignedToId, @Notes, NOW(), NOW())
|
||||
RETURNING id",
|
||||
schedule);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<TaxFilingSchedule>(
|
||||
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
|
||||
FROM tax_filing_schedules ORDER BY due_date DESC");
|
||||
}
|
||||
|
||||
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<TaxFilingSchedule>(
|
||||
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
|
||||
FROM tax_filing_schedules WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<TaxFilingSchedule>(
|
||||
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
|
||||
FROM tax_filing_schedules WHERE client_id = @ClientId ORDER BY due_date DESC",
|
||||
new { ClientId = clientId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<TaxFilingSchedule>(
|
||||
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
|
||||
FROM tax_filing_schedules
|
||||
WHERE status = 'pending' AND due_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '1 day' * @DaysAhead
|
||||
ORDER BY due_date ASC",
|
||||
new { DaysAhead = daysAhead });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetByStatusAsync(string status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<TaxFilingSchedule>(
|
||||
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
|
||||
FROM tax_filing_schedules WHERE status = @Status ORDER BY due_date",
|
||||
new { Status = status });
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE tax_filing_schedules SET filing_type = @FilingType, due_date = @DueDate, status = @Status,
|
||||
assigned_to = @AssignedToId, notes = @Notes, updated_at = NOW() WHERE id = @Id",
|
||||
schedule);
|
||||
}
|
||||
|
||||
public async Task MarkCompletedAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE tax_filing_schedules SET status = 'completed', completed_date = NOW(), updated_at = NOW() WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class TaxProfileRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ITaxProfileRepository
|
||||
{
|
||||
public async Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO tax_profiles (client_id, business_registration, business_type, establishment_date,
|
||||
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
|
||||
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at)
|
||||
VALUES (@ClientId, @BusinessRegistration, @BusinessType, @EstablishmentDate, @AnnualRevenueRange,
|
||||
@EmployeeCount, @AccountingMethod, @FiscalYearEnd, @LastFilingDate, @NextFilingDueDate,
|
||||
@TaxRiskLevel, @PreviousAuditHistory, @SpecialNotes, NOW(), NOW())
|
||||
RETURNING id",
|
||||
profile);
|
||||
}
|
||||
|
||||
public async Task<TaxProfile?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<TaxProfile>(
|
||||
@"SELECT id, client_id, business_registration, business_type, establishment_date,
|
||||
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
|
||||
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
|
||||
FROM tax_profiles WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<TaxProfile>(
|
||||
@"SELECT id, client_id, business_registration, business_type, establishment_date,
|
||||
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
|
||||
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
|
||||
FROM tax_profiles ORDER BY id DESC");
|
||||
}
|
||||
|
||||
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<TaxProfile>(
|
||||
@"SELECT id, client_id, business_registration, business_type, establishment_date,
|
||||
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
|
||||
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
|
||||
FROM tax_profiles WHERE client_id = @ClientId",
|
||||
new { ClientId = clientId });
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE tax_profiles SET business_registration = @BusinessRegistration, business_type = @BusinessType,
|
||||
establishment_date = @EstablishmentDate, annual_revenue_range = @AnnualRevenueRange,
|
||||
employee_count = @EmployeeCount, accounting_method = @AccountingMethod, fiscal_year_end = @FiscalYearEnd,
|
||||
last_filing_date = @LastFilingDate, next_filing_due_date = @NextFilingDueDate,
|
||||
tax_risk_level = @TaxRiskLevel, previous_audit_history = @PreviousAuditHistory,
|
||||
special_notes = @SpecialNotes, updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
profile);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<TaxProfile>(
|
||||
@"SELECT id, client_id, business_registration, business_type, establishment_date,
|
||||
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
|
||||
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
|
||||
FROM tax_profiles WHERE tax_risk_level = @RiskLevel ORDER BY client_id",
|
||||
new { RiskLevel = riskLevel });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxProfile>> GetUpcomingFilingDuesAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<TaxProfile>(
|
||||
@"SELECT id, client_id, business_registration, business_type, establishment_date,
|
||||
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
|
||||
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
|
||||
FROM tax_profiles WHERE next_filing_due_date BETWEEN @StartDate AND @EndDate
|
||||
ORDER BY next_filing_due_date",
|
||||
new { StartDate = startDate, EndDate = endDate });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
class Program
|
||||
{
|
||||
private const string PortFile = "/home/kjh2064/taxbaik_port";
|
||||
private static int _fallbackPort = 5003;
|
||||
|
||||
static async Task Main(string[] args)
|
||||
{
|
||||
// Allow setting fallback port via args
|
||||
if (args.Length > 0 && int.TryParse(args[0], out var port))
|
||||
{
|
||||
_fallbackPort = port;
|
||||
}
|
||||
|
||||
var listener = new TcpListener(IPAddress.Loopback, 5001);
|
||||
listener.Start();
|
||||
Console.WriteLine($"[TaxBaik Proxy] Listening on 127.0.0.1:5001 (Forwarding to target in {PortFile})");
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync();
|
||||
_ = HandleClientAsync(client);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[TaxBaik Proxy] Accept error: {ex.Message}");
|
||||
await Task.Delay(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetTargetPort()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(PortFile))
|
||||
{
|
||||
var content = File.ReadAllText(PortFile).Trim();
|
||||
if (int.TryParse(content, out var port) && port > 1024 && port < 65535)
|
||||
{
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return _fallbackPort;
|
||||
}
|
||||
|
||||
private static async Task HandleClientAsync(TcpClient client)
|
||||
{
|
||||
client.NoDelay = true;
|
||||
int targetPort = GetTargetPort();
|
||||
using var backend = new TcpClient();
|
||||
backend.NoDelay = true;
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await backend.ConnectAsync(IPAddress.Loopback, targetPort, cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[TaxBaik Proxy] Failed to connect to backend on port {targetPort}: {ex.Message}");
|
||||
client.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var clientStream = client.GetStream();
|
||||
using var backendStream = backend.GetStream();
|
||||
|
||||
var toBackend = clientStream.CopyToAsync(backendStream);
|
||||
var toClient = backendStream.CopyToAsync(clientStream);
|
||||
|
||||
await Task.WhenAny(toBackend, toClient);
|
||||
}
|
||||
catch { }
|
||||
finally
|
||||
{
|
||||
client.Close();
|
||||
backend.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"runtimeOptions": {
|
||||
"tfm": "net10.0",
|
||||
"includedFrameworks": [
|
||||
{
|
||||
"name": "Microsoft.NETCore.App",
|
||||
"version": "10.0.0"
|
||||
}
|
||||
],
|
||||
"wasmHostProperties": {
|
||||
"perHostConfig": [
|
||||
{
|
||||
"name": "browser",
|
||||
"host": "browser"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configProperties": {
|
||||
"Microsoft.AspNetCore.Components.Routing.RegexConstraintSupport": false,
|
||||
"Microsoft.Extensions.DependencyInjection.VerifyOpenGenericServiceTrimmability": true,
|
||||
"System.ComponentModel.DefaultValueAttribute.IsSupported": false,
|
||||
"System.ComponentModel.Design.IDesignerHost.IsSupported": false,
|
||||
"System.ComponentModel.TypeConverter.EnableUnsafeBinaryFormatterInDesigntimeLicenseContextSerialization": false,
|
||||
"System.ComponentModel.TypeDescriptor.IsComObjectDescriptorSupported": false,
|
||||
"System.Data.DataSet.XmlSerializationIsSupported": false,
|
||||
"System.Diagnostics.Debugger.IsSupported": false,
|
||||
"System.Diagnostics.Metrics.Meter.IsSupported": false,
|
||||
"System.Diagnostics.Tracing.EventSource.IsSupported": false,
|
||||
"System.GC.Server": true,
|
||||
"System.Globalization.Invariant": false,
|
||||
"System.TimeZoneInfo.Invariant": false,
|
||||
"System.Linq.Enumerable.IsSizeOptimized": true,
|
||||
"System.Net.Http.EnableActivityPropagation": false,
|
||||
"System.Net.Http.WasmEnableStreamingResponse": true,
|
||||
"System.Net.SocketsHttpHandler.Http3Support": false,
|
||||
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
|
||||
"System.Resources.ResourceManager.AllowCustomResourceTypes": false,
|
||||
"System.Resources.UseSystemResourceKeys": true,
|
||||
"System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported": true,
|
||||
"System.Runtime.InteropServices.BuiltInComInterop.IsSupported": false,
|
||||
"System.Runtime.InteropServices.EnableConsumingManagedCodeFromNativeHosting": false,
|
||||
"System.Runtime.InteropServices.EnableCppCLIHostActivation": false,
|
||||
"System.Runtime.InteropServices.Marshalling.EnableGeneratedComInterfaceComImportInterop": false,
|
||||
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false,
|
||||
"System.StartupHookProvider.IsSupported": false,
|
||||
"System.Text.Encoding.EnableUnsafeUTF7Encoding": false,
|
||||
"System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": true,
|
||||
"System.Threading.Thread.EnableAutoreleasePool": false,
|
||||
"Microsoft.AspNetCore.Components.Endpoints.NavigationManager.DisableThrowNavigationException": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
global using System.Net.Http;
|
||||
global using System.Net.Http.Json;
|
||||
@@ -0,0 +1,13 @@
|
||||
@* WASM 기반(M3) 검증용 컴포넌트. 라우팅/렌더모드 전면 적용은 M4에서 처리한다. *@
|
||||
@rendermode InteractiveWebAssembly
|
||||
|
||||
<MudPaper Class="pa-6 ma-4" Elevation="2">
|
||||
<MudText Typo="Typo.h5" GutterBottom="true">WebAssembly 렌더 모드 점검</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-4">이 컴포넌트가 클릭에 반응하면 Interactive WebAssembly 기반이 정상 동작하는 것입니다.</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Increment">카운트: @count</MudButton>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private int count;
|
||||
private void Increment() => count++;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using MudBlazor.Services;
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Web.Services;
|
||||
using TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
// MudBlazor (WASM 측 인터랙티브 컴포넌트용)
|
||||
builder.Services.AddMudServices(config =>
|
||||
{
|
||||
config.SnackbarConfiguration.HideTransitionDuration = 400;
|
||||
config.SnackbarConfiguration.ShowTransitionDuration = 300;
|
||||
config.PopoverOptions.ThrowOnDuplicateProvider = false;
|
||||
});
|
||||
|
||||
// API Base Url 동적 구성 (호스트 기준 /taxbaik/api/)
|
||||
var apiBaseUrl = builder.HostEnvironment.BaseAddress.TrimEnd('/') + "/taxbaik/api/";
|
||||
|
||||
// HTTP Client for API (with automatic token refresh)
|
||||
builder.Services.AddScoped<ITokenStore, TokenStore>();
|
||||
builder.Services.AddScoped<TokenRefreshHandler>();
|
||||
|
||||
builder.Services.AddHttpClient<IApiClient, ApiClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
}).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
|
||||
// 각 Browser API Client 등록
|
||||
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<ICommonCodeBrowserClient, CommonCodeBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
|
||||
// Blazor 인증 (WASM 측 클라이언트)
|
||||
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<CustomAuthenticationStateProvider>());
|
||||
builder.Services.AddScoped<ILocalStorageService, LocalStorageService>();
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddAuthorizationCore();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -0,0 +1,118 @@
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
public interface ICommonCodeBrowserClient
|
||||
{
|
||||
Task<List<string>> GetGroupsAsync(CancellationToken ct = default);
|
||||
Task<List<CommonCode>> GetAllActiveAsync(CancellationToken ct = default);
|
||||
Task<List<CommonCode>> GetByGroupAsync(string group, CancellationToken ct = default);
|
||||
Task<CommonCode?> GetAsync(string group, string value, CancellationToken ct = default);
|
||||
Task<bool> UpsertAsync(CommonCode code, CancellationToken ct = default);
|
||||
Task<bool> DeleteAsync(string group, string value, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class CommonCodeBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<CommonCodeBrowserClient> logger) : ICommonCodeBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/commoncode";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<CommonCode>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get all active common codes");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<CommonCode>> GetByGroupAsync(string group, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<CommonCode>>($"{BaseUrl}/group/{group}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get common codes for group {Group}", group);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetGroupsAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<string>>($"{BaseUrl}/groups", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get common code groups");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CommonCode?> GetAsync(string group, string value, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<CommonCode>($"{BaseUrl}/{group}/{value}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get common code {Group}/{Value}", group, value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpsertAsync(CommonCode code, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, code, ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to upsert common code {Group}/{Value}", code.CodeGroup, code.CodeValue);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string group, string value, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{group}/{value}", ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete common code {Group}/{Value}", group, value);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IConsultingActivityBrowserClient
|
||||
{
|
||||
Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<List<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||
Task<List<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default);
|
||||
Task<int> CreateAsync(int clientId, string activityType, DateTime activityDate, string description,
|
||||
int? consultantId = null, DateTime? nextFollowupDate = null, CancellationToken ct = default);
|
||||
Task UpdateAsync(int id, string? outcome = null, DateTime? nextFollowupDate = null, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ConsultingActivityBrowserClient> logger)
|
||||
: IConsultingActivityBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/consultingactivity";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get consulting activities");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get activities for client {ClientId}", clientId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending-followups", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<ConsultingActivity>>(data.GetRawText()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get pending followups");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(int clientId, string activityType, DateTime activityDate, string description,
|
||||
int? consultantId = null, DateTime? nextFollowupDate = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, activityType, activityDate, description, consultantId, nextFollowupDate };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create consulting activity");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(int id, string? outcome = null, DateTime? nextFollowupDate = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { outcome, nextFollowupDate };
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to update consulting activity {Id}", id);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete consulting activity {Id}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IContractBrowserClient
|
||||
{
|
||||
Task<List<Contract>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<List<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||
Task<List<Contract>> GetActiveContractsAsync(CancellationToken ct = default);
|
||||
Task<List<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken ct = default);
|
||||
Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken ct = default);
|
||||
Task<int> CreateAsync(int clientId, string contractNumber, string serviceType, DateTime startDate,
|
||||
decimal? monthlyFee = null, decimal? totalAmount = null, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ContractBrowserClient> logger)
|
||||
: IContractBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/contract";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<Contract>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get contracts");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<Contract>($"{BaseUrl}/{id}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get contract {Id}", id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get contracts for client {ClientId}", clientId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Contract>> GetActiveContractsAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/active", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get active contracts");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/expiring?daysAhead={daysAhead}", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get expiring contracts");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/mrr", ct);
|
||||
if (response.TryGetProperty("mrr", out var mrrValue))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<decimal>(mrrValue.GetRawText());
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get MRR");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(int clientId, string contractNumber, string serviceType, DateTime startDate,
|
||||
decimal? monthlyFee = null, decimal? totalAmount = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, contractNumber, serviceType, startDate, monthlyFee, totalAmount };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create contract");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete contract {Id}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IRevenueTrackingBrowserClient
|
||||
{
|
||||
Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<List<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||
Task<List<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default);
|
||||
Task<List<RevenueTracking>> GetMonthlyRevenueAsync(int year, int month, CancellationToken ct = default);
|
||||
Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default);
|
||||
Task<int> CreateAsync(int clientId, string invoiceNumber, DateTime invoiceDate, decimal amount,
|
||||
string? serviceType = null, DateTime? dueDate = null, CancellationToken ct = default);
|
||||
Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<RevenueTrackingBrowserClient> logger)
|
||||
: IRevenueTrackingBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/revenuetracking";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get revenue tracking");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get revenue for client {ClientId}", clientId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get pending payments");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<RevenueTracking>> GetMonthlyRevenueAsync(int year, int month, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/monthly?year={year}&month={month}", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get monthly revenue {Year}-{Month}", year, month);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>(
|
||||
$"{BaseUrl}/total?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}", ct);
|
||||
if (response.TryGetProperty("total", out var totalValue))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<decimal>(totalValue.GetRawText());
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get total revenue");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(int clientId, string invoiceNumber, DateTime invoiceDate, decimal amount,
|
||||
string? serviceType = null, DateTime? dueDate = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, invoiceNumber, invoiceDate, amount, serviceType, dueDate };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create revenue tracking");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { paymentDate };
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/paid", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to mark payment {Id}", id);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete revenue tracking {Id}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface ITaxFilingScheduleBrowserClient
|
||||
{
|
||||
Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<List<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||
Task<List<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken ct = default);
|
||||
Task<int> CreateAsync(int clientId, string filingType, DateTime dueDate, int filingYear,
|
||||
int? assignedTo = null, CancellationToken ct = default);
|
||||
Task MarkCompletedAsync(int id, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxFilingScheduleBrowserClient> logger)
|
||||
: ITaxFilingScheduleBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/taxfilingschedule";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get tax filing schedules");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<TaxFilingSchedule>($"{BaseUrl}/{id}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get tax filing schedule {Id}", id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get filing schedules for client {ClientId}", clientId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming?daysAhead={daysAhead}", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<TaxFilingSchedule>>(data.GetRawText()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get upcoming filings");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(int clientId, string filingType, DateTime dueDate, int filingYear,
|
||||
int? assignedTo = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, filingType, dueDate, filingYear, assignedTo };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create tax filing schedule");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkCompletedAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/complete", new { }, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to mark filing as completed {Id}", id);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete tax filing schedule {Id}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface ITaxProfileBrowserClient
|
||||
{
|
||||
Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<TaxProfile?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<List<TaxProfile>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||
Task<List<TaxProfile>> GetHighRiskProfilesAsync(CancellationToken ct = default);
|
||||
Task<List<TaxProfile>> GetUpcomingFilingDuesAsync(int daysAhead = 30, CancellationToken ct = default);
|
||||
Task<int> CreateAsync(int clientId, string businessType, string? businessRegistration = null,
|
||||
string? accountingMethod = null, DateTime? establishmentDate = null, CancellationToken ct = default);
|
||||
Task UpdateAsync(int id, string? businessType = null, string? accountingMethod = null,
|
||||
DateTime? nextFilingDueDate = null, string? taxRiskLevel = null, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/taxprofile";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get tax profiles");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TaxProfile?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<TaxProfile>($"{BaseUrl}/{id}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get tax profile {Id}", id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TaxProfile>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get tax profiles for client {ClientId}", clientId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TaxProfile>> GetHighRiskProfilesAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/high-risk", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get high-risk profiles");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TaxProfile>> GetUpcomingFilingDuesAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming-filings?daysAhead={daysAhead}", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get upcoming filings");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(int clientId, string businessType, string? businessRegistration = null,
|
||||
string? accountingMethod = null, DateTime? establishmentDate = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, businessType, businessRegistration, accountingMethod, establishmentDate };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create tax profile");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(int id, string? businessType = null, string? accountingMethod = null,
|
||||
DateTime? nextFilingDueDate = null, string? taxRiskLevel = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { businessType, accountingMethod, nextFilingDueDate, taxRiskLevel };
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to update tax profile {Id}", id);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete tax profile {Id}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Admin Dashboard API Client
|
||||
/// SOLID: Single Responsibility - Dashboard API 호출만 담당
|
||||
/// Dependency Inversion - 추상화된 인터페이스 사용
|
||||
/// </summary>
|
||||
public interface IAdminDashboardClient
|
||||
{
|
||||
Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default);
|
||||
Task<IEnumerable<TaxFiling>> GetUpcomingFilingsAsync(int days = 30, CancellationToken ct = default);
|
||||
Task<IEnumerable<Inquiry>> GetRecentInquiriesAsync(int limit = 10, CancellationToken ct = default);
|
||||
Task<object> GetMonthlyStatsAsync(string? month = null, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class AdminDashboardClient : IAdminDashboardClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<AdminDashboardClient> _logger;
|
||||
private readonly ITokenStore _tokenStore;
|
||||
|
||||
public AdminDashboardClient(HttpClient http, ILogger<AdminDashboardClient> logger, ITokenStore tokenStore)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
_tokenStore = tokenStore;
|
||||
}
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var result = await _http.GetFromJsonAsync<AdminDashboardSummary>(
|
||||
"admin-dashboard/summary", cancellationToken: ct);
|
||||
return result ?? new(0, 0, 0, 0, []);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch dashboard summary");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFiling>> GetUpcomingFilingsAsync(int days = 30, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var result = await _http.GetFromJsonAsync<ApiResponse<TaxFiling>>(
|
||||
$"admin-dashboard/upcoming-filings?days={days}", cancellationToken: ct);
|
||||
return result?.Data ?? [];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch upcoming filings");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Inquiry>> GetRecentInquiriesAsync(int limit = 10, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var result = await _http.GetFromJsonAsync<ApiResponse<Inquiry>>(
|
||||
$"admin-dashboard/recent-inquiries?limit={limit}", cancellationToken: ct);
|
||||
return result?.Data ?? [];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch recent inquiries");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetMonthlyStatsAsync(string? month = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var url = "admin-dashboard/monthly-stats";
|
||||
if (!string.IsNullOrEmpty(month))
|
||||
url += $"?month={month}";
|
||||
|
||||
var result = await _http.GetFromJsonAsync<object>(url, cancellationToken: ct);
|
||||
return result ?? new();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch monthly stats");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API Response wrapper
|
||||
/// </summary>
|
||||
internal class ApiResponse<T>
|
||||
{
|
||||
public IEnumerable<T>? Data { get; set; }
|
||||
public int Total { get; set; }
|
||||
public int Page { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using TaxBaik.Application.DTOs;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IAnnouncementBrowserClient
|
||||
{
|
||||
Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<Announcement?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<Announcement?> CreateAsync(AnnouncementDto dto, CancellationToken ct = default);
|
||||
Task<Announcement?> UpdateAsync(int id, AnnouncementDto dto, CancellationToken ct = default);
|
||||
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class AnnouncementBrowserClient : IAnnouncementBrowserClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<AnnouncementBrowserClient> _logger;
|
||||
private readonly ITokenStore _tokenStore;
|
||||
|
||||
public AnnouncementBrowserClient(HttpClient http, ILogger<AnnouncementBrowserClient> logger, ITokenStore tokenStore)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
_tokenStore = tokenStore;
|
||||
}
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var result = await _http.GetFromJsonAsync<AnnouncementListResponse>("announcement", cancellationToken: ct);
|
||||
return result?.Data ?? [];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch announcements");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Announcement?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await _http.GetFromJsonAsync<Announcement>($"announcement/{id}", cancellationToken: ct);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch announcement {AnnouncementId}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Announcement?> CreateAsync(AnnouncementDto dto, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PostAsJsonAsync("announcement", dto, cancellationToken: ct);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<Announcement>(
|
||||
content,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create announcement");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Announcement?> UpdateAsync(int id, AnnouncementDto dto, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PutAsJsonAsync($"announcement/{id}", dto, cancellationToken: ct);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<Announcement>(
|
||||
content,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update announcement {AnnouncementId}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.DeleteAsync($"announcement/{id}", cancellationToken: ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete announcement {AnnouncementId}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private class AnnouncementListResponse
|
||||
{
|
||||
public List<Announcement> Data { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using TaxBaik.Application.DTOs;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Client API Client for Admin Blazor
|
||||
/// SOLID: Single Responsibility - Client API calls only
|
||||
/// </summary>
|
||||
public interface IClientBrowserClient
|
||||
{
|
||||
Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
||||
int page = 1, int pageSize = 20, string? status = null, string? search = null, CancellationToken ct = default);
|
||||
Task<Client?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<Client?> CreateAsync(CreateClientDto dto, CancellationToken ct = default);
|
||||
Task<Client?> UpdateAsync(int id, CreateClientDto dto, CancellationToken ct = default);
|
||||
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class ClientBrowserClient : IClientBrowserClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<ClientBrowserClient> _logger;
|
||||
private readonly ITokenStore _tokenStore;
|
||||
|
||||
public ClientBrowserClient(HttpClient http, ILogger<ClientBrowserClient> logger, ITokenStore tokenStore)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
_tokenStore = tokenStore;
|
||||
}
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
||||
int page = 1, int pageSize = 20, string? status = null, string? search = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var query = $"client?page={page}&pageSize={pageSize}";
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
query += $"&status={status}";
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
query += $"&search={Uri.EscapeDataString(search)}";
|
||||
|
||||
var result = await _http.GetFromJsonAsync<ClientPagedResponse>(query, cancellationToken: ct);
|
||||
return result != null ? (result.Data, result.Total) : ([], 0);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch clients");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Client?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await _http.GetFromJsonAsync<Client>($"client/{id}", cancellationToken: ct);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch client {ClientId}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Client?> CreateAsync(CreateClientDto dto, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PostAsJsonAsync("client", dto, cancellationToken: ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<Client>(
|
||||
content,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create client");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Client?> UpdateAsync(int id, CreateClientDto dto, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PutAsJsonAsync($"client/{id}", dto, cancellationToken: ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<Client>(
|
||||
content,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update client {ClientId}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.DeleteAsync($"client/{id}", cancellationToken: ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete client {ClientId}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private class ClientPagedResponse
|
||||
{
|
||||
public List<Client> Data { get; set; } = [];
|
||||
public int Total { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using TaxBaik.Application.Services;
|
||||
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly ILocalStorageService _localStorage;
|
||||
private readonly ITokenStore _tokenStore;
|
||||
private readonly IApiClient _apiClient;
|
||||
private readonly ILogger<CustomAuthenticationStateProvider> _logger;
|
||||
|
||||
public CustomAuthenticationStateProvider(
|
||||
ILocalStorageService localStorage,
|
||||
ITokenStore tokenStore,
|
||||
IApiClient apiClient,
|
||||
ILogger<CustomAuthenticationStateProvider> logger)
|
||||
{
|
||||
_localStorage = localStorage;
|
||||
_tokenStore = tokenStore;
|
||||
_apiClient = apiClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var accessToken = _tokenStore.AccessToken;
|
||||
|
||||
// TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후)
|
||||
if (string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
var storedToken = await _localStorage.GetItemAsStringAsync("accessToken");
|
||||
if (!string.IsNullOrEmpty(storedToken))
|
||||
{
|
||||
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
|
||||
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
|
||||
if (long.TryParse(ticksStr, out var ticks))
|
||||
{
|
||||
_tokenStore.AccessToken = storedToken;
|
||||
_tokenStore.RefreshToken = refreshToken;
|
||||
_tokenStore.TokenExpiryTicks = ticks;
|
||||
accessToken = storedToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
{
|
||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
}
|
||||
|
||||
// 토큰이 만료되면 로그아웃
|
||||
if (_tokenStore.IsAccessTokenExpired())
|
||||
{
|
||||
_logger.LogWarning("Access token 만료됨 - 자동 로그아웃");
|
||||
await LogoutAsync();
|
||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
}
|
||||
|
||||
// 토큰이 5분 이내로 만료되면 자동 갱신 시도 (사용자 경험 향상)
|
||||
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken) && ShouldRefreshToken())
|
||||
{
|
||||
_logger.LogInformation("토큰 만료 5분 전 - 자동 갱신 시작");
|
||||
var request = new { RefreshToken = _tokenStore.RefreshToken };
|
||||
var newTokenPair = await _apiClient.PostAsync<WasmAuthTokenPair>("auth/refresh", request);
|
||||
if (newTokenPair != null && !string.IsNullOrEmpty(newTokenPair.AccessToken))
|
||||
{
|
||||
await LoginAsync(newTokenPair.AccessToken, newTokenPair.RefreshToken, newTokenPair.ExpiresIn);
|
||||
_logger.LogInformation("토큰 자동 갱신 성공");
|
||||
accessToken = newTokenPair.AccessToken;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("토큰 자동 갱신 실패 - 로그아웃");
|
||||
await LogoutAsync();
|
||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
}
|
||||
}
|
||||
|
||||
var principal = ValidateTokenWithoutDb(accessToken ?? string.Empty);
|
||||
if (principal == null)
|
||||
{
|
||||
await LogoutAsync();
|
||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
}
|
||||
|
||||
return new AuthenticationState(principal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "인증 상태 조회 중 오류 발생");
|
||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
}
|
||||
}
|
||||
|
||||
private ClaimsPrincipal? ValidateTokenWithoutDb(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwtToken = handler.ReadJwtToken(token);
|
||||
var identity = new ClaimsIdentity(jwtToken.Claims, "jwt");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task LoginAsync(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
var tokenExpiryTicks = DateTime.UtcNow.AddSeconds(expiresIn).Ticks;
|
||||
|
||||
// TokenStore에 저장 (DelegatingHandler에서 사용)
|
||||
_tokenStore.AccessToken = accessToken;
|
||||
_tokenStore.RefreshToken = refreshToken;
|
||||
_tokenStore.TokenExpiryTicks = tokenExpiryTicks;
|
||||
|
||||
// localStorage에도 저장 (페이지 리로드 후 복원)
|
||||
await _localStorage.SetItemAsStringAsync("accessToken", accessToken);
|
||||
await _localStorage.SetItemAsStringAsync("refreshToken", refreshToken);
|
||||
await _localStorage.SetItemAsStringAsync("tokenExpiry", tokenExpiryTicks.ToString());
|
||||
|
||||
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
||||
}
|
||||
|
||||
private bool ShouldRefreshToken()
|
||||
{
|
||||
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
|
||||
if (!_tokenStore.TokenExpiryTicks.HasValue || _tokenStore.TokenExpiryTicks.Value <= 0)
|
||||
return false;
|
||||
|
||||
const int refreshThresholdSeconds = 300;
|
||||
try
|
||||
{
|
||||
var expiryTime = new DateTime(_tokenStore.TokenExpiryTicks.Value, DateTimeKind.Utc);
|
||||
var timeUntilExpiry = expiryTime - DateTime.UtcNow;
|
||||
return timeUntilExpiry.TotalSeconds <= refreshThresholdSeconds && timeUntilExpiry.TotalSeconds > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
// TokenStore 초기화
|
||||
_tokenStore.Clear();
|
||||
|
||||
// localStorage 초기화
|
||||
await _localStorage.RemoveItemAsync("accessToken");
|
||||
await _localStorage.RemoveItemAsync("refreshToken");
|
||||
await _localStorage.RemoveItemAsync("tokenExpiry");
|
||||
|
||||
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
||||
}
|
||||
|
||||
private bool IsTokenExpired(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwtToken = handler.ReadJwtToken(token);
|
||||
return jwtToken.ValidTo < DateTime.UtcNow;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class WasmAuthTokenPair
|
||||
{
|
||||
public WasmAuthTokenPair() { }
|
||||
public WasmAuthTokenPair(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
AccessToken = accessToken;
|
||||
RefreshToken = refreshToken;
|
||||
ExpiresIn = expiresIn;
|
||||
}
|
||||
public string AccessToken { get; set; } = "";
|
||||
public string RefreshToken { get; set; } = "";
|
||||
public int ExpiresIn { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IFaqBrowserClient
|
||||
{
|
||||
Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<Faq?> CreateAsync(Faq faq, CancellationToken ct = default);
|
||||
Task<Faq?> UpdateAsync(int id, Faq faq, CancellationToken ct = default);
|
||||
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class FaqBrowserClient : IFaqBrowserClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<FaqBrowserClient> _logger;
|
||||
private readonly ITokenStore _tokenStore;
|
||||
|
||||
public FaqBrowserClient(HttpClient http, ILogger<FaqBrowserClient> logger, ITokenStore tokenStore)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
_tokenStore = tokenStore;
|
||||
}
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var result = await _http.GetFromJsonAsync<FaqListResponse>("faq", cancellationToken: ct);
|
||||
return result?.Data ?? [];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch FAQs");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await _http.GetFromJsonAsync<Faq>($"faq/{id}", cancellationToken: ct);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch FAQ {FaqId}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Faq?> CreateAsync(Faq faq, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PostAsJsonAsync("faq", faq, cancellationToken: ct);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<Faq>(
|
||||
content,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create FAQ");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Faq?> UpdateAsync(int id, Faq faq, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PutAsJsonAsync($"faq/{id}", faq, cancellationToken: ct);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<Faq>(
|
||||
content,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update FAQ {FaqId}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.DeleteAsync($"faq/{id}", cancellationToken: ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete FAQ {FaqId}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private class FaqListResponse
|
||||
{
|
||||
public List<Faq> Data { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Scoped in-memory token store for Blazor Server.
|
||||
/// SOLID: Single Responsibility - Token lifecycle management
|
||||
/// Avoids JS interop from DelegatingHandler (which runs on non-circuit thread)
|
||||
/// </summary>
|
||||
public interface ITokenStore
|
||||
{
|
||||
string? AccessToken { get; set; }
|
||||
string? RefreshToken { get; set; }
|
||||
long? TokenExpiryTicks { get; set; }
|
||||
|
||||
bool IsAccessTokenExpired();
|
||||
void Clear();
|
||||
}
|
||||
|
||||
public class TokenStore : ITokenStore
|
||||
{
|
||||
public string? AccessToken { get; set; }
|
||||
public string? RefreshToken { get; set; }
|
||||
public long? TokenExpiryTicks { get; set; }
|
||||
|
||||
public bool IsAccessTokenExpired()
|
||||
{
|
||||
if (TokenExpiryTicks == null)
|
||||
return true;
|
||||
|
||||
var expiryTime = new DateTime(TokenExpiryTicks.Value, DateTimeKind.Utc);
|
||||
return expiryTime <= DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
AccessToken = null;
|
||||
RefreshToken = null;
|
||||
TokenExpiryTicks = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Inquiry API Client for Admin Blazor
|
||||
/// SOLID: Single Responsibility - Inquiry API calls only
|
||||
/// Dependency Inversion - abstraction via interface
|
||||
/// </summary>
|
||||
public interface IInquiryBrowserClient
|
||||
{
|
||||
Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
|
||||
Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<bool> UpdateStatusAsync(int id, string status, CancellationToken ct = default);
|
||||
Task<bool> UpdateAdminMemoAsync(int id, string adminMemo, CancellationToken ct = default);
|
||||
Task<int> ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class InquiryBrowserClient : IInquiryBrowserClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<InquiryBrowserClient> _logger;
|
||||
private readonly ITokenStore _tokenStore;
|
||||
|
||||
public InquiryBrowserClient(HttpClient http, ILogger<InquiryBrowserClient> logger, ITokenStore tokenStore)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
_tokenStore = tokenStore;
|
||||
}
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
|
||||
int page = 1, int pageSize = 20, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var result = await _http.GetFromJsonAsync<InquiryPagedResponse>(
|
||||
$"inquiry?page={page}&pageSize={pageSize}",
|
||||
cancellationToken: ct);
|
||||
|
||||
return result != null
|
||||
? (result.Data, result.Total)
|
||||
: ([], 0);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch inquiries");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await _http.GetFromJsonAsync<Inquiry>(
|
||||
$"inquiry/{id}",
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch inquiry {InquiryId}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateStatusAsync(int id, string status, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { status };
|
||||
var response = await _http.PutAsJsonAsync(
|
||||
$"inquiry/{id}/status",
|
||||
request,
|
||||
cancellationToken: ct);
|
||||
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update inquiry {InquiryId} status", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAdminMemoAsync(int id, string adminMemo, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { adminMemo };
|
||||
var response = await _http.PutAsJsonAsync(
|
||||
$"inquiry/{id}/memo",
|
||||
request,
|
||||
cancellationToken: ct);
|
||||
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update inquiry {InquiryId} memo", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
$"inquiry/{id}/convert-to-client",
|
||||
new { name, phone, serviceType },
|
||||
cancellationToken: ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return 0;
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
var result = System.Text.Json.JsonSerializer.Deserialize<ConvertToClientResponse>(
|
||||
content,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
return result?.ClientId ?? 0;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to convert inquiry {InquiryId} to client", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private class InquiryPagedResponse
|
||||
{
|
||||
public List<Inquiry> Data { get; set; } = [];
|
||||
public int Total { get; set; }
|
||||
public int Page { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
private class ConvertToClientResponse
|
||||
{
|
||||
public int ClientId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// TaxFiling API Client for Admin Blazor
|
||||
/// </summary>
|
||||
public interface ITaxFilingBrowserClient
|
||||
{
|
||||
Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default);
|
||||
Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||
Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<TaxFiling?> CreateAsync(TaxFiling filing, CancellationToken ct = default);
|
||||
Task<TaxFiling?> UpdateAsync(int id, TaxFiling filing, CancellationToken ct = default);
|
||||
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class TaxFilingBrowserClient : ITaxFilingBrowserClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<TaxFilingBrowserClient> _logger;
|
||||
private readonly ITokenStore _tokenStore;
|
||||
|
||||
public TaxFilingBrowserClient(HttpClient http, ILogger<TaxFilingBrowserClient> logger, ITokenStore tokenStore)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
_tokenStore = tokenStore;
|
||||
}
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
|
||||
$"taxfiling/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
|
||||
return result?.Data ?? [];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch upcoming filings");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
|
||||
$"taxfiling/client/{clientId}", cancellationToken: ct);
|
||||
return result?.Data ?? [];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch filings for client {ClientId}", clientId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await _http.GetFromJsonAsync<TaxFiling>(
|
||||
$"taxfiling/{id}", cancellationToken: ct);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch filing {FilingId}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TaxFiling?> CreateAsync(TaxFiling filing, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PostAsJsonAsync("taxfiling", filing, cancellationToken: ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<TaxFiling>(
|
||||
content,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create filing");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TaxFiling?> UpdateAsync(int id, TaxFiling filing, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PutAsJsonAsync($"taxfiling/{id}", filing, cancellationToken: ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<TaxFiling>(
|
||||
content,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update filing {FilingId}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.DeleteAsync($"taxfiling/{id}", cancellationToken: ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete filing {FilingId}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private class TaxFilingListResponse
|
||||
{
|
||||
public List<TaxFiling> Data { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP 요청 시 자동으로 access token을 추가하고,
|
||||
/// 401 응답을 받으면 refresh token으로 새 토큰을 획득한 후 재시도합니다.
|
||||
/// SOLID: Single Responsibility - 토큰 갱신 로직만 담당
|
||||
/// </summary>
|
||||
public class TokenRefreshHandler : DelegatingHandler
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<TokenRefreshHandler> _logger;
|
||||
|
||||
public TokenRefreshHandler(IServiceProvider serviceProvider, ILogger<TokenRefreshHandler> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 최신 Scoped ITokenStore 실시간 해석 (Scope Capture 차단 및 기존 Blazor 회로 수명 공유)
|
||||
var tokenStore = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<ITokenStore>(_serviceProvider);
|
||||
|
||||
// 요청에 access token 추가
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
{
|
||||
request.Headers.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
}
|
||||
|
||||
var response = await base.SendAsync(request, cancellationToken);
|
||||
|
||||
// 401 응답이면 토큰 갱신 시도
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.RefreshToken))
|
||||
{
|
||||
var newTokenPair = await RefreshTokenAsync(tokenStore.RefreshToken, request, cancellationToken);
|
||||
if (newTokenPair != null)
|
||||
{
|
||||
// TokenStore에 토큰 저장
|
||||
tokenStore.AccessToken = newTokenPair.AccessToken;
|
||||
tokenStore.RefreshToken = newTokenPair.RefreshToken;
|
||||
tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks;
|
||||
|
||||
// 새 토큰으로 재요청
|
||||
request.Headers.Authorization = new("Bearer", newTokenPair.AccessToken);
|
||||
response = await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("토큰 갱신 실패 - 로그아웃");
|
||||
tokenStore.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<WasmAuthTokenPair?> RefreshTokenAsync(string refreshToken, HttpRequestMessage originalRequest, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 원래 요청의 호스트 정보 추출
|
||||
var authority = originalRequest.RequestUri?.Authority ?? "localhost:5001";
|
||||
var scheme = originalRequest.RequestUri?.Scheme ?? "http";
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
var refreshUri = new Uri($"{scheme}://{authority}/taxbaik/api/auth/refresh");
|
||||
var json = JsonSerializer.Serialize(new { refreshToken });
|
||||
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await httpClient.PostAsync(refreshUri, content, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning($"Token refresh failed with status {response.StatusCode}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync(ct);
|
||||
var result = JsonSerializer.Deserialize<AuthTokenResponse>(responseContent,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
return result != null
|
||||
? new WasmAuthTokenPair(result.AccessToken, result.RefreshToken, result.ExpiresIn)
|
||||
: null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception during token refresh");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class AuthTokenResponse
|
||||
{
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
public int ExpiresIn { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>TaxBaik.WasmClient</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
|
||||
<PackageReference Include="MudBlazor" Version="6.10.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
||||
@using Microsoft.JSInterop
|
||||
@using MudBlazor
|
||||
@using TaxBaik.WasmClient
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@@ -0,0 +1,573 @@
|
||||
{
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v10.0",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v10.0": {
|
||||
"TaxBaik.Web/1.0.0": {
|
||||
"dependencies": {
|
||||
"BCrypt.Net-Next": "4.0.3",
|
||||
"Microsoft.AspNetCore.Authentication.Google": "10.0.9",
|
||||
"Microsoft.AspNetCore.Authentication.JwtBearer": "10.0.9",
|
||||
"Microsoft.AspNetCore.Components.WebAssembly.Server": "10.0.9",
|
||||
"Microsoft.IdentityModel.Tokens": "8.19.1",
|
||||
"MudBlazor": "6.10.0",
|
||||
"Serilog.AspNetCore": "8.0.1",
|
||||
"Serilog.Sinks.Console": "6.0.0",
|
||||
"Serilog.Sinks.File": "5.0.0",
|
||||
"System.IdentityModel.Tokens.Jwt": "8.19.1",
|
||||
"TaxBaik.Application": "1.0.0",
|
||||
"TaxBaik.Infrastructure": "1.0.0",
|
||||
"TaxBaik.Web.Client": "1.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"TaxBaik.Web.dll": {}
|
||||
}
|
||||
},
|
||||
"BCrypt.Net-Next/4.0.3": {
|
||||
"runtime": {
|
||||
"lib/net6.0/BCrypt.Net-Next.dll": {
|
||||
"assemblyVersion": "4.0.3.0",
|
||||
"fileVersion": "4.0.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Dapper/2.1.15": {
|
||||
"runtime": {
|
||||
"lib/net5.0/Dapper.dll": {
|
||||
"assemblyVersion": "2.0.0.0",
|
||||
"fileVersion": "2.1.15.52653"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.AspNetCore.Authentication.Google/10.0.9": {
|
||||
"runtime": {
|
||||
"lib/net10.0/Microsoft.AspNetCore.Authentication.Google.dll": {
|
||||
"assemblyVersion": "10.0.9.0",
|
||||
"fileVersion": "10.0.926.27113"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.AspNetCore.Authentication.JwtBearer/10.0.9": {
|
||||
"dependencies": {
|
||||
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net10.0/Microsoft.AspNetCore.Authentication.JwtBearer.dll": {
|
||||
"assemblyVersion": "10.0.9.0",
|
||||
"fileVersion": "10.0.926.27113"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.AspNetCore.Components.WebAssembly/10.0.9": {
|
||||
"dependencies": {
|
||||
"Microsoft.JSInterop.WebAssembly": "10.0.9"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net10.0/Microsoft.AspNetCore.Components.WebAssembly.dll": {
|
||||
"assemblyVersion": "10.0.9.0",
|
||||
"fileVersion": "10.0.926.27113"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.AspNetCore.Components.WebAssembly.Server/10.0.9": {
|
||||
"dependencies": {
|
||||
"Microsoft.AspNetCore.Components.WebAssembly": "10.0.9"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net10.0/Microsoft.AspNetCore.Components.WebAssembly.Server.dll": {
|
||||
"assemblyVersion": "10.0.9.0",
|
||||
"fileVersion": "10.0.926.27113"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.Bcl.Cryptography/10.0.2": {
|
||||
"runtime": {
|
||||
"lib/net10.0/Microsoft.Bcl.Cryptography.dll": {
|
||||
"assemblyVersion": "10.0.0.2",
|
||||
"fileVersion": "10.0.225.61305"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyModel/8.0.0": {
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.Extensions.DependencyModel.dll": {
|
||||
"assemblyVersion": "8.0.0.0",
|
||||
"fileVersion": "8.0.23.53103"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.IdentityModel.Abstractions/8.19.1": {
|
||||
"runtime": {
|
||||
"lib/net10.0/Microsoft.IdentityModel.Abstractions.dll": {
|
||||
"assemblyVersion": "8.19.1.0",
|
||||
"fileVersion": "8.19.1.26153"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.IdentityModel.JsonWebTokens/8.19.1": {
|
||||
"dependencies": {
|
||||
"Microsoft.IdentityModel.Tokens": "8.19.1"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net10.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
|
||||
"assemblyVersion": "8.19.1.0",
|
||||
"fileVersion": "8.19.1.26153"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.IdentityModel.Logging/8.19.1": {
|
||||
"dependencies": {
|
||||
"Microsoft.IdentityModel.Abstractions": "8.19.1"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net10.0/Microsoft.IdentityModel.Logging.dll": {
|
||||
"assemblyVersion": "8.19.1.0",
|
||||
"fileVersion": "8.19.1.26153"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.IdentityModel.Protocols/8.0.1": {
|
||||
"dependencies": {
|
||||
"Microsoft.IdentityModel.Tokens": "8.19.1"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll": {
|
||||
"assemblyVersion": "8.0.1.0",
|
||||
"fileVersion": "8.0.1.50722"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
|
||||
"dependencies": {
|
||||
"Microsoft.IdentityModel.Protocols": "8.0.1",
|
||||
"System.IdentityModel.Tokens.Jwt": "8.19.1"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
|
||||
"assemblyVersion": "8.0.1.0",
|
||||
"fileVersion": "8.0.1.50722"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.IdentityModel.Tokens/8.19.1": {
|
||||
"dependencies": {
|
||||
"Microsoft.Bcl.Cryptography": "10.0.2",
|
||||
"Microsoft.IdentityModel.Logging": "8.19.1"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net10.0/Microsoft.IdentityModel.Tokens.dll": {
|
||||
"assemblyVersion": "8.19.1.0",
|
||||
"fileVersion": "8.19.1.26153"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.JSInterop.WebAssembly/10.0.9": {
|
||||
"runtime": {
|
||||
"lib/net10.0/Microsoft.JSInterop.WebAssembly.dll": {
|
||||
"assemblyVersion": "10.0.9.0",
|
||||
"fileVersion": "10.0.926.27113"
|
||||
}
|
||||
}
|
||||
},
|
||||
"MudBlazor/6.10.0": {
|
||||
"runtime": {
|
||||
"lib/net7.0/MudBlazor.dll": {
|
||||
"assemblyVersion": "6.10.0.0",
|
||||
"fileVersion": "6.10.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Npgsql/10.0.3": {
|
||||
"runtime": {
|
||||
"lib/net10.0/Npgsql.dll": {
|
||||
"assemblyVersion": "10.0.3.0",
|
||||
"fileVersion": "10.0.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Serilog/4.0.0": {
|
||||
"runtime": {
|
||||
"lib/net8.0/Serilog.dll": {
|
||||
"assemblyVersion": "4.0.0.0",
|
||||
"fileVersion": "4.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Serilog.AspNetCore/8.0.1": {
|
||||
"dependencies": {
|
||||
"Serilog": "4.0.0",
|
||||
"Serilog.Extensions.Hosting": "8.0.0",
|
||||
"Serilog.Extensions.Logging": "8.0.0",
|
||||
"Serilog.Formatting.Compact": "2.0.0",
|
||||
"Serilog.Settings.Configuration": "8.0.0",
|
||||
"Serilog.Sinks.Console": "6.0.0",
|
||||
"Serilog.Sinks.Debug": "2.0.0",
|
||||
"Serilog.Sinks.File": "5.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Serilog.AspNetCore.dll": {
|
||||
"assemblyVersion": "8.0.1.0",
|
||||
"fileVersion": "8.0.1.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Serilog.Extensions.Hosting/8.0.0": {
|
||||
"dependencies": {
|
||||
"Serilog": "4.0.0",
|
||||
"Serilog.Extensions.Logging": "8.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Serilog.Extensions.Hosting.dll": {
|
||||
"assemblyVersion": "7.0.0.0",
|
||||
"fileVersion": "8.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Serilog.Extensions.Logging/8.0.0": {
|
||||
"dependencies": {
|
||||
"Serilog": "4.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Serilog.Extensions.Logging.dll": {
|
||||
"assemblyVersion": "7.0.0.0",
|
||||
"fileVersion": "8.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Serilog.Formatting.Compact/2.0.0": {
|
||||
"dependencies": {
|
||||
"Serilog": "4.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net7.0/Serilog.Formatting.Compact.dll": {
|
||||
"assemblyVersion": "2.0.0.0",
|
||||
"fileVersion": "2.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Serilog.Settings.Configuration/8.0.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyModel": "8.0.0",
|
||||
"Serilog": "4.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Serilog.Settings.Configuration.dll": {
|
||||
"assemblyVersion": "8.0.0.0",
|
||||
"fileVersion": "8.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Serilog.Sinks.Console/6.0.0": {
|
||||
"dependencies": {
|
||||
"Serilog": "4.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Serilog.Sinks.Console.dll": {
|
||||
"assemblyVersion": "6.0.0.0",
|
||||
"fileVersion": "6.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Serilog.Sinks.Debug/2.0.0": {
|
||||
"dependencies": {
|
||||
"Serilog": "4.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/netstandard2.1/Serilog.Sinks.Debug.dll": {
|
||||
"assemblyVersion": "2.0.0.0",
|
||||
"fileVersion": "2.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Serilog.Sinks.File/5.0.0": {
|
||||
"dependencies": {
|
||||
"Serilog": "4.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net5.0/Serilog.Sinks.File.dll": {
|
||||
"assemblyVersion": "5.0.0.0",
|
||||
"fileVersion": "5.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.IdentityModel.Tokens.Jwt/8.19.1": {
|
||||
"dependencies": {
|
||||
"Microsoft.IdentityModel.JsonWebTokens": "8.19.1",
|
||||
"Microsoft.IdentityModel.Tokens": "8.19.1"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net10.0/System.IdentityModel.Tokens.Jwt.dll": {
|
||||
"assemblyVersion": "8.19.1.0",
|
||||
"fileVersion": "8.19.1.26153"
|
||||
}
|
||||
}
|
||||
},
|
||||
"TaxBaik.Application/1.0.0": {
|
||||
"dependencies": {
|
||||
"TaxBaik.Domain": "1.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"TaxBaik.Application.dll": {
|
||||
"assemblyVersion": "1.0.0.0",
|
||||
"fileVersion": "1.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"TaxBaik.Domain/1.0.0": {
|
||||
"runtime": {
|
||||
"TaxBaik.Domain.dll": {
|
||||
"assemblyVersion": "1.0.0.0",
|
||||
"fileVersion": "1.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"TaxBaik.Infrastructure/1.0.0": {
|
||||
"dependencies": {
|
||||
"Dapper": "2.1.15",
|
||||
"Npgsql": "10.0.3",
|
||||
"TaxBaik.Domain": "1.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"TaxBaik.Infrastructure.dll": {
|
||||
"assemblyVersion": "1.0.0.0",
|
||||
"fileVersion": "1.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"TaxBaik.Web.Client/1.0.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.AspNetCore.Components.WebAssembly": "10.0.9",
|
||||
"Microsoft.IdentityModel.Tokens": "8.19.1",
|
||||
"MudBlazor": "6.10.0",
|
||||
"System.IdentityModel.Tokens.Jwt": "8.19.1",
|
||||
"TaxBaik.Application": "1.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"TaxBaik.Web.Client.dll": {
|
||||
"assemblyVersion": "1.0.0.0",
|
||||
"fileVersion": "1.0.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"TaxBaik.Web/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"BCrypt.Net-Next/4.0.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-W+U9WvmZQgi5cX6FS5GDtDoPzUCV4LkBLkywq/kRZhuDwcbavOzcDAr3LXJFqHUi952Yj3LEYoWW0jbEUQChsA==",
|
||||
"path": "bcrypt.net-next/4.0.3",
|
||||
"hashPath": "bcrypt.net-next.4.0.3.nupkg.sha512"
|
||||
},
|
||||
"Dapper/2.1.15": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-1aWSAosZymEM+mRwfrXteRIN74/JTUjqj9B/KqEbanH6vfUKy9D9cemRN0q1ZOEfSB7d1PpFTpVOCbf2Uv70Og==",
|
||||
"path": "dapper/2.1.15",
|
||||
"hashPath": "dapper.2.1.15.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.AspNetCore.Authentication.Google/10.0.9": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-xqjTc8/ap0dwKmdaqSlV8RxjXb02uQ8rynDtTuHRU2gmOYaNm6O+uUjobp4Ararzq0ndKNXiWnQErxjWEGFGiA==",
|
||||
"path": "microsoft.aspnetcore.authentication.google/10.0.9",
|
||||
"hashPath": "microsoft.aspnetcore.authentication.google.10.0.9.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.AspNetCore.Authentication.JwtBearer/10.0.9": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-Hs5NDsGm8YicDDNx5RoBIT+H2AB9R27MvZ2gHoupTiHr+nnH3VxzY7DcmlbJ3b5DvvOhK35lWt/9Odtrq9sjtA==",
|
||||
"path": "microsoft.aspnetcore.authentication.jwtbearer/10.0.9",
|
||||
"hashPath": "microsoft.aspnetcore.authentication.jwtbearer.10.0.9.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.AspNetCore.Components.WebAssembly/10.0.9": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-tBv68AsZ3r6z2QdV2m3cSSKUCbvEscN8REpHxcUs22vlR6UjTz6IKdInKNREkJ/3G1AQrBKrRTdrfrHVffE8Iw==",
|
||||
"path": "microsoft.aspnetcore.components.webassembly/10.0.9",
|
||||
"hashPath": "microsoft.aspnetcore.components.webassembly.10.0.9.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.AspNetCore.Components.WebAssembly.Server/10.0.9": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-ZTtYvBILwGxhIiXi1L03ETBBOgMmizStu7dO/YblK6rPTa27wpEgYKp5Z9bUfr+wsFvHIDWd/ZMGb9on41f6yw==",
|
||||
"path": "microsoft.aspnetcore.components.webassembly.server/10.0.9",
|
||||
"hashPath": "microsoft.aspnetcore.components.webassembly.server.10.0.9.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Bcl.Cryptography/10.0.2": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==",
|
||||
"path": "microsoft.bcl.cryptography/10.0.2",
|
||||
"hashPath": "microsoft.bcl.cryptography.10.0.2.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Extensions.DependencyModel/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-NSmDw3K0ozNDgShSIpsZcbFIzBX4w28nDag+TfaQujkXGazBm+lid5onlWoCBy4VsLxqnnKjEBbGSJVWJMf43g==",
|
||||
"path": "microsoft.extensions.dependencymodel/8.0.0",
|
||||
"hashPath": "microsoft.extensions.dependencymodel.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.IdentityModel.Abstractions/8.19.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-gFA8THIk23uNF/vMdOHnjIdXD1LyA2g12cHzMJ+Xag6WpgWLw6E/6uCXxvA0gp9d2yAvkRt3xzFzMUiO/hofnQ==",
|
||||
"path": "microsoft.identitymodel.abstractions/8.19.1",
|
||||
"hashPath": "microsoft.identitymodel.abstractions.8.19.1.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.IdentityModel.JsonWebTokens/8.19.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-6eeY+y2QFyjj3XnCz/8gJdoP5smYHTS9ow1bw2nsZzDIPjPhBZlackYTIduSMipVpxnoT/B62LkrXX2jPggOXg==",
|
||||
"path": "microsoft.identitymodel.jsonwebtokens/8.19.1",
|
||||
"hashPath": "microsoft.identitymodel.jsonwebtokens.8.19.1.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.IdentityModel.Logging/8.19.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-H+sMrMpdbWnwkQnpb/ESkQovtOgdefmj0ecGCcP40mDKzE5i4dUYkH6599M9mWYFNGNJnTp92l/9wLubYXWimw==",
|
||||
"path": "microsoft.identitymodel.logging/8.19.1",
|
||||
"hashPath": "microsoft.identitymodel.logging.8.19.1.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.IdentityModel.Protocols/8.0.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-uA2vpKqU3I2mBBEaeJAWPTjT9v1TZrGWKdgK6G5qJd03CLx83kdiqO9cmiK8/n1erkHzFBwU/RphP83aAe3i3g==",
|
||||
"path": "microsoft.identitymodel.protocols/8.0.1",
|
||||
"hashPath": "microsoft.identitymodel.protocols.8.0.1.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-AQDbfpL+yzuuGhO/mQhKNsp44pm5Jv8/BI4KiFXR7beVGZoSH35zMV3PrmcfvSTsyI6qrcR898NzUauD6SRigg==",
|
||||
"path": "microsoft.identitymodel.protocols.openidconnect/8.0.1",
|
||||
"hashPath": "microsoft.identitymodel.protocols.openidconnect.8.0.1.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.IdentityModel.Tokens/8.19.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-KDiuSLXud2AFVNAOottd8ztVysfPeHyr4r8gofU3/VKUXlI7oytzGTnPsNJ/B3nui17rgz8wAdWNJOtzPjkUxw==",
|
||||
"path": "microsoft.identitymodel.tokens/8.19.1",
|
||||
"hashPath": "microsoft.identitymodel.tokens.8.19.1.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.JSInterop.WebAssembly/10.0.9": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-4G0A7GuQrtCAes8PuJPTDUcy+lCrxHWjr8ZlkDOa4h8a2Txj1XdhbXKLnld2vMY5EyZNC5jZXxa1xTD/AOCUlw==",
|
||||
"path": "microsoft.jsinterop.webassembly/10.0.9",
|
||||
"hashPath": "microsoft.jsinterop.webassembly.10.0.9.nupkg.sha512"
|
||||
},
|
||||
"MudBlazor/6.10.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-Dpjouo3MVva4p8Nh2VCzHzvzReWhnzmCBNlrhymeXjn6oBEtT3Oi9z/R2sHOg/jYrW/hIPKMhfZHnptilHScsw==",
|
||||
"path": "mudblazor/6.10.0",
|
||||
"hashPath": "mudblazor.6.10.0.nupkg.sha512"
|
||||
},
|
||||
"Npgsql/10.0.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
|
||||
"path": "npgsql/10.0.3",
|
||||
"hashPath": "npgsql.10.0.3.nupkg.sha512"
|
||||
},
|
||||
"Serilog/4.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-2jDkUrSh5EofOp7Lx5Zgy0EB+7hXjjxE2ktTb1WVQmU00lDACR2TdROGKU0K1pDTBSJBN1PqgYpgOZF8mL7NJw==",
|
||||
"path": "serilog/4.0.0",
|
||||
"hashPath": "serilog.4.0.0.nupkg.sha512"
|
||||
},
|
||||
"Serilog.AspNetCore/8.0.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-B/X+wAfS7yWLVOTD83B+Ip9yl4MkhioaXj90JSoWi1Ayi8XHepEnsBdrkojg08eodCnmOKmShFUN2GgEc6c0CQ==",
|
||||
"path": "serilog.aspnetcore/8.0.1",
|
||||
"hashPath": "serilog.aspnetcore.8.0.1.nupkg.sha512"
|
||||
},
|
||||
"Serilog.Extensions.Hosting/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-db0OcbWeSCvYQkHWu6n0v40N4kKaTAXNjlM3BKvcbwvNzYphQFcBR+36eQ/7hMMwOkJvAyLC2a9/jNdUL5NjtQ==",
|
||||
"path": "serilog.extensions.hosting/8.0.0",
|
||||
"hashPath": "serilog.extensions.hosting.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"Serilog.Extensions.Logging/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-YEAMWu1UnWgf1c1KP85l1SgXGfiVo0Rz6x08pCiPOIBt2Qe18tcZLvdBUuV5o1QHvrs8FAry9wTIhgBRtjIlEg==",
|
||||
"path": "serilog.extensions.logging/8.0.0",
|
||||
"hashPath": "serilog.extensions.logging.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"Serilog.Formatting.Compact/2.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-ob6z3ikzFM3D1xalhFuBIK1IOWf+XrQq+H4KeH4VqBcPpNcmUgZlRQ2h3Q7wvthpdZBBoY86qZOI2LCXNaLlNA==",
|
||||
"path": "serilog.formatting.compact/2.0.0",
|
||||
"hashPath": "serilog.formatting.compact.2.0.0.nupkg.sha512"
|
||||
},
|
||||
"Serilog.Settings.Configuration/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-nR0iL5HwKj5v6ULo3/zpP8NMcq9E2pxYA6XKTSWCbugVs4YqPyvaqaKOY+OMpPivKp7zMEpax2UKHnDodbRB0Q==",
|
||||
"path": "serilog.settings.configuration/8.0.0",
|
||||
"hashPath": "serilog.settings.configuration.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"Serilog.Sinks.Console/6.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-fQGWqVMClCP2yEyTXPIinSr5c+CBGUvBybPxjAGcf7ctDhadFhrQw03Mv8rJ07/wR5PDfFjewf2LimvXCDzpbA==",
|
||||
"path": "serilog.sinks.console/6.0.0",
|
||||
"hashPath": "serilog.sinks.console.6.0.0.nupkg.sha512"
|
||||
},
|
||||
"Serilog.Sinks.Debug/2.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-Y6g3OBJ4JzTyyw16fDqtFcQ41qQAydnEvEqmXjhwhgjsnG/FaJ8GUqF5ldsC/bVkK8KYmqrPhDO+tm4dF6xx4A==",
|
||||
"path": "serilog.sinks.debug/2.0.0",
|
||||
"hashPath": "serilog.sinks.debug.2.0.0.nupkg.sha512"
|
||||
},
|
||||
"Serilog.Sinks.File/5.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-uwV5hdhWPwUH1szhO8PJpFiahqXmzPzJT/sOijH/kFgUx+cyoDTMM8MHD0adw9+Iem6itoibbUXHYslzXsLEAg==",
|
||||
"path": "serilog.sinks.file/5.0.0",
|
||||
"hashPath": "serilog.sinks.file.5.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.IdentityModel.Tokens.Jwt/8.19.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-2VHcRtT95GAcW1E3aVBLvL2rAAMxKHXKMXKXFyWzwgkdFXZPMMvP8tVOfnRydL4vTr1RirNuGC6T8VSEF2YsPQ==",
|
||||
"path": "system.identitymodel.tokens.jwt/8.19.1",
|
||||
"hashPath": "system.identitymodel.tokens.jwt.8.19.1.nupkg.sha512"
|
||||
},
|
||||
"TaxBaik.Application/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"TaxBaik.Domain/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"TaxBaik.Infrastructure/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"TaxBaik.Web.Client/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"runtimeOptions": {
|
||||
"tfm": "net10.0",
|
||||
"frameworks": [
|
||||
{
|
||||
"name": "Microsoft.NETCore.App",
|
||||
"version": "10.0.0"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft.AspNetCore.App",
|
||||
"version": "10.0.0"
|
||||
}
|
||||
],
|
||||
"configProperties": {
|
||||
"System.GC.Server": true,
|
||||
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
|
||||
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user