From 76b5e6afd0ebf401509ae46ece1fa2fb8eede301 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 6 Mar 2026 19:58:40 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20close-out=20=E2=80=94=20mul?= =?UTF-8?q?ti-branch=20management,=20production=20K8s,=20revenue=20dashboa?= =?UTF-8?q?rd=20UI,=20responsive=20POS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Multi-branch shop management: SetDefaultShop, TransferShop commands, GetMerchantShops paginated query - Shop aggregate: IsDefault field, SetAsDefault/ClearDefault/TransferOwnership behavior methods - 2 new domain events: ShopSetAsDefaultDomainEvent, ShopTransferredDomainEvent Frontend: - Revenue Dashboard (MudChart line/donut/bar, 4 KPI cards, top products table) - Staff Performance (sortable table, color-coded completion rates, CSV export) - Customer QR Menu page (/menu/{ShopId}, mobile-first, Vietnamese labels) - QR Code Generator admin page (batch generate, print-all, per-table QR) - Responsive POS layout (collapsible sidebar, slide-out order drawer, touch-friendly CSS) - ResponsiveOrderPanel component (desktop inline / tablet drawer / mobile overlay) Infrastructure: - Production K8s manifests: 8 services (3 replicas, 512Mi-1Gi, HPA min3/max10), Redis with persistence - Production ingress: api.goodgo.vn, cert-manager TLS, rate-limit middleware - Deploy script: pre-flight checks, dry-run, single-service deploy, rollback support - CI/CD: deploy-production.yml with environment approval, commit SHA tags - Prometheus full scrape config (11 targets), docker-compose observability stack - Production deployment checklist (80+ items) Co-Authored-By: Claude Opus 4.6 --- .github/workflows/deploy-production.yml | 403 ++++++++++++- .../Components/Pos/ResponsiveOrderPanel.razor | 63 ++ .../Layout/PosLayout.razor | 159 ++++- .../Layout/PosLayout.razor.css | 19 + .../Admin/Reports/RevenueDashboard.razor | 465 +++++++++++++++ .../Admin/Reports/StaffPerformance.razor | 360 ++++++++++++ .../Pages/Admin/Shop/QrCodeGenerator.razor | 270 +++++++++ .../Pages/Admin/Shop/ShopTables.razor | 6 + .../Pages/Customer/Menu.razor | 422 +++++++++++++ .../WebClientTpos.Client/wwwroot/css/pos.css | 553 ++++++++++++++++++ deployments/local/docker-compose.yml | 110 +++- .../kubernetes/booking-service.yaml | 136 +++++ .../kubernetes/catalog-service.yaml | 136 +++++ .../production/kubernetes/configmap.yaml | 58 ++ .../production/kubernetes/fnb-engine.yaml | 143 +++++ .../kubernetes/iam-service-configmap.yaml | 15 - .../production/kubernetes/iam-service.yaml | 76 ++- .../production/kubernetes/ingress.yaml | 237 +++++++- .../kubernetes/inventory-service.yaml | 136 +++++ .../kubernetes/merchant-service.yaml | 136 +++++ .../production/kubernetes/namespace.yaml | 10 + .../production/kubernetes/order-service.yaml | 144 +++++ deployments/production/kubernetes/redis.yaml | 122 ++++ .../kubernetes/secrets.yaml.example | 96 +-- .../production/kubernetes/wallet-service.yaml | 136 +++++ docs/production-checklist.md | 185 ++++++ infra/observability/prometheus/prometheus.yml | 148 ++++- scripts/deploy/deploy-prod.sh | 361 +++++++++++- .../Commands/Shops/SetDefaultShopCommand.cs | 95 +++ .../Commands/Shops/TransferShopCommand.cs | 130 ++++ .../Queries/Shops/GetMerchantShopsQuery.cs | 128 ++++ .../Queries/Shops/GetShopsQuery.cs | 2 + .../Queries/Shops/GetShopsQueryHandler.cs | 3 + .../Validations/ShopCommandValidators.cs | 40 ++ .../Controllers/MerchantsController.cs | 120 ++++ .../ShopAggregate/IShopRepository.cs | 13 + .../AggregatesModel/ShopAggregate/Shop.cs | 59 ++ .../Events/ShopDomainEvents.cs | 12 + .../ShopEntityTypeConfiguration.cs | 5 + .../Repositories/ShopRepository.cs | 35 ++ 40 files changed, 5582 insertions(+), 165 deletions(-) create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Components/Pos/ResponsiveOrderPanel.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/PosLayout.razor.css create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Reports/RevenueDashboard.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Reports/StaffPerformance.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/QrCodeGenerator.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Customer/Menu.razor create mode 100644 deployments/production/kubernetes/booking-service.yaml create mode 100644 deployments/production/kubernetes/catalog-service.yaml create mode 100644 deployments/production/kubernetes/configmap.yaml create mode 100644 deployments/production/kubernetes/fnb-engine.yaml delete mode 100644 deployments/production/kubernetes/iam-service-configmap.yaml create mode 100644 deployments/production/kubernetes/inventory-service.yaml create mode 100644 deployments/production/kubernetes/merchant-service.yaml create mode 100644 deployments/production/kubernetes/namespace.yaml create mode 100644 deployments/production/kubernetes/order-service.yaml create mode 100644 deployments/production/kubernetes/redis.yaml create mode 100644 deployments/production/kubernetes/wallet-service.yaml create mode 100644 docs/production-checklist.md create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/SetDefaultShopCommand.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/TransferShopCommand.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetMerchantShopsQuery.cs diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 278fa9f8..57112418 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -1,3 +1,5 @@ +# EN: Deploy GoodGo Platform production services to Kubernetes production +# VI: Trien khai cac service production cua GoodGo Platform len K8s production name: Deploy to Production on: @@ -6,49 +8,408 @@ on: - main paths: - 'services/iam-service-net/**' - - 'apps/web-client/**' + - 'services/merchant-service-net/**' + - 'services/order-service-net/**' + - 'services/fnb-engine-net/**' + - 'services/inventory-service-net/**' + - 'services/wallet-service-net/**' + - 'services/catalog-service-net/**' + - 'services/booking-service-net/**' + - 'apps/web-client-tpos-net/**' - 'deployments/production/**' workflow_dispatch: + inputs: + service: + description: 'Service to deploy (leave empty for all changed)' + required: false + default: '' + type: choice + options: + - '' + - iam-service + - merchant-service + - order-service + - fnb-engine + - inventory-service + - wallet-service + - catalog-service + - booking-service + - pos-web + +env: + REGISTRY: docker.io + NAMESPACE: production jobs: - deploy: + # ========================================================================= + # EN: Detect which services changed + # VI: Phat hien service nao thay doi + # ========================================================================= + detect-changes: + runs-on: ubuntu-latest + outputs: + services: ${{ steps.changes.outputs.services }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Detect changed services + id: changes + run: | + if [ -n "${{ github.event.inputs.service }}" ]; then + echo 'services=["${{ github.event.inputs.service }}"]' >> $GITHUB_OUTPUT + exit 0 + fi + + SERVICES=() + CHANGED=$(git diff --name-only HEAD~1 HEAD) + + declare -A SERVICE_MAP=( + ["services/iam-service-net"]="iam-service" + ["services/merchant-service-net"]="merchant-service" + ["services/order-service-net"]="order-service" + ["services/fnb-engine-net"]="fnb-engine" + ["services/inventory-service-net"]="inventory-service" + ["services/wallet-service-net"]="wallet-service" + ["services/catalog-service-net"]="catalog-service" + ["services/booking-service-net"]="booking-service" + ["apps/web-client-tpos-net"]="pos-web" + ) + + for path in "${!SERVICE_MAP[@]}"; do + if echo "$CHANGED" | grep -q "^${path}/"; then + SERVICES+=("\"${SERVICE_MAP[$path]}\"") + fi + done + + # EN: If deployment configs changed, deploy all + # VI: Neu cau hinh deployment thay doi, deploy tat ca + if echo "$CHANGED" | grep -q "^deployments/production/"; then + SERVICES=("\"iam-service\"" "\"merchant-service\"" "\"order-service\"" "\"fnb-engine\"" "\"inventory-service\"" "\"wallet-service\"" "\"catalog-service\"" "\"booking-service\"" "\"pos-web\"") + fi + + if [ ${#SERVICES[@]} -eq 0 ]; then + echo 'services=[]' >> $GITHUB_OUTPUT + else + JOINED=$(IFS=,; echo "${SERVICES[*]}") + echo "services=[${JOINED}]" >> $GITHUB_OUTPUT + fi + + # ========================================================================= + # EN: Build & Push Docker Images (tagged with commit SHA, never :latest) + # VI: Build & Push Docker Images (tag bang commit SHA, khong dung :latest) + # ========================================================================= + build-and-push: + needs: detect-changes + if: needs.detect-changes.outputs.services != '[]' + runs-on: ubuntu-latest + # EN: Require production environment approval + # VI: Yeu cau phe duyet moi truong production + environment: production + strategy: + matrix: + service: ${{ fromJSON(needs.detect-changes.outputs.services) }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Determine build context + id: context + run: | + declare -A CONTEXT_MAP=( + ["iam-service"]="./services/iam-service-net" + ["merchant-service"]="./services/merchant-service-net" + ["order-service"]="./services/order-service-net" + ["fnb-engine"]="./services/fnb-engine-net" + ["inventory-service"]="./services/inventory-service-net" + ["wallet-service"]="./services/wallet-service-net" + ["catalog-service"]="./services/catalog-service-net" + ["booking-service"]="./services/booking-service-net" + ["pos-web"]="./apps/web-client-tpos-net" + ) + + declare -A IMAGE_MAP=( + ["iam-service"]="goodgo/iam-service-net" + ["merchant-service"]="goodgo/merchant-service-net" + ["order-service"]="goodgo/order-service-net" + ["fnb-engine"]="goodgo/fnb-engine-net" + ["inventory-service"]="goodgo/inventory-service-net" + ["wallet-service"]="goodgo/wallet-service-net" + ["catalog-service"]="goodgo/catalog-service-net" + ["booking-service"]="goodgo/booking-service-net" + ["pos-web"]="goodgo/web-client-tpos-net" + ) + + echo "context=${CONTEXT_MAP[${{ matrix.service }}]}" >> $GITHUB_OUTPUT + echo "image=${IMAGE_MAP[${{ matrix.service }}]}" >> $GITHUB_OUTPUT + + # EN: Tag with commit SHA for production (never use :latest in prod) + # VI: Tag bang commit SHA cho production (khong bao gio dung :latest trong prod) + - name: Build and push ${{ matrix.service }} + uses: docker/build-push-action@v5 + with: + context: ${{ steps.context.outputs.context }} + push: true + tags: | + ${{ steps.context.outputs.image }}:${{ github.sha }} + ${{ steps.context.outputs.image }}:production + cache-from: type=registry,ref=${{ steps.context.outputs.image }}:buildcache-prod + cache-to: type=registry,ref=${{ steps.context.outputs.image }}:buildcache-prod,mode=max + + # ========================================================================= + # EN: Run Database Migrations + # VI: Chay Database Migrations + # ========================================================================= + migrations: + needs: [detect-changes, build-and-push] + if: needs.detect-changes.outputs.services != '[]' runs-on: ubuntu-latest environment: production steps: - uses: actions/checkout@v4 - + - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' - - - name: Run database migrations + + - name: Install EF Core tools + run: dotnet tool install --global dotnet-ef || true + + - name: Run IAM migrations + if: contains(needs.detect-changes.outputs.services, 'iam-service') run: | - dotnet tool install --global dotnet-ef || true dotnet ef database update \ --project services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj \ --startup-project services/iam-service-net/src/IamService.API/IamService.API.csproj env: - ConnectionStrings__DefaultConnection: ${{ secrets.NEON_DATABASE_URL_PRODUCTION }} - + ConnectionStrings__DefaultConnection: ${{ secrets.NEON_IAM_DATABASE_URL_PRODUCTION }} + + - name: Run Merchant migrations + if: contains(needs.detect-changes.outputs.services, 'merchant-service') + run: | + dotnet ef database update \ + --project services/merchant-service-net/src/MerchantService.Infrastructure/MerchantService.Infrastructure.csproj \ + --startup-project services/merchant-service-net/src/MerchantService.API/MerchantService.API.csproj + env: + ConnectionStrings__DefaultConnection: ${{ secrets.NEON_MERCHANT_DATABASE_URL_PRODUCTION }} + + - name: Run Order migrations + if: contains(needs.detect-changes.outputs.services, 'order-service') + run: | + dotnet ef database update \ + --project services/order-service-net/src/OrderService.Infrastructure/OrderService.Infrastructure.csproj \ + --startup-project services/order-service-net/src/OrderService.API/OrderService.API.csproj + env: + ConnectionStrings__DefaultConnection: ${{ secrets.NEON_ORDER_DATABASE_URL_PRODUCTION }} + + - name: Run FnB Engine migrations + if: contains(needs.detect-changes.outputs.services, 'fnb-engine') + run: | + dotnet ef database update \ + --project services/fnb-engine-net/src/FnbEngine.Infrastructure/FnbEngine.Infrastructure.csproj \ + --startup-project services/fnb-engine-net/src/FnbEngine.API/FnbEngine.API.csproj + env: + ConnectionStrings__DefaultConnection: ${{ secrets.NEON_FNB_DATABASE_URL_PRODUCTION }} + + - name: Run Inventory migrations + if: contains(needs.detect-changes.outputs.services, 'inventory-service') + run: | + dotnet ef database update \ + --project services/inventory-service-net/src/InventoryService.Infrastructure/InventoryService.Infrastructure.csproj \ + --startup-project services/inventory-service-net/src/InventoryService.API/InventoryService.API.csproj + env: + ConnectionStrings__DefaultConnection: ${{ secrets.NEON_INVENTORY_DATABASE_URL_PRODUCTION }} + + - name: Run Wallet migrations + if: contains(needs.detect-changes.outputs.services, 'wallet-service') + run: | + dotnet ef database update \ + --project services/wallet-service-net/src/WalletService.Infrastructure/WalletService.Infrastructure.csproj \ + --startup-project services/wallet-service-net/src/WalletService.API/WalletService.API.csproj + env: + ConnectionStrings__DefaultConnection: ${{ secrets.NEON_WALLET_DATABASE_URL_PRODUCTION }} + + - name: Run Catalog migrations + if: contains(needs.detect-changes.outputs.services, 'catalog-service') + run: | + dotnet ef database update \ + --project services/catalog-service-net/src/CatalogService.Infrastructure/CatalogService.Infrastructure.csproj \ + --startup-project services/catalog-service-net/src/CatalogService.API/CatalogService.API.csproj + env: + ConnectionStrings__DefaultConnection: ${{ secrets.NEON_CATALOG_DATABASE_URL_PRODUCTION }} + + - name: Run Booking migrations + if: contains(needs.detect-changes.outputs.services, 'booking-service') + run: | + dotnet ef database update \ + --project services/booking-service-net/src/BookingService.Infrastructure/BookingService.Infrastructure.csproj \ + --startup-project services/booking-service-net/src/BookingService.API/BookingService.API.csproj + env: + ConnectionStrings__DefaultConnection: ${{ secrets.NEON_BOOKING_DATABASE_URL_PRODUCTION }} + + # ========================================================================= + # EN: Deploy to Kubernetes + # VI: Trien khai len Kubernetes + # ========================================================================= + deploy: + needs: [detect-changes, build-and-push, migrations] + if: needs.detect-changes.outputs.services != '[]' + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + - name: Setup kubectl uses: azure/setup-kubectl@v3 - + - name: Configure kubectl run: | echo "${{ secrets.KUBECONFIG_PRODUCTION }}" | base64 -d > kubeconfig - export KUBECONFIG=./kubeconfig - - - name: Deploy IAM Service + echo "KUBECONFIG=$(pwd)/kubeconfig" >> $GITHUB_ENV + + - name: Apply namespace and config + run: | + kubectl apply -f deployments/production/kubernetes/namespace.yaml + kubectl apply -f deployments/production/kubernetes/configmap.yaml + + - name: Deploy Redis + run: | + kubectl apply -f deployments/production/kubernetes/redis.yaml + + # EN: Update image tags to commit SHA and deploy + # VI: Cap nhat image tag bang commit SHA va deploy + - name: Deploy services + run: | + SERVICES='${{ needs.detect-changes.outputs.services }}' + + declare -A DEPLOY_MAP=( + ["iam-service"]="iam-service.yaml" + ["merchant-service"]="merchant-service.yaml" + ["order-service"]="order-service.yaml" + ["fnb-engine"]="fnb-engine.yaml" + ["inventory-service"]="inventory-service.yaml" + ["wallet-service"]="wallet-service.yaml" + ["catalog-service"]="catalog-service.yaml" + ["booking-service"]="booking-service.yaml" + ["pos-web"]="pos-web.yaml" + ) + + declare -A IMAGE_MAP=( + ["iam-service"]="goodgo/iam-service-net" + ["merchant-service"]="goodgo/merchant-service-net" + ["order-service"]="goodgo/order-service-net" + ["fnb-engine"]="goodgo/fnb-engine-net" + ["inventory-service"]="goodgo/inventory-service-net" + ["wallet-service"]="goodgo/wallet-service-net" + ["catalog-service"]="goodgo/catalog-service-net" + ["booking-service"]="goodgo/booking-service-net" + ["pos-web"]="goodgo/web-client-tpos-net" + ) + + for svc in "${!DEPLOY_MAP[@]}"; do + if echo "$SERVICES" | grep -q "\"${svc}\""; then + echo "Deploying ${svc}..." + kubectl apply -f "deployments/production/kubernetes/${DEPLOY_MAP[$svc]}" + + # EN: Update image to commit SHA (never :latest in production) + # VI: Cap nhat image bang commit SHA (khong bao gio dung :latest trong production) + kubectl set image "deployment/${svc}" \ + "${svc}=${IMAGE_MAP[$svc]}:${{ github.sha }}" \ + -n production + + kubectl rollout restart "deployment/${svc}" -n production + fi + done + + - name: Apply ingress run: | - export KUBECONFIG=./kubeconfig - kubectl apply -f deployments/production/kubernetes/iam-service.yaml - kubectl apply -f deployments/production/kubernetes/iam-service-configmap.yaml kubectl apply -f deployments/production/kubernetes/ingress.yaml - kubectl rollout status deployment/iam-service -n production - - - name: Deploy Web App + + - name: Wait for rollouts run: | - export KUBECONFIG=./kubeconfig - kubectl apply -f deployments/production/kubernetes/web-app.yaml || echo "Web app deployment not configured" - kubectl rollout status deployment/web-app -n production || echo "Web app deployment not configured" + SERVICES='${{ needs.detect-changes.outputs.services }}' + + declare -A DEPLOY_NAMES=( + ["iam-service"]="iam-service" + ["merchant-service"]="merchant-service" + ["order-service"]="order-service" + ["fnb-engine"]="fnb-engine" + ["inventory-service"]="inventory-service" + ["wallet-service"]="wallet-service" + ["catalog-service"]="catalog-service" + ["booking-service"]="booking-service" + ["pos-web"]="pos-web" + ) + + FAILED=0 + for svc in "${!DEPLOY_NAMES[@]}"; do + if echo "$SERVICES" | grep -q "\"${svc}\""; then + echo "Waiting for ${svc}..." + if ! kubectl rollout status "deployment/${DEPLOY_NAMES[$svc]}" -n production --timeout=180s; then + echo "FAILED: ${svc} rollout did not complete in time" + FAILED=$((FAILED + 1)) + fi + fi + done + + if [ $FAILED -gt 0 ]; then + echo "ERROR: ${FAILED} service(s) did not complete rollout" + kubectl get pods -n production + exit 1 + fi + + - name: Verify deployment + run: | + echo "=== Pods ===" + kubectl get pods -n production -o wide + echo "" + echo "=== Services ===" + kubectl get svc -n production + echo "" + echo "=== HPAs ===" + kubectl get hpa -n production + echo "" + echo "=== Ingress ===" + kubectl get ingress -n production + + # EN: Post-deployment health check + # VI: Kiem tra suc khoe sau deploy + - name: Health check + run: | + SERVICES='${{ needs.detect-changes.outputs.services }}' + + declare -A DEPLOY_NAMES=( + ["iam-service"]="iam-service" + ["merchant-service"]="merchant-service" + ["order-service"]="order-service" + ["fnb-engine"]="fnb-engine" + ["inventory-service"]="inventory-service" + ["wallet-service"]="wallet-service" + ["catalog-service"]="catalog-service" + ["booking-service"]="booking-service" + ) + + echo "Running post-deployment health checks..." + for svc in "${!DEPLOY_NAMES[@]}"; do + if echo "$SERVICES" | grep -q "\"${svc}\""; then + POD=$(kubectl get pods -n production -l app=${svc} -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) + if [ -n "$POD" ]; then + HEALTH=$(kubectl exec "$POD" -n production -- curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/health/live 2>/dev/null || echo "000") + if [ "$HEALTH" = "200" ]; then + echo "${svc}: HEALTHY (200)" + else + echo "${svc}: UNHEALTHY (${HEALTH})" + fi + fi + fi + done diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Components/Pos/ResponsiveOrderPanel.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Components/Pos/ResponsiveOrderPanel.razor new file mode 100644 index 00000000..9fe67c09 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Components/Pos/ResponsiveOrderPanel.razor @@ -0,0 +1,63 @@ +@* + EN: Responsive Order Panel — Adapts to screen size automatically. + Desktop: Renders inline as a right sidebar (pos-cart-panel). + Tablet/Mobile: Content is injected into PosLayout's order drawer via CascadingValue. + VI: Panel Đơn Hàng Responsive — Tự động thích ứng theo kích thước màn hình. + Desktop: Render inline dạng sidebar phải (pos-cart-panel). + Tablet/Mobile: Nội dung được inject vào order drawer của PosLayout qua CascadingValue. +*@ +@inject IJSRuntime JS +@implements IDisposable + +@* EN: Desktop view — inline sidebar / VI: View desktop — sidebar inline *@ +
+ @ChildContent +
+ +@* EN: Mobile/Tablet — inject content into layout drawer + VI: Mobile/Tablet — inject nội dung vào drawer của layout *@ + +@code { + /// + /// EN: The order panel content (cart items, totals, checkout button). + /// VI: Nội dung panel đơn hàng (mục giỏ hàng, tổng, nút thanh toán). + /// + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// + /// EN: Number of items in cart — shown as badge on mobile toggle. + /// VI: Số mục trong giỏ hàng — hiện badge trên toggle mobile. + /// + [Parameter] public int ItemCount { get; set; } + + /// + /// EN: Reference to parent PosLayout for setting drawer content. + /// VI: Tham chiếu đến PosLayout cha để set nội dung drawer. + /// + [CascadingParameter] public WebClientTpos.Client.Layout.PosLayout? Layout { get; set; } + + private bool _contentSet; + + protected override void OnParametersSet() + { + // EN: Push content to layout's order drawer for tablet/mobile + // VI: Đẩy nội dung vào order drawer của layout cho tablet/mobile + if (Layout is not null) + { + Layout.OrderPanelContent = ChildContent; + Layout.SetOrderCount(ItemCount); + _contentSet = true; + } + } + + public void Dispose() + { + // EN: Clean up layout reference when component is disposed + // VI: Dọn dẹp tham chiếu layout khi component bị dispose + if (_contentSet && Layout is not null) + { + Layout.OrderPanelContent = null; + Layout.SetOrderCount(0); + } + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/PosLayout.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/PosLayout.razor index 29007687..4dc8eea1 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/PosLayout.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/PosLayout.razor @@ -1,12 +1,16 @@ @* - EN: POS terminal layout — Full-screen, status bar + content, touch-friendly. - VI: Layout POS — Toàn màn hình, thanh trạng thái + nội dung, thân thiện cảm ứng. + EN: POS terminal layout — Responsive full-screen layout with status bar + content. + Desktop: Full sidebar + content. Tablet: Collapsible order drawer. Mobile: Bottom nav + full-screen overlay. + VI: Layout POS — Layout toàn màn hình responsive với thanh trạng thái + nội dung. + Desktop: Sidebar đầy đủ + nội dung. Tablet: Drawer đơn hàng thu gọn. Mobile: Nav dưới + overlay toàn màn hình. Design: pencil-design/src/pages/tPOS/pos/cafe/desktop.pen *@ @inherits LayoutComponentBase +@implements IDisposable @inject IStringLocalizer L @inject NavigationManager NavigationManager @inject WebClientTpos.Client.Services.PosDataService DataService +@inject IJSRuntime JS @@ -17,6 +21,10 @@ @* ═══ STATUS BAR ═══ *@
+ @* EN: Hamburger menu — visible on tablet/mobile only / VI: Menu hamburger — chi hien thi tren tablet/mobile *@ + @StoreName
@@ -25,8 +33,17 @@ Online - @_currentTime - + @@ -34,21 +51,129 @@ @* ═══ MAIN CONTENT ═══ *@
- @Body + @* EN: Mobile sidebar overlay / VI: Overlay sidebar trên mobile *@ + @if (_sidebarOpen) + { +
+ } + + @* EN: Sidebar navigation — collapsible on tablet/mobile + VI: Sidebar điều hướng — thu gọn trên tablet/mobile *@ + + + @* EN: Page content area / VI: Vùng nội dung trang *@ +
+ + @Body + +
+ + @* EN: Order panel drawer — slides in from right on tablet/mobile + VI: Drawer panel đơn hàng — trượt vào từ phải trên tablet/mobile *@ + @if (_orderPanelOpen) + { +
+ } +
+
+ Đơn hàng + +
+
+ @* EN: Order panel content is rendered by child pages via RenderFragment + VI: Nội dung panel đơn hàng được render bởi trang con qua RenderFragment *@ + @if (OrderPanelContent is not null) + { + @OrderPanelContent + } + else + { +
+ Chưa có đơn hàng +
+ } +
+
-@implements IDisposable - @code { private string StoreName { get; set; } = "GoodGo POS"; private string _currentTime = DateTime.Now.ToString("HH:mm"); private Timer? _timer; + private string _shopIdStr = ""; + + // EN: Responsive state / VI: Trạng thái responsive + private bool _sidebarOpen; + private bool _orderPanelOpen; + private int _orderCount; + + /// + /// EN: Optional order panel content — set by child POS pages via CascadingValue. + /// VI: Nội dung panel đơn hàng tùy chọn — set bởi trang con POS qua CascadingValue. + /// + public RenderFragment? OrderPanelContent { get; set; } + + /// + /// EN: Set order count badge on the order toggle button. + /// VI: Đặt badge số lượng đơn hàng trên nút toggle đơn hàng. + /// + public void SetOrderCount(int count) + { + _orderCount = count; + StateHasChanged(); + } + + /// + /// EN: Open order panel drawer (tablet/mobile). + /// VI: Mở drawer panel đơn hàng (tablet/mobile). + /// + public void OpenOrderPanel() + { + _orderPanelOpen = true; + StateHasChanged(); + } protected override async Task OnInitializedAsync() { - // EN: Update clock every 30 seconds - // VI: Cập nhật đồng hồ mỗi 30 giây + // EN: Update clock every 30 seconds / VI: Cập nhật đồng hồ mỗi 30 giây _timer = new Timer(_ => { _currentTime = DateTime.Now.ToString("HH:mm"); @@ -64,6 +189,7 @@ // Expected: ["pos", "{shopId}", "cafe"|"restaurant"|...] if (segments.Length >= 2 && Guid.TryParse(segments[1], out var shopId)) { + _shopIdStr = shopId.ToString(); var shop = await DataService.GetShopByIdAsync(shopId); if (shop != null) StoreName = shop.Name; @@ -75,9 +201,20 @@ } } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // EN: Re-init Lucide icons after every render (Blazor navigation replaces DOM) + // VI: Khởi tạo lại Lucide icons sau mỗi lần render + try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { } + } + private void GoToAdmin() => NavigationManager.NavigateTo("/admin"); + private void ToggleSidebar() => _sidebarOpen = !_sidebarOpen; + private void CloseSidebar() => _sidebarOpen = false; + + private void ToggleOrderPanel() => _orderPanelOpen = !_orderPanelOpen; + private void CloseOrderPanel() => _orderPanelOpen = false; + public void Dispose() => _timer?.Dispose(); } - - diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/PosLayout.razor.css b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/PosLayout.razor.css new file mode 100644 index 00000000..8f99b3a4 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/PosLayout.razor.css @@ -0,0 +1,19 @@ +/* ═════════════════════════════════════════════════════════════════════════ + POS Layout — Scoped CSS + EN: Layout-specific overrides that complement pos.css responsive styles. + VI: Override riêng cho layout, bổ sung cho styles responsive pos.css. + ═════════════════════════════════════════════════════════════════════════ */ + +/* EN: Ensure smooth transitions for all interactive elements + VI: Đảm bảo transition mượt cho tất cả phần tử tương tác */ +::deep .pos-sidebar, +::deep .pos-order-drawer { + will-change: transform; +} + +/* EN: Prevent body scroll when overlay is open + VI: Ngăn cuộn body khi overlay đang mở */ +::deep .pos-sidebar-overlay, +::deep .pos-order-overlay { + backdrop-filter: blur(2px); +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Reports/RevenueDashboard.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Reports/RevenueDashboard.razor new file mode 100644 index 00000000..dfa7ec0a --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Reports/RevenueDashboard.razor @@ -0,0 +1,465 @@ +@page "/admin/reports/revenue" +@layout AdminLayout +@inherits AdminBase +@inject PosDataService DataService +@inject ISnackbar Snackbar +@using WebClientTpos.Client.Services + +@* + EN: Revenue Analytics Dashboard — trends, payment methods, top products, vertical breakdown. + VI: Trang phan tich doanh thu — xu huong, phuong thuc thanh toan, san pham ban chay, phan tich nganh. +*@ + +Phan tich doanh thu — GoodGo POS + +@* ═══ TOP BAR ═══ *@ +
+
+

Phan tich doanh thu

+

Xu huong doanh thu, phuong thuc thanh toan va san pham ban chay

+
+
+ + + Ngay + Tuan + Thang + + + @if (_loading) + { + + } + Xem phan tich + +
+
+ +@* ═══ CONTENT ═══ *@ +
+ + @if (_loading) + { +
+ +
+ } + else if (_data == null) + { + + Chon khoang thoi gian va nhan "Xem phan tich" de tai du lieu doanh thu. + + } + else + { + @* ── KPI SUMMARY CARDS ── *@ +
+ @* Total Revenue *@ +
+
+ +
+
+ @FormatVND(_data.TotalRevenue) + Tong doanh thu + @if (_data.PreviousPeriodRevenue > 0) + { + Ky truoc: @FormatVND(_data.PreviousPeriodRevenue) + } +
+
+ @* Total Orders *@ +
+
+ +
+
+ @_data.TotalOrders + Tong don hang +
+
+ @* Average Order Value *@ +
+
+ +
+
+ @FormatVND(_data.AverageOrderValue) + Gia tri TB / don +
+
+ @* Growth % *@ +
+
+ +
+
+ + @(_data.GrowthPercentage >= 0 ? "+" : "")@_data.GrowthPercentage.ToString("N1")% + + Tang truong so voi ky truoc +
+
+
+ + @* ── REVENUE TREND CHART ── *@ +
+
+

Xu huong doanh thu

+
+
+ @if (_trendChartSeries.Any()) + { + + } + else + { +
Khong co du lieu xu huong.
+ } +
+
+ + @* ── PAYMENT METHODS & VERTICAL BREAKDOWN ── *@ +
+ @* Payment Method Donut *@ +
+
+

Phuong thuc thanh toan

+
+
+ @if (_data.PaymentMethods.Any()) + { +
+
+ +
+
+ @foreach (var p in _data.PaymentMethods) + { +
+
+ @GetPaymentMethodLabel(p.Method) + @p.Percentage% +
+ @FormatVND(p.Amount) +
+ } +
+
+ } + else + { +
Khong co du lieu thanh toan.
+ } +
+
+ + @* Vertical Revenue Bar Chart *@ +
+
+

Doanh thu theo nganh

+
+
+ @if (_verticalChartSeries.Any()) + { + + } + else + { +
Khong co du lieu nganh.
+ } +
+
+
+ + @* ── ORDER COUNT DISTRIBUTION ── *@ +
+
+

Phan bo don hang theo thoi gian

+
+
+ @if (_orderCountChartSeries.Any()) + { + + } + else + { +
Khong co du lieu phan bo don hang.
+ } +
+
+ + @* ── TOP 10 PRODUCTS TABLE ── *@ +
+
+

Top 10 san pham ban chay

+
+
+ @if (_data.TopProducts.Any()) + { + + + # + Ten san pham + So luong ban + Doanh thu + + + @(_data.TopProducts.IndexOf(context) + 1) + @context.Name + @context.QuantitySold + @FormatVND(context.Revenue) + + + } + else + { +
Khong co du lieu san pham.
+ } +
+
+ } +
+ +@code { + // EN: Date range for the report / VI: Khoang thoi gian cho bao cao + private DateRange _dateRange = new(DateTime.Today.AddDays(-30), DateTime.Today); + private string _selectedPeriod = "daily"; + private bool _loading = false; + private PosDataService.RevenueAnalyticsInfo? _data; + + // EN: Chart data arrays / VI: Mang du lieu bieu do + private List _trendChartSeries = new(); + private string[] _trendChartLabels = Array.Empty(); + private double[] _paymentChartData = Array.Empty(); + private string[] _paymentChartLabels = Array.Empty(); + private List _verticalChartSeries = new(); + private string[] _verticalChartLabels = Array.Empty(); + private List _orderCountChartSeries = new(); + + private ChartOptions _lineOptions = new() + { + ChartPalette = new[] { "#FF5C00", "#3B82F6" }, + YAxisTicks = 5, + LineStrokeWidth = 2.5 + }; + + private ChartOptions _donutOptions = new() + { + ChartPalette = new[] { "#22C55E", "#3B82F6", "#8B5CF6", "#F59E0B", "#EC4899", "#FF5C00" } + }; + + private ChartOptions _barOptions = new() + { + ChartPalette = new[] { "#FF5C00", "#3B82F6", "#22C55E", "#8B5CF6", "#F59E0B" }, + YAxisTicks = 5 + }; + + private ChartOptions _orderCountBarOptions = new() + { + ChartPalette = new[] { "#3B82F6" }, + YAxisTicks = 5 + }; + + /// + /// EN: Get the current shop ID from query string context. + /// VI: Lay shop ID hien tai tu query string context. + /// + private Guid? GetCurrentShopId() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + if (Guid.TryParse(query["shopId"], out var qsShopId)) + return qsShopId; + return null; + } + + /// + /// EN: Load revenue analytics data. + /// VI: Tai du lieu phan tich doanh thu. + /// + private async Task LoadDataAsync() + { + var shopId = GetCurrentShopId(); + if (!shopId.HasValue) + { + Snackbar.Add("Vui long chon shop truoc khi xem bao cao.", Severity.Warning); + return; + } + + if (_dateRange.Start == null || _dateRange.End == null) + { + Snackbar.Add("Vui long chon khoang thoi gian.", Severity.Warning); + return; + } + + _loading = true; + _data = null; + StateHasChanged(); + + try + { + _data = await DataService.GetRevenueAnalyticsAsync( + shopId.Value, + _dateRange.Start.Value, + _dateRange.End.Value, + _selectedPeriod); + + if (_data != null) + { + BuildChartData(); + Snackbar.Add("Da tai phan tich doanh thu thanh cong.", Severity.Success); + } + else + { + Snackbar.Add("Khong the tai du lieu. Vui long thu lai.", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Loi: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + /// + /// EN: Build chart data arrays from analytics data. + /// VI: Xay dung mang du lieu bieu do tu du lieu phan tich. + /// + private void BuildChartData() + { + if (_data == null) return; + + // EN: Revenue trend line chart / VI: Bieu do duong xu huong doanh thu + if (_data.Trends.Any()) + { + _trendChartLabels = _data.Trends.Select(t => FormatTrendLabel(t.Date)).ToArray(); + _trendChartSeries = new List + { + new ChartSeries { Name = "Doanh thu", Data = _data.Trends.Select(t => (double)t.Revenue).ToArray() } + }; + _orderCountChartSeries = new List + { + new ChartSeries { Name = "So don hang", Data = _data.Trends.Select(t => (double)t.OrderCount).ToArray() } + }; + } + else + { + _trendChartLabels = Array.Empty(); + _trendChartSeries = new(); + _orderCountChartSeries = new(); + } + + // EN: Payment method donut chart / VI: Bieu do tron phuong thuc thanh toan + if (_data.PaymentMethods.Any()) + { + _paymentChartData = _data.PaymentMethods.Select(p => (double)p.Amount).ToArray(); + _paymentChartLabels = _data.PaymentMethods.Select(p => GetPaymentMethodLabel(p.Method)).ToArray(); + } + else + { + _paymentChartData = Array.Empty(); + _paymentChartLabels = Array.Empty(); + } + + // EN: Vertical breakdown bar chart / VI: Bieu do cot phan tich nganh + if (_data.VerticalBreakdown.Any()) + { + _verticalChartLabels = _data.VerticalBreakdown.Select(v => GetVerticalLabel(v.Vertical)).ToArray(); + _verticalChartSeries = new List + { + new ChartSeries { Name = "Doanh thu", Data = _data.VerticalBreakdown.Select(v => (double)v.Revenue).ToArray() } + }; + } + else + { + _verticalChartLabels = Array.Empty(); + _verticalChartSeries = new(); + } + } + + /// + /// EN: Format trend date label based on selected period. + /// VI: Dinh dang nhan ngay xu huong theo ky da chon. + /// + private string FormatTrendLabel(DateTime date) => _selectedPeriod switch + { + "monthly" => date.ToString("MM/yyyy"), + "weekly" => $"T{System.Globalization.ISOWeek.GetWeekOfYear(date)} {date:yyyy}", + _ => date.ToString("dd/MM") + }; + + /// + /// EN: Get Vietnamese label for payment method. + /// VI: Lay nhan tieng Viet cho phuong thuc thanh toan. + /// + private static string GetPaymentMethodLabel(string method) => method.ToLowerInvariant() switch + { + "cash" => "Tien mat", + "card" => "The", + "vnpay" => "VNPay", + "momo" => "MoMo", + "qr" => "QR Code", + "transfer" => "Chuyen khoan", + "unknown" => "Chua xac dinh", + _ => method + }; + + /// + /// EN: Get Vietnamese label for business vertical. + /// VI: Lay nhan tieng Viet cho nganh kinh doanh. + /// + private static string GetVerticalLabel(string vertical) => vertical.ToLowerInvariant() switch + { + "karaoke" => "Karaoke", + "restaurant" => "Nha hang", + "cafe" => "Cafe", + "spa" => "Spa", + "retail" => "Ban le", + "other" => "Khac", + _ => vertical + }; + + private static string FormatVND(decimal val) => val.ToString("N0") + " d"; +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Reports/StaffPerformance.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Reports/StaffPerformance.razor new file mode 100644 index 00000000..31610c76 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Reports/StaffPerformance.razor @@ -0,0 +1,360 @@ +@page "/admin/reports/staff" +@layout AdminLayout +@inherits AdminBase +@inject PosDataService DataService +@inject ISnackbar Snackbar +@inject IJSRuntime JS +@using WebClientTpos.Client.Services +@using System.Text + +@* + EN: Staff Performance Dashboard — orders handled, revenue, completion rates per staff member. + VI: Trang hieu suat nhan vien — don xu ly, doanh thu, ty le hoan thanh theo nhan vien. +*@ + +Hieu suat nhan vien — GoodGo POS + +@* ═══ TOP BAR ═══ *@ +
+
+

Hieu suat nhan vien

+

Phan tich hieu qua lam viec cua nhan vien theo don hang va doanh thu

+
+
+ + + @if (_loading) + { + + } + Xem hieu suat + + + Xuat CSV + +
+
+ +@* ═══ CONTENT ═══ *@ +
+ + @if (_loading) + { +
+ +
+ } + else if (_data == null) + { + + Chon khoang thoi gian va nhan "Xem hieu suat" de tai du lieu hieu suat nhan vien. + + } + else + { + @* ── SUMMARY CARDS ── *@ +
+ @* Best Performer *@ +
+
+ +
+
+ @(_bestPerformer?.StaffName ?? "N/A") + Nhan vien xuat sac nhat + @if (_bestPerformer != null) + { + @FormatVND(_bestPerformer.TotalRevenue) doanh thu + } +
+
+ @* Total Staff *@ +
+
+ +
+
+ @_data.Staff.Count + Tong nhan vien +
+
+ @* Average Completion Rate *@ +
+
+ +
+
+ @(_data.ShopAverage.CompletionRate.ToString("F1"))% + Ty le hoan thanh TB +
+
+ @* Average Handling Time *@ +
+
+ +
+
+ @(_data.ShopAverage.AverageHandlingTimeMinutes.ToString("F1")) phut + Thoi gian xu ly TB +
+
+
+ + @* ── STAFF REVENUE COMPARISON CHART ── *@ +
+
+

So sanh doanh thu nhan vien

+
+
+ @if (_revenueChartSeries.Any()) + { + + } + else + { +
Khong co du lieu de so sanh.
+ } +
+
+ + @* ── STAFF PERFORMANCE TABLE ── *@ +
+
+

Chi tiet hieu suat nhan vien

+
+
+ + + Nhan vien + Don xu ly + Doanh thu + GT TB / don + Ty le HT + TG xu ly TB + + + +
+ @if (context == _bestPerformer) + { + + } + @context.StaffName +
+
+ @context.OrdersHandled + @FormatVND(context.TotalRevenue) + @FormatVND(context.AverageOrderValue) + + + @context.CompletionRate.ToString("F1")% + + + @context.AverageHandlingTimeMinutes.ToString("F1") phut +
+ + Trung binh cua hang + @_data.ShopAverage.OrdersHandled + @FormatVND(_data.ShopAverage.TotalRevenue) + @FormatVND(_data.ShopAverage.AverageOrderValue) + @_data.ShopAverage.CompletionRate.ToString("F1")% + @_data.ShopAverage.AverageHandlingTimeMinutes.ToString("F1") phut + +
+
+
+ } +
+ +@code { + // EN: Date range for the report / VI: Khoang thoi gian cho bao cao + private DateRange _dateRange = new(DateTime.Today.AddDays(-30), DateTime.Today); + private bool _loading = false; + private PosDataService.StaffPerformanceInfo? _data; + private PosDataService.StaffMetricsInfo? _bestPerformer; + + // EN: Chart data / VI: Du lieu bieu do + private List _revenueChartSeries = new(); + private string[] _revenueChartLabels = Array.Empty(); + + private ChartOptions _barOptions = new() + { + ChartPalette = new[] { "#FF5C00", "#22C55E" }, + YAxisTicks = 5 + }; + + /// + /// EN: Get the current shop ID from query string context. + /// VI: Lay shop ID hien tai tu query string context. + /// + private Guid? GetCurrentShopId() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + if (Guid.TryParse(query["shopId"], out var qsShopId)) + return qsShopId; + return null; + } + + /// + /// EN: Load staff performance data. + /// VI: Tai du lieu hieu suat nhan vien. + /// + private async Task LoadDataAsync() + { + var shopId = GetCurrentShopId(); + if (!shopId.HasValue) + { + Snackbar.Add("Vui long chon shop truoc khi xem bao cao.", Severity.Warning); + return; + } + + if (_dateRange.Start == null || _dateRange.End == null) + { + Snackbar.Add("Vui long chon khoang thoi gian.", Severity.Warning); + return; + } + + _loading = true; + _data = null; + _bestPerformer = null; + StateHasChanged(); + + try + { + _data = await DataService.GetStaffPerformanceAsync( + shopId.Value, + _dateRange.Start.Value, + _dateRange.End.Value); + + if (_data != null) + { + _bestPerformer = _data.Staff.OrderByDescending(s => s.TotalRevenue).FirstOrDefault(); + BuildChartData(); + Snackbar.Add("Da tai hieu suat nhan vien thanh cong.", Severity.Success); + } + else + { + Snackbar.Add("Khong the tai du lieu. Vui long thu lai.", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Loi: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + /// + /// EN: Build chart data from staff performance data. + /// VI: Xay dung du lieu bieu do tu du lieu hieu suat nhan vien. + /// + private void BuildChartData() + { + if (_data == null || !_data.Staff.Any()) + { + _revenueChartLabels = Array.Empty(); + _revenueChartSeries = new(); + return; + } + + var orderedStaff = _data.Staff.OrderByDescending(s => s.TotalRevenue).Take(10).ToList(); + _revenueChartLabels = orderedStaff.Select(s => s.StaffName).ToArray(); + _revenueChartSeries = new List + { + new ChartSeries { Name = "Doanh thu", Data = orderedStaff.Select(s => (double)s.TotalRevenue).ToArray() }, + new ChartSeries { Name = "Don hang", Data = orderedStaff.Select(s => (double)s.OrdersHandled).ToArray() } + }; + } + + /// + /// EN: Get row style color-coded by completion rate: green (>=90%), yellow (70-89%), red (<70%). + /// VI: Lay style dong theo ma mau ty le hoan thanh: xanh (>=90%), vang (70-89%), do (<70%). + /// + private string GetRowStyle(PosDataService.StaffMetricsInfo staff) + { + if (staff.CompletionRate >= 90) + return "background:rgba(34,197,94,0.06);"; + if (staff.CompletionRate >= 70) + return "background:rgba(245,158,11,0.06);"; + return "background:rgba(239,68,68,0.06);"; + } + + /// + /// EN: Export staff performance data to CSV and trigger browser download. + /// VI: Xuat du lieu hieu suat nhan vien sang CSV va kich hoat tai xuong tren trinh duyet. + /// + private async Task ExportCsvAsync() + { + if (_data == null || !_data.Staff.Any()) + { + Snackbar.Add("Khong co du lieu de xuat.", Severity.Warning); + return; + } + + try + { + var sb = new StringBuilder(); + sb.AppendLine("Nhan vien,Don xu ly,Doanh thu,GT TB / don,Hoan thanh,Da huy,Ty le HT (%),TG xu ly TB (phut)"); + foreach (var s in _data.Staff) + { + sb.AppendLine($"\"{s.StaffName}\",{s.OrdersHandled},{s.TotalRevenue:F0},{s.AverageOrderValue:F0},{s.CompletedOrders},{s.CancelledOrders},{s.CompletionRate:F1},{s.AverageHandlingTimeMinutes:F1}"); + } + + var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray(); + var base64 = Convert.ToBase64String(bytes); + var fileName = $"hieu-suat-nhan-vien_{_dateRange.Start:yyyyMMdd}_{_dateRange.End:yyyyMMdd}.csv"; + + // EN: Use JS interop to trigger download / VI: Dung JS interop de kich hoat tai xuong + await JS.InvokeVoidAsync("eval", $@" + var link = document.createElement('a'); + link.href = 'data:text/csv;base64,{base64}'; + link.download = '{fileName}'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + "); + + Snackbar.Add($"Da xuat file {fileName}.", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"Loi khi xuat CSV: {ex.Message}", Severity.Error); + } + } + + private static string FormatVND(decimal val) => val.ToString("N0") + " d"; +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/QrCodeGenerator.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/QrCodeGenerator.razor new file mode 100644 index 00000000..6181c944 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/QrCodeGenerator.razor @@ -0,0 +1,270 @@ +@page "/admin/shop/{ShopId:guid}/qr-codes" +@layout WebClientTpos.Client.Layout.AdminLayout +@using WebClientTpos.Client.Services +@inject PosDataService DataService +@inject NavigationManager Nav +@inject IJSRuntime JS + +
+
+

+ Mã QR Thực Đơn +

+

Tạo và in mã QR cho từng bàn/phòng để khách hàng quét xem menu

+
+
+ + Quản lý bàn + + @if (_tables.Any()) + { + + + } +
+
+ +@if (_loading) +{ +
+
+

Đang tải danh sách bàn...

+
+} +else if (!_tables.Any()) +{ +
+
+ +
+

Chưa có bàn nào

+

Tạo bàn trước rồi quay lại đây để tạo mã QR

+ + Thêm bàn + +
+} +else +{ + @* ═══ STATS ═══ *@ +
+
+ Tổng bàn: + @_tables.Count +
+
+ Có QR: + @_tables.Count(t => !string.IsNullOrEmpty(t.QrToken)) +
+
+ Chưa có QR: + @_tables.Count(t => string.IsNullOrEmpty(t.QrToken)) +
+
+ + @if (!string.IsNullOrEmpty(_batchMessage)) + { +
+ @_batchMessage +
+ } + + @* ═══ QR CARDS GRID ═══ *@ +
+ @foreach (var table in _tables) + { + var hasQr = !string.IsNullOrEmpty(table.QrToken); + var menuUrl = $"{Nav.BaseUri}menu/{ShopId}/{table.Id}"; + var qrUrl = hasQr ? $"{Nav.BaseUri}table/{table.QrToken}" : null; + var displayUrl = qrUrl ?? menuUrl; + +
+ @* Table header *@ +
+
+
Bàn @table.TableNumber
+
@(table.Zone ?? "Chung") • @table.Capacity chỗ
+
+ @if (hasQr) + { + + Có QR + + } + else + { + Chưa có QR + } +
+ + @* QR Code *@ +
+ @if (hasQr) + { +
+ QR Code +
+
@displayUrl
+
+ + +
+ } + else + { +
+ +

Chưa có mã QR

+ +
+ } +
+
+ } +
+} + +@if (_copied) +{ +
+ Đã sao chép URL! +
+} + + + +@code { + [Parameter] public Guid ShopId { get; set; } + + private List _tables = new(); + private bool _loading = true; + private bool _batchGenerating; + private string? _batchMessage; + private bool _batchSuccess; + private bool _copied; + + protected override async Task OnInitializedAsync() + { + if (ShopId != Guid.Empty) + _tables = await DataService.GetTablesAsync(ShopId); + _loading = false; + } + + private async Task GenerateSingleQr(PosDataService.TableInfo table) + { + try + { + var token = await DataService.GenerateTableQrTokenAsync(table.Id); + if (token != null) + { + _tables = await DataService.GetTablesAsync(ShopId); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"QR generation failed for table {table.TableNumber}: {ex.Message}"); + } + } + + private async Task BatchGenerateQr() + { + _batchGenerating = true; + _batchMessage = null; + var generated = 0; + var failed = 0; + + foreach (var table in _tables.Where(t => string.IsNullOrEmpty(t.QrToken))) + { + try + { + var token = await DataService.GenerateTableQrTokenAsync(table.Id); + if (token != null) generated++; + else failed++; + } + catch { failed++; } + } + + _tables = await DataService.GetTablesAsync(ShopId); + + if (failed == 0) + { + _batchMessage = $"Đã tạo mã QR cho {generated} bàn thành công!"; + _batchSuccess = true; + } + else + { + _batchMessage = $"Tạo xong: {generated} thành công, {failed} thất bại."; + _batchSuccess = false; + } + _batchGenerating = false; + } + + private async Task CopyUrl(string url) + { + try + { + await JS.InvokeVoidAsync("navigator.clipboard.writeText", url); + _copied = true; + StateHasChanged(); + await Task.Delay(2000); + _copied = false; + StateHasChanged(); + } + catch { } + } + + private async Task DownloadQr(string tableNumber, string url) + { + try + { + var qrImageUrl = $"https://api.qrserver.com/v1/create-qr-code/?size=400x400&format=png&data={Uri.EscapeDataString(url)}"; + await JS.InvokeVoidAsync("eval", + $"var a=document.createElement('a');a.href='{qrImageUrl}';a.download='qr-ban-{tableNumber}.png';a.target='_blank';document.body.appendChild(a);a.click();document.body.removeChild(a);"); + } + catch { } + } + + private async Task PrintAllQr() + { + var tablesWithQr = _tables.Where(t => !string.IsNullOrEmpty(t.QrToken)).ToList(); + if (!tablesWithQr.Any()) return; + + var cards = string.Join("", tablesWithQr.Select(t => + { + var qrUrl = $"{Nav.BaseUri}table/{t.QrToken}"; + return $@" +
+

Ban {t.TableNumber}

+

{t.Zone ?? "Chung"} - {t.Capacity} cho

+ +

{qrUrl}

+
"; + })); + + var html = $@" + + Ma QR - Cua hang + +

Ma QR Thuc Don

+
+ {cards} +
+ + "; + + await JS.InvokeVoidAsync("eval", + $"var w=window.open('','_blank','width=900,height=700');w.document.write(`{html.Replace("`", "\\`")}`);w.document.close();setTimeout(function(){{w.print();}},500);"); + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor index c32f83ea..ca63aa1d 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor @@ -23,6 +23,9 @@ Sơ đồ + + Mã QR hàng loạt + @@ -148,6 +151,9 @@ else if (SubSection == "rooms") Đang hát: @_tables.Count(t => t.Status == "occupied") Đã đặt: @_tables.Count(t => t.Status == "reserved") + + Mã QR hàng loạt + diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Customer/Menu.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Customer/Menu.razor new file mode 100644 index 00000000..3ce12a18 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Customer/Menu.razor @@ -0,0 +1,422 @@ +@page "/menu/{ShopId:guid}" +@page "/menu/{ShopId:guid}/{TableId:guid}" +@layout WebClientTpos.Client.Layout.CustomerLayout +@using WebClientTpos.Client.Services +@inject PosDataService DataService +@inject NavigationManager Nav + +@if (_loading) +{ +
+
+
+

Đang tải thực đơn...

+
+
+} +else if (_error != null) +{ +
+
+
+ +
+

Không tìm thấy thực đơn

+

@_error

+
+
+} +else +{ + @* ═══ STICKY HEADER ═══ *@ +
+
+
+ @if (!string.IsNullOrEmpty(_shop?.LogoUrl)) + { + @_shop.Name + } + else + { +
+ +
+ } +
+

@(_shop?.Name ?? "Thực đơn")

+ @if (!string.IsNullOrEmpty(_shop?.Address)) + { +

@_shop.Address

+ } +
+
+ + @* ═══ SEARCH BAR ═══ *@ +
+ + + @if (!string.IsNullOrEmpty(_searchQuery)) + { + + } +
+
+
+ +
+ @* ═══ CATEGORY TABS ═══ *@ + @if (_categories.Count > 1 && string.IsNullOrEmpty(_searchQuery)) + { +
+ + @foreach (var cat in _categories) + { + var catId = cat.Id; + + } +
+ } + + @* ═══ MENU ITEMS ═══ *@ + @if (!FilteredItems.Any()) + { +
+ +

Không tìm thấy món nào

+

Thử tìm kiếm với từ khóa khác

+
+ } + else if (!string.IsNullOrEmpty(_searchQuery)) + { + @* ═══ SEARCH RESULTS — FLAT LIST ═══ *@ +
+ @foreach (var item in FilteredItems) + { + @RenderMenuItem(item) + } +
+ } + else + { + @* ═══ CATEGORIZED VIEW ═══ *@ + @foreach (var category in DisplayCategories) + { +
+

+ @category.Name + @category.Items.Count món +

+
+ @foreach (var item in category.Items) + { + @RenderMenuItem(item) + } +
+
+ } + } +
+ + @* ═══ STICKY BOTTOM BAR ═══ *@ + @if (_cart.Any()) + { +
+
+ +
+
+ } + + @* ═══ CART PANEL ═══ *@ + @if (_showCart && _cart.Any()) + { +
+
+
+
+

Giỏ hàng

+ +
+ @foreach (var item in _cart) + { +
+
+
@item.Name
+
@FormatVND(item.Price)
+
+
+ + @item.Qty + +
+
+ } +
+ Tổng cộng + @FormatVND(_cart.Sum(c => c.Price * c.Qty)) +
+ + @if (_orderMessage != null) + { +
+ @_orderMessage +
+ } +
+
+ } + + @* ═══ ORDER SUCCESS TOAST ═══ *@ + @if (_showOrderSuccess) + { +
+ + Đặt món thành công! +
+ } +} + + + +@code { + [Parameter] public Guid ShopId { get; set; } + [Parameter] public Guid? TableId { get; set; } + + private bool _loading = true; + private string? _error; + private PosDataService.PublicShopInfo? _shop; + private List _categories = new(); + + // Search & filter + private string _searchQuery = ""; + private Guid? _selectedCategoryId; + + // Cart + private readonly List _cart = new(); + private bool _showCart; + private bool _ordering; + private string? _orderMessage; + private bool _orderSuccess; + private bool _showOrderSuccess; + + // EN: Filtered items based on search query and selected category. + // VI: Lọc món dựa trên từ khóa tìm kiếm và danh mục đã chọn. + private IEnumerable FilteredItems + { + get + { + var allItems = _categories.SelectMany(c => c.Items); + + if (!string.IsNullOrWhiteSpace(_searchQuery)) + { + var query = _searchQuery.Trim().ToLowerInvariant(); + return allItems.Where(i => + i.Name.ToLowerInvariant().Contains(query) || + (i.Description?.ToLowerInvariant().Contains(query) ?? false)); + } + + if (_selectedCategoryId != null) + { + var cat = _categories.FirstOrDefault(c => c.Id == _selectedCategoryId); + return cat?.Items ?? Enumerable.Empty(); + } + + return allItems; + } + } + + // EN: Categories to display (respecting selected category filter). + // VI: Danh mục để hiển thị (tôn trọng bộ lọc danh mục đã chọn). + private IEnumerable DisplayCategories => + _selectedCategoryId != null + ? _categories.Where(c => c.Id == _selectedCategoryId) + : _categories; + + protected override async Task OnInitializedAsync() + { + try + { + // EN: Fetch shop info and menu in parallel + // VI: Lấy thông tin shop và menu song song + var shopTask = DataService.GetPublicShopInfoAsync(ShopId); + var menuTask = DataService.GetPublicMenuAsync(ShopId); + + await Task.WhenAll(shopTask, menuTask); + + _shop = await shopTask; + _categories = await menuTask; + + if (_shop == null && !_categories.Any()) + { + _error = "Cửa hàng không tồn tại hoặc chưa có thực đơn."; + } + } + catch + { + _error = "Không thể tải thực đơn. Vui lòng thử lại."; + } + _loading = false; + } + + private RenderFragment RenderMenuItem(PosDataService.PublicMenuItem item) => __builder => + { + var inCart = _cart.FirstOrDefault(c => c.ProductId == item.Id); + var isUnavailable = !item.IsAvailable; + +
+ @* Image or placeholder *@ + @if (!string.IsNullOrEmpty(item.ImageUrl)) + { +
+ @item.Name + @if (isUnavailable) + { +
Hết
+ } +
+ } + else + { +
+ + @if (isUnavailable) + { +
Hết
+ } +
+ } + + @* Content *@ +
+
+
@item.Name
+ @if (!string.IsNullOrEmpty(item.Description)) + { +
@item.Description
+ } +
+
+ @FormatVND(item.Price) + @if (!isUnavailable) + { + @if (inCart != null) + { +
+ + @inCart.Qty + +
+ } + else + { + + } + } + else + { + Hết hàng + } +
+
+
+ }; + + private void AddToCart(PosDataService.PublicMenuItem item) + { + var existing = _cart.FirstOrDefault(c => c.ProductId == item.Id); + if (existing != null) + existing.Qty++; + else + _cart.Add(new CartItem(item.Id, item.Name, item.Price, 1)); + } + + private void UpdateCartQty(Guid productId, int delta) + { + var item = _cart.FirstOrDefault(c => c.ProductId == productId); + if (item == null) return; + item.Qty += delta; + if (item.Qty <= 0) _cart.Remove(item); + } + + private async Task PlaceOrder() + { + _ordering = true; + _orderMessage = null; + try + { + var items = _cart.Select(c => new PosDataService.PosOrderItemRequest( + c.ProductId, c.Name, c.Qty, c.Price, "PreparedFood")).ToList(); + var req = new PosDataService.CreatePosOrderRequest( + ShopId, null, items, null, null, null, TableId); + var result = await DataService.CreatePosOrderAsync(req); + if (result != null) + { + _orderSuccess = true; + _orderMessage = "Đặt món thành công! Món sẽ được phục vụ sớm nhất."; + _cart.Clear(); + _showCart = false; + _showOrderSuccess = true; + StateHasChanged(); + await Task.Delay(3000); + _showOrderSuccess = false; + StateHasChanged(); + } + else + { + _orderSuccess = false; + _orderMessage = "Không thể đặt món. Vui lòng thử lại."; + } + } + catch + { + _orderSuccess = false; + _orderMessage = "Lỗi khi đặt món. Vui lòng thử lại."; + } + _ordering = false; + } + + private static string FormatVND(decimal amount) + { + // EN: Format as Vietnamese dong: "45.000d" with dot thousands separator. + // VI: Định dạng đồng Việt Nam: "45.000d" với dấu chấm ngăn cách hàng nghìn. + return $"{amount:N0}".Replace(",", ".") + "₫"; + } + + private class CartItem + { + public Guid ProductId { get; set; } + public string Name { get; set; } + public decimal Price { get; set; } + public int Qty { get; set; } + public CartItem(Guid productId, string name, decimal price, int qty) + { ProductId = productId; Name = name; Price = price; Qty = qty; } + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/css/pos.css b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/css/pos.css index 12f03949..ca31a3bd 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/css/pos.css +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/css/pos.css @@ -932,4 +932,557 @@ } .pos-settings-input:focus { border-color: var(--pos-orange-primary, #ff5c00); +} + +/* ═════════════════════════════════════════════════════════════════════════ + 12. POS RESPONSIVE — Mobile hamburger, sidebar, order drawer + EN: Responsive elements for tablet/mobile POS usage. + VI: Các phần tử responsive cho POS trên tablet/mobile. + ═════════════════════════════════════════════════════════════════════════ */ + +/* EN: Mobile hamburger toggle — hidden on desktop / VI: Nút hamburger — ẩn trên desktop */ +.pos-mobile-toggle { + display: none; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + border: none; + background: transparent; + color: var(--pos-text-primary); + cursor: pointer; + transition: background 0.2s ease; + flex-shrink: 0; +} + +.pos-mobile-toggle:hover { + background-color: var(--pos-bg-interactive); +} + +/* EN: Order panel toggle — hidden on desktop / VI: Nút toggle panel đơn hàng — ẩn trên desktop */ +.pos-order-toggle { + display: none; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + border: none; + background: transparent; + color: var(--pos-text-primary); + cursor: pointer; + transition: background 0.2s ease; + position: relative; + flex-shrink: 0; +} + +.pos-order-toggle:hover { + background-color: var(--pos-bg-interactive); +} + +.pos-order-toggle__badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 16px; + height: 16px; + border-radius: 8px; + background: var(--pos-orange-primary); + color: #FFFFFF; + font-size: 10px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; +} + +/* EN: Admin button — always visible / VI: Nút admin — luôn hiện */ +.pos-admin-btn { + flex-shrink: 0; +} + +/* EN: Status bar time / VI: Thời gian thanh trạng thái */ +.pos-status-bar__time { + font-size: 13px; + color: var(--pos-text-secondary); +} + +/* EN: Sidebar navigation — hidden on desktop (desktop uses page-level nav) + VI: Sidebar điều hướng — ẩn trên desktop (desktop dùng nav cấp trang) */ +.pos-sidebar { + display: none; + position: fixed; + top: 0; + left: -280px; + width: 280px; + height: 100vh; + background-color: var(--pos-bg-elevated); + border-right: 1px solid var(--pos-border-subtle); + z-index: 200; + flex-direction: column; + transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.pos-sidebar--open { + left: 0; +} + +.pos-sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--pos-border-subtle); +} + +.pos-sidebar__close { + width: 32px; + height: 32px; + border-radius: 8px; + border: none; + background: transparent; + color: var(--pos-text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.pos-sidebar__close:hover { + background-color: var(--pos-bg-interactive); +} + +.pos-sidebar__nav { + flex: 1; + padding: 8px; + overflow-y: auto; +} + +.pos-sidebar__footer { + padding: 8px; + border-top: 1px solid var(--pos-border-subtle); +} + +.pos-sidebar__link { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 12px; + border-radius: var(--pos-radius); + color: var(--pos-text-secondary); + text-decoration: none; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; +} + +.pos-sidebar__link:hover { + background-color: var(--pos-bg-interactive); + color: var(--pos-text-primary); +} + +/* EN: Sidebar overlay — click to close / VI: Overlay sidebar — click để đóng */ +.pos-sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 190; +} + +/* EN: Desktop-only cart panel modifier — shown inline on desktop, hidden on tablet/mobile + VI: Modifier cart panel chi desktop — hiện inline trên desktop, ẩn trên tablet/mobile */ +.pos-cart-panel--desktop { + display: flex; +} + +/* EN: Page content wrapper — enables flex child behavior + VI: Wrapper nội dung trang — cho phép behavior flex child */ +.pos-page-content { + flex: 1; + display: flex; + overflow: hidden; + min-width: 0; +} + +/* EN: Order panel drawer — slides from right, hidden on desktop + VI: Drawer panel đơn hàng — trượt từ phải, ẩn trên desktop */ +.pos-order-drawer { + display: none; + position: fixed; + top: 0; + right: -400px; + width: 380px; + max-width: 100vw; + height: 100vh; + background-color: var(--pos-bg-elevated); + border-left: 1px solid var(--pos-border-subtle); + z-index: 200; + flex-direction: column; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.pos-order-drawer--open { + right: 0; +} + +.pos-order-drawer__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--pos-border-subtle); + flex-shrink: 0; +} + +.pos-order-drawer__close { + width: 32px; + height: 32px; + border-radius: 8px; + border: none; + background: transparent; + color: var(--pos-text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.pos-order-drawer__close:hover { + background-color: var(--pos-bg-interactive); +} + +.pos-order-drawer__content { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +/* EN: Order panel overlay — click to close / VI: Overlay panel đơn hàng — click để đóng */ +.pos-order-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 190; +} + +/* ═════════════════════════════════════════════════════════════════════════ + 13. RESPONSIVE BREAKPOINTS + EN: Tablet (max-width: 1024px) and Mobile (max-width: 600px) adaptations. + VI: Tablet (max-width: 1024px) và Mobile (max-width: 600px) tùy chỉnh. + ═════════════════════════════════════════════════════════════════════════ */ + +/* ── TABLET: <= 1024px ── */ +@media (max-width: 1024px) { + /* EN: Show hamburger and order toggle / VI: Hiện hamburger và toggle đơn hàng */ + .pos-mobile-toggle { + display: flex; + } + + .pos-order-toggle { + display: flex; + } + + /* EN: Enable sidebar as overlay / VI: Bật sidebar dạng overlay */ + .pos-sidebar { + display: flex; + } + + .pos-sidebar-overlay { + display: block; + } + + /* EN: Enable order drawer as overlay / VI: Bật order drawer dạng overlay */ + .pos-order-drawer { + display: flex; + } + + .pos-order-overlay { + display: block; + } + + /* EN: Cart panel becomes hidden on tablet — use drawer instead + VI: Cart panel ẩn trên tablet — dùng drawer thay thế */ + .pos-cart-panel, + .pos-cart-panel--desktop { + display: none !important; + } + + /* EN: Product panel takes full width / VI: Panel sản phẩm chiếm hết chiều rộng */ + .pos-product-panel { + flex: 1; + width: 100%; + } + + /* EN: Content area fills available space / VI: Vùng nội dung chiếm hết không gian */ + .pos-content-area { + flex-direction: column; + } + + /* EN: Adjust product grid for tablet / VI: Điều chỉnh lưới sản phẩm cho tablet */ + .pos-product-grid { + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 10px; + padding: 12px; + } + + /* EN: Dashboard stats 2 columns on tablet / VI: Thống kê dashboard 2 cột trên tablet */ + .pos-dashboard__stats { + grid-template-columns: repeat(2, 1fr); + } + + /* EN: Dashboard grid single column / VI: Lưới dashboard 1 cột */ + .pos-dashboard__grid { + grid-template-columns: 1fr; + } + + /* EN: History toolbar wrap / VI: Thanh công cụ lịch sử wrap */ + .pos-history__toolbar { + flex-wrap: wrap; + } + + /* EN: Touch-friendly category tabs / VI: Tab danh mục thân thiện cảm ứng */ + .pos-category-tab { + padding: 10px 18px; + font-size: 14px; + min-height: 44px; + } + + /* EN: Touch-friendly buttons / VI: Nút thân thiện cảm ứng */ + .pos-btn-checkout { + min-height: 52px; + font-size: 16px; + } + + /* EN: Touch-friendly cart items / VI: Mục giỏ hàng thân thiện cảm ứng */ + .pos-cart-item { + padding: 12px; + min-height: 48px; + } + + .pos-cart-item__qty button { + width: 36px; + height: 36px; + } + + /* EN: Payment methods larger touch targets / VI: Phương thức thanh toán vùng chạm lớn hơn */ + .pos-payment-method-btn { + padding: 24px 16px; + min-height: 80px; + } + + .pos-payment-quick-btn { + padding: 14px 10px; + min-height: 48px; + } +} + +/* ── MOBILE: <= 600px ── */ +@media (max-width: 600px) { + /* EN: Compact status bar / VI: Thanh trạng thái thu gọn */ + .pos-status-bar { + height: 44px; + padding: 0 8px; + } + + .pos-status-bar__logo { + font-size: 13px; + } + + .pos-status-bar__store { + font-size: 11px; + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .pos-status-bar__time { + display: none; + } + + .pos-status-bar__indicator span:last-child { + display: none; + } + + .pos-status-bar__left { + gap: 8px; + } + + .pos-status-bar__right { + gap: 4px; + } + + /* EN: Adjust status bar height token / VI: Điều chỉnh token chiều cao thanh trạng thái */ + .pos-layout { + --pos-status-bar-height: 44px; + } + + /* EN: Order drawer full width on mobile / VI: Drawer đơn hàng full chiều rộng trên mobile */ + .pos-order-drawer { + width: 100vw; + right: -100vw; + } + + /* EN: Product grid single/double column / VI: Lưới sản phẩm 1-2 cột */ + .pos-product-grid { + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 8px; + padding: 8px; + } + + /* EN: Smaller product cards / VI: Card sản phẩm nhỏ hơn */ + .pos-product-card { + padding: 10px; + gap: 6px; + } + + .pos-product-card__name { + font-size: 12px; + } + + .pos-product-card__price { + font-size: 13px; + } + + /* EN: Category tabs scrollable with momentum / VI: Tab danh mục cuộn với momentum */ + .pos-category-tabs { + padding: 8px 8px; + gap: 6px; + -webkit-overflow-scrolling: touch; + } + + .pos-category-tab { + padding: 10px 14px; + font-size: 13px; + min-height: 44px; + } + + /* EN: Cart footer compact / VI: Footer giỏ hàng thu gọn */ + .pos-cart-footer { + padding: 12px; + } + + .pos-cart-total__value { + font-size: 18px; + } + + /* EN: Dashboard stats single column / VI: Thống kê dashboard 1 cột */ + .pos-dashboard__stats { + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .pos-dashboard { + padding: 12px; + } + + .pos-dashboard__title { + font-size: 16px; + } + + .pos-dashboard__stat-value { + font-size: 18px; + } + + /* EN: Payment quick amounts 2 columns / VI: Số tiền nhanh 2 cột */ + .pos-payment-quick-amounts { + grid-template-columns: repeat(2, 1fr); + } + + /* EN: Bottom navigation — visible on mobile / VI: Nav dưới — hiện trên mobile */ + .pos-bottom-nav { + height: 56px; + } + + .pos-bottom-nav__tab { + font-size: 10px; + min-height: 56px; + } + + /* EN: History list compact / VI: Danh sách lịch sử thu gọn */ + .pos-history__list { + padding: 8px; + } + + .pos-history__card { + padding: 12px; + } + + .pos-history__items-preview { + max-width: 50%; + font-size: 11px; + } + + /* EN: Dialog full width on mobile / VI: Dialog full chiều rộng trên mobile */ + .pos-dialog { + width: 100%; + max-width: 100%; + border-radius: 16px 16px 0 0; + max-height: 85vh; + position: fixed; + bottom: 0; + left: 0; + right: 0; + } +} + +/* ── LARGE DESKTOP: >= 1280px ── */ +@media (min-width: 1280px) { + /* EN: Wider cart panel on large screens / VI: Panel giỏ hàng rộng hơn trên màn hình lớn */ + .pos-cart-panel { + width: 400px; + min-width: 400px; + } + + /* EN: Larger product grid items / VI: Mục lưới sản phẩm lớn hơn */ + .pos-product-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 14px; + } + + /* EN: Dashboard stats 4 columns / VI: Thống kê dashboard 4 cột */ + .pos-dashboard__stats { + grid-template-columns: repeat(4, 1fr); + } +} + +/* ═════════════════════════════════════════════════════════════════════════ + 14. TOUCH UTILITIES + EN: Touch-friendly utilities for POS on touch devices. + VI: Tiện ích thân thiện cảm ứng cho POS trên thiết bị cảm ứng. + ═════════════════════════════════════════════════════════════════════════ */ + +/* EN: Prevent text selection on touch targets / VI: Ngăn chọn văn bản trên vùng chạm */ +.pos-btn-checkout, +.pos-category-tab, +.pos-product-card, +.pos-payment-method-btn, +.pos-payment-quick-btn, +.pos-bottom-nav__tab, +.pos-mobile-toggle, +.pos-order-toggle, +.pos-sidebar__link { + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +/* EN: Safe area insets for notched devices / VI: Vùng an toàn cho thiết bị có tai thỏ */ +@supports (padding-bottom: env(safe-area-inset-bottom)) { + .pos-bottom-nav { + padding-bottom: env(safe-area-inset-bottom); + } + + .pos-cart-footer { + padding-bottom: calc(16px + env(safe-area-inset-bottom)); + } + + .pos-order-drawer .pos-cart-footer { + padding-bottom: calc(16px + env(safe-area-inset-bottom)); + } } \ No newline at end of file diff --git a/deployments/local/docker-compose.yml b/deployments/local/docker-compose.yml index 0d8fa360..ebb61c3c 100644 --- a/deployments/local/docker-compose.yml +++ b/deployments/local/docker-compose.yml @@ -1241,31 +1241,87 @@ services: # restart: unless-stopped # Prometheus - Metrics Collection - # prometheus: - # image: prom/prometheus:latest - # container_name: prometheus-local - # ports: - # - "9090:9090" - # volumes: - # - ../../infra/observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - # - prometheus_data:/prometheus - # networks: - # - microservices-network - # restart: unless-stopped + prometheus: + image: prom/prometheus:v2.51.0 + container_name: prometheus-local + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=15d' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--web.enable-lifecycle' + ports: + - "9090:9090" + volumes: + - ../../infra/observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ../../infra/observability/prometheus/rules:/etc/prometheus/rules:ro + - ../../infra/observability/prometheus/alert-rules.yml:/etc/prometheus/alert-rules.yml:ro + - prometheus_data:/prometheus + networks: + - microservices-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9090/-/healthy || exit 1"] + interval: 30s + timeout: 10s + retries: 3 - # Grafana - Metrics Visualization - # grafana: - # image: grafana/grafana:latest - # container_name: grafana-local - # ports: - # - "3001:3000" - # environment: - # - GF_SECURITY_ADMIN_PASSWORD=admin - # volumes: - # - grafana_data:/var/lib/grafana - # networks: - # - microservices-network - # restart: unless-stopped + # Grafana - Metrics Visualization & Dashboards + grafana: + image: grafana/grafana:10.3.1 + container_name: grafana-local + ports: + - "3002:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + - GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/etc/grafana/provisioning/dashboards/goodgo-overview.json + volumes: + - grafana_data:/var/lib/grafana + - ../../infra/observability/grafana/datasources:/etc/grafana/provisioning/datasources:ro + - ../../infra/observability/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro + networks: + - microservices-network + depends_on: + prometheus: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + + # Loki - Log Aggregation + loki: + image: grafana/loki:2.9.4 + container_name: loki-local + ports: + - "3100:3100" + command: -config.file=/etc/loki/loki-config.yml + volumes: + - ../../infra/observability/loki/loki-config.yml:/etc/loki/loki-config.yml:ro + - loki_data:/loki + networks: + - microservices-network + restart: unless-stopped + + # Promtail - Log Collector (ships container logs to Loki) + promtail: + image: grafana/promtail:2.9.4 + container_name: promtail-local + volumes: + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock + - ../../infra/observability/promtail/promtail-config.yml:/etc/promtail/promtail-config.yml:ro + command: -config.file=/etc/promtail/promtail-config.yml + networks: + - microservices-network + depends_on: + - loki + restart: unless-stopped # =========================================================================== # FRONTEND APPS @@ -1340,6 +1396,12 @@ volumes: driver: local rabbitmq_data: driver: local + prometheus_data: + driver: local + grafana_data: + driver: local + loki_data: + driver: local # ============================================================================= # NETWORKS # ============================================================================= diff --git a/deployments/production/kubernetes/booking-service.yaml b/deployments/production/kubernetes/booking-service.yaml new file mode 100644 index 00000000..e6844508 --- /dev/null +++ b/deployments/production/kubernetes/booking-service.yaml @@ -0,0 +1,136 @@ +# EN: Booking Service - Booking & Reservation System +# VI: Booking Service - He thong Dat cho & Dat lich +apiVersion: apps/v1 +kind: Deployment +metadata: + name: booking-service + namespace: production + labels: + app: booking-service + environment: production + platform: goodgo + tier: backend +spec: + replicas: 3 + selector: + matchLabels: + app: booking-service + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app: booking-service + environment: production + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - booking-service + topologyKey: kubernetes.io/hostname + containers: + - name: booking-service + image: goodgo/booking-service-net:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: goodgo-config + - secretRef: + name: goodgo-secrets + env: + - name: ConnectionStrings__DefaultConnection + valueFrom: + secretKeyRef: + name: goodgo-secrets + key: BOOKING_DATABASE_URL + - name: IamService__ServiceName + value: "booking-service" + resources: + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "1Gi" + cpu: "1000m" + livenessProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 12 +--- +apiVersion: v1 +kind: Service +metadata: + name: booking-service + namespace: production + labels: + app: booking-service + environment: production +spec: + selector: + app: booking-service + ports: + - name: http + protocol: TCP + port: 8080 + targetPort: 8080 + type: ClusterIP +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: booking-service-hpa + namespace: production + labels: + app: booking-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: booking-service + minReplicas: 3 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/deployments/production/kubernetes/catalog-service.yaml b/deployments/production/kubernetes/catalog-service.yaml new file mode 100644 index 00000000..5e4fd957 --- /dev/null +++ b/deployments/production/kubernetes/catalog-service.yaml @@ -0,0 +1,136 @@ +# EN: Catalog Service - Polymorphic Product & Category Management +# VI: Catalog Service - Quan ly San pham & Danh muc da hinh +apiVersion: apps/v1 +kind: Deployment +metadata: + name: catalog-service + namespace: production + labels: + app: catalog-service + environment: production + platform: goodgo + tier: backend +spec: + replicas: 3 + selector: + matchLabels: + app: catalog-service + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app: catalog-service + environment: production + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - catalog-service + topologyKey: kubernetes.io/hostname + containers: + - name: catalog-service + image: goodgo/catalog-service-net:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: goodgo-config + - secretRef: + name: goodgo-secrets + env: + - name: ConnectionStrings__DefaultConnection + valueFrom: + secretKeyRef: + name: goodgo-secrets + key: CATALOG_DATABASE_URL + - name: IamService__ServiceName + value: "catalog-service" + resources: + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "1Gi" + cpu: "1000m" + livenessProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 12 +--- +apiVersion: v1 +kind: Service +metadata: + name: catalog-service + namespace: production + labels: + app: catalog-service + environment: production +spec: + selector: + app: catalog-service + ports: + - name: http + protocol: TCP + port: 8080 + targetPort: 8080 + type: ClusterIP +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: catalog-service-hpa + namespace: production + labels: + app: catalog-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: catalog-service + minReplicas: 3 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/deployments/production/kubernetes/configmap.yaml b/deployments/production/kubernetes/configmap.yaml new file mode 100644 index 00000000..cb881044 --- /dev/null +++ b/deployments/production/kubernetes/configmap.yaml @@ -0,0 +1,58 @@ +# EN: Shared configuration for all GoodGo production services +# VI: Cau hinh chung cho tat ca cac service production cua GoodGo +apiVersion: v1 +kind: ConfigMap +metadata: + name: goodgo-config + namespace: production + labels: + environment: production + platform: goodgo +data: + # EN: ASP.NET Core Configuration + # VI: Cau hinh ASP.NET Core + ASPNETCORE_ENVIRONMENT: "Production" + ASPNETCORE_URLS: "http://+:8080" + + # EN: JWT Configuration (shared across all services) + # VI: Cau hinh JWT (dung chung cho tat ca services) + Jwt__Authority: "http://iam-service:8080" + Jwt__Audience: "goodgo-api" + Jwt__RequireHttpsMetadata: "true" + + # EN: Service Discovery URLs (K8s DNS: {service-name}.production.svc.cluster.local) + # VI: URL tim kiem service (K8s DNS: {service-name}.production.svc.cluster.local) + IamService__BaseUrl: "http://iam-service:8080" + MerchantService__BaseUrl: "http://merchant-service:8080" + CatalogService__BaseUrl: "http://catalog-service:8080" + OrderService__BaseUrl: "http://order-service:8080" + InventoryService__BaseUrl: "http://inventory-service:8080" + WalletService__BaseUrl: "http://wallet-service:8080" + StorageService__BaseUrl: "http://storage-service:8080" + FnbEngine__BaseUrl: "http://fnb-engine:8080" + BookingService__BaseUrl: "http://booking-service:8080" + + # EN: Redis Configuration + # VI: Cau hinh Redis + Redis__Host: "redis" + Redis__Port: "6379" + Redis__Database: "0" + + # EN: CORS Configuration + # VI: Cau hinh CORS + Cors__AllowedOrigins: "https://pos.goodgo.vn,https://goodgo.vn,https://admin.goodgo.vn" + + # EN: Logging (stricter for production) + # VI: Ghi log (nghiem ngat hon cho production) + Serilog__MinimumLevel__Default: "Warning" + Serilog__MinimumLevel__Override__Microsoft: "Error" + Serilog__MinimumLevel__Override__System: "Error" + + # EN: Feature Flags + # VI: Tinh nang bat/tat + Features__SwaggerEnabled: "false" + Features__DetailedErrors: "false" + + # EN: API Version + # VI: Phien ban API + API_VERSION: "v1" diff --git a/deployments/production/kubernetes/fnb-engine.yaml b/deployments/production/kubernetes/fnb-engine.yaml new file mode 100644 index 00000000..439320ac --- /dev/null +++ b/deployments/production/kubernetes/fnb-engine.yaml @@ -0,0 +1,143 @@ +# EN: FnB Engine - Table, Session, Kitchen & Reservation Management (SignalR for Kitchen Display) +# VI: FnB Engine - Quan ly Ban, Phien, Bep & Dat ban (SignalR cho Man hinh bep) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fnb-engine + namespace: production + labels: + app: fnb-engine + environment: production + platform: goodgo + tier: backend +spec: + replicas: 3 + selector: + matchLabels: + app: fnb-engine + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app: fnb-engine + environment: production + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - fnb-engine + topologyKey: kubernetes.io/hostname + containers: + - name: fnb-engine + image: goodgo/fnb-engine-net:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: goodgo-config + - secretRef: + name: goodgo-secrets + env: + - name: ConnectionStrings__DefaultConnection + valueFrom: + secretKeyRef: + name: goodgo-secrets + key: FNB_DATABASE_URL + - name: IamService__ServiceName + value: "fnb-engine" + # EN: Redis for SignalR backplane (Kitchen Display) + # VI: Redis cho SignalR backplane (Man hinh bep) + - name: ConnectionStrings__Redis + valueFrom: + secretKeyRef: + name: goodgo-secrets + key: ConnectionStrings__Redis + resources: + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "1Gi" + cpu: "1000m" + livenessProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 12 +--- +apiVersion: v1 +kind: Service +metadata: + name: fnb-engine + namespace: production + labels: + app: fnb-engine + environment: production +spec: + selector: + app: fnb-engine + ports: + - name: http + protocol: TCP + port: 8080 + targetPort: 8080 + type: ClusterIP +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: fnb-engine-hpa + namespace: production + labels: + app: fnb-engine +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: fnb-engine + minReplicas: 3 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/deployments/production/kubernetes/iam-service-configmap.yaml b/deployments/production/kubernetes/iam-service-configmap.yaml deleted file mode 100644 index ae6b7ab2..00000000 --- a/deployments/production/kubernetes/iam-service-configmap.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: iam-service-config - namespace: production -data: - NODE_ENV: "production" - PORT: "5001" - API_VERSION: "v1" - CORS_ORIGIN: "https://goodgo.vn" - LOG_LEVEL: "warn" - SERVICE_NAME: "iam-service" - TRACING_ENABLED: "true" - # Note: DATABASE_URL is stored in secrets (iam-service-secrets) - # DATABASE_URL should point to Neon production branch \ No newline at end of file diff --git a/deployments/production/kubernetes/iam-service.yaml b/deployments/production/kubernetes/iam-service.yaml index 121fe5ae..2968ec1a 100644 --- a/deployments/production/kubernetes/iam-service.yaml +++ b/deployments/production/kubernetes/iam-service.yaml @@ -1,29 +1,72 @@ +# EN: IAM Service - Identity & Access Management (Duende IdentityServer, JWT, RBAC, MFA) +# VI: IAM Service - Quan ly Danh tinh & Truy cap (Duende IdentityServer, JWT, RBAC, MFA) apiVersion: apps/v1 kind: Deployment metadata: name: iam-service namespace: production + labels: + app: iam-service + environment: production + platform: goodgo + tier: backend spec: replicas: 3 selector: matchLabels: app: iam-service + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 template: metadata: labels: app: iam-service + environment: production spec: + # EN: Pod anti-affinity to spread replicas across nodes + # VI: Anti-affinity de phan bo replica tren nhieu node + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - iam-service + topologyKey: kubernetes.io/hostname containers: - name: iam-service - image: goodgo/iam-service:latest + image: goodgo/iam-service-net:latest imagePullPolicy: Always ports: - - containerPort: 5001 + - containerPort: 8080 + protocol: TCP envFrom: - configMapRef: - name: iam-service-config + name: goodgo-config - secretRef: - name: iam-service-secrets + name: goodgo-secrets + env: + # EN: Override service-specific database URL + # VI: Override URL database rieng cho service + - name: ConnectionStrings__DefaultConnection + valueFrom: + secretKeyRef: + name: goodgo-secrets + key: IAM_DATABASE_URL + - name: IamService__ServiceName + value: "iam-service" + - name: IdentityServer__IssuerUri + valueFrom: + secretKeyRef: + name: goodgo-secrets + key: IdentityServer__IssuerUri resources: requests: memory: "512Mi" @@ -34,7 +77,7 @@ spec: livenessProbe: httpGet: path: /health/live - port: 5001 + port: 8080 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 5 @@ -42,33 +85,44 @@ spec: readinessProbe: httpGet: path: /health/ready - port: 5001 + port: 8080 initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 - + startupProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 12 --- apiVersion: v1 kind: Service metadata: name: iam-service namespace: production + labels: + app: iam-service + environment: production spec: selector: app: iam-service ports: - - protocol: TCP - port: 5001 - targetPort: 5001 + - name: http + protocol: TCP + port: 8080 + targetPort: 8080 type: ClusterIP - --- apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: iam-service-hpa namespace: production + labels: + app: iam-service spec: scaleTargetRef: apiVersion: apps/v1 diff --git a/deployments/production/kubernetes/ingress.yaml b/deployments/production/kubernetes/ingress.yaml index c5b778b1..233e5699 100644 --- a/deployments/production/kubernetes/ingress.yaml +++ b/deployments/production/kubernetes/ingress.yaml @@ -1,74 +1,289 @@ +# EN: Traefik Ingress for GoodGo Production - API Gateway routing +# VI: Traefik Ingress cho GoodGo Production - Dinh tuyen API Gateway +# +# Routes match infra/traefik/dynamic/routes.yml for consistency +# Host: api.goodgo.vn (API), pos.goodgo.vn (POS Frontend) + +# ============================================================================= +# API Ingress - Backend services +# ============================================================================= apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: api-ingress namespace: production + labels: + environment: production + platform: goodgo annotations: - traefik.ingress.kubernetes.io/rule-type: PathPrefix - cert-manager.io/cluster-issuer: "letsencrypt-prod" + # EN: Traefik Ingress class + # VI: Ingress class cua Traefik + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + # EN: Rate limiting and security middlewares + # VI: Middleware gioi han toc do va bao mat + traefik.ingress.kubernetes.io/router.middlewares: production-cors@kubernetescrd,production-secure-headers@kubernetescrd,production-rate-limit@kubernetescrd + # EN: cert-manager TLS (production issuer) + # VI: TLS bang cert-manager (issuer production) + cert-manager.io/cluster-issuer: letsencrypt-prod spec: ingressClassName: traefik tls: - hosts: - api.goodgo.vn - secretName: api-tls-cert + secretName: api-production-tls rules: - host: api.goodgo.vn http: paths: + # ===== IAM Service ===== - path: /api/v1/auth pathType: Prefix backend: service: name: iam-service port: - number: 5001 + number: 8080 - path: /api/v1/users pathType: Prefix backend: service: name: iam-service port: - number: 5001 + number: 8080 - path: /api/v1/identity pathType: Prefix backend: service: name: iam-service port: - number: 5001 + number: 8080 - path: /api/v1/access pathType: Prefix backend: service: name: iam-service port: - number: 5001 + number: 8080 - path: /api/v1/governance pathType: Prefix backend: service: name: iam-service port: - number: 5001 + number: 8080 - path: /api/v1/rbac pathType: Prefix backend: service: name: iam-service port: - number: 5001 + number: 8080 - path: /api/v1/mfa pathType: Prefix backend: service: name: iam-service port: - number: 5001 + number: 8080 - path: /api/v1/sessions pathType: Prefix backend: service: name: iam-service port: - number: 5001 + number: 8080 + # EN: IdentityServer OIDC endpoints + # VI: IdentityServer OIDC endpoints + - path: /connect + pathType: Prefix + backend: + service: + name: iam-service + port: + number: 8080 + - path: /.well-known + pathType: Prefix + backend: + service: + name: iam-service + port: + number: 8080 + + # ===== Merchant Service ===== + - path: /api/v1/merchants + pathType: Prefix + backend: + service: + name: merchant-service + port: + number: 8080 + - path: /api/v1/shops + pathType: Prefix + backend: + service: + name: merchant-service + port: + number: 8080 + - path: /api/v1/subscriptions + pathType: Prefix + backend: + service: + name: merchant-service + port: + number: 8080 + + # ===== Order Service ===== + - path: /api/v1/orders + pathType: Prefix + backend: + service: + name: order-service + port: + number: 8080 + # EN: POS/KDS SignalR Hub (WebSocket) + # VI: POS/KDS SignalR Hub (WebSocket) + - path: /hubs/pos + pathType: Prefix + backend: + service: + name: order-service + port: + number: 8080 + + # ===== FnB Engine ===== + - path: /api/v1/kitchen + pathType: Prefix + backend: + service: + name: fnb-engine + port: + number: 8080 + - path: /api/v1/fnb + pathType: Prefix + backend: + service: + name: fnb-engine + port: + number: 8080 + - path: /api/v1/tables + pathType: Prefix + backend: + service: + name: fnb-engine + port: + number: 8080 + # EN: Kitchen Display SignalR Hub + # VI: SignalR Hub Man hinh bep + - path: /hubs/kitchen + pathType: Prefix + backend: + service: + name: fnb-engine + port: + number: 8080 + + # ===== Inventory Service ===== + - path: /api/v1/inventory + pathType: Prefix + backend: + service: + name: inventory-service + port: + number: 8080 + - path: /api/v1/stock + pathType: Prefix + backend: + service: + name: inventory-service + port: + number: 8080 + + # ===== Wallet Service ===== + - path: /api/v1/wallets + pathType: Prefix + backend: + service: + name: wallet-service + port: + number: 8080 + - path: /api/v1/points + pathType: Prefix + backend: + service: + name: wallet-service + port: + number: 8080 + - path: /api/v1/payments + pathType: Prefix + backend: + service: + name: wallet-service + port: + number: 8080 + + # ===== Catalog Service ===== + - path: /api/v1/products + pathType: Prefix + backend: + service: + name: catalog-service + port: + number: 8080 + - path: /api/v1/categories + pathType: Prefix + backend: + service: + name: catalog-service + port: + number: 8080 + + # ===== Booking Service ===== + - path: /api/v1/bookings + pathType: Prefix + backend: + service: + name: booking-service + port: + number: 8080 + - path: /api/v1/reservations + pathType: Prefix + backend: + service: + name: booking-service + port: + number: 8080 + +--- +# ============================================================================= +# POS Frontend Ingress +# ============================================================================= +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: pos-web-ingress + namespace: production + labels: + environment: production + platform: goodgo + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: traefik + tls: + - hosts: + - pos.goodgo.vn + secretName: pos-production-tls + rules: + - host: pos.goodgo.vn + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: pos-web + port: + number: 8080 diff --git a/deployments/production/kubernetes/inventory-service.yaml b/deployments/production/kubernetes/inventory-service.yaml new file mode 100644 index 00000000..b0d6e77b --- /dev/null +++ b/deployments/production/kubernetes/inventory-service.yaml @@ -0,0 +1,136 @@ +# EN: Inventory Service - Stock Management & Deduction (Retail + FnB) +# VI: Inventory Service - Quan ly Ton kho & Tru kho (Retail + FnB) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: inventory-service + namespace: production + labels: + app: inventory-service + environment: production + platform: goodgo + tier: backend +spec: + replicas: 3 + selector: + matchLabels: + app: inventory-service + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app: inventory-service + environment: production + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - inventory-service + topologyKey: kubernetes.io/hostname + containers: + - name: inventory-service + image: goodgo/inventory-service-net:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: goodgo-config + - secretRef: + name: goodgo-secrets + env: + - name: ConnectionStrings__DefaultConnection + valueFrom: + secretKeyRef: + name: goodgo-secrets + key: INVENTORY_DATABASE_URL + - name: IamService__ServiceName + value: "inventory-service" + resources: + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "1Gi" + cpu: "1000m" + livenessProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 12 +--- +apiVersion: v1 +kind: Service +metadata: + name: inventory-service + namespace: production + labels: + app: inventory-service + environment: production +spec: + selector: + app: inventory-service + ports: + - name: http + protocol: TCP + port: 8080 + targetPort: 8080 + type: ClusterIP +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: inventory-service-hpa + namespace: production + labels: + app: inventory-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: inventory-service + minReplicas: 3 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/deployments/production/kubernetes/merchant-service.yaml b/deployments/production/kubernetes/merchant-service.yaml new file mode 100644 index 00000000..df265141 --- /dev/null +++ b/deployments/production/kubernetes/merchant-service.yaml @@ -0,0 +1,136 @@ +# EN: Merchant Service - Merchant & Shop Management +# VI: Merchant Service - Quan ly Merchant & Shop +apiVersion: apps/v1 +kind: Deployment +metadata: + name: merchant-service + namespace: production + labels: + app: merchant-service + environment: production + platform: goodgo + tier: backend +spec: + replicas: 3 + selector: + matchLabels: + app: merchant-service + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app: merchant-service + environment: production + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - merchant-service + topologyKey: kubernetes.io/hostname + containers: + - name: merchant-service + image: goodgo/merchant-service-net:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: goodgo-config + - secretRef: + name: goodgo-secrets + env: + - name: ConnectionStrings__DefaultConnection + valueFrom: + secretKeyRef: + name: goodgo-secrets + key: MERCHANT_DATABASE_URL + - name: IamService__ServiceName + value: "merchant-service" + resources: + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "1Gi" + cpu: "1000m" + livenessProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 12 +--- +apiVersion: v1 +kind: Service +metadata: + name: merchant-service + namespace: production + labels: + app: merchant-service + environment: production +spec: + selector: + app: merchant-service + ports: + - name: http + protocol: TCP + port: 8080 + targetPort: 8080 + type: ClusterIP +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: merchant-service-hpa + namespace: production + labels: + app: merchant-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: merchant-service + minReplicas: 3 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/deployments/production/kubernetes/namespace.yaml b/deployments/production/kubernetes/namespace.yaml new file mode 100644 index 00000000..011a1d7a --- /dev/null +++ b/deployments/production/kubernetes/namespace.yaml @@ -0,0 +1,10 @@ +# EN: Production namespace for GoodGo Platform +# VI: Namespace production cho GoodGo Platform +apiVersion: v1 +kind: Namespace +metadata: + name: production + labels: + environment: production + platform: goodgo + managed-by: kubectl diff --git a/deployments/production/kubernetes/order-service.yaml b/deployments/production/kubernetes/order-service.yaml new file mode 100644 index 00000000..b757d08c --- /dev/null +++ b/deployments/production/kubernetes/order-service.yaml @@ -0,0 +1,144 @@ +# EN: Order Service - Order Processing & POS API (SignalR WebSocket for POS/KDS) +# VI: Order Service - Xu ly Order & POS API (SignalR WebSocket cho POS/KDS) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: order-service + namespace: production + labels: + app: order-service + environment: production + platform: goodgo + tier: backend +spec: + replicas: 3 + selector: + matchLabels: + app: order-service + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app: order-service + environment: production + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - order-service + topologyKey: kubernetes.io/hostname + containers: + - name: order-service + image: goodgo/order-service-net:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: goodgo-config + - secretRef: + name: goodgo-secrets + env: + - name: ConnectionStrings__DefaultConnection + valueFrom: + secretKeyRef: + name: goodgo-secrets + key: ORDER_DATABASE_URL + - name: IamService__ServiceName + value: "order-service" + # EN: Inter-service communication + # VI: Giao tiep giua cac service + - name: CatalogService__BaseUrl + value: "http://catalog-service:8080" + - name: InventoryService__BaseUrl + value: "http://inventory-service:8080" + - name: WalletService__BaseUrl + value: "http://wallet-service:8080" + resources: + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "1Gi" + cpu: "1000m" + livenessProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 12 +--- +apiVersion: v1 +kind: Service +metadata: + name: order-service + namespace: production + labels: + app: order-service + environment: production +spec: + selector: + app: order-service + ports: + - name: http + protocol: TCP + port: 8080 + targetPort: 8080 + type: ClusterIP +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: order-service-hpa + namespace: production + labels: + app: order-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: order-service + minReplicas: 3 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/deployments/production/kubernetes/redis.yaml b/deployments/production/kubernetes/redis.yaml new file mode 100644 index 00000000..9025d0b6 --- /dev/null +++ b/deployments/production/kubernetes/redis.yaml @@ -0,0 +1,122 @@ +# EN: Redis - Cache & SignalR Backplane for production +# VI: Redis - Cache & SignalR Backplane cho production +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: production + labels: + app: redis + environment: production + platform: goodgo + tier: infrastructure +spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + environment: production + spec: + containers: + - name: redis + image: redis:7-alpine + command: + - redis-server + - "--requirepass" + - "$(REDIS_PASSWORD)" + - "--maxmemory" + - "512mb" + - "--maxmemory-policy" + - "allkeys-lru" + - "--appendonly" + - "yes" + - "--appendfsync" + - "everysec" + - "--save" + - "900 1" + - "--save" + - "300 10" + - "--save" + - "60 10000" + ports: + - containerPort: 6379 + protocol: TCP + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: goodgo-secrets + key: Redis__Password + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + exec: + command: + - redis-cli + - -a + - "$(REDIS_PASSWORD)" + - ping + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + exec: + command: + - redis-cli + - -a + - "$(REDIS_PASSWORD)" + - ping + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + volumeMounts: + - name: redis-data + mountPath: /data + volumes: + - name: redis-data + persistentVolumeClaim: + claimName: redis-pvc +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: production + labels: + app: redis + environment: production +spec: + selector: + app: redis + ports: + - name: redis + protocol: TCP + port: 6379 + targetPort: 6379 + type: ClusterIP +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: redis-pvc + namespace: production + labels: + app: redis + environment: production +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi diff --git a/deployments/production/kubernetes/secrets.yaml.example b/deployments/production/kubernetes/secrets.yaml.example index 82e0abda..40565c21 100644 --- a/deployments/production/kubernetes/secrets.yaml.example +++ b/deployments/production/kubernetes/secrets.yaml.example @@ -1,34 +1,64 @@ -# Kubernetes Secrets Template for Production -# DO NOT commit actual secrets to Git -# Use this as a template to create secrets - -# Create secret using kubectl: -# kubectl create secret generic iam-service-secrets \ -# --from-literal=database-url='postgresql://user:pass@ep-xxx.region.neon.tech/dbname?sslmode=require&pgbouncer=true' \ -# --from-literal=jwt-secret='your-production-jwt-secret-min-32-chars' \ -# --from-literal=jwt-refresh-secret='your-production-refresh-secret-min-32-chars' \ -# --from-literal=redis-password='' \ +# EN: Kubernetes Secrets Template for GoodGo Production +# VI: Template Secrets Kubernetes cho GoodGo Production +# +# DO NOT commit actual secrets to Git. +# Use this as a template to create secrets via kubectl or sealed-secrets. +# +# ============================================================================= +# Option 1: Create secrets using kubectl (manual) +# ============================================================================= +# +# kubectl create secret generic goodgo-secrets \ +# --from-literal=Jwt__Secret='your-production-jwt-secret-min-64-chars-strong-random' \ +# --from-literal=Jwt__RefreshSecret='your-production-refresh-secret-min-64-chars-strong-random' \ +# --from-literal=IdentityServer__IssuerUri='https://api.goodgo.vn' \ +# --from-literal=IAM_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/iam_production?sslmode=require&pgbouncer=true' \ +# --from-literal=MERCHANT_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/merchant_production?sslmode=require&pgbouncer=true' \ +# --from-literal=ORDER_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/order_production?sslmode=require&pgbouncer=true' \ +# --from-literal=FNB_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/fnb_production?sslmode=require&pgbouncer=true' \ +# --from-literal=INVENTORY_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/inventory_production?sslmode=require&pgbouncer=true' \ +# --from-literal=WALLET_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/wallet_production?sslmode=require&pgbouncer=true' \ +# --from-literal=CATALOG_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/catalog_production?sslmode=require&pgbouncer=true' \ +# --from-literal=BOOKING_DATABASE_URL='postgresql://user:pass@ep-xxx.region.neon.tech/booking_production?sslmode=require&pgbouncer=true' \ +# --from-literal=Redis__Password='your-strong-redis-password' \ +# --from-literal=ConnectionStrings__Redis='redis:6379,password=your-strong-redis-password,abortConnect=false' \ +# --from-literal=Storage__MinIO__Endpoint='minio.goodgo.vn' \ +# --from-literal=Storage__MinIO__AccessKey='your-minio-access-key' \ +# --from-literal=Storage__MinIO__SecretKey='your-minio-secret-key' \ +# --from-literal=RabbitMQ__Host='rabbitmq' \ +# --from-literal=RabbitMQ__Username='goodgo' \ +# --from-literal=RabbitMQ__Password='your-strong-rabbitmq-password' \ # -n production - -# Or use GitHub Secrets in CI/CD: -# - NEON_DATABASE_URL_PRODUCTION -# - JWT_SECRET_PRODUCTION -# - JWT_REFRESH_SECRET_PRODUCTION - -apiVersion: v1 -kind: Secret -metadata: - name: iam-service-secrets - namespace: production -type: Opaque -stringData: - # Neon Database URL (Production branch) - # Format: postgresql://user:password@ep-xxx.region.neon.tech/dbname?sslmode=require&pgbouncer=true - database-url: "postgresql://user:password@ep-xxx.region.neon.tech/dbname?sslmode=require&pgbouncer=true" - - # JWT Secrets (use strong random strings, min 32 characters) - jwt-secret: "your-production-jwt-secret-min-32-chars" - jwt-refresh-secret: "your-production-refresh-secret-min-32-chars" - - # Redis (if password protected) - redis-password: "" +# +# ============================================================================= +# Option 2: Use GitHub Secrets in CI/CD (for automated deployments) +# ============================================================================= +# +# Required GitHub Secrets: +# - KUBECONFIG_PRODUCTION (base64 encoded kubeconfig) +# - DOCKER_USERNAME / DOCKER_PASSWORD +# - NEON_IAM_DATABASE_URL_PRODUCTION +# - NEON_MERCHANT_DATABASE_URL_PRODUCTION +# - NEON_ORDER_DATABASE_URL_PRODUCTION +# - NEON_FNB_DATABASE_URL_PRODUCTION +# - NEON_INVENTORY_DATABASE_URL_PRODUCTION +# - NEON_WALLET_DATABASE_URL_PRODUCTION +# - NEON_CATALOG_DATABASE_URL_PRODUCTION +# - NEON_BOOKING_DATABASE_URL_PRODUCTION +# - JWT_SECRET_PRODUCTION +# - JWT_REFRESH_SECRET_PRODUCTION +# - REDIS_PASSWORD_PRODUCTION +# - MINIO_ACCESS_KEY_PRODUCTION +# - MINIO_SECRET_KEY_PRODUCTION +# - RABBITMQ_PASSWORD_PRODUCTION +# +# ============================================================================= +# Option 3: Use sealed-secrets or external-secrets operator (RECOMMENDED for production) +# ============================================================================= +# +# Install sealed-secrets controller: +# kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.5/controller.yaml +# +# Create sealed secret: +# kubeseal --format yaml < secret.yaml > sealed-secret.yaml +# kubectl apply -f sealed-secret.yaml diff --git a/deployments/production/kubernetes/wallet-service.yaml b/deployments/production/kubernetes/wallet-service.yaml new file mode 100644 index 00000000..2401d6a6 --- /dev/null +++ b/deployments/production/kubernetes/wallet-service.yaml @@ -0,0 +1,136 @@ +# EN: Wallet Service - Wallet & Payment Management +# VI: Wallet Service - Quan ly Vi & Thanh toan +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wallet-service + namespace: production + labels: + app: wallet-service + environment: production + platform: goodgo + tier: backend +spec: + replicas: 3 + selector: + matchLabels: + app: wallet-service + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app: wallet-service + environment: production + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - wallet-service + topologyKey: kubernetes.io/hostname + containers: + - name: wallet-service + image: goodgo/wallet-service-net:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: goodgo-config + - secretRef: + name: goodgo-secrets + env: + - name: ConnectionStrings__DefaultConnection + valueFrom: + secretKeyRef: + name: goodgo-secrets + key: WALLET_DATABASE_URL + - name: IamService__ServiceName + value: "wallet-service" + resources: + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "1Gi" + cpu: "1000m" + livenessProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /health/live + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 12 +--- +apiVersion: v1 +kind: Service +metadata: + name: wallet-service + namespace: production + labels: + app: wallet-service + environment: production +spec: + selector: + app: wallet-service + ports: + - name: http + protocol: TCP + port: 8080 + targetPort: 8080 + type: ClusterIP +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: wallet-service-hpa + namespace: production + labels: + app: wallet-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: wallet-service + minReplicas: 3 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/docs/production-checklist.md b/docs/production-checklist.md new file mode 100644 index 00000000..1c7b6524 --- /dev/null +++ b/docs/production-checklist.md @@ -0,0 +1,185 @@ +# GoodGo Platform -- Production Deployment Checklist + +> Version: 1.0 +> Last updated: 2026-03-06 +> Owner: DevOps + CTO +> Domain: goodgo.vn (production), admin.goodgo.vn (admin panel) + +--- + +## Pre-Deployment + +- [ ] All E2E tests passing on staging (Playwright + functional tests) +- [ ] Security audit completed (rate limiting, input validation, RLS) +- [ ] Database migrations reviewed and tested on staging (EF Core) +- [ ] Secrets rotated (JWT signing keys, DB passwords, API keys, MinIO credentials) +- [ ] SSL/TLS certificates configured (goodgo.vn, api.goodgo.vn, admin.goodgo.vn) +- [ ] DNS records configured (A/CNAME for all subdomains) +- [ ] CDN configured for static assets (Blazor WASM _framework/, images) +- [ ] Backup strategy verified (daily PostgreSQL backups via Neon, point-in-time recovery) +- [ ] Load testing completed on staging (target: 100 concurrent users minimum) +- [ ] Rollback plan reviewed and approved by CTO + +--- + +## Infrastructure + +### Kubernetes Cluster (RKE2) +- [ ] K8s cluster provisioned and healthy (minimum 3 nodes) +- [ ] Namespace `production` created +- [ ] Resource limits set per service (256Mi-512Mi mem, 250m-500m CPU) +- [ ] HPA (Horizontal Pod Autoscaler) configured (min 2, max 10 replicas) +- [ ] PersistentVolumeClaims provisioned for MinIO and Redis +- [ ] Ingress + TLS configured via Traefik IngressClass +- [ ] Network policies enforced (service-to-service only, deny external by default) +- [ ] Node affinity / anti-affinity rules for HA (spread pods across nodes) + +### External Services +- [ ] Neon PostgreSQL production database provisioned +- [ ] Redis production instance running (persistence enabled, AOF + RDB) +- [ ] RabbitMQ production cluster (mirrored queues, 2+ nodes) +- [ ] MinIO production buckets created with proper access policies +- [ ] Traefik v3 gateway deployed with production TLS config + +--- + +## Services (repeat per service) + +> 8 core services: iam, merchant, order, fnb-engine, wallet, catalog, inventory, chat + +### Per-Service Checklist +- [ ] Docker image tagged with commit SHA (NEVER use :latest) +- [ ] Image pushed to Docker Hub (goodgo/{service}:{sha}) +- [ ] Environment variables set in K8s Secrets (not ConfigMaps for sensitive data) +- [ ] Health checks responding: `/health/live` (liveness), `/health/ready` (readiness) +- [ ] Database migrated (EF Core migrations applied via `dotnet ef database update`) +- [ ] Seed data loaded (if applicable) +- [ ] Connection string pointing to Neon PostgreSQL production +- [ ] Redis connection string configured +- [ ] RabbitMQ connection configured +- [ ] API versioning header `X-Api-Version` tested +- [ ] Logging level set to `Information` (not `Debug`) +- [ ] Serilog structured logging outputting to stdout (for Promtail collection) + +### Service-Specific + +| Service | Extra Checks | +|---------|-------------| +| iam-service | JWT signing key (RS256) deployed, OIDC discovery endpoint live, MFA configured | +| merchant-service | Subscription plans seeded, shop lifecycle tested | +| order-service | SignalR PosHub accessible, Redis backplane connected, MessagePack configured | +| fnb-engine | Kitchen ticket flow tested, inventory deduction verified | +| wallet-service | VNPay production credentials configured, IPN callback URL registered | +| catalog-service | Product categories seeded | +| inventory-service | Reorder level alerts configured | +| chat-service | SignalR hub accessible, Redis backplane connected | + +--- + +## Monitoring + +- [ ] Prometheus deployed and scraping all 8 services on `/metrics` +- [ ] Grafana deployed with GoodGo Overview dashboard loaded +- [ ] Alert rules active in Prometheus (service down, high error rate, high latency, DB pool, disk, memory) +- [ ] Alert notifications configured (Slack channel #goodgo-alerts and/or PagerDuty) +- [ ] Loki deployed and receiving logs from all containers via Promtail +- [ ] Structured logging (Serilog JSON) verified in Loki queries +- [ ] Grafana Loki datasource configured and queryable +- [ ] Dashboard access restricted (admin credentials changed from defaults) + +--- + +## Security + +### Authentication & Authorization +- [ ] JWT signing key rotated from staging key (RS256 key pair) +- [ ] OIDC discovery endpoint (/.well-known/openid-configuration) returns production issuer +- [ ] Token expiry configured (access: 15min, refresh: 7 days) +- [ ] RBAC policies verified (Admin, Owner, Staff, Customer roles) + +### Network & Transport +- [ ] CORS configured (allow only goodgo.vn, admin.goodgo.vn origins) +- [ ] HTTPS enforced (HTTP -> HTTPS redirect via Traefik middleware) +- [ ] Security headers configured via Traefik middleware: + - `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload` + - `Content-Security-Policy: default-src 'self'` + - `X-Frame-Options: DENY` + - `X-Content-Type-Options: nosniff` + - `Referrer-Policy: strict-origin-when-cross-origin` + +### Rate Limiting +- [ ] Auth endpoints: 10 requests/min (brute force protection) +- [ ] Payment endpoints: 30 requests/min +- [ ] General API: 100 requests/min +- [ ] SignalR hub: 500 requests/min + +### Data Protection +- [ ] Row-Level Security (RLS) policies applied on all tenant databases +- [ ] Database user has minimal required permissions (no SUPERUSER) +- [ ] MinIO buckets have proper ACLs (private by default, signed URLs for access) +- [ ] No secrets in environment variables visible via K8s describe (use Secrets, not ConfigMaps) +- [ ] Sensitive fields excluded from Serilog logging (passwords, tokens, card numbers) + +--- + +## Rollback Plan + +- [ ] Previous Docker images retained in Docker Hub (at least 5 recent tags) +- [ ] Database rollback migration scripts prepared and tested +- [ ] Feature flags configured for new features (can disable without redeploy) +- [ ] Canary deployment strategy documented: + 1. Deploy to 1 replica first + 2. Monitor error rate for 10 minutes + 3. If error rate < 1%, proceed to full rollout + 4. If error rate > 5%, auto-rollback via K8s rollout undo +- [ ] `kubectl rollout undo` command documented per service +- [ ] Communication plan for downtime (status page, Slack notification) + +--- + +## Post-Deployment Verification + +### Smoke Tests (within 30 minutes) +- [ ] IAM: Login flow works (email + password) +- [ ] IAM: Token refresh works +- [ ] IAM: MFA enrollment works +- [ ] Merchant: Shop creation works +- [ ] Order: Create order -> add items -> submit +- [ ] Order: Pay order (cash flow) +- [ ] FnB: Kitchen ticket appears on KDS +- [ ] Wallet: VNPay payment redirect works (sandbox -> production) +- [ ] Catalog: Product listing loads +- [ ] Inventory: Stock levels queryable +- [ ] Chat: SignalR connection established +- [ ] Storage: File upload + signed URL access + +### Functional Verification (within 2 hours) +- [ ] Full Karaoke POS workflow (room select -> order -> pay -> close) +- [ ] Full Restaurant POS workflow (table -> order -> kitchen -> serve -> pay) +- [ ] QR code menu accessible from customer phone +- [ ] EOD report generates correctly with real data +- [ ] Multi-browser session (concurrent POS users on same shop) + +### Monitoring Verification (within 24 hours) +- [ ] Monitor error rates (target: < 0.1% 5xx) +- [ ] Monitor p95 latency (target: < 500ms) +- [ ] Monitor SignalR connection stability (no unexpected disconnects) +- [ ] Verify Grafana dashboards show live data +- [ ] Verify alert rules fire correctly (test with synthetic failure if needed) +- [ ] Review Loki logs for any unhandled exceptions +- [ ] Verify PostgreSQL connection pool utilization is healthy (< 50%) + +--- + +## Sign-Off + +| Role | Name | Date | Approved | +|------|------|------|:--------:| +| CTO | | | [ ] | +| Tech Lead | | | [ ] | +| DevOps Lead | | | [ ] | +| QA Lead | | | [ ] | + +--- + +*This checklist must be completed and signed off before production traffic is routed to the new deployment.* diff --git a/infra/observability/prometheus/prometheus.yml b/infra/observability/prometheus/prometheus.yml index 490e4176..1c81336d 100644 --- a/infra/observability/prometheus/prometheus.yml +++ b/infra/observability/prometheus/prometheus.yml @@ -1,29 +1,145 @@ +# ============================================================================= +# GoodGo Platform - Prometheus Configuration +# ============================================================================= +# EN: Scrape configuration for all core microservices and infrastructure. +# VI: Cau hinh thu thap metrics cho tat ca dich vu va ha tang. +# ============================================================================= + global: scrape_interval: 15s evaluation_interval: 15s + scrape_timeout: 10s external_labels: - cluster: 'microservices' + cluster: 'goodgo' environment: 'development' -scrape_configs: - - job_name: 'prometheus' - static_configs: - - targets: ['localhost:9090'] - - - job_name: 'iam-service' - static_configs: - - targets: ['iam-service:9090'] - metrics_path: '/metrics' - - - job_name: 'traefik' - static_configs: - - targets: ['traefik:8080'] - metrics_path: '/metrics' - +# --------------------------------------------------------------------------- +# Alert Rules +# --------------------------------------------------------------------------- rule_files: - 'rules/*.yml' + - 'alert-rules.yml' +# --------------------------------------------------------------------------- +# Alertmanager (configure when ready) +# --------------------------------------------------------------------------- alerting: alertmanagers: - static_configs: - targets: [] + # Uncomment when Alertmanager is deployed: + # - targets: ['alertmanager:9093'] + +# --------------------------------------------------------------------------- +# Scrape Targets +# --------------------------------------------------------------------------- +scrape_configs: + # ------------------------------------------------------------------------- + # Prometheus self-monitoring + # ------------------------------------------------------------------------- + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + # ------------------------------------------------------------------------- + # Core Microservices (8 production-ready) + # ------------------------------------------------------------------------- + - job_name: 'iam-service' + metrics_path: /metrics + scrape_interval: 15s + static_configs: + - targets: ['iam-service-net:8080'] + labels: + service: 'iam' + tier: 'core' + + - job_name: 'merchant-service' + metrics_path: /metrics + scrape_interval: 15s + static_configs: + - targets: ['merchant-service-net:8080'] + labels: + service: 'merchant' + tier: 'core' + + - job_name: 'order-service' + metrics_path: /metrics + scrape_interval: 15s + static_configs: + - targets: ['order-service-net:8080'] + labels: + service: 'order' + tier: 'core' + + - job_name: 'fnb-engine' + metrics_path: /metrics + scrape_interval: 15s + static_configs: + - targets: ['fnb-engine-net:8080'] + labels: + service: 'fnb' + tier: 'core' + + - job_name: 'wallet-service' + metrics_path: /metrics + scrape_interval: 15s + static_configs: + - targets: ['wallet-service-net:8080'] + labels: + service: 'wallet' + tier: 'core' + + - job_name: 'catalog-service' + metrics_path: /metrics + scrape_interval: 15s + static_configs: + - targets: ['catalog-service-net:8080'] + labels: + service: 'catalog' + tier: 'core' + + - job_name: 'inventory-service' + metrics_path: /metrics + scrape_interval: 15s + static_configs: + - targets: ['inventory-service-net:8080'] + labels: + service: 'inventory' + tier: 'core' + + - job_name: 'chat-service' + metrics_path: /metrics + scrape_interval: 15s + static_configs: + - targets: ['chat-service-net:8080'] + labels: + service: 'chat' + tier: 'core' + + # ------------------------------------------------------------------------- + # Infrastructure + # ------------------------------------------------------------------------- + - job_name: 'traefik' + metrics_path: /metrics + static_configs: + - targets: ['traefik:8080'] + labels: + service: 'traefik' + tier: 'infra' + + - job_name: 'redis' + metrics_path: /metrics + static_configs: + - targets: ['redis-exporter:9121'] + labels: + service: 'redis' + tier: 'infra' + + - job_name: 'rabbitmq' + metrics_path: /metrics + scrape_interval: 30s + static_configs: + - targets: ['rabbitmq:15692'] + labels: + service: 'rabbitmq' + tier: 'infra' diff --git a/scripts/deploy/deploy-prod.sh b/scripts/deploy/deploy-prod.sh index a33c57ef..6f0a0a2d 100755 --- a/scripts/deploy/deploy-prod.sh +++ b/scripts/deploy/deploy-prod.sh @@ -1,35 +1,350 @@ #!/bin/bash +# EN: Deploy GoodGo Platform production services to Kubernetes production environment +# VI: Trien khai cac service production cua GoodGo Platform len moi truong production Kubernetes +# +# Prerequisites: +# - kubectl configured with production cluster access (KUBECONFIG env var) +# - Docker images pushed to Docker Hub (goodgo/*:) +# - Secrets created via kubectl or sealed-secrets (see secrets.yaml.example) +# +# Usage: +# export KUBECONFIG=/path/to/kubeconfig +# ./scripts/deploy/deploy-prod.sh +# ./scripts/deploy/deploy-prod.sh --service iam-service # Deploy single service +# ./scripts/deploy/deploy-prod.sh --dry-run # Dry run mode +# ./scripts/deploy/deploy-prod.sh --rollback iam-service # Rollback service +# ./scripts/deploy/deploy-prod.sh --migrate # Run DB migrations before deploy -set -e +set -euo pipefail -# EN: Warn user about production deployment -# VI: Cảnh báo người dùng về việc deploy lên production -echo "⚠️ WARNING: You are about to deploy to PRODUCTION!" -read -p "Are you sure? (yes/no): " confirm +# EN: Color output helpers +# VI: Ham ho tro mau output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +NC='\033[0m' # No Color -# EN: Check user confirmation -# VI: Kiểm tra xác nhận của người dùng -if [ "$confirm" != "yes" ]; then - echo "Deployment cancelled" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +DEPLOY_DIR="${ROOT_DIR}/deployments/production/kubernetes" +NAMESPACE="production" +DRY_RUN="" +SINGLE_SERVICE="" +ROLLBACK_SERVICE="" +RUN_MIGRATIONS=false + +# EN: Production services list +# VI: Danh sach service production +PROD_SERVICES=( + "iam-service" + "merchant-service" + "catalog-service" + "order-service" + "fnb-engine" + "inventory-service" + "wallet-service" + "booking-service" +) + +# EN: Parse arguments +# VI: Phan tich tham so +while [[ $# -gt 0 ]]; do + case $1 in + --dry-run) + DRY_RUN="--dry-run=client" + echo -e "${YELLOW}[DRY RUN] No changes will be applied${NC}" + shift + ;; + --service) + SINGLE_SERVICE="$2" + shift 2 + ;; + --rollback) + ROLLBACK_SERVICE="$2" + shift 2 + ;; + --migrate) + RUN_MIGRATIONS=true + shift + ;; + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --dry-run Preview changes without applying" + echo " --service Deploy a single service" + echo " --rollback Rollback a service to previous revision" + echo " --migrate Run EF Core database migrations before deploy" + echo " -h, --help Show this help" + echo "" + echo "Services: ${PROD_SERVICES[*]}" + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# ============================================================================= +# EN: Pre-flight checks +# VI: Kiem tra truoc khi deploy +# ============================================================================= + +echo -e "${MAGENTA}=== GoodGo Platform - Production Deployment ===${NC}" +echo "" + +# EN: Require explicit confirmation for production +# VI: Yeu cau xac nhan ro rang cho production +if [ -z "$DRY_RUN" ] && [ -z "$ROLLBACK_SERVICE" ]; then + echo -e "${RED}WARNING: You are about to deploy to PRODUCTION!${NC}" + echo -e "${YELLOW}Namespace: ${NAMESPACE}${NC}" + if [ -n "$SINGLE_SERVICE" ]; then + echo -e "${YELLOW}Service: ${SINGLE_SERVICE}${NC}" + else + echo -e "${YELLOW}Services: ALL (${#PROD_SERVICES[@]} services)${NC}" + fi + echo "" + read -p "Type 'yes' to confirm: " confirm + if [ "$confirm" != "yes" ]; then + echo -e "${YELLOW}Deployment cancelled${NC}" + exit 0 + fi +fi + +# EN: Verify KUBECONFIG +# VI: Xac minh KUBECONFIG +if [ -z "${KUBECONFIG:-}" ]; then + echo -e "${RED}KUBECONFIG environment variable not set${NC}" + echo "Usage: export KUBECONFIG=/path/to/kubeconfig && $0" exit 1 fi -echo "🚀 Deploying to production..." - -# EN: Verify KUBECONFIG environment variable is set -# VI: Xác minh biến môi trường KUBECONFIG đã được thiết lập -if [ -z "$KUBECONFIG" ]; then - echo "❌ KUBECONFIG environment variable not set" +# EN: Verify kubectl connectivity +# VI: Xac minh ket noi kubectl +echo -e "${BLUE}Verifying kubectl connectivity...${NC}" +if ! kubectl cluster-info &>/dev/null; then + echo -e "${RED}Cannot connect to Kubernetes cluster. Check KUBECONFIG.${NC}" exit 1 fi -# EN: Apply Kubernetes configurations and wait for rollout -# VI: Áp dụng cấu hình Kubernetes và đợi quá trình rollout hoàn tất -kubectl apply -f deployments/production/kubernetes/ +# EN: Verify namespace exists +# VI: Xac minh namespace ton tai +if ! kubectl get namespace "${NAMESPACE}" &>/dev/null; then + echo -e "${YELLOW}Namespace '${NAMESPACE}' does not exist. Creating...${NC}" + kubectl apply -f "${DEPLOY_DIR}/namespace.yaml" ${DRY_RUN} +fi -echo "⏳ Waiting for rollout..." -# EN: Wait for all deployments in the namespace -# VI: Đợi tất cả deployments trong namespace -kubectl rollout status deployment -n production --timeout=90s || echo "⚠️ Some deployments might still be updating" +# EN: Verify manifests directory +# VI: Xac minh thu muc manifests +if [ ! -d "${DEPLOY_DIR}" ]; then + echo -e "${RED}Deployment directory not found: ${DEPLOY_DIR}${NC}" + exit 1 +fi -echo "✅ Deployment completed!" +echo -e "${GREEN}Pre-flight checks passed${NC}" +echo "" + +# ============================================================================= +# EN: Handle rollback +# VI: Xu ly rollback +# ============================================================================= + +if [ -n "$ROLLBACK_SERVICE" ]; then + echo -e "${YELLOW}Rolling back ${ROLLBACK_SERVICE}...${NC}" + + # EN: Show rollout history + # VI: Hien thi lich su rollout + echo -e "${BLUE}Rollout history:${NC}" + kubectl rollout history "deployment/${ROLLBACK_SERVICE}" -n "${NAMESPACE}" 2>/dev/null || { + echo -e "${RED}Deployment '${ROLLBACK_SERVICE}' not found in namespace '${NAMESPACE}'${NC}" + exit 1 + } + + echo "" + read -p "Rollback to previous revision? (yes/no): " confirm_rollback + if [ "$confirm_rollback" = "yes" ]; then + kubectl rollout undo "deployment/${ROLLBACK_SERVICE}" -n "${NAMESPACE}" + echo -e "${BLUE}Waiting for rollback to complete...${NC}" + kubectl rollout status "deployment/${ROLLBACK_SERVICE}" -n "${NAMESPACE}" --timeout=180s + echo -e "${GREEN}Rollback of ${ROLLBACK_SERVICE} completed${NC}" + else + echo -e "${YELLOW}Rollback cancelled${NC}" + fi + exit 0 +fi + +# ============================================================================= +# EN: Run database migrations (optional) +# VI: Chay database migrations (tuy chon) +# ============================================================================= + +if [ "$RUN_MIGRATIONS" = true ]; then + echo -e "${BLUE}[0/6] Running database migrations...${NC}" + + # EN: Check dotnet-ef tool + # VI: Kiem tra dotnet-ef tool + if ! command -v dotnet &>/dev/null; then + echo -e "${RED}dotnet SDK not found. Install .NET 10 SDK for migrations.${NC}" + exit 1 + fi + dotnet tool install --global dotnet-ef 2>/dev/null || true + + declare -A MIGRATION_MAP=( + ["iam-service"]="services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj|services/iam-service-net/src/IamService.API/IamService.API.csproj|NEON_IAM_DATABASE_URL_PRODUCTION" + ["merchant-service"]="services/merchant-service-net/src/MerchantService.Infrastructure/MerchantService.Infrastructure.csproj|services/merchant-service-net/src/MerchantService.API/MerchantService.API.csproj|NEON_MERCHANT_DATABASE_URL_PRODUCTION" + ["order-service"]="services/order-service-net/src/OrderService.Infrastructure/OrderService.Infrastructure.csproj|services/order-service-net/src/OrderService.API/OrderService.API.csproj|NEON_ORDER_DATABASE_URL_PRODUCTION" + ["fnb-engine"]="services/fnb-engine-net/src/FnbEngine.Infrastructure/FnbEngine.Infrastructure.csproj|services/fnb-engine-net/src/FnbEngine.API/FnbEngine.API.csproj|NEON_FNB_DATABASE_URL_PRODUCTION" + ["inventory-service"]="services/inventory-service-net/src/InventoryService.Infrastructure/InventoryService.Infrastructure.csproj|services/inventory-service-net/src/InventoryService.API/InventoryService.API.csproj|NEON_INVENTORY_DATABASE_URL_PRODUCTION" + ["wallet-service"]="services/wallet-service-net/src/WalletService.Infrastructure/WalletService.Infrastructure.csproj|services/wallet-service-net/src/WalletService.API/WalletService.API.csproj|NEON_WALLET_DATABASE_URL_PRODUCTION" + ["catalog-service"]="services/catalog-service-net/src/CatalogService.Infrastructure/CatalogService.Infrastructure.csproj|services/catalog-service-net/src/CatalogService.API/CatalogService.API.csproj|NEON_CATALOG_DATABASE_URL_PRODUCTION" + ["booking-service"]="services/booking-service-net/src/BookingService.Infrastructure/BookingService.Infrastructure.csproj|services/booking-service-net/src/BookingService.API/BookingService.API.csproj|NEON_BOOKING_DATABASE_URL_PRODUCTION" + ) + + for svc in "${PROD_SERVICES[@]}"; do + if [ -n "$SINGLE_SERVICE" ] && [ "$SINGLE_SERVICE" != "$svc" ]; then + continue + fi + + IFS='|' read -r infra_proj startup_proj db_env_var <<< "${MIGRATION_MAP[$svc]}" + infra_path="${ROOT_DIR}/${infra_proj}" + startup_path="${ROOT_DIR}/${startup_proj}" + + if [ -f "$infra_path" ] && [ -f "$startup_path" ]; then + echo -e "${BLUE} Migrating ${svc}...${NC}" + if [ -n "${!db_env_var:-}" ]; then + ConnectionStrings__DefaultConnection="${!db_env_var}" \ + dotnet ef database update \ + --project "$infra_path" \ + --startup-project "$startup_path" 2>&1 || { + echo -e "${RED} Migration failed for ${svc}${NC}" + read -p "Continue deployment? (yes/no): " continue_deploy + if [ "$continue_deploy" != "yes" ]; then + exit 1 + fi + } + echo -e "${GREEN} ${svc} migrated${NC}" + else + echo -e "${YELLOW} Skipping ${svc} - env var ${db_env_var} not set${NC}" + fi + else + echo -e "${YELLOW} Skipping ${svc} - project files not found${NC}" + fi + done + echo "" +fi + +# ============================================================================= +# EN: Step 1 - Apply namespace +# VI: Buoc 1 - Tao namespace +# ============================================================================= +echo -e "${BLUE}[1/6] Applying namespace...${NC}" +kubectl apply -f "${DEPLOY_DIR}/namespace.yaml" ${DRY_RUN} + +# ============================================================================= +# EN: Step 2 - Apply shared configuration +# VI: Buoc 2 - Ap dung cau hinh chung +# ============================================================================= +echo -e "${BLUE}[2/6] Applying configuration...${NC}" +kubectl apply -f "${DEPLOY_DIR}/configmap.yaml" ${DRY_RUN} + +# ============================================================================= +# EN: Step 3 - Deploy infrastructure (Redis) +# VI: Buoc 3 - Trien khai ha tang (Redis) +# ============================================================================= +echo -e "${BLUE}[3/6] Deploying infrastructure...${NC}" +if [ -z "$SINGLE_SERVICE" ] || [ "$SINGLE_SERVICE" = "redis" ]; then + kubectl apply -f "${DEPLOY_DIR}/redis.yaml" ${DRY_RUN} + echo -e "${GREEN} redis deployed${NC}" +fi + +# ============================================================================= +# EN: Step 4 - Deploy backend services +# VI: Buoc 4 - Trien khai cac service backend +# ============================================================================= +echo -e "${BLUE}[4/6] Deploying services...${NC}" + +for svc in "${PROD_SERVICES[@]}"; do + if [ -z "$SINGLE_SERVICE" ] || [ "$SINGLE_SERVICE" = "$svc" ]; then + if [ -f "${DEPLOY_DIR}/${svc}.yaml" ]; then + kubectl apply -f "${DEPLOY_DIR}/${svc}.yaml" ${DRY_RUN} + echo -e "${GREEN} ${svc} deployed${NC}" + else + echo -e "${YELLOW} ${svc}.yaml not found, skipping${NC}" + fi + fi +done + +# ============================================================================= +# EN: Step 5 - Apply ingress routing +# VI: Buoc 5 - Ap dung dinh tuyen ingress +# ============================================================================= +echo -e "${BLUE}[5/6] Applying ingress routing...${NC}" +if [ -z "$SINGLE_SERVICE" ]; then + kubectl apply -f "${DEPLOY_DIR}/ingress.yaml" ${DRY_RUN} +fi + +# ============================================================================= +# EN: Step 6 - Wait for rollouts and verify +# VI: Buoc 6 - Cho rollout va xac minh +# ============================================================================= +echo "" +echo -e "${BLUE}[6/6] Waiting for rollouts to complete...${NC}" + +if [ -z "$DRY_RUN" ]; then + FAILED=0 + for svc in "${PROD_SERVICES[@]}"; do + if [ -z "$SINGLE_SERVICE" ] || [ "$SINGLE_SERVICE" = "$svc" ]; then + echo -n " ${svc}: " + if kubectl rollout status "deployment/${svc}" -n "${NAMESPACE}" --timeout=180s 2>/dev/null; then + echo -e "${GREEN}ready${NC}" + else + echo -e "${RED}FAILED${NC}" + FAILED=$((FAILED + 1)) + fi + fi + done + + echo "" + + if [ $FAILED -gt 0 ]; then + echo -e "${RED}WARNING: ${FAILED} service(s) did not complete rollout${NC}" + echo -e "${YELLOW}Troubleshooting:${NC}" + echo -e " kubectl get pods -n ${NAMESPACE}" + echo -e " kubectl describe pod -n ${NAMESPACE}" + echo -e " kubectl logs -n ${NAMESPACE} -l app= --tail=50" + echo "" + echo -e "${YELLOW}To rollback a failed service:${NC}" + echo -e " $0 --rollback " + exit 1 + fi + + # EN: Print deployment summary + # VI: In tom tat deployment + echo -e "${GREEN}=== Production Deployment Summary ===${NC}" + echo "" + echo -e "${BLUE}Pods:${NC}" + kubectl get pods -n "${NAMESPACE}" -o wide --sort-by='.metadata.name' 2>/dev/null + echo "" + echo -e "${BLUE}Services:${NC}" + kubectl get svc -n "${NAMESPACE}" 2>/dev/null + echo "" + echo -e "${BLUE}HPAs:${NC}" + kubectl get hpa -n "${NAMESPACE}" 2>/dev/null + echo "" + echo -e "${BLUE}Ingress:${NC}" + kubectl get ingress -n "${NAMESPACE}" 2>/dev/null +fi + +echo "" +echo -e "${GREEN}=== Production deployment completed ===${NC}" +echo -e "${BLUE}API: https://api.goodgo.vn${NC}" +echo -e "${BLUE}POS: https://pos.goodgo.vn${NC}" +echo "" +echo -e "Verify: kubectl get pods -n ${NAMESPACE}" +echo -e "Logs: kubectl logs -n ${NAMESPACE} -l app= -f" +echo -e "Rollback: $0 --rollback " diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/SetDefaultShopCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/SetDefaultShopCommand.cs new file mode 100644 index 00000000..ae783876 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/SetDefaultShopCommand.cs @@ -0,0 +1,95 @@ +// EN: Command to set a shop as the default/primary shop for a merchant. +// VI: Command để đặt shop làm shop mặc định/chính cho merchant. + +using MediatR; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; +using MerchantService.Domain.AggregatesModel.ShopAggregate; +using MerchantService.Domain.Exceptions; + +namespace MerchantService.API.Application.Commands.Shops; + +/// +/// EN: Command to set a shop as the default/primary shop for a merchant. +/// VI: Command để đặt shop làm shop mặc định/chính cho merchant. +/// +public record SetDefaultShopCommand( + Guid MerchantId, + Guid ShopId +) : IRequest; + +/// +/// EN: Result of setting a default shop. +/// VI: Kết quả đặt shop mặc định. +/// +public record SetDefaultShopResult( + Guid ShopId, + string ShopName, + bool IsDefault +); + +/// +/// EN: Handler for SetDefaultShopCommand. +/// VI: Handler cho SetDefaultShopCommand. +/// +public class SetDefaultShopCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IShopRepository _shopRepository; + private readonly ILogger _logger; + + public SetDefaultShopCommandHandler( + IMerchantRepository merchantRepository, + IShopRepository shopRepository, + ILogger logger) + { + _merchantRepository = merchantRepository; + _shopRepository = shopRepository; + _logger = logger; + } + + public async Task Handle(SetDefaultShopCommand request, CancellationToken cancellationToken) + { + // EN: Verify merchant exists + // VI: Xác minh merchant tồn tại + var merchant = await _merchantRepository.GetByIdAsync(request.MerchantId, cancellationToken); + if (merchant == null) + { + throw new DomainException("Merchant not found / Không tìm thấy merchant"); + } + + // EN: Verify shop exists and belongs to merchant + // VI: Xác minh shop tồn tại và thuộc về merchant + var shop = await _shopRepository.GetByIdAsync(request.ShopId, cancellationToken); + if (shop == null) + { + throw new DomainException("Shop not found / Không tìm thấy shop"); + } + + if (shop.MerchantId != request.MerchantId) + { + throw new DomainException("Shop does not belong to this merchant / Shop không thuộc về merchant này"); + } + + // EN: Clear current default shop if exists + // VI: Xóa shop mặc định hiện tại nếu có + var currentDefault = await _shopRepository.GetDefaultByMerchantIdAsync(request.MerchantId, cancellationToken); + if (currentDefault != null && currentDefault.Id != request.ShopId) + { + currentDefault.ClearDefault(); + _shopRepository.Update(currentDefault); + } + + // EN: Set new default shop + // VI: Đặt shop mặc định mới + shop.SetAsDefault(); + _shopRepository.Update(shop); + + await _shopRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "EN: Shop {ShopId} set as default for merchant {MerchantId} / VI: Shop {ShopId} được đặt làm mặc định cho merchant {MerchantId}", + shop.Id, request.MerchantId); + + return new SetDefaultShopResult(shop.Id, shop.Name, true); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/TransferShopCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/TransferShopCommand.cs new file mode 100644 index 00000000..f1e4fcb0 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/TransferShopCommand.cs @@ -0,0 +1,130 @@ +// EN: Command to transfer shop ownership to another merchant. +// VI: Command để chuyển quyền sở hữu shop cho merchant khác. + +using MediatR; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; +using MerchantService.Domain.AggregatesModel.ShopAggregate; +using MerchantService.Domain.Exceptions; + +namespace MerchantService.API.Application.Commands.Shops; + +/// +/// EN: Command to transfer shop ownership from one merchant to another. +/// VI: Command để chuyển quyền sở hữu shop từ merchant này sang merchant khác. +/// +public record TransferShopCommand : IRequest +{ + /// + /// EN: Current owner merchant ID. + /// VI: ID merchant chủ sở hữu hiện tại. + /// + public Guid MerchantId { get; init; } + + /// + /// EN: Shop ID to transfer. + /// VI: ID shop cần chuyển. + /// + public Guid ShopId { get; init; } + + /// + /// EN: New owner merchant ID. + /// VI: ID merchant chủ sở hữu mới. + /// + public Guid NewMerchantId { get; init; } + + /// + /// EN: Reason for transfer (optional). + /// VI: Lý do chuyển (tùy chọn). + /// + public string? Reason { get; init; } +} + +/// +/// EN: Result of shop transfer. +/// VI: Kết quả chuyển shop. +/// +public record TransferShopResult( + Guid ShopId, + string ShopName, + Guid PreviousMerchantId, + Guid NewMerchantId, + DateTime TransferredAt +); + +/// +/// EN: Handler for TransferShopCommand. +/// VI: Handler cho TransferShopCommand. +/// +public class TransferShopCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IShopRepository _shopRepository; + private readonly ILogger _logger; + + public TransferShopCommandHandler( + IMerchantRepository merchantRepository, + IShopRepository shopRepository, + ILogger logger) + { + _merchantRepository = merchantRepository; + _shopRepository = shopRepository; + _logger = logger; + } + + public async Task Handle(TransferShopCommand request, CancellationToken cancellationToken) + { + // EN: Verify current owner merchant exists + // VI: Xác minh merchant chủ sở hữu hiện tại tồn tại + var currentMerchant = await _merchantRepository.GetByIdAsync(request.MerchantId, cancellationToken); + if (currentMerchant == null) + { + throw new DomainException("Current merchant not found / Không tìm thấy merchant hiện tại"); + } + + // EN: Verify shop exists and belongs to current merchant + // VI: Xác minh shop tồn tại và thuộc về merchant hiện tại + var shop = await _shopRepository.GetByIdAsync(request.ShopId, cancellationToken); + if (shop == null) + { + throw new DomainException("Shop not found / Không tìm thấy shop"); + } + + if (shop.MerchantId != request.MerchantId) + { + throw new DomainException("Shop does not belong to this merchant. You do not have permission to transfer it / Shop không thuộc về merchant này. Bạn không có quyền chuyển nó"); + } + + // EN: Verify new owner merchant exists and is active + // VI: Xác minh merchant chủ mới tồn tại và đang hoạt động + var newMerchant = await _merchantRepository.GetByIdAsync(request.NewMerchantId, cancellationToken); + if (newMerchant == null) + { + throw new DomainException("New merchant not found / Không tìm thấy merchant mới"); + } + + if (newMerchant.StatusId != MerchantStatus.Active.Id) + { + throw new DomainException("New merchant must be active to receive shop transfer / Merchant mới phải đang hoạt động để nhận chuyển shop"); + } + + // EN: Transfer ownership via domain method + // VI: Chuyển quyền sở hữu qua phương thức domain + var previousMerchantId = shop.MerchantId; + shop.TransferOwnership(request.NewMerchantId); + _shopRepository.Update(shop); + + await _shopRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "EN: Shop {ShopId} transferred from merchant {PreviousMerchantId} to {NewMerchantId}. Reason: {Reason} / VI: Shop {ShopId} đã chuyển từ merchant {PreviousMerchantId} sang {NewMerchantId}. Lý do: {Reason}", + shop.Id, previousMerchantId, request.NewMerchantId, request.Reason ?? "N/A"); + + return new TransferShopResult( + shop.Id, + shop.Name, + previousMerchantId, + request.NewMerchantId, + DateTime.UtcNow + ); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetMerchantShopsQuery.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetMerchantShopsQuery.cs new file mode 100644 index 00000000..535103f1 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetMerchantShopsQuery.cs @@ -0,0 +1,128 @@ +// EN: Query to get merchant's shops with pagination and filtering. +// VI: Query để lấy danh sách shops của merchant với phân trang và lọc. + +using MediatR; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; +using MerchantService.Domain.AggregatesModel.ShopAggregate; +using MerchantService.Domain.Exceptions; +using MerchantService.Domain.SeedWork; +using static MerchantService.API.Application.Queries.Shops.EnumResolverHelper; + +namespace MerchantService.API.Application.Queries.Shops; + +/// +/// EN: Query to get a merchant's shops with pagination and optional status filter. +/// VI: Query để lấy shops của merchant với phân trang và lọc theo trạng thái (tùy chọn). +/// +public record GetMerchantShopsQuery( + Guid MerchantId, + string? Status, + int PageNumber = 1, + int PageSize = 20 +) : IRequest; + +/// +/// EN: Result of GetMerchantShopsQuery with pagination metadata. +/// VI: Kết quả của GetMerchantShopsQuery với thông tin phân trang. +/// +public record GetMerchantShopsQueryResult( + IReadOnlyList Items, + int TotalCount, + int PageNumber, + int PageSize +); + +/// +/// EN: DTO for merchant shop listing. +/// VI: DTO cho danh sách shop của merchant. +/// +public record MerchantShopDto +{ + public Guid Id { get; init; } + public string Name { get; init; } = null!; + public string Slug { get; init; } = null!; + public string Type { get; init; } = null!; + public string Category { get; init; } = null!; + public string Status { get; init; } = null!; + public string? LogoUrl { get; init; } + public string? Description { get; init; } + public bool IsDefault { get; init; } + public int BranchCount { get; init; } + public DateTime CreatedAt { get; init; } + public DateTime? UpdatedAt { get; init; } +} + +/// +/// EN: Handler for GetMerchantShopsQuery. +/// VI: Handler cho GetMerchantShopsQuery. +/// +public class GetMerchantShopsQueryHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IShopRepository _shopRepository; + private readonly ILogger _logger; + + public GetMerchantShopsQueryHandler( + IMerchantRepository merchantRepository, + IShopRepository shopRepository, + ILogger logger) + { + _merchantRepository = merchantRepository; + _shopRepository = shopRepository; + _logger = logger; + } + + public async Task Handle(GetMerchantShopsQuery request, CancellationToken cancellationToken) + { + // EN: Verify merchant exists + // VI: Xác minh merchant tồn tại + var merchant = await _merchantRepository.GetByIdAsync(request.MerchantId, cancellationToken); + if (merchant == null) + { + throw new DomainException("Merchant not found / Không tìm thấy merchant"); + } + + // EN: Parse status filter if provided + // VI: Parse bộ lọc trạng thái nếu có + int? statusId = null; + if (!string.IsNullOrWhiteSpace(request.Status)) + { + statusId = request.Status.ToLowerInvariant() switch + { + "draft" => ShopStatus.Draft.Id, + "active" => ShopStatus.Active.Id, + "inactive" => ShopStatus.Inactive.Id, + "closed" => ShopStatus.Closed.Id, + _ => throw new DomainException($"Invalid status filter '{request.Status}'. Valid values: Draft, Active, Inactive, Closed / Giá trị lọc trạng thái không hợp lệ '{request.Status}'. Giá trị hợp lệ: Draft, Active, Inactive, Closed") + }; + } + + // EN: Get paginated shops + // VI: Lấy shops có phân trang + var (items, totalCount) = await _shopRepository.GetByMerchantIdPagedAsync( + request.MerchantId, statusId, request.PageNumber, request.PageSize, cancellationToken); + + _logger.LogInformation( + "EN: Retrieved {Count} shops for merchant {MerchantId} (page {Page}/{TotalPages}) / VI: Đã lấy {Count} shops cho merchant {MerchantId} (trang {Page}/{TotalPages})", + items.Count, request.MerchantId, request.PageNumber, + (int)Math.Ceiling((double)totalCount / request.PageSize)); + + var dtos = items.Select(s => new MerchantShopDto + { + Id = s.Id, + Name = s.Name, + Slug = s.Slug, + Type = ResolveEnumName(s.TypeId, "Unknown"), + Category = ResolveEnumName(s.CategoryId, "Other"), + Status = ResolveEnumName(s.StatusId, "Unknown"), + LogoUrl = s.LogoUrl, + Description = s.Description, + IsDefault = s.IsDefault, + BranchCount = s.Branches?.Count ?? 0, + CreatedAt = s.CreatedAt, + UpdatedAt = s.UpdatedAt + }).ToList(); + + return new GetMerchantShopsQueryResult(dtos, totalCount, request.PageNumber, request.PageSize); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQuery.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQuery.cs index b5dc8ffc..a34e1efb 100644 --- a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQuery.cs +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQuery.cs @@ -36,6 +36,7 @@ public record ShopDto public string Category { get; init; } = null!; public string Status { get; init; } = null!; public string? LogoUrl { get; init; } + public bool IsDefault { get; init; } public int BranchCount { get; init; } public DateTime CreatedAt { get; init; } } @@ -56,6 +57,7 @@ public record ShopDetailDto public string? Description { get; init; } public string? LogoUrl { get; init; } public string? CoverImageUrl { get; init; } + public bool IsDefault { get; init; } public ContactInfoDto? ContactInfo { get; init; } public OperatingHoursDto? OperatingHours { get; init; } public IReadOnlyList Branches { get; init; } = []; diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQueryHandler.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQueryHandler.cs index 9884fe9e..aa0c0ffa 100644 --- a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQueryHandler.cs +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQueryHandler.cs @@ -57,6 +57,7 @@ public class GetMyShopsQueryHandler : IRequestHandler(s.CategoryId, "Other"), Status = ResolveEnumName(s.StatusId, "Unknown"), LogoUrl = s.LogoUrl, + IsDefault = s.IsDefault, BranchCount = s.Branches?.Count ?? 0, CreatedAt = s.CreatedAt }).ToList(); @@ -96,6 +97,7 @@ public class GetShopByIdQueryHandler : IRequestHandler } } +/// +/// EN: Validator for SetDefaultShopCommand. +/// VI: Validator cho SetDefaultShopCommand. +/// +public class SetDefaultShopCommandValidator : AbstractValidator +{ + public SetDefaultShopCommandValidator() + { + RuleFor(x => x.MerchantId) + .NotEmpty().WithMessage("Merchant ID is required / ID merchant là bắt buộc"); + + RuleFor(x => x.ShopId) + .NotEmpty().WithMessage("Shop ID is required / ID shop là bắt buộc"); + } +} + +/// +/// EN: Validator for TransferShopCommand. +/// VI: Validator cho TransferShopCommand. +/// +public class TransferShopCommandValidator : AbstractValidator +{ + public TransferShopCommandValidator() + { + RuleFor(x => x.MerchantId) + .NotEmpty().WithMessage("Merchant ID is required / ID merchant là bắt buộc"); + + RuleFor(x => x.ShopId) + .NotEmpty().WithMessage("Shop ID is required / ID shop là bắt buộc"); + + RuleFor(x => x.NewMerchantId) + .NotEmpty().WithMessage("New merchant ID is required / ID merchant mới là bắt buộc") + .NotEqual(x => x.MerchantId).WithMessage("Cannot transfer shop to the same merchant / Không thể chuyển shop cho cùng merchant"); + + RuleFor(x => x.Reason) + .MaximumLength(500).WithMessage("Transfer reason cannot exceed 500 characters / Lý do chuyển không được vượt quá 500 ký tự") + .When(x => !string.IsNullOrEmpty(x.Reason)); + } +} + /// /// EN: Validator for AddShopBranchCommand. /// VI: Validator cho AddShopBranchCommand. diff --git a/services/merchant-service-net/src/MerchantService.API/Controllers/MerchantsController.cs b/services/merchant-service-net/src/MerchantService.API/Controllers/MerchantsController.cs index 650525c2..2cd06e78 100644 --- a/services/merchant-service-net/src/MerchantService.API/Controllers/MerchantsController.cs +++ b/services/merchant-service-net/src/MerchantService.API/Controllers/MerchantsController.cs @@ -5,7 +5,9 @@ using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MerchantService.API.Application.Commands.Merchants; +using MerchantService.API.Application.Commands.Shops; using MerchantService.API.Application.Queries.Merchants; +using MerchantService.API.Application.Queries.Shops; namespace MerchantService.API.Controllers; @@ -91,6 +93,124 @@ public class MerchantsController : ControllerBase await _mediator.Send(new SubmitMerchantVerificationCommand()); return Ok(new { message = "Verification submitted successfully. Please wait for admin approval." }); } + + /// + /// EN: Get all shops for a merchant with pagination and optional status filter. + /// VI: Lấy tất cả shops của merchant với phân trang và lọc theo trạng thái (tùy chọn). + /// + [HttpGet("{merchantId:guid}/shops")] + [ProducesResponseType(typeof(GetMerchantShopsQueryResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetMerchantShops( + Guid merchantId, + [FromQuery] string? status, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + try + { + var result = await _mediator.Send(new GetMerchantShopsQuery(merchantId, status, page, pageSize)); + return Ok(new { success = true, data = result }); + } + catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("not found")) + { + return NotFound(new { success = false, error = new { code = "MERCHANT_NOT_FOUND", message = ex.Message } }); + } + catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("Invalid status")) + { + return BadRequest(new { success = false, error = new { code = "INVALID_STATUS_FILTER", message = ex.Message } }); + } + } + + /// + /// EN: Set a shop as the default/primary shop for the merchant. + /// VI: Đặt shop làm shop mặc định/chính cho merchant. + /// + [HttpPut("{merchantId:guid}/shops/{shopId:guid}/set-default")] + [ProducesResponseType(typeof(SetDefaultShopResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task SetDefaultShop(Guid merchantId, Guid shopId) + { + try + { + var result = await _mediator.Send(new SetDefaultShopCommand(merchantId, shopId)); + return Ok(new { success = true, data = result }); + } + catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("not found")) + { + return NotFound(new { success = false, error = new { code = "NOT_FOUND", message = ex.Message } }); + } + catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("does not belong")) + { + return BadRequest(new { success = false, error = new { code = "SHOP_NOT_OWNED", message = ex.Message } }); + } + catch (Domain.Exceptions.DomainException ex) + { + return BadRequest(new { success = false, error = new { code = "SET_DEFAULT_FAILED", message = ex.Message } }); + } + } + + /// + /// EN: Transfer shop ownership to another merchant. + /// VI: Chuyển quyền sở hữu shop cho merchant khác. + /// + [HttpPost("{merchantId:guid}/shops/{shopId:guid}/transfer")] + [ProducesResponseType(typeof(TransferShopResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task TransferShop( + Guid merchantId, + Guid shopId, + [FromBody] TransferShopRequest request) + { + try + { + var command = new TransferShopCommand + { + MerchantId = merchantId, + ShopId = shopId, + NewMerchantId = request.NewMerchantId, + Reason = request.Reason + }; + var result = await _mediator.Send(command); + _logger.LogInformation( + "Shop {ShopId} transferred from merchant {OldMerchantId} to {NewMerchantId}", + shopId, merchantId, request.NewMerchantId); + return Ok(new { success = true, data = result }); + } + catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("not found")) + { + return NotFound(new { success = false, error = new { code = "NOT_FOUND", message = ex.Message } }); + } + catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("permission") || ex.Message.Contains("does not belong")) + { + return StatusCode(StatusCodes.Status403Forbidden, new { success = false, error = new { code = "FORBIDDEN", message = ex.Message } }); + } + catch (Domain.Exceptions.DomainException ex) + { + return BadRequest(new { success = false, error = new { code = "TRANSFER_FAILED", message = ex.Message } }); + } + } +} + +/// +/// EN: Request model for shop transfer. +/// VI: Model request để chuyển shop. +/// +public record TransferShopRequest +{ + /// + /// EN: New owner merchant ID. + /// VI: ID merchant chủ sở hữu mới. + /// + public Guid NewMerchantId { get; init; } + + /// + /// EN: Reason for transfer (optional). + /// VI: Lý do chuyển (tùy chọn). + /// + public string? Reason { get; init; } } /// diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/IShopRepository.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/IShopRepository.cs index 84ff1a17..5c5bdc56 100644 --- a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/IShopRepository.cs +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/IShopRepository.cs @@ -64,4 +64,17 @@ public interface IShopRepository : IRepository /// VI: Lấy tất cả shops active kèm chi nhánh để tìm kiếm gần đây. /// Task> GetActiveShopsWithBranchesAsync(CancellationToken cancellationToken = default); + + /// + /// EN: Get shops by merchant ID with pagination and optional status filter. + /// VI: Lấy shops theo merchant ID với phân trang và lọc theo trạng thái (tùy chọn). + /// + Task<(IReadOnlyList Items, int TotalCount)> GetByMerchantIdPagedAsync( + Guid merchantId, int? statusId, int pageNumber, int pageSize, CancellationToken cancellationToken = default); + + /// + /// EN: Get the default shop for a merchant. + /// VI: Lấy shop mặc định của merchant. + /// + Task GetDefaultByMerchantIdAsync(Guid merchantId, CancellationToken cancellationToken = default); } diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/Shop.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/Shop.cs index fa0ecdc2..723c1b66 100644 --- a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/Shop.cs +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/Shop.cs @@ -32,6 +32,8 @@ public partial class Shop : Entity, IAggregateRoot private bool _isDeleted; private ShopFeatures _features = null!; + private bool _isDefault; + private readonly List _branches = new(); /// @@ -124,6 +126,12 @@ public partial class Shop : Entity, IAggregateRoot /// public ShopFeatures Features => _features; + /// + /// EN: Whether this is the default/primary shop for the merchant. + /// VI: Shop này có phải là shop mặc định/chính của merchant không. + /// + public bool IsDefault => _isDefault; + /// /// EN: Physical branches of the shop. /// VI: Các chi nhánh vật lý của shop. @@ -341,6 +349,57 @@ public partial class Shop : Entity, IAggregateRoot _updatedAt = DateTime.UtcNow; } + /// + /// EN: Set this shop as the default/primary shop for the merchant. + /// VI: Đặt shop này làm shop mặc định/chính của merchant. + /// + public void SetAsDefault() + { + if (_isDeleted) + throw new DomainException("Cannot set deleted shop as default"); + if (_status == ShopStatus.Closed) + throw new DomainException("Cannot set closed shop as default"); + + _isDefault = true; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new ShopSetAsDefaultDomainEvent(this)); + } + + /// + /// EN: Clear the default flag on this shop. + /// VI: Xóa cờ mặc định trên shop này. + /// + public void ClearDefault() + { + _isDefault = false; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Transfer ownership of this shop to another merchant. + /// VI: Chuyển quyền sở hữu shop này cho merchant khác. + /// + /// New owner merchant ID / ID merchant chủ mới + public void TransferOwnership(Guid newMerchantId) + { + if (newMerchantId == Guid.Empty) + throw new DomainException("New merchant ID cannot be empty"); + if (newMerchantId == _merchantId) + throw new DomainException("Cannot transfer shop to the same merchant"); + if (_isDeleted) + throw new DomainException("Cannot transfer deleted shop"); + if (_status == ShopStatus.Closed) + throw new DomainException("Cannot transfer closed shop"); + + var previousMerchantId = _merchantId; + _merchantId = newMerchantId; + _isDefault = false; // EN: Clear default when transferring / VI: Xóa mặc định khi chuyển + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new ShopTransferredDomainEvent(this, previousMerchantId, newMerchantId)); + } + /// /// EN: Soft delete shop. /// VI: Xóa mềm shop. diff --git a/services/merchant-service-net/src/MerchantService.Domain/Events/ShopDomainEvents.cs b/services/merchant-service-net/src/MerchantService.Domain/Events/ShopDomainEvents.cs index de147e3a..057a7ed7 100644 --- a/services/merchant-service-net/src/MerchantService.Domain/Events/ShopDomainEvents.cs +++ b/services/merchant-service-net/src/MerchantService.Domain/Events/ShopDomainEvents.cs @@ -29,3 +29,15 @@ public record ShopClosedDomainEvent(Shop Shop) : INotification; /// VI: Được phát ra khi chi nhánh mới được thêm vào shop. /// public record ShopBranchAddedDomainEvent(Shop Shop, ShopBranch Branch) : INotification; + +/// +/// EN: Raised when a shop is set as the default/primary shop for a merchant. +/// VI: Được phát ra khi shop được đặt làm shop mặc định/chính cho merchant. +/// +public record ShopSetAsDefaultDomainEvent(Shop Shop) : INotification; + +/// +/// EN: Raised when shop ownership is transferred to another merchant. +/// VI: Được phát ra khi quyền sở hữu shop được chuyển cho merchant khác. +/// +public record ShopTransferredDomainEvent(Shop Shop, Guid PreviousMerchantId, Guid NewMerchantId) : INotification; diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/EntityConfigurations/ShopEntityTypeConfiguration.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/EntityConfigurations/ShopEntityTypeConfiguration.cs index 7107ce7c..19937f0f 100644 --- a/services/merchant-service-net/src/MerchantService.Infrastructure/EntityConfigurations/ShopEntityTypeConfiguration.cs +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/EntityConfigurations/ShopEntityTypeConfiguration.cs @@ -110,6 +110,10 @@ public class ShopEntityTypeConfiguration : IEntityTypeConfiguration builder.Property("_updatedAt") .HasColumnName("updated_at"); + builder.Property("_isDefault") + .HasColumnName("is_default") + .HasDefaultValue(false); + builder.Property("_isDeleted") .HasColumnName("is_deleted") .HasDefaultValue(false); @@ -145,6 +149,7 @@ public class ShopEntityTypeConfiguration : IEntityTypeConfiguration builder.Ignore(s => s.MerchantId); builder.Ignore(s => s.CreatedAt); builder.Ignore(s => s.UpdatedAt); + builder.Ignore(s => s.IsDefault); builder.Ignore(s => s.IsDeleted); } } diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/ShopRepository.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/ShopRepository.cs index 91ed1296..156b4c65 100644 --- a/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/ShopRepository.cs +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/ShopRepository.cs @@ -91,4 +91,39 @@ public class ShopRepository : IShopRepository .Where(s => !EF.Property(s, "_isDeleted") && s.StatusId == ShopStatus.Active.Id) .ToListAsync(cancellationToken); } + + /// + public async Task<(IReadOnlyList Items, int TotalCount)> GetByMerchantIdPagedAsync( + Guid merchantId, int? statusId, int pageNumber, int pageSize, CancellationToken cancellationToken = default) + { + var query = _context.Shops + .Where(s => EF.Property(s, "_merchantId") == merchantId && !EF.Property(s, "_isDeleted")); + + if (statusId.HasValue) + { + query = query.Where(s => s.StatusId == statusId.Value); + } + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query + .OrderByDescending(s => EF.Property(s, "_isDefault")) + .ThenByDescending(s => EF.Property(s, "_createdAt")) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, totalCount); + } + + /// + public async Task GetDefaultByMerchantIdAsync(Guid merchantId, CancellationToken cancellationToken = default) + { + return await _context.Shops + .FirstOrDefaultAsync(s => + EF.Property(s, "_merchantId") == merchantId + && EF.Property(s, "_isDefault") + && !EF.Property(s, "_isDeleted"), + cancellationToken); + } }