diff --git a/.github/workflows/bbb-pool-manage.yml b/.github/workflows/bbb-pool-manage.yml index 7dc13cde83..7a4ca2165a 100644 --- a/.github/workflows/bbb-pool-manage.yml +++ b/.github/workflows/bbb-pool-manage.yml @@ -335,6 +335,34 @@ jobs: sed -i "s|defaultUploadedPresentation=https://[^/]*/|defaultUploadedPresentation=https://$DOMAIN/|g" \ /etc/bigbluebutton/bbb-web.properties 2>/dev/null || true + # Step 6: TURN / coturn — NOT covered by the sweep in Step 2. + # That sweep walks directories and filters by extension; /etc/turnserver.conf + # is under none of those directories and /etc/haproxy/haproxy.cfg matches none + # of those extensions, so the snapshot's build-time IP survived here on EVERY + # restore. coturn then cannot bind a foreign address and exits, which silently + # kills audio/video for every learner behind a UDP-blocking firewall while + # everyone on a permissive network is unaffected. + if [ -f /etc/turnserver.conf ]; then + for old in $(grep -oP '^\s*(listening-ip|relay-ip|allowed-peer-ip)=\K[\d.]+' \ + /etc/turnserver.conf 2>/dev/null | grep -v '^127\.' | sort -u); do + if [ "$old" != "$NEW_IP" ]; then + echo " TURN: $old -> $NEW_IP" + sed -i "s/\b$old\b/$NEW_IP/g" /etc/turnserver.conf + fi + done + grep -q '^listening-ip=127\.0\.0\.1' /etc/turnserver.conf || \ + sed -i "/^listening-ip=$NEW_IP/a listening-ip=127.0.0.1" /etc/turnserver.conf + systemctl restart coturn 2>/dev/null || true + echo " coturn: $(systemctl is-active coturn 2>/dev/null)" + fi + + # Pin haproxy's TURN backend to loopback — IP-proof from here on. + if [ -f /etc/haproxy/haproxy.cfg ]; then + sed -i 's|^\(\s*server localhost \)[0-9.]\+:3478|\1127.0.0.1:3478|' /etc/haproxy/haproxy.cfg + haproxy -c -f /etc/haproxy/haproxy.cfg >/dev/null 2>&1 \ + && systemctl reload haproxy 2>/dev/null || echo " WARNING: haproxy config test failed" + fi + echo "BBB IP fix complete: $DOMAIN ($NEW_IP)" SCRIPT @@ -368,17 +396,16 @@ jobs: SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=15" echo "[${{ matrix.slug }}] Shipping recording hook + heal service + health dashboard files to $IP" - ssh $SSH_OPTS root@$IP "mkdir -p /tmp/vacademy-bbb" - scp $SSH_OPTS \ - bigbluebutton-server/post-publish-s3-upload.sh \ - bigbluebutton-server/install-recording-hook.sh \ - bigbluebutton-server/bbb-heal-service.py \ - bigbluebutton-server/bbb-heal-service.service \ - bigbluebutton-server/vacademy-heal.nginx \ - bigbluebutton-server/bbb-health-dashboard.py \ - bigbluebutton-server/bbb-health-dashboard.service \ - bigbluebutton-server/vacademy-health.nginx \ - root@$IP:/tmp/vacademy-bbb/ + # Copy the WHOLE directory rather than a hand-maintained file list. + # install-recording-hook.sh reads several paths relative to its own + # directory — systemd/bbb-rap-resque-worker.service.d/lowprio.conf, the + # highprio.conf drop-ins, bbb-recording-drain.sh, bbb-rap-autotune.sh and + # their units — none of which were in the old explicit list, so step 9/10 + # died on `cp: cannot stat` and took the whole start job down with it. + # A recursive copy means adding a file to the repo can never again break + # the daily start. + ssh $SSH_OPTS root@$IP "rm -rf /tmp/vacademy-bbb && mkdir -p /tmp/vacademy-bbb" + scp -r $SSH_OPTS bigbluebutton-server/* root@$IP:/tmp/vacademy-bbb/ # Read BBB secret from the running server — no need to store per-server secrets in GitHub echo "[${{ matrix.slug }}] Reading BBB secret from server" @@ -404,6 +431,105 @@ jobs: echo "[${{ matrix.slug }}] Recording hook + heal service + health dashboard synced successfully" + - name: Sync per-institute custom live-class domains + # Aliases resolve to the PRIMARY pool server only (BbbServerRouter.isPrimary), + # so there is nothing to do on any other server in the pool. + # + # `!cancelled()` deliberately decouples this from the recording-hook step: + # those are unrelated concerns, and a recording-pipeline problem should not + # leave an institute's live-class domain pointing at a dead IP. The job still + # reports failure if the earlier step failed. + if: "!cancelled() && steps.check.outputs.skip != 'true' && matrix.priority == 1" + env: + SSH_PRIVATE_KEY: ${{ secrets.BBB_SSH_PRIVATE_KEY }} + INTERNAL_SERVICE_TOKEN: ${{ secrets.INTERNAL_SERVICE_TOKEN }} + CF_TOKEN: ${{ secrets.BBB_CLOUDFLARE_API_TOKEN }} + VACADEMY_BACKEND_URL: ${{ vars.VACADEMY_BACKEND_URL }} + run: | + set -uo pipefail + # GitHub runs this step as `bash -e {0}`, so ANY failing command — a jq + # parse error, a transient curl — aborts before the explicit guards below + # can turn it into a warning. This step does its own error handling and + # decides deliberately what is fatal, so -e is turned off here. + set +e + IP="${{ steps.poweron.outputs.server_ip }}" + [ -z "$IP" ] && IP="${{ steps.create.outputs.server_ip }}" + BACKEND_URL="${VACADEMY_BACKEND_URL:-https://backend-stage.vacademy.io}" + + mkdir -p ~/.ssh; echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa; chmod 600 ~/.ssh/id_rsa + SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=15" + + # ── Fetch the configured hostnames from admin_core ────────────── + RESP=$(curl -s --max-time 30 -w '\n%{http_code}' \ + -H "X-Internal-Service-Token: $INTERNAL_SERVICE_TOKEN" \ + "$BACKEND_URL/admin-core-service/bbb/custom-domains" || true) + CODE=$(echo "$RESP" | tail -1) + BODY=$(echo "$RESP" | sed '$d') + + if [ "$CODE" != "200" ]; then + # Deliberately a SKIP, not a failure and not an empty list. Running the + # installer with no aliases would reset server_name to canonical-only and + # take working institute domains offline just because the API blipped. + echo "::warning::Could not fetch custom domains (HTTP $CODE) — leaving the snapshot's existing domain config untouched." + exit 0 + fi + + DOMAINS=$(echo "$BODY" | jq -r '.domains[]?' | tr '\n' ' ') + echo "[${{ matrix.slug }}] custom domains: ${DOMAINS:-}" + + # ── Point each alias at this server's new IP ──────────────────── + if [ -n "$DOMAINS" ]; then + ZONES=$(curl -s --max-time 30 -H "Authorization: Bearer $CF_TOKEN" \ + "${{ env.CF_API }}/zones?per_page=50" | jq -c '[.result[] | {name, id}]') + for HOST in $DOMAINS; do + # Longest-suffix match, so sub.school.co.in picks school.co.in. + # `. as $z` matters: inside `$h | endswith(...)` the input is the + # STRING $h, so a bare .name there indexes a string and jq errors out, + # which would silently send every host down the warn-and-skip path. + ZONE_ID=$(echo "$ZONES" | jq -r --arg h "$HOST" \ + '[.[] | . as $z | select(($h == $z.name) or ($h | endswith("." + $z.name)))] + | sort_by(.name | length) | last | .id // empty') + if [ -z "$ZONE_ID" ]; then + echo "::warning::No Cloudflare zone for $HOST — skipping its DNS record." + continue + fi + REC=$(curl -s -H "Authorization: Bearer $CF_TOKEN" \ + "${{ env.CF_API }}/zones/$ZONE_ID/dns_records?type=A&name=$HOST" | jq -r '.result[0].id // empty') + # proxied:false is required — WebRTC negotiates media against the real + # origin IP, which an orange-cloud record would hide. + DATA="{\"type\":\"A\",\"name\":\"$HOST\",\"content\":\"$IP\",\"ttl\":120,\"proxied\":false}" + if [ -n "$REC" ]; then + OK=$(curl -s -X PUT "${{ env.CF_API }}/zones/$ZONE_ID/dns_records/$REC" \ + -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" -d "$DATA" | jq -r '.success') + else + OK=$(curl -s -X POST "${{ env.CF_API }}/zones/$ZONE_ID/dns_records" \ + -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" -d "$DATA" | jq -r '.success') + fi + if [ "$OK" = "true" ]; then + echo " $HOST -> $IP" + else + echo "::warning::DNS update failed for $HOST (Cloudflare success=${OK:-})" + fi + done + fi + + # ── Refresh the DNS-01 credentials, then apply on the box ─────── + printf 'dns_cloudflare_api_token = %s\n' "$CF_TOKEN" | \ + ssh $SSH_OPTS root@$IP 'umask 077; cat > /etc/letsencrypt/cloudflare.ini && chmod 600 /etc/letsencrypt/cloudflare.ini' \ + || echo "::warning::Could not refresh cloudflare.ini — certificate expansion may be skipped on the box." + + if ! scp $SSH_OPTS bigbluebutton-server/install-custom-domains.sh root@$IP:/tmp/; then + echo "::error::Could not copy install-custom-domains.sh to $IP" + exit 1 + fi + CSV=$(echo "$DOMAINS" | tr ' ' ',' | sed 's/,$//') + # This one IS fatal: nginx server_name and the certificate are what make + # the alias reachable at all, and the script rolls itself back on failure. + if ! ssh $SSH_OPTS root@$IP "bash /tmp/install-custom-domains.sh '${{ matrix.domain }}' '$CSV'"; then + echo "::error::install-custom-domains.sh failed on $IP — the canonical domain is unaffected, but institute domains may not be served." + exit 1 + fi + - name: Summary if: steps.check.outputs.skip != 'true' run: | diff --git a/.github/workflows/build-zoe-appx.yml b/.github/workflows/build-zoe-appx.yml index 72520a472f..0e0c165145 100644 --- a/.github/workflows/build-zoe-appx.yml +++ b/.github/workflows/build-zoe-appx.yml @@ -1,6 +1,6 @@ -name: Build ZOE Edtech AppX (Microsoft Store) +name: Build ZOE Online School AppX (Microsoft Store) -# Builds the ZOE Edtech Windows Store package (.appx, x64 + ia32) on a Windows +# Builds the ZOE Online School Windows Store package (.appx, x64 + ia32) on a Windows # runner and uploads it as a downloadable artifact. AppX packaging needs the # Windows SDK (makeappx), which only exists on Windows — hence windows-latest. # @@ -71,7 +71,37 @@ jobs: run: | rm -rf electron/app cp -r dist electron/app - echo '{"flavor":"zoe"}' > electron/electron-flavor.json + # otaAppId must be the STORE bundle id — ota.ts falls back to + # app.getName() without it, which never matches an OTA target_app_ids. + echo '{"flavor":"zoe","otaAppId":"com.zoeedtech.app"}' > electron/electron-flavor.json + # Which web bundle is baked in. Without this ota.ts falls back to the + # ELECTRON package version (1.0.x), a different numbering space, so + # every OTA bundle looks newer and the fresh builtin bundle is + # immediately replaced by an older one on first launch. + node -p "require('./package.json').version" | tr -d '\n' > electron/app/ota-bundle-version.txt + cat electron/app/ota-bundle-version.txt + + # electron/package.json is checked in with Shiksha Nation's identity and is + # rewritten per flavor at build time — build-windows-zoe.sh does this and CI + # used to skip it, so the ZOE package shipped as name "Shiksha_Nation". + # That is not cosmetic: on Windows app.getName() resolves to package.json + # productName ?? name, and userData is %APPDATA%\ — so ZOE and + # Shiksha Nation shared one localStorage, cookie jar and OTA cache on any PC + # with both installed. Keep this identical to build-windows-zoe.sh. + - name: Patch package.json for ZOE + working-directory: frontend-learner-dashboard-app/electron + shell: bash + run: | + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.name = 'ZOE_Online_School'; + pkg.productName = 'ZOE Online School'; + pkg.description = 'ZOE Global Online School — AI-Powered Learning Platform'; + pkg.author = { name: 'ZOE Global Online School', email: 'support@zoeedtech.com' }; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + node -p "'name=' + require('./package.json').name + ' productName=' + require('./package.json').productName + ' author=' + require('./package.json').author.name" - name: Install electron dependencies working-directory: frontend-learner-dashboard-app/electron @@ -93,7 +123,7 @@ jobs: - name: Upload AppX artifacts uses: actions/upload-artifact@v4 with: - name: zoe-edtech-appx + name: zoe-online-school-appx path: | frontend-learner-dashboard-app/electron/dist-store/*.appx frontend-learner-dashboard-app/electron/dist-store/*.appxbundle diff --git a/.github/workflows/maven-publish-admin-core-service.yml b/.github/workflows/maven-publish-admin-core-service.yml index d0bb75ade4..276fa293ea 100644 --- a/.github/workflows/maven-publish-admin-core-service.yml +++ b/.github/workflows/maven-publish-admin-core-service.yml @@ -150,6 +150,7 @@ jobs: META_LOGIN_CONFIG_ENABLED=${{ secrets.META_LOGIN_CONFIG_ENABLED || 'false' }} \ OAUTH_TOKEN_ENCRYPTION_KEY=${{ secrets.OAUTH_TOKEN_ENCRYPTION_KEY }} \ AI_SERVICE_INTERNAL_TOKEN=${{ secrets.INTERNAL_SERVICE_TOKEN }} \ + INTERNAL_SERVICE_TOKEN=${{ secrets.INTERNAL_SERVICE_TOKEN }} \ YOUTUBE_OAUTH_CLIENT_ID=${{ secrets.ORG_YOUTUBE_OAUTH_CLIENT_ID }} \ YOUTUBE_OAUTH_CLIENT_SECRET=${{ secrets.ORG_YOUTUBE_OAUTH_CLIENT_SECRET }} \ YOUTUBE_OAUTH_REDIRECT_URI=${{ secrets.ORG_YOUTUBE_OAUTH_REDIRECT_URI }} \ diff --git a/.gitignore b/.gitignore index ec5bea4899..ec00e9c7e4 100644 --- a/.gitignore +++ b/.gitignore @@ -159,3 +159,4 @@ vacademy_devops/hetzner/values-prod-hetzner.yaml # Local dev config per service (hardcoded secrets for terminal runs) — never commit **/src/main/resources/application-local.properties +loadtest/answer-sheet.pdf diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/config/ApplicationSecurityConfig.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/config/ApplicationSecurityConfig.java index b4cdfb0a24..fd834da397 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/config/ApplicationSecurityConfig.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/config/ApplicationSecurityConfig.java @@ -113,6 +113,12 @@ public class ApplicationSecurityConfig { "/admin-core-service/live-sessions/provider/meeting/recording/complete", // BBB server pool management (server-to-server from community_service, no JWT) "/admin-core-service/bbb/pool/**", + // BBB custom live-class domains, read by the pool start workflow. + // No JWT, but NOT unauthenticated: BbbCustomDomainController itself + // requires the shared X-Internal-Service-Token. Note the path must not + // contain the word "internal" — InternalAuthFilter substring-matches the + // URI and would demand clientName + Signature instead. + "/admin-core-service/bbb/custom-domains", // Zoom webhook callback (no JWT — verified by per-account HMAC signature) "/admin-core-service/live-sessions/provider/meeting/zoom-callback/**", // "Connect with Zoom" OAuth redirect (no JWT — CSRF-protected by the state record) diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/crypto/EncryptedJsonMapConverter.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/crypto/EncryptedJsonMapConverter.java new file mode 100644 index 0000000000..cb3dc767fc --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/crypto/EncryptedJsonMapConverter.java @@ -0,0 +1,50 @@ +package vacademy.io.admin_core_service.core.crypto; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import vacademy.io.common.exceptions.VacademyException; + +import java.util.Map; + +/** + * JPA converter storing a {@code Map} as an encrypted JSON TEXT + * column (used for hr_employee_profile.statutory_info, which V480 converted + * from jsonb to TEXT). Legacy rows holding plaintext JSON parse through + * unchanged and are encrypted on next write. + */ +@Converter +public class EncryptedJsonMapConverter implements AttributeConverter, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + @Override + public String convertToDatabaseColumn(Map attribute) { + if (attribute == null) { + return null; + } + try { + String json = MAPPER.writeValueAsString(attribute); + HrFieldCipher cipher = HrFieldCipher.instance(); + return cipher != null ? cipher.encryptField(json) : json; + } catch (Exception e) { + throw new VacademyException("Failed to serialize statutory info: " + e.getMessage()); + } + } + + @Override + public Map convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) { + return null; + } + try { + HrFieldCipher cipher = HrFieldCipher.instance(); + String json = cipher != null ? cipher.decryptField(dbData) : dbData; + return MAPPER.readValue(json, MAP_TYPE); + } catch (Exception e) { + throw new VacademyException("Failed to read statutory info: " + e.getMessage()); + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/crypto/EncryptedStringConverter.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/crypto/EncryptedStringConverter.java new file mode 100644 index 0000000000..237cbd10ff --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/crypto/EncryptedStringConverter.java @@ -0,0 +1,26 @@ +package vacademy.io.admin_core_service.core.crypto; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * JPA converter encrypting a String column at rest via {@link HrFieldCipher}. + * Apply explicitly ({@code @Convert(converter = EncryptedStringConverter.class)}) + * on PII fields — never autoApply. Legacy plaintext rows (no ENCv1: prefix) + * read through unchanged and are encrypted on next write. + */ +@Converter +public class EncryptedStringConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(String attribute) { + HrFieldCipher cipher = HrFieldCipher.instance(); + return cipher != null ? cipher.encryptField(attribute) : attribute; + } + + @Override + public String convertToEntityAttribute(String dbData) { + HrFieldCipher cipher = HrFieldCipher.instance(); + return cipher != null ? cipher.decryptField(dbData) : dbData; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/crypto/HrFieldCipher.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/crypto/HrFieldCipher.java new file mode 100644 index 0000000000..5296edb239 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/crypto/HrFieldCipher.java @@ -0,0 +1,117 @@ +package vacademy.io.admin_core_service.core.crypto; + +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import vacademy.io.common.exceptions.VacademyException; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * AES-256-GCM cipher for HR PII stored at rest (bank account numbers, PAN, + * UAN, statutory info). Same crypto scheme as {@code TokenEncryptionService} + * (12-byte IV prefixed to ciphertext+tag, base64), with two additions: + * + * 1. Ciphertext is tagged with the {@code ENCv1:} prefix so reads can tell + * encrypted values from legacy plaintext rows: {@link #decryptField} returns + * an unprefixed value AS-IS (pre-encryption data keeps working; it becomes + * encrypted the next time the row is written). + * 2. A static holder ({@link #instance()}) so JPA {@code AttributeConverter}s + * work whether Hibernate instantiates them through Spring's BeanContainer + * or reflectively. + * + * Key management: set HR_FIELD_ENCRYPTION_KEY to a base64-encoded 32-byte key + * (openssl rand -base64 32). Without it a deterministic dev key is used, with + * a loud warning — production must set the env var. + */ +@Service +public class HrFieldCipher { + + private static final Logger log = LoggerFactory.getLogger(HrFieldCipher.class); + + public static final String ENC_PREFIX = "ENCv1:"; + + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int IV_LENGTH_BYTES = 12; + private static final int GCM_TAG_LENGTH_BITS = 128; + + private static volatile HrFieldCipher INSTANCE; + + private final SecretKeySpec keySpec; + + public HrFieldCipher(@Value("${hr.field.encryption.key:}") String base64Key) { + if (base64Key == null || base64Key.isBlank()) { + log.warn("⚠ HR_FIELD_ENCRYPTION_KEY is not set — HR PII fields are encrypted with an " + + "insecure dev key. Set this env var before deploying to production."); + this.keySpec = new SecretKeySpec(new byte[32], "AES"); + } else { + byte[] keyBytes = Base64.getDecoder().decode(base64Key); + if (keyBytes.length != 32) { + throw new IllegalArgumentException( + "HR_FIELD_ENCRYPTION_KEY must be a base64-encoded 32-byte key"); + } + this.keySpec = new SecretKeySpec(keyBytes, "AES"); + } + } + + @PostConstruct + void register() { + INSTANCE = this; + } + + /** Static accessor for JPA converters; null only before Spring context init. */ + public static HrFieldCipher instance() { + return INSTANCE; + } + + /** Encrypts and prefixes; null/blank and already-encrypted values pass through. */ + public String encryptField(String plaintext) { + if (plaintext == null || plaintext.isBlank() || plaintext.startsWith(ENC_PREFIX)) { + return plaintext; + } + try { + byte[] iv = new byte[IV_LENGTH_BYTES]; + new SecureRandom().nextBytes(iv); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + byte[] combined = new byte[iv.length + ciphertext.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length); + return ENC_PREFIX + Base64.getEncoder().encodeToString(combined); + } catch (Exception e) { + // Never write plaintext on a crypto failure — fail the transaction. + throw new VacademyException("Failed to encrypt HR field: " + e.getMessage()); + } + } + + /** + * Decrypts an {@code ENCv1:}-prefixed value; returns unprefixed (legacy + * plaintext) values as-is. A decrypt failure (rotated key, corrupt row) + * throws — silently returning ciphertext would leak it into DTOs/exports. + */ + public String decryptField(String stored) { + if (stored == null || !stored.startsWith(ENC_PREFIX)) { + return stored; + } + try { + byte[] combined = Base64.getDecoder().decode(stored.substring(ENC_PREFIX.length())); + byte[] iv = new byte[IV_LENGTH_BYTES]; + byte[] ciphertext = new byte[combined.length - IV_LENGTH_BYTES]; + System.arraycopy(combined, 0, iv, 0, IV_LENGTH_BYTES); + System.arraycopy(combined, IV_LENGTH_BYTES, ciphertext, 0, ciphertext.length); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new VacademyException("Failed to decrypt HR field: " + e.getMessage()); + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/security/HrAccessGuard.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/security/HrAccessGuard.java new file mode 100644 index 0000000000..9c64e32969 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/core/security/HrAccessGuard.java @@ -0,0 +1,125 @@ +package vacademy.io.admin_core_service.core.security; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.exceptions.ForbiddenException; +import vacademy.io.common.exceptions.VacademyException; + +/** + * Role + resource-ownership guard for every HR & Payroll endpoint, implementing + * the access matrix from docs/erp/plan.md section G on top of + * {@link InstituteAccessValidator} (which only proves institute MEMBERSHIP — + * a student "belongs" to the institute too, so membership alone must never + * gate an HR endpoint). + * + * Roles are authority strings minted by CustomUserDetails from the caller's + * user_role rows for the clientId institute: ADMIN (institute admin, superset + * of all HR access), HR_ADMIN (full HR & payroll), HR_MANAGER (team-scoped HR + * operations). Seeded in auth_service V17__Seed_hr_roles.sql. + * + * Conventions this guard enforces across hr_* services: + * - Admin/staff endpoints call {@link #requireHrAdmin} / {@link #requireHrStaff}. + * - Self-service endpoints never trust an employeeId from the request body: + * {@link #requireSelfOrHrStaff} resolves the target employee, verifies it + * belongs to the validated institute, and only lets non-HR callers act on + * their OWN profile (profile.userId == jwt userId). + * - Every entity loaded by id must be checked against the validated institute + * via {@link #requireInstituteMatch} (cross-tenant IDOR fix). + */ +@Component +public class HrAccessGuard { + + public static final String ROLE_ADMIN = "ADMIN"; + public static final String ROLE_HR_ADMIN = "HR_ADMIN"; + public static final String ROLE_HR_MANAGER = "HR_MANAGER"; + + @Autowired + private InstituteAccessValidator instituteAccessValidator; + + @Autowired + private EmployeeProfileRepository employeeProfileRepository; + + /** Membership only — for endpoints any institute member may hit (rare in HR). */ + public void validateMember(CustomUserDetails user, String instituteId) { + instituteAccessValidator.validateUserAccess(user, instituteId); + } + + /** Membership + ADMIN or HR_ADMIN. For payroll processing, salary admin, config. */ + public void requireHrAdmin(CustomUserDetails user, String instituteId) { + instituteAccessValidator.validateUserAccess(user, instituteId); + if (!isHrAdmin(user)) { + throw new ForbiddenException("Access denied: HR admin role required"); + } + } + + /** Membership + ADMIN, HR_ADMIN or HR_MANAGER. For team-scoped HR operations. */ + public void requireHrStaff(CustomUserDetails user, String instituteId) { + instituteAccessValidator.validateUserAccess(user, instituteId); + if (!isHrStaff(user)) { + throw new ForbiddenException("Access denied: HR role required"); + } + } + + public boolean isHrAdmin(CustomUserDetails user) { + return hasAnyAuthority(user, ROLE_ADMIN, ROLE_HR_ADMIN); + } + + public boolean isHrStaff(CustomUserDetails user) { + return hasAnyAuthority(user, ROLE_ADMIN, ROLE_HR_ADMIN, ROLE_HR_MANAGER); + } + + /** + * Resource-ownership check: the loaded entity's institute must be the + * validated one. Throws the same message for "wrong institute" as a plain + * lookup miss would, so ids are not oracle-able across tenants. + */ + public void requireInstituteMatch(String entityInstituteId, String validatedInstituteId, String entityName) { + if (entityInstituteId == null || !entityInstituteId.equals(validatedInstituteId)) { + throw new VacademyException(entityName + " not found"); + } + } + + /** + * Self-service resolution: membership + load the target employee, verify it + * belongs to the validated institute, and require the caller to either hold + * an HR role or BE that employee. Returns the employee so services never + * re-fetch by unchecked id. + */ + public EmployeeProfile requireSelfOrHrStaff(CustomUserDetails user, String instituteId, String employeeId) { + instituteAccessValidator.validateUserAccess(user, instituteId); + EmployeeProfile employee = employeeProfileRepository.findById(employeeId) + .orElseThrow(() -> new VacademyException("Employee not found")); + requireInstituteMatch(employee.getInstituteId(), instituteId, "Employee"); + if (isHrStaff(user)) { + return employee; + } + if (user.getUserId() == null || !user.getUserId().equals(employee.getUserId())) { + throw new ForbiddenException("Access denied: you can only act on your own employee record"); + } + return employee; + } + + /** The caller's own employee profile in this institute (self-service endpoints). */ + public EmployeeProfile resolveSelfEmployee(CustomUserDetails user, String instituteId) { + instituteAccessValidator.validateUserAccess(user, instituteId); + return employeeProfileRepository.findByUserIdAndInstituteId(user.getUserId(), instituteId) + .orElseThrow(() -> new VacademyException("No employee profile found for the current user")); + } + + private boolean hasAnyAuthority(CustomUserDetails user, String... roles) { + if (user == null) return false; + if (user.isRootUser()) return true; + if (user.getAuthorities() == null) return false; + return user.getAuthorities().stream().anyMatch(a -> { + String auth = a.getAuthority(); + if (auth == null) return false; + for (String role : roles) { + if (auth.equalsIgnoreCase(role)) return true; + } + return false; + }); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/app_status/client/CommunityAppRegistryClient.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/app_status/client/CommunityAppRegistryClient.java new file mode 100644 index 0000000000..7cbd329712 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/app_status/client/CommunityAppRegistryClient.java @@ -0,0 +1,70 @@ +package vacademy.io.admin_core_service.features.app_status.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import vacademy.io.common.core.internal_api_wrapper.InternalClientUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Reads app-registry records for an institute from community_service. + * + *

The registry lives in community_service's own database (institute-role membership tables do + * not — see {@code InternalAppRegistryController}'s javadoc on that side), so this institute's + * app status can only be fetched over the network, not joined in SQL. This class trusts the + * caller (AppStatusService) to have already verified the requesting user belongs to + * {@code instituteId} — the internal endpoint itself only checks HMAC service identity. + */ +@Component +@Slf4j +public class CommunityAppRegistryClient { + + private final InternalClientUtils internalClientUtils; + private final ObjectMapper objectMapper; + private final String communityServiceBaseUrl; + private final String clientName; + + public CommunityAppRegistryClient( + InternalClientUtils internalClientUtils, + ObjectMapper objectMapper, + @Value("${community.server.baseurl:http://localhost:8072}") String communityServiceBaseUrl, + @Value("${spring.application.name:admin_core_service}") String clientName) { + this.internalClientUtils = internalClientUtils; + this.objectMapper = objectMapper; + this.communityServiceBaseUrl = communityServiceBaseUrl; + this.clientName = clientName; + } + + /** + * @return the institute's app records, or an empty list when the call fails. Empty-on-failure + * (rather than propagating the error) is deliberate: this backs a read-only status + * panel on an institute admin's settings page, and one flaky internal call must not + * break page load for an unrelated feature on the same screen. + */ + public List fetchByInstitute(String instituteId) { + try { + String route = "/community-service/internal/v1/app-registry/by-institute?instituteId=" + instituteId; + + ResponseEntity response = internalClientUtils.makeHmacRequest( + clientName, "GET", communityServiceBaseUrl, route, null); + + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { + List out = new ArrayList<>(); + objectMapper.readTree(response.getBody()).forEach(out::add); + return out; + } + log.warn("[app-status] community_service app-registry lookup returned {} for institute {}", + response.getStatusCode(), instituteId); + } catch (Exception e) { + log.warn("[app-status] community_service app-registry lookup failed for institute {}: {}", + instituteId, e.getMessage()); + } + return List.of(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/app_status/controller/AppStatusController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/app_status/controller/AppStatusController.java new file mode 100644 index 0000000000..e33a5abd11 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/app_status/controller/AppStatusController.java @@ -0,0 +1,38 @@ +package vacademy.io.admin_core_service.features.app_status.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import vacademy.io.admin_core_service.features.app_status.dto.AppStatusResponse; +import vacademy.io.admin_core_service.features.app_status.service.AppStatusService; +import vacademy.io.common.auth.model.CustomUserDetails; + +/** + * Institute-admin-facing, read-only view of an institute's registered white-label apps. + * + * Deliberately NOT under /admin/* (same reasoning as WhiteLabelController) so the institute + * admin dashboard can call it without an elevated super-admin role — the service layer verifies + * the caller actually belongs to the instituteId being queried. + * + * Registration/editing stays exclusive to the health-check dashboard's App Registration module + * (SuperAdmin-only, in community_service) — this endpoint has no write path on purpose. + */ +@RestController +@RequestMapping("/admin-core-service/institute/app-registry/v1") +@RequiredArgsConstructor +public class AppStatusController { + + private final AppStatusService appStatusService; + + @GetMapping("/status") + public ResponseEntity getStatus( + @RequestAttribute("user") CustomUserDetails user, + @RequestParam("instituteId") String instituteId) { + + return ResponseEntity.ok(appStatusService.getStatus(user, instituteId)); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/app_status/dto/AppStatusResponse.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/app_status/dto/AppStatusResponse.java new file mode 100644 index 0000000000..69bfd9739c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/app_status/dto/AppStatusResponse.java @@ -0,0 +1,76 @@ +package vacademy.io.admin_core_service.features.app_status.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * Read-only view of an institute's registered white-label apps and their per-platform store + * status — returned by GET /admin-core-service/institute/app-registry/v1/status. + * + *

Registration itself (uploading icons, filling in store metadata, linking bundle/package + * ids) stays an ops-only workflow in the health-check dashboard's App Registration module — + * this endpoint is deliberately read-only so an institute admin can see where their app stands + * without being able to edit a record that belongs to the platform team. + */ +@Data +@Builder +public class AppStatusResponse { + + @JsonProperty("institute_id") + private String instituteId; + + @JsonProperty("apps") + private List apps; + + @Data + @Builder + public static class RegisteredApp { + @JsonProperty("id") + private String id; + + @JsonProperty("name") + private String name; + + @JsonProperty("display_name") + private String displayName; + + @JsonProperty("package_name") + private String packageName; + + @JsonProperty("platforms") + private List platforms; + } + + @Data + @Builder + public static class PlatformStatus { + /** ANDROID / IOS / WINDOWS / MACOS — matches the health-check dashboard's Platform enum. */ + @JsonProperty("platform") + private String platform; + + @JsonProperty("enabled") + private boolean enabled; + + /** One of the health-check dashboard's StoreStatus values (e.g. LIVE, IN_REVIEW, REJECTED). */ + @JsonProperty("status") + private String status; + + @JsonProperty("store_url") + private String storeUrl; + + @JsonProperty("current_version") + private String currentVersion; + + @JsonProperty("current_build") + private String currentBuild; + + @JsonProperty("released_at") + private String releasedAt; + + @JsonProperty("last_synced_at") + private String lastSyncedAt; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/app_status/service/AppStatusService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/app_status/service/AppStatusService.java new file mode 100644 index 0000000000..0204d7fe1a --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/app_status/service/AppStatusService.java @@ -0,0 +1,110 @@ +package vacademy.io.admin_core_service.features.app_status.service; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vacademy.io.admin_core_service.features.app_status.client.CommunityAppRegistryClient; +import vacademy.io.admin_core_service.features.app_status.dto.AppStatusResponse; +import vacademy.io.admin_core_service.features.institute.repository.InstituteRepository; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.auth.repository.UserRoleRepository; +import vacademy.io.common.exceptions.VacademyException; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AppStatusService { + + private static final String ROLE_NAME_ADMIN = "ADMIN"; + + private final CommunityAppRegistryClient communityAppRegistryClient; + private final InstituteRepository instituteRepository; + private final UserRoleRepository userRoleRepository; + + public AppStatusResponse getStatus(CustomUserDetails user, String instituteId) { + assertInstituteAccess(user, instituteId); + + List apps = new ArrayList<>(); + for (JsonNode record : communityAppRegistryClient.fetchByInstitute(instituteId)) { + apps.add(toRegisteredApp(record)); + } + + return AppStatusResponse.builder() + .instituteId(instituteId) + .apps(apps) + .build(); + } + + private AppStatusResponse.RegisteredApp toRegisteredApp(JsonNode record) { + JsonNode basics = record.path("basics"); + + List platforms = new ArrayList<>(); + JsonNode platformsNode = record.path("platforms"); + platformsNode.fieldNames().forEachRemaining(platformKey -> { + JsonNode p = platformsNode.path(platformKey); + // An institute admin only cares about platforms actually turned on for this app — + // a disabled platform is registry bookkeeping, not something to show as "status". + if (!p.path("enabled").asBoolean(false)) { + return; + } + platforms.add(AppStatusResponse.PlatformStatus.builder() + .platform(platformKey) + .enabled(true) + .status(textOrDefault(p, "status", "NOT_REGISTERED")) + .storeUrl(textOrDefault(p, "storeUrl", "")) + .currentVersion(textOrDefault(p, "currentVersion", "")) + .currentBuild(textOrDefault(p, "currentBuild", "")) + .releasedAt(textOrDefault(p, "releasedAt", "")) + .lastSyncedAt(textOrDefault(p, "lastSyncedAt", "")) + .build()); + }); + + return AppStatusResponse.RegisteredApp.builder() + .id(textOrDefault(record, "id", "")) + .name(textOrDefault(basics, "name", "")) + .displayName(textOrDefault(basics, "displayName", "")) + .packageName(textOrDefault(basics, "packageName", "")) + .platforms(platforms) + .build(); + } + + private static String textOrDefault(JsonNode node, String field, String fallback) { + if (node == null) return fallback; + JsonNode value = node.get(field); + return value == null || value.isNull() ? fallback : value.asText(fallback); + } + + /** + * Same three-tier check as WhiteLabelService#assertInstituteAccess (root bypass → user_role + * ADMIN row → legacy staff-table fallback) — duplicated rather than shared because + * WhiteLabelService keeps it private, and this endpoint has the identical authorization + * requirement: only that institute's admins (or a root user) may read its data. + */ + private void assertInstituteAccess(CustomUserDetails user, String instituteId) { + if (user == null) { + throw new VacademyException("Access denied: no authenticated user"); + } + + if (user.isRootUser()) { + return; + } + + if (userRoleRepository.existsByUserIdAndInstituteIdAndRoleName( + user.getUserId(), instituteId, ROLE_NAME_ADMIN)) { + return; + } + + boolean isStaff = instituteRepository.findInstitutesByUserId(user.getUserId()) + .stream() + .anyMatch(i -> i.getId().equals(instituteId)); + if (!isStaff) { + log.warn("[AppStatus] Unauthorized attempt by userId={} on instituteId={}", + user.getUserId(), instituteId); + throw new VacademyException("Access denied: you are not a member of institute " + instituteId); + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/audience/service/LeadStatusService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/audience/service/LeadStatusService.java index cb9c7179d8..2f60ee4796 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/audience/service/LeadStatusService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/audience/service/LeadStatusService.java @@ -213,7 +213,7 @@ public AudienceResponse changeLeadStatus(String audienceResponseId, String newSt .build()); logStatusChangeToTimeline(saved, oldStatusId, target, actorUserId, source); - emitStatusChanged(saved, instituteId, oldStatusId, target); + emitStatusChanged(saved, instituteId, oldStatusId, target, source, actorUserId); // Keep the user's profile conversion_status (what the side-view reads) in sync with this // per-response change, so the leads list and the side-view never disagree. Best-effort — @@ -282,7 +282,8 @@ private void logStatusChangeToTimeline(AudienceResponse lead, String oldStatusId } } - private void emitStatusChanged(AudienceResponse lead, String instituteId, String oldStatusId, LeadStatus target) { + private void emitStatusChanged(AudienceResponse lead, String instituteId, String oldStatusId, LeadStatus target, + String source, String actorUserId) { if (instituteId == null || instituteId.isBlank()) return; try { String oldKey = oldStatusId == null ? null @@ -291,6 +292,13 @@ private void emitStatusChanged(AudienceResponse lead, String instituteId, String leadTriggerContextBuilder.put(ctx, "changeType", "LEAD_STATUS"); leadTriggerContextBuilder.put(ctx, "oldStatus", oldKey); leadTriggerContextBuilder.put(ctx, "newStatus", target.getStatusKey()); + // WHO moved the status — the same token written to lead_status_history.source + // ("MANUAL" | "MANUAL_DISPOSITION" | "AI_CALLING" | "AI_WORKFLOW" | ...). Without + // it a workflow on this event cannot tell a human's change from one the workflow + // itself caused, and a graph that reacts to a status by writing another status + // re-triggers itself (this event has no idempotency dedup — strategy UUID). + leadTriggerContextBuilder.put(ctx, "statusChangeSource", source != null ? source : "MANUAL"); + leadTriggerContextBuilder.put(ctx, "statusChangedByUserId", actorUserId); workflowTriggerService.handleTriggerEvents( WorkflowTriggerEvent.LEAD_STATUS_CHANGED.name(), lead.getId(), instituteId, ctx); } catch (Exception ex) { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/auth_service/service/AuthService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/auth_service/service/AuthService.java index 8f82adaa3e..afbc6275e0 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/auth_service/service/AuthService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/auth_service/service/AuthService.java @@ -87,6 +87,25 @@ public List getUsersFromAuthServiceByUserIds(List userIds) { * dies partway and leaves a half-charged run behind. */ public List getUsersByInstituteAndRoles(String instituteId, List roles) { + try { + return requireUsersByInstituteAndRoles(instituteId, roles); + } catch (Exception e) { + log.warn("[reporting] could not expand roles {} for institute {} — no recipients resolved", + roles, instituteId, e); + return List.of(); + } + } + + /** + * The same lookup, but a failure surfaces instead of collapsing to an empty + * list. Anything that RENDERS this as a roster must use this variant: an + * unreachable auth_service and an institute with no staff are the same empty + * list, and showing "no staff" for an outage is a lie the caller acts on. + * + *

auth_service returns ACTIVE memberships only — INVITED users are not + * included. + */ + public List requireUsersByInstituteAndRoles(String instituteId, List roles) { if (instituteId == null || instituteId.isBlank() || roles == null || roles.isEmpty()) { return List.of(); } @@ -109,9 +128,8 @@ public List getUsersByInstituteAndRoles(String instituteId, List>() { }); } catch (Exception e) { - log.warn("[reporting] could not expand roles {} for institute {} — no recipients resolved", - roles, instituteId, e); - return List.of(); + throw new VacademyException("Could not reach auth_service to resolve users for institute " + + instituteId + ": " + e.getMessage()); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/controller/CatalogueAnalyticsController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/controller/CatalogueAnalyticsController.java new file mode 100644 index 0000000000..f14968579f --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/controller/CatalogueAnalyticsController.java @@ -0,0 +1,34 @@ +package vacademy.io.admin_core_service.features.catalogue_analytics.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import vacademy.io.admin_core_service.features.catalogue_analytics.dto.CatalogueAnalyticsResponse; +import vacademy.io.admin_core_service.features.catalogue_analytics.service.CatalogueAnalyticsQueryService; +import vacademy.io.common.auth.model.CustomUserDetails; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +/** Read side of catalogue analytics — the admin dashboard. */ +@RestController +@RequestMapping("/admin-core-service/v1/catalogue-analytics") +public class CatalogueAnalyticsController { + + @Autowired + private CatalogueAnalyticsQueryService queryService; + + /** + * Summary for one institute over the last `days` days. + * + * instituteId is a request param rather than being taken from the token + * because an admin can belong to several institutes and the editor already + * knows which site is open — but it is checked against the caller's own + * authorities, so it cannot be used to read another institute's traffic. + */ + @GetMapping("/summary") + public ResponseEntity summary( + @AuthenticationPrincipal CustomUserDetails user, + @RequestParam String instituteId, + @RequestParam(defaultValue = "30") int days) { + return ResponseEntity.ok(queryService.summary(user, instituteId, days)); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/controller/PublicCatalogueAnalyticsController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/controller/PublicCatalogueAnalyticsController.java new file mode 100644 index 0000000000..33bcd39856 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/controller/PublicCatalogueAnalyticsController.java @@ -0,0 +1,54 @@ +package vacademy.io.admin_core_service.features.catalogue_analytics.controller; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import vacademy.io.admin_core_service.features.catalogue_analytics.dto.CatalogueEventRequest; +import vacademy.io.admin_core_service.features.catalogue_analytics.service.CatalogueAnalyticsRateLimiter; +import vacademy.io.admin_core_service.features.catalogue_analytics.service.CatalogueAnalyticsService; + +/** + * Public analytics beacon for catalogue sites. Unauthenticated by necessity — + * it is called from a visitor's browser on a public marketing page. + * + * Consequences of that, handled here: + * - rate limited per IP and per institute (its own limiter; the lead limiter's + * 8/minute would throttle a reader browsing a few pages) + * - always answers 204, even when rejected. A beacon must not leak whether a + * limit was hit, and sendBeacon ignores the body anyway. + * - identity is derived server-side, never accepted from the caller. + */ +@RestController +@RequestMapping("/admin-core-service/open/v1/catalogue-analytics") +public class PublicCatalogueAnalyticsController { + + @Autowired + private CatalogueAnalyticsService service; + + @Autowired + private CatalogueAnalyticsRateLimiter rateLimiter; + + @PostMapping("/event") + public ResponseEntity record(@RequestBody CatalogueEventRequest body, + HttpServletRequest request) { + String ip = clientIp(request); + if (body != null && rateLimiter.tryAcquire(ip, body.getInstituteId())) { + service.record(body, ip, request.getHeader("User-Agent")); + } + // 204 regardless: never tell a caller whether it was counted. + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** Real client IP behind the ingress/CDN — XFF is a chain, take the first. */ + private String clientIp(HttpServletRequest request) { + String xff = request.getHeader("X-Forwarded-For"); + if (xff != null && !xff.isBlank()) { + int comma = xff.indexOf(','); + return (comma > 0 ? xff.substring(0, comma) : xff).trim(); + } + String real = request.getHeader("X-Real-IP"); + return (real != null && !real.isBlank()) ? real.trim() : request.getRemoteAddr(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/dto/CatalogueAnalyticsResponse.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/dto/CatalogueAnalyticsResponse.java new file mode 100644 index 0000000000..1d701a2b1e --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/dto/CatalogueAnalyticsResponse.java @@ -0,0 +1,46 @@ +package vacademy.io.admin_core_service.features.catalogue_analytics.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** Everything the site-analytics screen renders, in one round trip. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CatalogueAnalyticsResponse { + + private long views; + private long visitors; + private long sessions; + /** Leads captured in the same window, so the funnel is one number. */ + private long leads; + + private List daily; + private List pages; + private List sources; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class DailyPoint { + private String day; + private long views; + private long visitors; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class NamedCount { + private String name; + private long views; + private long visitors; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/dto/CatalogueEventRequest.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/dto/CatalogueEventRequest.java new file mode 100644 index 0000000000..7670f7681c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/dto/CatalogueEventRequest.java @@ -0,0 +1,27 @@ +package vacademy.io.admin_core_service.features.catalogue_analytics.dto; + +import lombok.Data; + +/** + * One analytics beacon from a catalogue site. Everything is optional except + * the institute: a beacon must never be the reason a visitor's page breaks, + * so the server fills gaps rather than rejecting. + * + * Deliberately does NOT accept a visitor id, an IP, or a full referrer — those + * are derived or truncated server-side so a caller cannot inject identity. + */ +@Data +public class CatalogueEventRequest { + private String instituteId; + private String catalogueId; + private String pageRoute; + private String eventType; + private String sessionId; + /** Full referrer; only its host is stored. */ + private String referrer; + private String utmSource; + private String utmMedium; + private String utmCampaign; + /** 'mobile' | 'desktop'; anything else is normalised away. */ + private String device; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/entity/CataloguePageEvent.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/entity/CataloguePageEvent.java new file mode 100644 index 0000000000..ef45f06231 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/entity/CataloguePageEvent.java @@ -0,0 +1,81 @@ +package vacademy.io.admin_core_service.features.catalogue_analytics.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; + +import java.sql.Timestamp; + +/** + * One recorded interaction with a catalogue (page-builder) site. + * + * Catalogue sites fire GA4/Meta events but recorded nothing here, so an admin + * could only see traffic inside a Google property they usually had not + * connected — while the LEADS those visits produced sat in our own database. + * The two halves of the funnel were on opposite sides of a boundary we could + * not join. This is the missing half. + * + * Contains no PII by construction: no cookies, no raw IP, no full referring + * URL. See visitorHash. + */ +@Entity +@Table(name = "catalogue_page_event") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CataloguePageEvent { + + @Id + @UuidGenerator + @Column(name = "id", nullable = false, unique = true) + private String id; + + @Column(name = "institute_id", nullable = false, length = 36) + private String instituteId; + + @Column(name = "catalogue_id", length = 36) + private String catalogueId; + + /** '' is the site root; otherwise the page's route slug. */ + @Column(name = "page_route", nullable = false, length = 255) + private String pageRoute; + + /** VIEW today. CTA/LEAD reserved so click tracking needs no new table. */ + @Column(name = "event_type", nullable = false, length = 32) + private String eventType; + + /** + * Salted hash of IP + user-agent that ROTATES DAILY. Enough for "unique + * visitors today", deliberately useless for following someone across days + * — the salt changes, so yesterday's hash cannot be matched to today's. + */ + @Column(name = "visitor_hash", length = 64) + private String visitorHash; + + /** Client-generated per browsing session; no persistent identifier. */ + @Column(name = "session_id", length = 64) + private String sessionId; + + /** Host only. A referring PATH can carry PII in its query string. */ + @Column(name = "referrer_host", length = 255) + private String referrerHost; + + @Column(name = "utm_source", length = 128) + private String utmSource; + + @Column(name = "utm_medium", length = 128) + private String utmMedium; + + @Column(name = "utm_campaign", length = 191) + private String utmCampaign; + + @Column(name = "device", length = 16) + private String device; + + @Column(name = "created_at", insertable = false, updatable = false) + private Timestamp createdAt; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/repository/CataloguePageEventRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/repository/CataloguePageEventRepository.java new file mode 100644 index 0000000000..b0bf90e5de --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/repository/CataloguePageEventRepository.java @@ -0,0 +1,90 @@ +package vacademy.io.admin_core_service.features.catalogue_analytics.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.catalogue_analytics.entity.CataloguePageEvent; + +import java.sql.Timestamp; +import java.util.List; + +/** + * Aggregates for the site-analytics dashboard. + * + * Every query is native and grouped in SQL rather than in Java: these tables + * grow one row per page view, so pulling rows back to count them in memory + * stops working at exactly the traffic level where an institute starts caring + * about analytics. + */ +@Repository +public interface CataloguePageEventRepository extends JpaRepository { + + /** Views and unique visitors per day, for the trend line. */ + @Query(value = """ + SELECT DATE(created_at) AS day, + COUNT(*) AS views, + COUNT(DISTINCT visitor_hash) AS visitors + FROM catalogue_page_event + WHERE institute_id = :instituteId + AND event_type = 'VIEW' + AND created_at >= :from AND created_at < :to + GROUP BY DATE(created_at) + ORDER BY day + """, nativeQuery = true) + List dailyTotals(@Param("instituteId") String instituteId, + @Param("from") Timestamp from, + @Param("to") Timestamp to); + + /** Views and unique visitors per page. */ + @Query(value = """ + SELECT page_route, + COUNT(*) AS views, + COUNT(DISTINCT visitor_hash) AS visitors + FROM catalogue_page_event + WHERE institute_id = :instituteId + AND event_type = 'VIEW' + AND created_at >= :from AND created_at < :to + GROUP BY page_route + ORDER BY views DESC + LIMIT 100 + """, nativeQuery = true) + List byPage(@Param("instituteId") String instituteId, + @Param("from") Timestamp from, + @Param("to") Timestamp to); + + /** + * Where traffic came from. utm_source when the visit was tagged, otherwise + * the referring host, otherwise 'direct' — so the column always adds up to + * total traffic instead of quietly dropping untagged visits. + */ + @Query(value = """ + SELECT COALESCE(NULLIF(utm_source, ''), NULLIF(referrer_host, ''), 'direct') AS src, + COUNT(*) AS views, + COUNT(DISTINCT visitor_hash) AS visitors + FROM catalogue_page_event + WHERE institute_id = :instituteId + AND event_type = 'VIEW' + AND created_at >= :from AND created_at < :to + GROUP BY src + ORDER BY views DESC + LIMIT 25 + """, nativeQuery = true) + List bySource(@Param("instituteId") String instituteId, + @Param("from") Timestamp from, + @Param("to") Timestamp to); + + /** Headline totals for the range. */ + @Query(value = """ + SELECT COUNT(*) AS views, + COUNT(DISTINCT visitor_hash) AS visitors, + COUNT(DISTINCT session_id) AS sessions + FROM catalogue_page_event + WHERE institute_id = :instituteId + AND event_type = 'VIEW' + AND created_at >= :from AND created_at < :to + """, nativeQuery = true) + List totals(@Param("instituteId") String instituteId, + @Param("from") Timestamp from, + @Param("to") Timestamp to); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/service/CatalogueAnalyticsQueryService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/service/CatalogueAnalyticsQueryService.java new file mode 100644 index 0000000000..b06b7d37f9 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/service/CatalogueAnalyticsQueryService.java @@ -0,0 +1,86 @@ +package vacademy.io.admin_core_service.features.catalogue_analytics.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.features.catalogue_analytics.dto.CatalogueAnalyticsResponse; +import vacademy.io.admin_core_service.features.catalogue_analytics.repository.CataloguePageEventRepository; +import vacademy.io.common.auth.model.CustomUserDetails; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +@Service +public class CatalogueAnalyticsQueryService { + + /** A year of daily points is already more than any chart should render. */ + private static final int MAX_DAYS = 365; + + @Autowired + private CataloguePageEventRepository repository; + + @Autowired + private InstituteAccessValidator accessValidator; + + public CatalogueAnalyticsResponse summary(CustomUserDetails user, String instituteId, int days) { + // instituteId comes from the caller, so it MUST be checked against the + // caller's own authorities — otherwise any admin could read any + // institute's traffic by changing one query parameter. + accessValidator.validateUserAccess(user, instituteId); + + int window = Math.max(1, Math.min(days, MAX_DAYS)); + Instant to = Instant.now(); + Instant from = to.minus(window, ChronoUnit.DAYS); + Timestamp tsFrom = Timestamp.from(from); + Timestamp tsTo = Timestamp.from(to); + + List totals = repository.totals(instituteId, tsFrom, tsTo); + long views = 0, visitors = 0, sessions = 0; + if (!totals.isEmpty() && totals.get(0) != null) { + Object[] row = totals.get(0); + views = num(row, 0); + visitors = num(row, 1); + sessions = num(row, 2); + } + + List daily = new ArrayList<>(); + for (Object[] r : repository.dailyTotals(instituteId, tsFrom, tsTo)) { + daily.add(CatalogueAnalyticsResponse.DailyPoint.builder() + .day(String.valueOf(r[0])) + .views(num(r, 1)) + .visitors(num(r, 2)) + .build()); + } + + return CatalogueAnalyticsResponse.builder() + .views(views) + .visitors(visitors) + .sessions(sessions) + .leads(0) + .daily(daily) + .pages(named(repository.byPage(instituteId, tsFrom, tsTo))) + .sources(named(repository.bySource(instituteId, tsFrom, tsTo))) + .build(); + } + + private List named(List rows) { + List out = new ArrayList<>(); + for (Object[] r : rows) { + out.add(CatalogueAnalyticsResponse.NamedCount.builder() + .name(r[0] == null ? "" : String.valueOf(r[0])) + .views(num(r, 1)) + .visitors(num(r, 2)) + .build()); + } + return out; + } + + /** Native COUNT() comes back as Long on Postgres and BigInteger elsewhere. */ + private long num(Object[] row, int i) { + Object v = row.length > i ? row[i] : null; + return v instanceof Number n ? n.longValue() : 0L; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/service/CatalogueAnalyticsRateLimiter.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/service/CatalogueAnalyticsRateLimiter.java new file mode 100644 index 0000000000..044969bba2 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/service/CatalogueAnalyticsRateLimiter.java @@ -0,0 +1,81 @@ +package vacademy.io.admin_core_service.features.catalogue_analytics.service; + +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Abuse ceiling for the public analytics beacon. + * + * Separate from PublicLeadRateLimiter on purpose. That one allows 8 requests + * per IP per minute, which is right for form submissions and badly wrong here: + * one visitor reading five pages would be throttled, and we would silently + * under-count exactly the engaged visitors an admin most wants to see. These + * limits are set to catch a script, not a reader. + * + * In-process, like the lead limiter — with several replicas the effective + * ceiling is per-pod. That turns "unbounded" into "bounded and noisy", which + * is the property that matters for a write endpoint with no auth. + */ +@Service +public class CatalogueAnalyticsRateLimiter { + + // A determined reader might open 30 pages in a minute. A script does 300. + private static final int IP_PER_MINUTE = 60; + private static final int IP_PER_HOUR = 600; + + // Safety ceiling only. A site featured somewhere can legitimately serve + // thousands of views an hour, and losing real traffic data to a limiter + // defeats the point of collecting it. + private static final int INSTITUTE_PER_MINUTE = 3_000; + private static final int INSTITUTE_PER_HOUR = 60_000; + + private static final Duration MINUTE = Duration.ofMinutes(1); + private static final Duration HOUR = Duration.ofHours(1); + + private final Map minuteWindows = new ConcurrentHashMap<>(); + private final Map hourWindows = new ConcurrentHashMap<>(); + + private static final class Window { + private volatile Instant resetAt; + private final AtomicInteger count = new AtomicInteger(); + + Window(Instant resetAt) { + this.resetAt = resetAt; + } + } + + private boolean allow(Map windows, String key, int limit, Duration period) { + if (key == null || key.isBlank()) return true; + Instant now = Instant.now(); + Window w = windows.computeIfAbsent(key, k -> new Window(now.plus(period))); + synchronized (w) { + if (now.isAfter(w.resetAt)) { + w.count.set(0); + w.resetAt = now.plus(period); + } + return w.count.incrementAndGet() <= limit; + } + } + + public boolean tryAcquire(String ip, String instituteId) { + // Evaluate every window rather than short-circuiting, so one blocked + // key does not let the others drift out of sync with real traffic. + boolean ipMin = allow(minuteWindows, "ip:" + ip, IP_PER_MINUTE, MINUTE); + boolean ipHour = allow(hourWindows, "ip:" + ip, IP_PER_HOUR, HOUR); + boolean instMin = allow(minuteWindows, "in:" + instituteId, INSTITUTE_PER_MINUTE, MINUTE); + boolean instHour = allow(hourWindows, "in:" + instituteId, INSTITUTE_PER_HOUR, HOUR); + return ipMin && ipHour && instMin && instHour; + } + + /** Bound memory: windows for keys that stopped sending are dead weight. */ + public void evictExpired() { + Instant now = Instant.now(); + minuteWindows.entrySet().removeIf(e -> now.isAfter(e.getValue().resetAt.plus(MINUTE))); + hourWindows.entrySet().removeIf(e -> now.isAfter(e.getValue().resetAt.plus(HOUR))); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/service/CatalogueAnalyticsService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/service/CatalogueAnalyticsService.java new file mode 100644 index 0000000000..f65c89fdb6 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/catalogue_analytics/service/CatalogueAnalyticsService.java @@ -0,0 +1,113 @@ +package vacademy.io.admin_core_service.features.catalogue_analytics.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import vacademy.io.admin_core_service.features.catalogue_analytics.dto.CatalogueEventRequest; +import vacademy.io.admin_core_service.features.catalogue_analytics.entity.CataloguePageEvent; +import vacademy.io.admin_core_service.features.catalogue_analytics.repository.CataloguePageEventRepository; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.util.*; + +@Service +public class CatalogueAnalyticsService { + + private static final Logger logger = LoggerFactory.getLogger(CatalogueAnalyticsService.class); + + private static final Set ALLOWED_EVENTS = Set.of("VIEW", "CTA", "LEAD"); + + /** + * Process-lifetime random salt, mixed with the date. Two consequences, + * both intended: the hash cannot be reversed to an IP even with the + * algorithm, and it cannot be correlated across days or across restarts. + * The cost is that a restart splits a day's unique-visitor count — a price + * worth paying to hold no durable identifier at all. + */ + private static final String SALT; + static { + byte[] b = new byte[32]; + new SecureRandom().nextBytes(b); + SALT = Base64.getEncoder().encodeToString(b); + } + + @Autowired + private CataloguePageEventRepository repository; + + /** Never throws: a beacon must not be able to break a visitor's page. */ + public void record(CatalogueEventRequest req, String ip, String userAgent) { + try { + if (req == null || isBlank(req.getInstituteId())) return; + String type = req.getEventType() == null ? "VIEW" : req.getEventType().toUpperCase(Locale.ROOT); + if (!ALLOWED_EVENTS.contains(type)) type = "VIEW"; + + repository.save(CataloguePageEvent.builder() + .instituteId(trim(req.getInstituteId(), 36)) + .catalogueId(trim(req.getCatalogueId(), 36)) + .pageRoute(req.getPageRoute() == null ? "" : trim(req.getPageRoute(), 255)) + .eventType(type) + .visitorHash(visitorHash(ip, userAgent)) + .sessionId(trim(req.getSessionId(), 64)) + .referrerHost(referrerHost(req.getReferrer())) + .utmSource(trim(req.getUtmSource(), 128)) + .utmMedium(trim(req.getUtmMedium(), 128)) + .utmCampaign(trim(req.getUtmCampaign(), 191)) + .device(device(req.getDevice())) + .build()); + } catch (Exception e) { + logger.warn("[catalogue-analytics] dropped event: {}", e.getMessage()); + } + } + + /** Salted, date-scoped, one-way. Not a stable identifier. */ + private String visitorHash(String ip, String userAgent) { + if (isBlank(ip)) return null; + try { + String material = SALT + '|' + LocalDate.now() + '|' + ip + '|' + (userAgent == null ? "" : userAgent); + byte[] d = MessageDigest.getInstance("SHA-256").digest(material.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(64); + for (byte x : d) sb.append(Character.forDigit((x >> 4) & 0xF, 16)).append(Character.forDigit(x & 0xF, 16)); + return sb.toString(); + } catch (Exception e) { + return null; + } + } + + /** + * Host only. A referring URL's path and query routinely carry search terms + * and occasionally personal data; the host answers "where did they come + * from" without keeping any of it. + */ + private String referrerHost(String referrer) { + if (isBlank(referrer)) return null; + try { + String host = URI.create(referrer.trim()).getHost(); + return host == null ? null : trim(host.toLowerCase(Locale.ROOT), 255); + } catch (Exception e) { + return null; + } + } + + private String device(String device) { + if (device == null) return null; + String d = device.toLowerCase(Locale.ROOT); + return d.equals("mobile") || d.equals("tablet") || d.equals("desktop") ? d : null; + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + /** Column widths are a contract; an over-long value must not fail an insert. */ + private static String trim(String s, int max) { + if (s == null) return null; + String t = s.trim(); + return t.length() <= max ? t : t.substring(0, max); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/common/service/InstituteCustomFiledService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/common/service/InstituteCustomFiledService.java index 99ac6833b9..1ac3d391c7 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/common/service/InstituteCustomFiledService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/common/service/InstituteCustomFiledService.java @@ -837,6 +837,14 @@ private List getDefaultCustomFieldsForEnrollInvite(Stri .collect(Collectors.toList()); } + /** + * Same as the overload below, for callers that hold an institute id but not + * the entity — the `institute` argument there is accepted and never read. + */ + public String updateCustomField(CustomFieldDTO request, String fieldId) { + return updateCustomField(null, request, fieldId); + } + public String updateCustomField(Institute institute, CustomFieldDTO request, String fieldId) { Optional customFieldsOptional = customFieldRepository.findById(fieldId); if (customFieldsOptional.isEmpty()) { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/controller/OpenLearnerEnrollInviteController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/controller/OpenLearnerEnrollInviteController.java index c7a3b6bbac..e1e2c14e23 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/controller/OpenLearnerEnrollInviteController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/controller/OpenLearnerEnrollInviteController.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RestController; import vacademy.io.admin_core_service.features.enroll_invite.dto.EnrollInviteDTO; import vacademy.io.admin_core_service.features.enroll_invite.service.EnrollInviteService; +import vacademy.io.admin_core_service.features.enroll_invite.service.InviteFormAdminNotificationService; import vacademy.io.admin_core_service.features.enroll_invite.service.LearnerEnrollInviteService; @RestController @@ -20,6 +21,9 @@ public class OpenLearnerEnrollInviteController { @Autowired private EnrollInviteService enrollInviteService; + @Autowired + private InviteFormAdminNotificationService inviteFormAdminNotificationService; + @GetMapping public ResponseEntity getEnrollInvite(String instituteId, String inviteCode) { return ResponseEntity.ok(learnerEnrollInviteService.getEnrollInvite(instituteId, inviteCode)); @@ -28,6 +32,9 @@ public ResponseEntity getEnrollInvite(String instituteId, Strin @GetMapping("/{instituteId}/{enrollInviteId}") public ResponseEntity getEnrollInviteById(@PathVariable("instituteId") String instituteId, @PathVariable("enrollInviteId") String enrollInviteId) { - return ResponseEntity.ok(enrollInviteService.findByEnrollInviteId(enrollInviteId, instituteId)); + EnrollInviteDTO dto = enrollInviteService.findByEnrollInviteId(enrollInviteId, instituteId); + // Open endpoint — strip the team-notification email list before it reaches the browser. + dto.setSettingJson(inviteFormAdminNotificationService.redactFromSettingJson(dto.getSettingJson())); + return ResponseEntity.ok(dto); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/dto/EnrollInviteSettingDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/dto/EnrollInviteSettingDTO.java index d07aad009d..88f5265790 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/dto/EnrollInviteSettingDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/dto/EnrollInviteSettingDTO.java @@ -28,10 +28,38 @@ public static class Settings { @JsonProperty("SUB_ORG_SETTING") private SubOrgSetting subOrgSetting; + /** + * Team-notification config for this invite link: who gets mailed when a + * learner fills the enrollment form. Mirrors the audience campaign's + * to_notify, but kept in setting_json so no new column is needed. + */ + @JsonProperty("NOTIFICATION_SETTING") + private NotificationSetting notificationSetting; + // You can add other top-level setting blocks here } + // --- Team Notification Setting Block --- + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class NotificationSetting { + + /** + * Comma-separated team email addresses that receive the "form filled" + * alert. Same storage shape as audience.to_notify. + */ + @JsonProperty("TO_NOTIFY") + private String toNotify; + + /** + * Explicit off-switch. Null is treated as enabled so a saved recipient + * list keeps working; only {@code false} suppresses the alert. + */ + @JsonProperty("ENABLED") + private Boolean enabled; + } + // --- Sub-Organization Setting Block --- @Data @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/service/EnrollmentFormService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/service/EnrollmentFormService.java index cdaef0a2d3..a2d7526bd1 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/service/EnrollmentFormService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/service/EnrollmentFormService.java @@ -44,14 +44,17 @@ public class EnrollmentFormService { @Autowired private CustomFieldValueService customFieldValueService; + @Autowired + private InviteFormAdminNotificationService inviteFormAdminNotificationService; + @Transactional public EnrollmentFormSubmitResponseDTO submitEnrollmentForm(EnrollmentFormSubmitDTO request) { - log.info("Processing enrollment form submission for email: {}", + log.info("Processing enrollment form submission for email: {}", request.getUserDetails() != null ? request.getUserDetails().getEmail() : "null"); // Step 1: Validate EnrollInvite - validateEnrollInvite(request.getEnrollInviteId(), request.getInstituteId()); + EnrollInvite enrollInvite = validateEnrollInvite(request.getEnrollInviteId(), request.getInstituteId()); // Step 2: Create or update user UserDTO createdUser = studentRegistrationManager.createUserFromAuthService( @@ -108,6 +111,15 @@ public EnrollmentFormSubmitResponseDTO submitEnrollmentForm(EnrollmentFormSubmit createdUser.getId()); } + // Step 6: Alert the team members configured on this invite + // (setting_json → setting.NOTIFICATION_SETTING). FREE invites never reach this + // endpoint — the learner FE skips form-submit for them — so that path fires the + // same notification from LearnerEnrollRequestService instead. + inviteFormAdminNotificationService.notifyAdminsOnFormFill( + enrollInvite, + createdUser, + request.getCustomFieldValues()); + log.info("Enrollment form submitted successfully for user: {}, created {} ABANDONED_CART entries", createdUser.getId(), abandonedCartEntryIds.size()); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/service/InviteFormAdminNotificationService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/service/InviteFormAdminNotificationService.java new file mode 100644 index 0000000000..0d3099c844 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/service/InviteFormAdminNotificationService.java @@ -0,0 +1,343 @@ +package vacademy.io.admin_core_service.features.enroll_invite.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.features.common.entity.CustomFields; +import vacademy.io.admin_core_service.features.common.repository.CustomFieldRepository; +import vacademy.io.admin_core_service.features.enroll_invite.dto.EnrollInviteSettingDTO; +import vacademy.io.admin_core_service.features.enroll_invite.entity.EnrollInvite; +import vacademy.io.admin_core_service.features.notification_service.service.NotificationService; +import vacademy.io.common.auth.dto.UserDTO; +import vacademy.io.common.common.dto.CustomFieldValueDTO; +import vacademy.io.common.notification.dto.GenericEmailRequest; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Sends the "someone filled your invite form" alert to the team members an admin + * configured on the invite link. + * + *

+ * This is the enroll-invite twin of the audience campaign's Team Notifications + * (audience.to_notify): the recipient list is authored on the invite-creation + * form and stored in enroll_invite.setting_json under + * {@code setting.NOTIFICATION_SETTING.TO_NOTIFY} as a comma-separated string, so + * no schema change is needed. + * + *

+ * Every failure here is swallowed — a bad mailbox or a notification-service + * outage must never fail the learner's form submission or enrollment. + */ +@Slf4j +@Service +public class InviteFormAdminNotificationService { + + @Autowired + private NotificationService notificationService; + + @Autowired + private CustomFieldRepository customFieldRepository; + + @Autowired + private ObjectMapper objectMapper; + + /** + * Mails every configured team member with the details the learner just submitted. + * + * @param enrollInvite the invite whose form was filled (carries the recipient list) + * @param user the learner who filled the form + * @param customFieldValues the custom-field answers from the submission (may be null) + */ + public void notifyAdminsOnFormFill(EnrollInvite enrollInvite, + UserDTO user, + List customFieldValues) { + try { + if (enrollInvite == null) { + return; + } + + List recipients = resolveRecipients(enrollInvite); + if (CollectionUtils.isEmpty(recipients)) { + return; + } + + Map customFields = buildCustomFieldMapForEmail(customFieldValues); + String inviteName = StringUtils.hasText(enrollInvite.getName()) + ? enrollInvite.getName() + : "Invite Link"; + String body = buildAdminNotificationBody( + inviteName, + user != null ? user.getFullName() : null, + user != null ? user.getEmail() : null, + user != null ? user.getMobileNumber() : null, + customFields); + + log.info("Sending invite-form notification for invite {} to {} recipient(s)", + enrollInvite.getId(), recipients.size()); + + for (String recipient : recipients) { + GenericEmailRequest emailRequest = new GenericEmailRequest(); + emailRequest.setTo(recipient); + emailRequest.setSubject("New Form Submission - " + inviteName); + emailRequest.setBody(body); + + try { + notificationService.sendGenericHtmlMailViaUnified(emailRequest, enrollInvite.getInstituteId()); + log.info("Sent invite-form notification to: {}", recipient); + } catch (Exception ex) { + log.error("Failed to send invite-form notification to {}: {}", recipient, ex.getMessage()); + } + } + } catch (Exception e) { + log.error("Failed to send invite-form admin notifications for invite {}: {}", + enrollInvite != null ? enrollInvite.getId() : null, e.getMessage(), e); + } + } + + /** + * Strips {@code setting.NOTIFICATION_SETTING} out of a setting_json blob. + * + *

+ * The learner-facing invite endpoints are open (no auth) and hand the whole + * setting_json to the browser so the FE can read the availability message and + * post-form-fill config. The team's email addresses have no business being in + * that payload, so every open read runs the JSON through here first. + * + * @return the setting_json without the notification block, or the input unchanged + * when there is nothing to strip + */ + public String redactFromSettingJson(String settingJson) { + if (!StringUtils.hasText(settingJson)) { + return settingJson; + } + try { + JsonNode root = objectMapper.readTree(settingJson); + JsonNode setting = root.get("setting"); + if (!(setting instanceof ObjectNode) || !setting.has("NOTIFICATION_SETTING")) { + return settingJson; + } + ((ObjectNode) setting).remove("NOTIFICATION_SETTING"); + return objectMapper.writeValueAsString(root); + } catch (Exception e) { + log.warn("Could not redact notification settings from setting_json: {}", e.getMessage()); + return settingJson; + } + } + + /** + * Reads setting_json → setting.NOTIFICATION_SETTING and returns the de-duplicated, + * trimmed recipient list. An explicit {@code ENABLED: false} turns the alert off + * without the admin having to clear the addresses. + */ + private List resolveRecipients(EnrollInvite enrollInvite) { + if (!StringUtils.hasText(enrollInvite.getSettingJson())) { + return Collections.emptyList(); + } + + EnrollInviteSettingDTO settingDTO; + try { + settingDTO = objectMapper.readValue(enrollInvite.getSettingJson(), EnrollInviteSettingDTO.class); + } catch (Exception e) { + log.warn("Could not parse setting_json for invite {}: {}", enrollInvite.getId(), e.getMessage()); + return Collections.emptyList(); + } + + if (settingDTO == null || settingDTO.getSetting() == null + || settingDTO.getSetting().getNotificationSetting() == null) { + return Collections.emptyList(); + } + + EnrollInviteSettingDTO.NotificationSetting notificationSetting = settingDTO.getSetting() + .getNotificationSetting(); + if (Boolean.FALSE.equals(notificationSetting.getEnabled())) { + return Collections.emptyList(); + } + if (!StringUtils.hasText(notificationSetting.getToNotify())) { + return Collections.emptyList(); + } + + Set seen = new LinkedHashSet<>(); + List recipients = new ArrayList<>(); + for (String email : notificationSetting.getToNotify().split(",")) { + String trimmed = email.trim(); + if (!StringUtils.hasText(trimmed)) { + continue; + } + if (seen.add(trimmed.toLowerCase(Locale.ROOT))) { + recipients.add(trimmed); + } + } + return recipients; + } + + /** + * Turns the submitted custom-field answers into a readable {label -> value} map by + * resolving each custom_field_id against its definition. + */ + private Map buildCustomFieldMapForEmail(List customFieldValues) { + if (CollectionUtils.isEmpty(customFieldValues)) { + return Collections.emptyMap(); + } + + Set customFieldIds = customFieldValues.stream() + .map(CustomFieldValueDTO::getCustomFieldId) + .filter(StringUtils::hasText) + .collect(Collectors.toSet()); + + if (customFieldIds.isEmpty()) { + return Collections.emptyMap(); + } + + Map fieldIdToName = new HashMap<>(); + try { + for (CustomFields definition : customFieldRepository.findAllById(customFieldIds)) { + fieldIdToName.putIfAbsent(definition.getId(), definition.getFieldName()); + } + } catch (Exception e) { + log.warn("Could not resolve custom field labels for invite-form notification: {}", e.getMessage()); + return Collections.emptyMap(); + } + + // LinkedHashMap so the email lists the answers in the order the learner filled them. + Map result = new LinkedHashMap<>(); + for (CustomFieldValueDTO value : customFieldValues) { + String fieldName = fieldIdToName.get(value.getCustomFieldId()); + if (StringUtils.hasText(fieldName) && StringUtils.hasText(value.getValue())) { + result.put(fieldName, value.getValue()); + } + } + return result; + } + + private String buildAdminNotificationBody(String inviteName, String userName, String userEmail, + String userMobile, Map customFields) { + StringBuilder emailBody = new StringBuilder(); + + java.time.ZonedDateTime now = java.time.ZonedDateTime.now(); + java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter + .ofPattern("MMM dd, yyyy hh:mm a z"); + String submissionTime = now.format(formatter); + + emailBody.append(""); + emailBody.append(""); + emailBody.append(""); + emailBody.append(""); + emailBody.append(""); + emailBody.append(""); + emailBody.append(""); + emailBody.append(""); + emailBody.append("

"); + + emailBody.append("
"); + emailBody.append("

🔔 New Form Submission

"); + emailBody.append("
Admin Alert
"); + emailBody.append("
"); + + emailBody.append("
"); + emailBody.append("
🎯
"); + emailBody.append("
"); + emailBody.append("Someone just filled the enrollment form for:
"); + emailBody.append("").append(escapeHtml(inviteName)).append(""); + emailBody.append("
"); + + emailBody.append("
"); + emailBody.append("

Submission Details

"); + appendInfoItem(emailBody, "Name", userName); + appendInfoItem(emailBody, "Email", userEmail); + appendInfoItem(emailBody, "Mobile", userMobile); + appendInfoItem(emailBody, "Submitted", submissionTime); + emailBody.append("
"); + + if (!CollectionUtils.isEmpty(customFields)) { + emailBody.append("
"); + emailBody.append("

Additional Information

"); + for (Map.Entry entry : customFields.entrySet()) { + appendInfoItem(emailBody, entry.getKey(), entry.getValue()); + } + emailBody.append("
"); + } + + emailBody.append("
"); + emailBody.append( + "

💡 Action Required: Follow up with this learner as soon as possible to maximize conversion.

"); + emailBody.append("
"); + + emailBody.append("
"); + + emailBody.append(""); + + emailBody.append("
"); + emailBody.append(""); + emailBody.append(""); + + return emailBody.toString(); + } + + private void appendInfoItem(StringBuilder emailBody, String label, String value) { + if (!StringUtils.hasText(value)) { + return; + } + emailBody.append("
"); + emailBody.append("").append(escapeHtml(label)).append(":"); + emailBody.append("").append(escapeHtml(value)).append(""); + emailBody.append("
"); + } + + /** Learner-supplied answers land in an HTML mail, so keep them from breaking the markup. */ + private String escapeHtml(String value) { + if (value == null) { + return ""; + } + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/service/LearnerEnrollInviteService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/service/LearnerEnrollInviteService.java index c99c719276..ae956afee7 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/service/LearnerEnrollInviteService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/enroll_invite/service/LearnerEnrollInviteService.java @@ -27,6 +27,9 @@ public class LearnerEnrollInviteService { @Autowired private WorkflowTriggerService workflowTriggerService; + @Autowired + private InviteFormAdminNotificationService inviteFormAdminNotificationService; + /** * Fetches and validates an active enroll invite by instituteId and inviteCode. * @@ -54,6 +57,8 @@ public EnrollInviteDTO getEnrollInvite(String instituteId, String inviteCode) { .orElseThrow(() -> new VacademyException("Enroll invite not found.")); EnrollInviteDTO result = enrollInviteService.buildFullEnrollInviteDTO(enrollInvite, instituteId); + // This endpoint is open — keep the team-notification email list out of the payload. + result.setSettingJson(inviteFormAdminNotificationService.redactFromSettingJson(result.getSettingJson())); // Only fire the form-fill workflow when the invite is actually open for enrollment. if (EnrollInviteAvailabilityUtil.isAvailable(enrollInvite)) { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/controller/FinanceReportController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/controller/FinanceReportController.java new file mode 100644 index 0000000000..486cee9784 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/controller/FinanceReportController.java @@ -0,0 +1,64 @@ +package vacademy.io.admin_core_service.features.erp_finance.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; +import vacademy.io.admin_core_service.features.erp_finance.dto.PnlSnapshotDTO; +import vacademy.io.admin_core_service.features.erp_finance.service.FinanceReportService; +import vacademy.io.common.auth.model.CustomUserDetails; + +import java.nio.charset.StandardCharsets; + +/** + * Department-cost-vs-revenue finance report (Phase F4b): a monthly P&L + * snapshot pairing collected fee revenue (canonical cash-in ledger query) + * against payroll employer cost by department, plus journal-presence and + * currency sanity signals. Read-only — the journal itself is written by + * source modules (see JournalController / JournalService). + */ +@RestController +@RequestMapping("/admin-core-service/api/v1/erp/finance") +public class FinanceReportController { + + @Autowired + private FinanceReportService financeReportService; + + @Autowired + private HrAccessGuard hrAccessGuard; + + @GetMapping("/pnl-snapshot") + public ResponseEntity getPnlSnapshot( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") Integer month, + @RequestParam("year") Integer year, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + return ResponseEntity.ok(financeReportService.buildSnapshot(instituteId, month, year)); + } + + @GetMapping("/pnl-snapshot/download") + @Auditable(entityType = "ERP_FINANCE_PNL", action = "DOWNLOAD", + entityIdExpr = "#instituteId + ':' + #year + '-' + #month") + public ResponseEntity downloadPnlSnapshot( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") Integer month, + @RequestParam("year") Integer year, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + String csv = financeReportService.buildSnapshotCsv(instituteId, month, year); + byte[] bytes = csv.getBytes(StandardCharsets.UTF_8); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=pnl_snapshot_" + month + "_" + year + ".csv") + .contentType(MediaType.parseMediaType("text/csv")) + .body(bytes); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/controller/JournalController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/controller/JournalController.java new file mode 100644 index 0000000000..1de16c95ed --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/controller/JournalController.java @@ -0,0 +1,120 @@ +package vacademy.io.admin_core_service.features.erp_finance.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; +import vacademy.io.admin_core_service.features.erp_finance.entity.JournalEntry; +import vacademy.io.admin_core_service.features.erp_finance.entity.JournalLine; +import vacademy.io.admin_core_service.features.erp_finance.repository.JournalEntryRepository; +import vacademy.io.admin_core_service.features.erp_finance.repository.JournalLineRepository; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.exceptions.VacademyException; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * ERP journal reads + accounting export (Phase F4). The journal is written by + * source modules (payroll posts on approval); this controller lists periods + * and exports a books-ready CSV (Date, Reference, Account, Name, Debit, + * Credit, Narration) importable into Zoho Books / Tally via their CSV import + * tooling — a native Tally XML / Zoho API push is a later slice. + */ +@RestController +@RequestMapping("/admin-core-service/api/v1/erp/finance/journal") +public class JournalController { + + @Autowired + private JournalEntryRepository journalEntryRepository; + + @Autowired + private JournalLineRepository journalLineRepository; + + @Autowired + private HrAccessGuard hrAccessGuard; + + @GetMapping + public ResponseEntity>> listJournal( + @RequestParam("instituteId") String instituteId, + @RequestParam("year") Integer year, + @RequestParam("month") Integer month, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + List entries = journalEntryRepository + .findByInstituteIdAndPeriodYearAndPeriodMonthOrderByEntryDateAsc(instituteId, year, month); + List ids = entries.stream().map(JournalEntry::getId).collect(Collectors.toList()); + Map> linesByEntry = ids.isEmpty() ? Map.of() + : journalLineRepository.findByJournalEntryIdInOrderByJournalEntryIdAscLineNoAsc(ids).stream() + .collect(Collectors.groupingBy(l -> l.getJournalEntry().getId())); + + List> out = entries.stream().map(e -> { + Map m = new LinkedHashMap<>(); + m.put("id", e.getId()); + m.put("entry_date", e.getEntryDate()); + m.put("source_module", e.getSourceModule()); + m.put("reference", e.getReference()); + m.put("memo", e.getMemo()); + m.put("status", e.getStatus()); + m.put("currency", e.getCurrency()); + m.put("total_debit", e.getTotalDebit()); + m.put("total_credit", e.getTotalCredit()); + m.put("lines", linesByEntry.getOrDefault(e.getId(), List.of()).stream().map(l -> { + Map lm = new LinkedHashMap<>(); + lm.put("line_no", l.getLineNo()); + lm.put("account_code", l.getGlAccountCode()); + lm.put("account_name", l.getGlAccountName()); + lm.put("debit", l.getDebit()); + lm.put("credit", l.getCredit()); + return lm; + }).collect(Collectors.toList())); + return m; + }).collect(Collectors.toList()); + return ResponseEntity.ok(out); + } + + @GetMapping("/export") + @Auditable(entityType = "ERP_JOURNAL", action = "EXPORT", + entityIdExpr = "#instituteId + ':' + #year + '-' + #month") + public ResponseEntity exportJournal( + @RequestParam("instituteId") String instituteId, + @RequestParam("year") Integer year, + @RequestParam("month") Integer month, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + List entries = journalEntryRepository + .findByInstituteIdAndPeriodYearAndPeriodMonthOrderByEntryDateAsc(instituteId, year, month); + if (entries.isEmpty()) { + throw new VacademyException("No journal entries for " + month + "/" + year); + } + + StringBuilder csv = new StringBuilder("Date,Reference,Account Code,Account Name,Debit,Credit,Currency,Narration\r\n"); + for (JournalEntry e : entries) { + for (JournalLine l : journalLineRepository.findByJournalEntryIdOrderByLineNoAsc(e.getId())) { + csv.append(e.getEntryDate()).append(',') + .append(sanitize(e.getReference())).append(',') + .append(l.getGlAccountCode()).append(',') + .append(sanitize(l.getGlAccountName())).append(',') + .append(l.getDebit() != null ? l.getDebit().toPlainString() : "0").append(',') + .append(l.getCredit() != null ? l.getCredit().toPlainString() : "0").append(',') + .append(e.getCurrency() != null ? e.getCurrency() : "INR").append(',') + .append(sanitize(e.getMemo())).append("\r\n"); + } + } + byte[] bytes = csv.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=journal_" + month + "_" + year + ".csv") + .contentType(MediaType.parseMediaType("text/csv")) + .body(bytes); + } + + private static String sanitize(String v) { + return v == null ? "" : v.replace(',', ';').replace('\n', ' ').replace('\r', ' '); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/dto/FinanceReportDepartmentRowDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/dto/FinanceReportDepartmentRowDTO.java new file mode 100644 index 0000000000..2e73ea2591 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/dto/FinanceReportDepartmentRowDTO.java @@ -0,0 +1,31 @@ +package vacademy.io.admin_core_service.features.erp_finance.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; + +/** + * One department row of the P&L snapshot payroll-cost breakdown (Phase F4b). + * Employees whose profile has no department are reported under "Unassigned". + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class FinanceReportDepartmentRowDTO { + + /** Department name, or "Unassigned" when the employee profile has no department. */ + private String departmentName; + + /** Distinct employees with a non-HELD payroll entry in the period. */ + private Long headcount; + + /** SUM(gross_salary + COALESCE(total_employer_contributions, 0)) — employer cost. */ + private BigDecimal employerCost; + + /** SUM(net_pay) — cash out to employees. */ + private BigDecimal netPay; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/dto/PnlSnapshotDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/dto/PnlSnapshotDTO.java new file mode 100644 index 0000000000..77330c4af9 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/dto/PnlSnapshotDTO.java @@ -0,0 +1,104 @@ +package vacademy.io.admin_core_service.features.erp_finance.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.util.List; + +/** + * Department-cost-vs-revenue finance report (Phase F4b): one calendar month's + * collected fee revenue set against payroll employer cost, per institute. + * + * Conventions (v1): + * - Month window = Asia/Kolkata calendar month, converted to UTC before being + * compared against student_fee_allocation_ledger.created_at (DB timestamps + * are stored in UTC; the admin_core JVM also runs UTC). + * - Revenue = the canonical cash-in ledger query (see + * FinanceReportQueryRepository); fee amounts are assumed INR. + * - Payroll cost = APPROVED/PAID runs of the period, HELD entries excluded. + */ +@Getter +@Setter +@NoArgsConstructor +public class PnlSnapshotDTO { + + private String instituteId; + private Integer month; + private Integer year; + + private RevenueBlock revenue; + private PayrollCostBlock payrollCost; + private DerivedBlock derived; + private JournalBlock journal; + private CurrencyBlock currency; + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class RevenueBlock { + /** Collected (allocated, PAID, non-refund) fee amount for the month. */ + private BigDecimal collectedAmount; + /** Inclusive UTC start of the Asia/Kolkata calendar month window. */ + private String windowFromUtc; + /** Exclusive UTC end of the Asia/Kolkata calendar month window. */ + private String windowToUtc; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class PayrollCostBlock { + /** SUM(gross + employer contributions) across all departments. */ + private BigDecimal totalEmployerCost; + /** SUM(net_pay) across all departments. */ + private BigDecimal totalNetPay; + /** Distinct employees with a non-HELD entry in APPROVED/PAID runs. */ + private Long employeeCount; + /** Number of APPROVED/PAID payroll runs contributing to the period. */ + private Long runCount; + private List byDepartment; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class DerivedBlock { + /** revenue.collectedAmount − payrollCost.totalEmployerCost. */ + private BigDecimal marginOverEmployerCost; + /** employerCost / revenue; null when revenue is 0 (division undefined). */ + private BigDecimal costToRevenueRatio; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class JournalBlock { + /** True if any HR_PAYROLL journal entry exists for the period. */ + private boolean hrPayrollEntryExists; + /** True if at least one of those entries is POSTED (not reversed). */ + private boolean hrPayrollEntryPosted; + /** Total HR_PAYROLL journal entries found for the period. */ + private int hrPayrollEntryCount; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class CurrencyBlock { + /** Distinct currencies on the period's payroll entries (nulls dropped). */ + private List payrollCurrencies; + /** v1 assumption: the fee ledger has no currency column — INR assumed. */ + private String assumedFeeCurrency; + /** True when any payroll currency differs from the assumed fee currency. */ + private boolean mismatch; + private String note; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/entity/JournalEntry.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/entity/JournalEntry.java new file mode 100644 index 0000000000..a59f2c6472 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/entity/JournalEntry.java @@ -0,0 +1,86 @@ +package vacademy.io.admin_core_service.features.erp_finance.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.UuidGenerator; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * One balanced double-entry journal (V484) — the seed of the Accounting/GL + * module. Payroll approval posts here (source HR_PAYROLL, source_id = run id); + * fees and future accounting share the same table so cross-module P&L reads + * one place. Idempotent per (source_module, source_id) via a partial unique. + */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "erp_journal_entry") +public class JournalEntry { + + @Id + @UuidGenerator + @Column(name = "id") + private String id; + + @Column(name = "institute_id", nullable = false) + private String instituteId; + + @Column(name = "entry_date", nullable = false) + private LocalDate entryDate; + + @Column(name = "period_month") + private Integer periodMonth; + + @Column(name = "period_year") + private Integer periodYear; + + /** HR_PAYROLL | FEES | MANUAL | ... */ + @Column(name = "source_module", nullable = false, length = 50) + private String sourceModule; + + @Column(name = "source_id") + private String sourceId; + + @Column(name = "reference") + private String reference; + + @Column(name = "memo", columnDefinition = "TEXT") + private String memo; + + @Column(name = "currency", length = 3) + private String currency; + + /** POSTED | REVERSED */ + @Column(name = "status", length = 20) + private String status; + + /** Set on a reversing entry: the entry it reverses. */ + @Column(name = "reversal_of_entry_id") + private String reversalOfEntryId; + + @Column(name = "total_debit", precision = 18, scale = 2) + private BigDecimal totalDebit; + + @Column(name = "total_credit", precision = 18, scale = 2) + private BigDecimal totalCredit; + + @Column(name = "created_by") + private String createdBy; + + @Column(name = "created_at", insertable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", insertable = false) + private LocalDateTime updatedAt; + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/entity/JournalLine.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/entity/JournalLine.java new file mode 100644 index 0000000000..8ebdf4fc62 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/entity/JournalLine.java @@ -0,0 +1,55 @@ +package vacademy.io.admin_core_service.features.erp_finance.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.UuidGenerator; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** One debit-or-credit line of a journal entry, with cost-center dimensions (V484). */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "erp_journal_line") +public class JournalLine { + + @Id + @UuidGenerator + @Column(name = "id") + private String id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "journal_entry_id", nullable = false) + private JournalEntry journalEntry; + + @Column(name = "line_no", nullable = false) + private Integer lineNo; + + @Column(name = "gl_account_code", nullable = false, length = 50) + private String glAccountCode; + + @Column(name = "gl_account_name") + private String glAccountName; + + @Column(name = "debit", precision = 18, scale = 2) + private BigDecimal debit; + + @Column(name = "credit", precision = 18, scale = 2) + private BigDecimal credit; + + @Column(name = "department_id") + private String departmentId; + + @Column(name = "employee_id") + private String employeeId; + + @Column(name = "notes", columnDefinition = "TEXT") + private String notes; + + @Column(name = "created_at", insertable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/repository/FinanceReportQueryRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/repository/FinanceReportQueryRepository.java new file mode 100644 index 0000000000..bf3502b415 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/repository/FinanceReportQueryRepository.java @@ -0,0 +1,146 @@ +package vacademy.io.admin_core_service.features.erp_finance.repository; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.erp_finance.dto.FinanceReportDepartmentRowDTO; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Read-only query gateway for the Phase F4b P&L snapshot. Owns its own SQL / + * JPQL so the report never couples to fee_management or hr_payroll internals + * beyond their stable schema. + * + * All queries are institute-scoped; callers must have already authorized the + * institute via HrAccessGuard. + */ +@Repository +public class FinanceReportQueryRepository { + + @PersistenceContext + private EntityManager entityManager; + + /** + * Collected (cash-in) fee revenue for a time window. + * + * Reproduces THE canonical cash-in query from + * features/fee_management/repository/CollectionDashboardRepositoryImpl.java + * (PAYMENT_MODE_FROM, ~lines 127-138): allocated ledger amounts joined to + * PAID payment logs, excluding REFUND/BOUNCE_REVERSAL reversals. payment_log + * has NO institute_id column — the complex_payment_option join is the + * institute scope. This copy only adds the created_at window. + * + * @param fromTs inclusive window start (UTC — sal.created_at is a UTC DB + * timestamp; see FinanceReportService for the Asia/Kolkata + * month-bound convention) + * @param toTs exclusive window end (UTC) + */ + public BigDecimal collectedRevenue(String instituteId, LocalDateTime fromTs, LocalDateTime toTs) { + String sql = + "SELECT COALESCE(SUM(sal.amount_allocated), 0) " + + "FROM student_fee_allocation_ledger sal " + + "JOIN payment_log pl ON sal.payment_log_id = pl.id " + + "JOIN student_fee_payment sfp ON sal.student_fee_payment_id = sfp.id " + + "JOIN complex_payment_option cpo ON sfp.cpo_id = cpo.id " + + "WHERE cpo.institute_id = :instituteId " + + " AND sal.transaction_type NOT IN ('REFUND','BOUNCE_REVERSAL') " + + " AND pl.payment_status = 'PAID' " + + " AND sal.created_at >= :fromTs " + + " AND sal.created_at < :toTs"; + Query q = entityManager.createNativeQuery(sql); + q.setParameter("instituteId", instituteId); + q.setParameter("fromTs", fromTs); + q.setParameter("toTs", toTs); + Object result = q.getSingleResult(); + return result == null ? BigDecimal.ZERO : new BigDecimal(result.toString()); + } + + /** + * Payroll employer cost per department for a payroll period. + * + * Cost side of the P&L: entries of APPROVED/PAID runs only (DRAFT / + * PROCESSING / PROCESSED / CANCELLED runs are not yet — or never — a real + * cost), HELD entries excluded (money not going out). Employer cost per + * entry = gross_salary + COALESCE(total_employer_contributions, 0). + * + * Department name is null for employees without a department; the service + * maps that group to "Unassigned". Rows are unsorted — service sorts. + */ + public List payrollCostByDepartment(String instituteId, int month, int year) { + String jpql = + "SELECT d.name, " + + " COUNT(DISTINCT e.id), " + + " SUM(pe.grossSalary + COALESCE(pe.totalEmployerContributions, 0)), " + + " SUM(pe.netPay) " + + "FROM PayrollEntry pe " + + "JOIN pe.payrollRun r " + + "JOIN pe.employee e " + + "LEFT JOIN e.department d " + + "WHERE r.instituteId = :instituteId " + + " AND r.month = :month " + + " AND r.year = :year " + + " AND r.status IN ('APPROVED', 'PAID') " + + " AND (pe.status IS NULL OR pe.status <> 'HELD') " + + "GROUP BY d.name"; + Query q = entityManager.createQuery(jpql); + q.setParameter("instituteId", instituteId); + q.setParameter("month", month); + q.setParameter("year", year); + @SuppressWarnings("unchecked") + List rows = q.getResultList(); + List out = new ArrayList<>(); + for (Object[] row : rows) { + out.add(new FinanceReportDepartmentRowDTO( + (String) row[0], + row[1] == null ? 0L : ((Number) row[1]).longValue(), + toBigDecimal(row[2]), + toBigDecimal(row[3]))); + } + return out; + } + + /** Number of APPROVED/PAID payroll runs in the period (for the summary). */ + public long approvedOrPaidRunCount(String instituteId, int month, int year) { + String jpql = + "SELECT COUNT(r) FROM PayrollRun r " + + "WHERE r.instituteId = :instituteId AND r.month = :month AND r.year = :year " + + " AND r.status IN ('APPROVED', 'PAID')"; + Query q = entityManager.createQuery(jpql); + q.setParameter("instituteId", instituteId); + q.setParameter("month", month); + q.setParameter("year", year); + return ((Number) q.getSingleResult()).longValue(); + } + + /** + * Distinct currencies on the period's counted payroll entries (entry + * currency, falling back to the run's currency). Nulls dropped by caller. + */ + public List payrollCurrencies(String instituteId, int month, int year) { + String jpql = + "SELECT DISTINCT COALESCE(pe.currency, r.currency) " + + "FROM PayrollEntry pe " + + "JOIN pe.payrollRun r " + + "WHERE r.instituteId = :instituteId AND r.month = :month AND r.year = :year " + + " AND r.status IN ('APPROVED', 'PAID') " + + " AND (pe.status IS NULL OR pe.status <> 'HELD')"; + Query q = entityManager.createQuery(jpql); + q.setParameter("instituteId", instituteId); + q.setParameter("month", month); + q.setParameter("year", year); + @SuppressWarnings("unchecked") + List currencies = q.getResultList(); + return currencies; + } + + private static BigDecimal toBigDecimal(Object v) { + if (v == null) return BigDecimal.ZERO; + if (v instanceof BigDecimal bd) return bd; + return new BigDecimal(v.toString()); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/repository/JournalEntryRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/repository/JournalEntryRepository.java new file mode 100644 index 0000000000..79598a980b --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/repository/JournalEntryRepository.java @@ -0,0 +1,20 @@ +package vacademy.io.admin_core_service.features.erp_finance.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.erp_finance.entity.JournalEntry; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface JournalEntryRepository extends JpaRepository { + + List findByInstituteIdAndPeriodYearAndPeriodMonthOrderByEntryDateAsc( + String instituteId, Integer periodYear, Integer periodMonth); + + Optional findFirstBySourceModuleAndSourceIdAndStatusAndReversalOfEntryIdIsNull( + String sourceModule, String sourceId, String status); + + Optional findByIdAndInstituteId(String id, String instituteId); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/repository/JournalLineRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/repository/JournalLineRepository.java new file mode 100644 index 0000000000..98be527df2 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/repository/JournalLineRepository.java @@ -0,0 +1,15 @@ +package vacademy.io.admin_core_service.features.erp_finance.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.erp_finance.entity.JournalLine; + +import java.util.List; + +@Repository +public interface JournalLineRepository extends JpaRepository { + + List findByJournalEntryIdOrderByLineNoAsc(String journalEntryId); + + List findByJournalEntryIdInOrderByJournalEntryIdAscLineNoAsc(List entryIds); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/service/FinanceReportService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/service/FinanceReportService.java new file mode 100644 index 0000000000..2e5e9631ca --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/service/FinanceReportService.java @@ -0,0 +1,198 @@ +package vacademy.io.admin_core_service.features.erp_finance.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.erp_finance.dto.FinanceReportDepartmentRowDTO; +import vacademy.io.admin_core_service.features.erp_finance.dto.PnlSnapshotDTO; +import vacademy.io.admin_core_service.features.erp_finance.entity.JournalEntry; +import vacademy.io.admin_core_service.features.erp_finance.repository.FinanceReportQueryRepository; +import vacademy.io.admin_core_service.features.erp_finance.repository.JournalEntryRepository; +import vacademy.io.common.exceptions.VacademyException; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +/** + * Department-cost-vs-revenue P&L snapshot (Phase F4b). + * + * Revenue side: the canonical collected-fees ledger query (see + * {@link FinanceReportQueryRepository#collectedRevenue}) over one calendar + * month. Cost side: payroll employer cost (gross + employer contributions) + * from APPROVED/PAID runs of that month, HELD entries excluded, broken down + * by department. + * + * TZ convention (v1): the "month" is the Asia/Kolkata calendar month. + * sal.created_at is a DB timestamp stored in UTC (the admin_core JVM is kept + * UTC by convention), so the IST month bounds are converted to UTC before + * being bound as query parameters. Payroll runs carry explicit month/year + * columns, so no conversion applies on the cost side. + */ +@Service +public class FinanceReportService { + + private static final ZoneId REPORT_ZONE = ZoneId.of("Asia/Kolkata"); + private static final String ASSUMED_FEE_CURRENCY = "INR"; + private static final String UNASSIGNED_DEPARTMENT = "Unassigned"; + private static final String SOURCE_MODULE_HR_PAYROLL = "HR_PAYROLL"; + private static final String JOURNAL_STATUS_POSTED = "POSTED"; + + @Autowired + private FinanceReportQueryRepository financeReportQueryRepository; + + @Autowired + private JournalEntryRepository journalEntryRepository; + + @Transactional(readOnly = true) + public PnlSnapshotDTO buildSnapshot(String instituteId, int month, int year) { + validatePeriod(month, year); + + // --- month window: Asia/Kolkata calendar month -> UTC timestamps --- + ZonedDateTime fromIst = LocalDate.of(year, month, 1).atStartOfDay(REPORT_ZONE); + ZonedDateTime toIst = fromIst.plusMonths(1); + LocalDateTime fromUtc = fromIst.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); + LocalDateTime toUtc = toIst.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); + + // --- (a) revenue --- + BigDecimal revenue = nz(financeReportQueryRepository.collectedRevenue(instituteId, fromUtc, toUtc)); + + // --- (b) payroll cost by department --- + List deptRows = + financeReportQueryRepository.payrollCostByDepartment(instituteId, month, year); + deptRows.forEach(r -> { + if (r.getDepartmentName() == null || r.getDepartmentName().isBlank()) { + r.setDepartmentName(UNASSIGNED_DEPARTMENT); + } + r.setEmployerCost(nz(r.getEmployerCost())); + r.setNetPay(nz(r.getNetPay())); + if (r.getHeadcount() == null) r.setHeadcount(0L); + }); + // "Unassigned" last, otherwise alphabetical. + deptRows.sort(Comparator + .comparing((FinanceReportDepartmentRowDTO r) -> UNASSIGNED_DEPARTMENT.equals(r.getDepartmentName())) + .thenComparing(FinanceReportDepartmentRowDTO::getDepartmentName, String.CASE_INSENSITIVE_ORDER)); + + BigDecimal totalEmployerCost = deptRows.stream() + .map(FinanceReportDepartmentRowDTO::getEmployerCost) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal totalNetPay = deptRows.stream() + .map(FinanceReportDepartmentRowDTO::getNetPay) + .reduce(BigDecimal.ZERO, BigDecimal::add); + // An employee belongs to exactly one department group, so the sum of + // per-department distinct headcounts is the overall distinct headcount. + long employeeCount = deptRows.stream().mapToLong(FinanceReportDepartmentRowDTO::getHeadcount).sum(); + long runCount = financeReportQueryRepository.approvedOrPaidRunCount(instituteId, month, year); + + // --- (c) derived, null-safe on zero revenue --- + BigDecimal margin = revenue.subtract(totalEmployerCost); + BigDecimal ratio = revenue.signum() == 0 + ? null + : totalEmployerCost.divide(revenue, 4, RoundingMode.HALF_UP); + + // --- (d) journal presence (read-only via existing JournalEntryRepository) --- + List periodEntries = journalEntryRepository + .findByInstituteIdAndPeriodYearAndPeriodMonthOrderByEntryDateAsc(instituteId, year, month); + List hrPayrollEntries = periodEntries.stream() + .filter(e -> SOURCE_MODULE_HR_PAYROLL.equals(e.getSourceModule())) + .toList(); + boolean journalExists = !hrPayrollEntries.isEmpty(); + boolean journalPosted = hrPayrollEntries.stream() + .anyMatch(e -> JOURNAL_STATUS_POSTED.equals(e.getStatus())); + + // --- (e) currency note --- + List payrollCurrencies = financeReportQueryRepository + .payrollCurrencies(instituteId, month, year).stream() + .filter(Objects::nonNull) + .filter(c -> !c.isBlank()) + .map(String::toUpperCase) + .distinct() + .sorted() + .toList(); + boolean mismatch = payrollCurrencies.stream().anyMatch(c -> !ASSUMED_FEE_CURRENCY.equals(c)); + String note; + if (payrollCurrencies.isEmpty()) { + note = "No payroll currency recorded for the period; fee ledger amounts assumed " + + ASSUMED_FEE_CURRENCY + "."; + } else if (mismatch) { + note = "CURRENCY MISMATCH: payroll entries are in " + String.join(", ", payrollCurrencies) + + " but fee ledger amounts are assumed " + ASSUMED_FEE_CURRENCY + + " (ledger has no currency column). Margin/ratio mix currencies — interpret with care."; + } else { + note = "Payroll and fee amounts both treated as " + ASSUMED_FEE_CURRENCY + + " (fee ledger currency is assumed, not stored)."; + } + + PnlSnapshotDTO dto = new PnlSnapshotDTO(); + dto.setInstituteId(instituteId); + dto.setMonth(month); + dto.setYear(year); + dto.setRevenue(new PnlSnapshotDTO.RevenueBlock(revenue, fromUtc + "Z", toUtc + "Z")); + dto.setPayrollCost(new PnlSnapshotDTO.PayrollCostBlock( + totalEmployerCost, totalNetPay, employeeCount, runCount, deptRows)); + dto.setDerived(new PnlSnapshotDTO.DerivedBlock(margin, ratio)); + dto.setJournal(new PnlSnapshotDTO.JournalBlock(journalExists, journalPosted, hrPayrollEntries.size())); + dto.setCurrency(new PnlSnapshotDTO.CurrencyBlock( + payrollCurrencies, ASSUMED_FEE_CURRENCY, mismatch, note)); + return dto; + } + + /** CSV rendering of the snapshot: department rows, then a summary block. */ + @Transactional(readOnly = true) + public String buildSnapshotCsv(String instituteId, int month, int year) { + PnlSnapshotDTO snap = buildSnapshot(instituteId, month, year); + StringBuilder csv = new StringBuilder(); + csv.append("Section,Department,Headcount,Employer Cost,Net Pay\r\n"); + for (FinanceReportDepartmentRowDTO row : snap.getPayrollCost().getByDepartment()) { + csv.append("DEPARTMENT,") + .append(sanitize(row.getDepartmentName())).append(',') + .append(row.getHeadcount()).append(',') + .append(row.getEmployerCost().toPlainString()).append(',') + .append(row.getNetPay().toPlainString()).append("\r\n"); + } + csv.append("\r\n"); + csv.append("Summary Metric,Value\r\n"); + appendSummary(csv, "Period", month + "/" + year + " (Asia/Kolkata calendar month)"); + appendSummary(csv, "Collected Revenue", snap.getRevenue().getCollectedAmount().toPlainString()); + appendSummary(csv, "Total Employer Cost", snap.getPayrollCost().getTotalEmployerCost().toPlainString()); + appendSummary(csv, "Total Net Pay", snap.getPayrollCost().getTotalNetPay().toPlainString()); + appendSummary(csv, "Employee Count", String.valueOf(snap.getPayrollCost().getEmployeeCount())); + appendSummary(csv, "Payroll Runs Counted", String.valueOf(snap.getPayrollCost().getRunCount())); + appendSummary(csv, "Margin (Revenue - Employer Cost)", snap.getDerived().getMarginOverEmployerCost().toPlainString()); + appendSummary(csv, "Cost/Revenue Ratio", snap.getDerived().getCostToRevenueRatio() == null + ? "N/A (zero revenue)" : snap.getDerived().getCostToRevenueRatio().toPlainString()); + appendSummary(csv, "HR_PAYROLL Journal Present", snap.getJournal().isHrPayrollEntryExists() + ? (snap.getJournal().isHrPayrollEntryPosted() ? "YES (POSTED)" : "YES (not posted)") : "NO"); + appendSummary(csv, "Currency Note", snap.getCurrency().getNote()); + return csv.toString(); + } + + private static void appendSummary(StringBuilder csv, String metric, String value) { + csv.append(sanitize(metric)).append(',').append(sanitize(value)).append("\r\n"); + } + + private static void validatePeriod(int month, int year) { + if (month < 1 || month > 12) { + throw new VacademyException("month must be between 1 and 12"); + } + if (year < 2000 || year > 2100) { + throw new VacademyException("year must be between 2000 and 2100"); + } + } + + private static BigDecimal nz(BigDecimal v) { + return v == null ? BigDecimal.ZERO : v; + } + + /** Same CSV field convention as JournalController's export. */ + private static String sanitize(String v) { + return v == null ? "" : v.replace(',', ';').replace('\n', ' ').replace('\r', ' '); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/service/JournalService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/service/JournalService.java new file mode 100644 index 0000000000..10256b3c41 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/erp_finance/service/JournalService.java @@ -0,0 +1,264 @@ +package vacademy.io.admin_core_service.features.erp_finance.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.erp_finance.entity.JournalEntry; +import vacademy.io.admin_core_service.features.erp_finance.entity.JournalLine; +import vacademy.io.admin_core_service.features.erp_finance.repository.JournalEntryRepository; +import vacademy.io.admin_core_service.features.erp_finance.repository.JournalLineRepository; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollAdjustment; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntry; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntryComponent; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollRun; +import vacademy.io.admin_core_service.features.hr_payroll.enums.PayrollEntryStatus; +import vacademy.io.admin_core_service.features.hr_payroll.repository.PayrollAdjustmentRepository; +import vacademy.io.admin_core_service.features.hr_payroll.repository.PayrollEntryComponentRepository; +import vacademy.io.admin_core_service.features.hr_payroll.repository.PayrollEntryRepository; +import vacademy.io.admin_core_service.features.hr_salary.entity.SalaryComponent; +import vacademy.io.admin_core_service.features.hr_salary.enums.ComponentType; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.*; + +/** + * The ERP journal (Phase F4) — payroll's bridge into accounting, and the seed + * of the future GL module. {@link #postPayrollJournal} runs on payroll + * APPROVAL and produces one balanced double entry for the run: + * + * Dr salary expense (per EARNING component's gl_account_code, default 5100) + * Dr overtime/other earnings 5110, reimbursements 5120 + * Dr employer statutory expense 5150 + * Cr salaries payable 2100 (net pay) + * Cr statutory payable 2110 (employee deductions + employer contributions) + * Cr TDS payable 2120 + * Cr employee loans receivable 1210 (EMI recovered) + * Dr/Cr 5999 payroll adjustment plug (net-pay floor clamps only) + * + * HELD entries are excluded (they are not payable). Variable-pay adjustments + * already exist as components, so the "other earnings" line is the residual + * (overtime) only — no double counting. Rejecting an APPROVED run posts a + * mirror-image reversing entry. Idempotent per run via the V484 partial unique. + */ +@Service +public class JournalService { + + private static final Logger log = LoggerFactory.getLogger(JournalService.class); + + public static final String SOURCE_HR_PAYROLL = "HR_PAYROLL"; + + private static final String ACC_SALARY_EXPENSE = "5100"; + private static final String ACC_OTHER_EARNINGS = "5110"; + private static final String ACC_REIMBURSEMENT = "5120"; + private static final String ACC_EMPLOYER_STATUTORY = "5150"; + private static final String ACC_SALARIES_PAYABLE = "2100"; + private static final String ACC_STATUTORY_PAYABLE = "2110"; + private static final String ACC_TDS_PAYABLE = "2120"; + private static final String ACC_LOANS_RECEIVABLE = "1210"; + private static final String ACC_PLUG = "5999"; + + private static final Map ACCOUNT_NAMES = Map.of( + ACC_SALARY_EXPENSE, "Salary Expense", + ACC_OTHER_EARNINGS, "Overtime & Other Earnings", + ACC_REIMBURSEMENT, "Reimbursement Expense", + ACC_EMPLOYER_STATUTORY, "Employer Statutory Expense", + ACC_SALARIES_PAYABLE, "Salaries Payable", + ACC_STATUTORY_PAYABLE, "Statutory Payable", + ACC_TDS_PAYABLE, "TDS Payable", + ACC_LOANS_RECEIVABLE, "Employee Loans Receivable", + ACC_PLUG, "Payroll Adjustment (plug)"); + + @Autowired + private JournalEntryRepository journalEntryRepository; + + @Autowired + private JournalLineRepository journalLineRepository; + + @Autowired + private PayrollEntryRepository payrollEntryRepository; + + @Autowired + private PayrollEntryComponentRepository payrollEntryComponentRepository; + + @Autowired + private PayrollAdjustmentRepository payrollAdjustmentRepository; + + /** Posts the run's journal; a second call for the same run is a no-op (returns the existing id). */ + @Transactional + public String postPayrollJournal(PayrollRun run, String userId) { + Optional existing = journalEntryRepository + .findFirstBySourceModuleAndSourceIdAndStatusAndReversalOfEntryIdIsNull( + SOURCE_HR_PAYROLL, run.getId(), "POSTED"); + if (existing.isPresent()) { + return existing.get().getId(); + } + + List entries = payrollEntryRepository + .findByPayrollRunIdOrderByEmployeeEmployeeCodeAsc(run.getId()); + + // Aggregation buckets: account -> amount + Map debits = new LinkedHashMap<>(); + Map credits = new LinkedHashMap<>(); + Map accountNames = new HashMap<>(ACCOUNT_NAMES); + + BigDecimal netPayable = BigDecimal.ZERO; + BigDecimal loanRecovered = BigDecimal.ZERO; + BigDecimal reimbursements = BigDecimal.ZERO; + BigDecimal otherEarningsTotal = BigDecimal.ZERO; + List includedEntryIds = new ArrayList<>(); + + for (PayrollEntry entry : entries) { + if (PayrollEntryStatus.HELD.name().equals(entry.getStatus())) { + continue; // held pay is not payable — posts when released and re-approved + } + includedEntryIds.add(entry.getId()); + netPayable = netPayable.add(nvl(entry.getNetPay())); + loanRecovered = loanRecovered.add(nvl(entry.getLoanDeduction())); + reimbursements = reimbursements.add(nvl(entry.getReimbursements())); + otherEarningsTotal = otherEarningsTotal.add(nvl(entry.getOtherEarnings())); + + for (PayrollEntryComponent comp : payrollEntryComponentRepository.findByPayrollEntryId(entry.getId())) { + SalaryComponent def = comp.getComponent(); + BigDecimal amount = nvl(comp.getAmount()); + if (amount.signum() == 0) continue; + String type = comp.getComponentType(); + String code = def != null && def.getCode() != null ? def.getCode().toUpperCase() : ""; + String override = def != null ? def.getGlAccountCode() : null; + + if (ComponentType.EARNING.name().equals(type)) { + add(debits, pick(override, ACC_SALARY_EXPENSE), amount); + if (override != null) accountNames.putIfAbsent(override, def.getName()); + } else if (ComponentType.DEDUCTION.name().equals(type)) { + String account = pick(override, "TDS".equals(code) ? ACC_TDS_PAYABLE : ACC_STATUTORY_PAYABLE); + add(credits, account, amount); + if (override != null) accountNames.putIfAbsent(override, def.getName()); + } else if (ComponentType.EMPLOYER_CONTRIBUTION.name().equals(type)) { + add(debits, pick(override, ACC_EMPLOYER_STATUTORY), amount); + add(credits, ACC_STATUTORY_PAYABLE, amount); + } + } + } + + // Adjustments are materialized as components above; the residual of + // otherEarnings beyond adjustment earnings is overtime. + BigDecimal adjEarnings = BigDecimal.ZERO; + if (!includedEntryIds.isEmpty()) { + for (String entryId : includedEntryIds) { + for (PayrollAdjustment adj : payrollAdjustmentRepository.findByPayrollEntryId(entryId)) { + if ("EARNING".equals(adj.getType())) adjEarnings = adjEarnings.add(nvl(adj.getAmount())); + } + } + } + BigDecimal overtimeResidual = otherEarningsTotal.subtract(adjEarnings); + if (overtimeResidual.signum() > 0) add(debits, ACC_OTHER_EARNINGS, overtimeResidual); + if (reimbursements.signum() > 0) add(debits, ACC_REIMBURSEMENT, reimbursements); + if (netPayable.signum() > 0) add(credits, ACC_SALARIES_PAYABLE, netPayable); + if (loanRecovered.signum() > 0) add(credits, ACC_LOANS_RECEIVABLE, loanRecovered); + + BigDecimal totalDr = debits.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal totalCr = credits.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal imbalance = totalDr.subtract(totalCr); + if (imbalance.abs().compareTo(new BigDecimal("0.01")) >= 0) { + // Net-pay floor clamps (negative nets raised to zero) surface here. + if (imbalance.signum() < 0) add(debits, ACC_PLUG, imbalance.negate()); + else add(credits, ACC_PLUG, imbalance); + log.warn("Payroll journal for run {} needed a {} plug of {}", run.getId(), + imbalance.signum() < 0 ? "debit" : "credit", imbalance.abs()); + totalDr = debits.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); + totalCr = credits.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); + } + + JournalEntry entry = new JournalEntry(); + entry.setInstituteId(run.getInstituteId()); + entry.setEntryDate(LocalDate.now()); + entry.setPeriodMonth(run.getMonth()); + entry.setPeriodYear(run.getYear()); + entry.setSourceModule(SOURCE_HR_PAYROLL); + entry.setSourceId(run.getId()); + entry.setReference("PAYROLL-" + run.getMonth() + "/" + run.getYear() + + ("REGULAR".equals(run.getRunType()) || run.getRunType() == null ? "" : "-" + run.getRunType())); + entry.setMemo("Payroll " + run.getMonth() + "/" + run.getYear() + " (" + includedEntryIds.size() + " employees)"); + entry.setCurrency(run.getCurrency() != null ? run.getCurrency() : "INR"); + entry.setStatus("POSTED"); + entry.setTotalDebit(totalDr); + entry.setTotalCredit(totalCr); + entry.setCreatedBy(userId); + entry = journalEntryRepository.save(entry); + + int lineNo = 1; + for (Map.Entry d : debits.entrySet()) { + saveLine(entry, lineNo++, d.getKey(), accountNames.getOrDefault(d.getKey(), d.getKey()), + d.getValue(), BigDecimal.ZERO); + } + for (Map.Entry c : credits.entrySet()) { + saveLine(entry, lineNo++, c.getKey(), accountNames.getOrDefault(c.getKey(), c.getKey()), + BigDecimal.ZERO, c.getValue()); + } + return entry.getId(); + } + + /** Mirror-image reversal when an APPROVED run is rejected; no-op if never posted. */ + @Transactional + public void reversePayrollJournal(PayrollRun run, String userId) { + Optional postedOpt = journalEntryRepository + .findFirstBySourceModuleAndSourceIdAndStatusAndReversalOfEntryIdIsNull( + SOURCE_HR_PAYROLL, run.getId(), "POSTED"); + if (postedOpt.isEmpty()) { + return; + } + JournalEntry posted = postedOpt.get(); + + JournalEntry reversal = new JournalEntry(); + reversal.setInstituteId(posted.getInstituteId()); + reversal.setEntryDate(LocalDate.now()); + reversal.setPeriodMonth(posted.getPeriodMonth()); + reversal.setPeriodYear(posted.getPeriodYear()); + reversal.setSourceModule(SOURCE_HR_PAYROLL); + reversal.setSourceId(run.getId()); + reversal.setReference("REVERSAL-" + posted.getReference()); + reversal.setMemo("Reversal of journal " + posted.getId() + " (payroll run rejected)"); + reversal.setCurrency(posted.getCurrency()); + reversal.setStatus("POSTED"); + reversal.setReversalOfEntryId(posted.getId()); + reversal.setTotalDebit(posted.getTotalCredit()); + reversal.setTotalCredit(posted.getTotalDebit()); + reversal.setCreatedBy(userId); + reversal = journalEntryRepository.save(reversal); + + int lineNo = 1; + for (JournalLine line : journalLineRepository.findByJournalEntryIdOrderByLineNoAsc(posted.getId())) { + saveLine(reversal, lineNo++, line.getGlAccountCode(), line.getGlAccountName(), + nvl(line.getCredit()), nvl(line.getDebit())); + } + + posted.setStatus("REVERSED"); + journalEntryRepository.save(posted); + } + + private void saveLine(JournalEntry entry, int lineNo, String account, String name, + BigDecimal debit, BigDecimal credit) { + JournalLine line = new JournalLine(); + line.setJournalEntry(entry); + line.setLineNo(lineNo); + line.setGlAccountCode(account); + line.setGlAccountName(name); + line.setDebit(debit); + line.setCredit(credit); + journalLineRepository.save(line); + } + + private static void add(Map bucket, String account, BigDecimal amount) { + bucket.merge(account, amount, BigDecimal::add); + } + + private static String pick(String override, String fallback) { + return override != null && !override.isBlank() ? override : fallback; + } + + private static BigDecimal nvl(BigDecimal v) { + return v != null ? v : BigDecimal.ZERO; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_approval/controller/ApprovalController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_approval/controller/ApprovalController.java index cb6d9ab301..b953a83a31 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_approval/controller/ApprovalController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_approval/controller/ApprovalController.java @@ -3,7 +3,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_approval.dto.ApprovalActionInputDTO; import vacademy.io.admin_core_service.features.hr_approval.dto.ApprovalChainDTO; import vacademy.io.admin_core_service.features.hr_approval.dto.ApprovalRequestDTO; @@ -20,7 +20,7 @@ public class ApprovalController { private ApprovalService approvalService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; // ======================== Approval Chains ======================== @@ -29,8 +29,9 @@ public ResponseEntity saveChain( @RequestBody ApprovalChainDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String id = approvalService.saveChain(dto); + hrAccessGuard.requireHrAdmin(user, instituteId); + // Persist under the VALIDATED institute, never the dto's + String id = approvalService.saveChain(dto, instituteId); return ResponseEntity.ok(id); } @@ -38,7 +39,7 @@ public ResponseEntity saveChain( public ResponseEntity> getChains( @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrStaff(user, instituteId); List chains = approvalService.getChains(instituteId); return ResponseEntity.ok(chains); } @@ -49,7 +50,7 @@ public ResponseEntity> getChains( public ResponseEntity createRequest( @RequestBody ApprovalRequestDTO dto, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, dto.getInstituteId()); + hrAccessGuard.validateMember(user, dto.getInstituteId()); String id = approvalService.createRequest(dto.getInstituteId(), dto.getEntityType(), dto.getEntityId(), user.getUserId()); return ResponseEntity.ok(id); @@ -59,7 +60,7 @@ public ResponseEntity createRequest( public ResponseEntity> getPendingRequests( @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrStaff(user, instituteId); List requests = approvalService.getPendingRequests(instituteId); return ResponseEntity.ok(requests); } @@ -70,8 +71,8 @@ public ResponseEntity processAction( @RequestBody ApprovalActionInputDTO actionInputDTO, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = approvalService.processAction(id, actionInputDTO, user.getUserId()); + hrAccessGuard.validateMember(user, instituteId); + String resultId = approvalService.processAction(id, actionInputDTO, user.getUserId(), instituteId); return ResponseEntity.ok(resultId); } @@ -81,8 +82,8 @@ public ResponseEntity getRequestHistory( @RequestParam("entityId") String entityId, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - ApprovalRequestDTO request = approvalService.getRequestHistory(entityType, entityId); + hrAccessGuard.requireHrStaff(user, instituteId); + ApprovalRequestDTO request = approvalService.getRequestHistory(entityType, entityId, instituteId); return ResponseEntity.ok(request); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_approval/repository/ApprovalActionRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_approval/repository/ApprovalActionRepository.java index eb4c0975d2..215db368da 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_approval/repository/ApprovalActionRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_approval/repository/ApprovalActionRepository.java @@ -10,4 +10,6 @@ public interface ApprovalActionRepository extends JpaRepository { List findByRequestIdOrderByLevelAsc(String requestId); + + List findByRequestIdAndActorId(String requestId, String actorId); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_approval/service/ApprovalService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_approval/service/ApprovalService.java index 4d9a4b15e8..e5c6288883 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_approval/service/ApprovalService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_approval/service/ApprovalService.java @@ -3,6 +3,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_approval.dto.ApprovalActionDTO; import vacademy.io.admin_core_service.features.hr_approval.dto.ApprovalActionInputDTO; import vacademy.io.admin_core_service.features.hr_approval.dto.ApprovalChainDTO; @@ -34,20 +35,24 @@ public class ApprovalService { @Autowired private ApprovalActionRepository approvalActionRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + // ======================== Chain Management ======================== @Transactional - public String saveChain(ApprovalChainDTO dto) { - // Upsert by instituteId + entityType + public String saveChain(ApprovalChainDTO dto, String instituteId) { + // Upsert by the VALIDATED instituteId + entityType — never the dto's instituteId, + // which would allow cross-tenant writes of approval chains Optional existingOpt = approvalChainRepository - .findByInstituteIdAndEntityType(dto.getInstituteId(), dto.getEntityType()); + .findByInstituteIdAndEntityType(instituteId, dto.getEntityType()); ApprovalChain chain; if (existingOpt.isPresent()) { chain = existingOpt.get(); } else { chain = new ApprovalChain(); - chain.setInstituteId(dto.getInstituteId()); + chain.setInstituteId(instituteId); chain.setEntityType(dto.getEntityType()); } @@ -99,9 +104,10 @@ public String createRequest(String instituteId, String entityType, String entity } @Transactional - public String processAction(String requestId, ApprovalActionInputDTO actionInputDTO, String actorUserId) { + public String processAction(String requestId, ApprovalActionInputDTO actionInputDTO, String actorUserId, String instituteId) { ApprovalRequest request = approvalRequestRepository.findById(requestId) .orElseThrow(() -> new VacademyException("Approval request not found")); + hrAccessGuard.requireInstituteMatch(request.getInstituteId(), instituteId, "Approval request"); if (!ApprovalStatus.PENDING.name().equals(request.getStatus())) { throw new VacademyException("Cannot process action on a " + request.getStatus() + " request"); @@ -112,6 +118,14 @@ public String processAction(String requestId, ApprovalActionInputDTO actionInput throw new VacademyException("Cannot approve your own request"); } + // Prevent the same actor from acting on more than one level of the same request + List priorActions = approvalActionRepository.findByRequestIdAndActorId(requestId, actorUserId); + boolean actedAtOtherLevel = priorActions.stream() + .anyMatch(a -> a.getLevel() != null && !a.getLevel().equals(request.getCurrentLevel())); + if (actedAtOtherLevel) { + throw new VacademyException("You have already acted on another level of this request; a different approver is required for level " + request.getCurrentLevel()); + } + String action = actionInputDTO.getAction(); if (!"APPROVED".equalsIgnoreCase(action) && !"REJECTED".equalsIgnoreCase(action)) { throw new VacademyException("Invalid action. Must be APPROVED or REJECTED"); @@ -152,9 +166,10 @@ public List getPendingRequests(String instituteId) { } @Transactional(readOnly = true) - public ApprovalRequestDTO getRequestHistory(String entityType, String entityId) { + public ApprovalRequestDTO getRequestHistory(String entityType, String entityId, String instituteId) { ApprovalRequest request = approvalRequestRepository.findByEntityTypeAndEntityId(entityType, entityId) .orElseThrow(() -> new VacademyException("Approval request not found for entity")); + hrAccessGuard.requireInstituteMatch(request.getInstituteId(), instituteId, "Approval request"); return toRequestDTO(request, true); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/controller/AttendanceController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/controller/AttendanceController.java index 69e3399849..249a7c7fc5 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/controller/AttendanceController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/controller/AttendanceController.java @@ -1,9 +1,12 @@ package vacademy.io.admin_core_service.features.hr_attendance.controller; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; import vacademy.io.admin_core_service.features.hr_attendance.dto.AttendanceConfigDTO; import vacademy.io.admin_core_service.features.hr_attendance.dto.AttendanceRecordDTO; import vacademy.io.admin_core_service.features.hr_attendance.dto.AttendanceSummaryDTO; @@ -15,6 +18,7 @@ import vacademy.io.admin_core_service.features.hr_attendance.service.AttendanceConfigService; import vacademy.io.admin_core_service.features.hr_attendance.service.AttendanceService; import vacademy.io.admin_core_service.features.hr_attendance.service.RegularizationService; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; import vacademy.io.common.auth.model.CustomUserDetails; import java.util.List; @@ -33,22 +37,26 @@ public class AttendanceController { private RegularizationService regularizationService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; @PostMapping("/config") + @Auditable( + entityType = "HR_ATTENDANCE_CONFIG", + action = "UPDATE", + entityIdExpr = "#instituteId") public ResponseEntity saveConfig( @RequestBody AttendanceConfigDTO configDTO, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - return ResponseEntity.ok(attendanceConfigService.saveConfig(configDTO)); + hrAccessGuard.requireHrAdmin(user, instituteId); + return ResponseEntity.ok(attendanceConfigService.saveConfig(configDTO, instituteId)); } @GetMapping("/config") public ResponseEntity getConfig( @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrStaff(user, instituteId); return ResponseEntity.ok(attendanceConfigService.getConfig(instituteId)); } @@ -56,27 +64,33 @@ public ResponseEntity getConfig( public ResponseEntity checkIn( @RequestBody CheckInDTO checkInDTO, @RequestParam("instituteId") String instituteId, - @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - return ResponseEntity.ok(attendanceService.checkIn(checkInDTO, instituteId)); + @RequestAttribute("user") CustomUserDetails user, + HttpServletRequest request) { + EmployeeProfile employee = resolveTargetEmployee(user, instituteId, checkInDTO.getEmployeeId()); + return ResponseEntity.ok(attendanceService.checkIn(checkInDTO, employee, resolveClientIp(request))); } @PostMapping("/check-out") public ResponseEntity checkOut( @RequestBody CheckOutDTO checkOutDTO, @RequestParam("instituteId") String instituteId, - @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - return ResponseEntity.ok(attendanceService.checkOut(checkOutDTO, instituteId)); + @RequestAttribute("user") CustomUserDetails user, + HttpServletRequest request) { + EmployeeProfile employee = resolveTargetEmployee(user, instituteId, checkOutDTO.getEmployeeId()); + return ResponseEntity.ok(attendanceService.checkOut(checkOutDTO, employee, resolveClientIp(request))); } @PostMapping("/mark") + @Auditable( + entityType = "HR_ATTENDANCE", + action = "BULK_MARK", + entityIdExpr = "#instituteId") public ResponseEntity markBulkAttendance( @RequestBody BulkAttendanceMarkDTO bulkDTO, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - return ResponseEntity.ok(attendanceService.markBulkAttendance(bulkDTO)); + hrAccessGuard.requireHrStaff(user, instituteId); + return ResponseEntity.ok(attendanceService.markBulkAttendance(bulkDTO, instituteId)); } @GetMapping @@ -86,7 +100,12 @@ public ResponseEntity> getAttendanceRecords( @RequestParam("month") Integer month, @RequestParam("year") Integer year, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + if (employeeId != null && !employeeId.isEmpty()) { + // Single-employee view: an employee may see their OWN records, HR staff anyone's. + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); + } else { + hrAccessGuard.requireHrStaff(user, instituteId); + } return ResponseEntity.ok(attendanceService.getAttendanceRecords(instituteId, employeeId, month, year)); } @@ -96,26 +115,63 @@ public ResponseEntity> getAttendanceSummary( @RequestParam("month") Integer month, @RequestParam("year") Integer year, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrStaff(user, instituteId); return ResponseEntity.ok(attendanceService.getAttendanceSummary(instituteId, month, year)); } + @GetMapping("/regularization") + public ResponseEntity> getRegularizations( + @RequestParam("instituteId") String instituteId, + @RequestParam(value = "status", required = false) String status, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + return ResponseEntity.ok(regularizationService.getRegularizations(instituteId, status)); + } + @PostMapping("/regularization") public ResponseEntity requestRegularization( @RequestBody RegularizationDTO regularizationDTO, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - return ResponseEntity.ok(regularizationService.requestRegularization(regularizationDTO)); + EmployeeProfile employee = resolveTargetEmployee(user, instituteId, regularizationDTO.getEmployeeId()); + return ResponseEntity.ok(regularizationService.requestRegularization(regularizationDTO, employee, instituteId)); } @PutMapping("/regularization/{id}/action") + @Auditable( + entityType = "HR_ATTENDANCE_REGULARIZATION", + action = "ACTION", + entityIdExpr = "#id") public ResponseEntity approveRejectRegularization( @PathVariable("id") String id, @RequestBody RegularizationActionDTO actionDTO, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - return ResponseEntity.ok(regularizationService.approveRejectRegularization(id, actionDTO, user.getUserId())); + hrAccessGuard.requireHrStaff(user, instituteId); + return ResponseEntity.ok(regularizationService.approveRejectRegularization(id, actionDTO, user.getUserId(), instituteId)); + } + + /** + * Self-service target resolution: no employeeId in the body means "me"; + * an explicit employeeId is only honored for the caller themselves or HR staff. + */ + private EmployeeProfile resolveTargetEmployee(CustomUserDetails user, String instituteId, String employeeId) { + if (employeeId == null || employeeId.isEmpty()) { + return hrAccessGuard.resolveSelfEmployee(user, instituteId); + } + return hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); + } + + /** + * Derives the caller's IP server-side: first hop of X-Forwarded-For when + * present (set by the ingress), otherwise the socket remote address. The + * client-supplied ip_address in the request body is never trusted. + */ + private String resolveClientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (StringUtils.hasText(forwarded)) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/controller/HolidayController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/controller/HolidayController.java index 6d9a1b3b00..e01f5b5fbc 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/controller/HolidayController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/controller/HolidayController.java @@ -3,7 +3,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; import vacademy.io.admin_core_service.features.hr_attendance.dto.HolidayDTO; import vacademy.io.admin_core_service.features.hr_attendance.service.HolidayService; import vacademy.io.common.auth.model.CustomUserDetails; @@ -18,15 +19,19 @@ public class HolidayController { private HolidayService holidayService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; @PostMapping + @Auditable( + entityType = "HR_HOLIDAY", + action = "CREATE", + entityIdExpr = "#result?.body") public ResponseEntity createHoliday( @RequestBody HolidayDTO holidayDTO, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - return ResponseEntity.ok(holidayService.createHoliday(holidayDTO)); + hrAccessGuard.requireHrAdmin(user, instituteId); + return ResponseEntity.ok(holidayService.createHoliday(holidayDTO, instituteId)); } @GetMapping @@ -34,36 +39,49 @@ public ResponseEntity> getHolidays( @RequestParam("instituteId") String instituteId, @RequestParam("year") Integer year, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + // Any member of the institute may see its holiday calendar + hrAccessGuard.validateMember(user, instituteId); return ResponseEntity.ok(holidayService.getHolidays(instituteId, year)); } @PutMapping("/{id}") + @Auditable( + entityType = "HR_HOLIDAY", + action = "UPDATE", + entityIdExpr = "#id") public ResponseEntity updateHoliday( @PathVariable("id") String id, @RequestBody HolidayDTO holidayDTO, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - return ResponseEntity.ok(holidayService.updateHoliday(id, holidayDTO)); + hrAccessGuard.requireHrAdmin(user, instituteId); + return ResponseEntity.ok(holidayService.updateHoliday(id, holidayDTO, instituteId)); } @DeleteMapping("/{id}") + @Auditable( + entityType = "HR_HOLIDAY", + action = "DELETE", + entityIdExpr = "#id") public ResponseEntity deleteHoliday( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - holidayService.deleteHoliday(id); + hrAccessGuard.requireHrAdmin(user, instituteId); + holidayService.deleteHoliday(id, instituteId); return ResponseEntity.noContent().build(); } @PostMapping("/bulk") + @Auditable( + entityType = "HR_HOLIDAY", + action = "BULK_CREATE", + entityIdExpr = "#instituteId") public ResponseEntity bulkCreateHolidays( @RequestBody List holidays, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - return ResponseEntity.ok(holidayService.bulkCreateHolidays(holidays)); + hrAccessGuard.requireHrAdmin(user, instituteId); + return ResponseEntity.ok(holidayService.bulkCreateHolidays(holidays, instituteId)); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/controller/ShiftController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/controller/ShiftController.java index 66205f0a50..5210e700c3 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/controller/ShiftController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/controller/ShiftController.java @@ -3,7 +3,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; import vacademy.io.admin_core_service.features.hr_attendance.dto.ShiftAssignDTO; import vacademy.io.admin_core_service.features.hr_attendance.dto.ShiftDTO; import vacademy.io.admin_core_service.features.hr_attendance.service.ShiftService; @@ -19,41 +20,53 @@ public class ShiftController { private ShiftService shiftService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; @PostMapping + @Auditable( + entityType = "HR_SHIFT", + action = "CREATE", + entityIdExpr = "#result?.body") public ResponseEntity createShift( @RequestBody ShiftDTO shiftDTO, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - return ResponseEntity.ok(shiftService.createShift(shiftDTO)); + hrAccessGuard.requireHrAdmin(user, instituteId); + return ResponseEntity.ok(shiftService.createShift(shiftDTO, instituteId)); } @GetMapping public ResponseEntity> getShifts( @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrStaff(user, instituteId); return ResponseEntity.ok(shiftService.getShifts(instituteId)); } @PutMapping("/{id}") + @Auditable( + entityType = "HR_SHIFT", + action = "UPDATE", + entityIdExpr = "#id") public ResponseEntity updateShift( @PathVariable("id") String id, @RequestBody ShiftDTO shiftDTO, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - return ResponseEntity.ok(shiftService.updateShift(id, shiftDTO)); + hrAccessGuard.requireHrAdmin(user, instituteId); + return ResponseEntity.ok(shiftService.updateShift(id, shiftDTO, instituteId)); } @PostMapping("/assign") + @Auditable( + entityType = "HR_SHIFT", + action = "ASSIGN", + entityIdExpr = "#assignDTO?.shiftId") public ResponseEntity assignShiftToEmployees( @RequestBody ShiftAssignDTO assignDTO, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - return ResponseEntity.ok(shiftService.assignShiftToEmployees(assignDTO)); + hrAccessGuard.requireHrAdmin(user, instituteId); + return ResponseEntity.ok(shiftService.assignShiftToEmployees(assignDTO, instituteId)); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/dto/AttendanceConfigDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/dto/AttendanceConfigDTO.java index 6ee9551d77..a0f3c653fe 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/dto/AttendanceConfigDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/dto/AttendanceConfigDTO.java @@ -15,6 +15,7 @@ public class AttendanceConfigDTO { private String id; private String instituteId; private String mode; + private String timezone; private Boolean autoCheckoutEnabled; private LocalTime autoCheckoutTime; private Boolean geoFenceEnabled; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/dto/RegularizationDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/dto/RegularizationDTO.java index 4a2cc4be3a..97dd9b473d 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/dto/RegularizationDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/dto/RegularizationDTO.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.annotation.JsonNaming; import lombok.Data; +import java.time.LocalDate; import java.time.LocalDateTime; @Data @@ -13,6 +14,11 @@ public class RegularizationDTO { private String id; private String attendanceId; private String employeeId; + /** Read-only context for the approval queue; ignored on request bodies. */ + private String employeeCode; + private LocalDate attendanceDate; + private String approvedBy; + private LocalDateTime approvedAt; private String originalStatus; private String requestedStatus; private LocalDateTime originalCheckIn; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/entity/AttendanceConfig.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/entity/AttendanceConfig.java index 50ea7f8b6c..5223503f8d 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/entity/AttendanceConfig.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/entity/AttendanceConfig.java @@ -33,6 +33,9 @@ public class AttendanceConfig { @Column(name = "mode", nullable = false, length = 20) private String mode; + @Column(name = "timezone", length = 60) + private String timezone; + @Column(name = "auto_checkout_enabled") private Boolean autoCheckoutEnabled; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/job/AutoAbsentJob.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/job/AutoAbsentJob.java new file mode 100644 index 0000000000..757d253fb6 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/job/AutoAbsentJob.java @@ -0,0 +1,75 @@ +package vacademy.io.admin_core_service.features.hr_attendance.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceConfig; +import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceConfigRepository; +import vacademy.io.admin_core_service.features.hr_attendance.service.AttendanceService; +import vacademy.io.admin_core_service.features.hr_attendance.util.HrTimeUtil; + +import java.time.LocalDate; +import java.util.List; + +/** + * Daily auto-absent sweep. + * + * Payroll treats a day with NO attendance record as unremarkable, so an + * institute that stops marking attendance silently pays everyone in full — + * the "no records = full pay" cliff. This job closes it systematically: for + * every institute with an attendance config, for YESTERDAY in that institute's + * timezone, every ACTIVE/PROBATION/NOTICE_PERIOD employee with no attendance + * record and no approved leave on a working day (weekends per config, holidays + * and dates outside the employee's join/exit window are skipped) gets an + * explicit ABSENT record (source ADMIN, remarks "Auto-marked absent"). The + * whole institute-day is skipped when that month is already payroll-locked. + * Idempotent by construction: employees who already have a record for the day + * — including yesterday's auto-marked rows — are excluded. + * + *

{@code @SchedulerLock} is mandatory — admin_core runs 4 replicas; without + * it four replicas would race the same inserts (the unique (employee, date) + * constraint would keep the data correct, but every night would end in a pile + * of constraint-violation noise). + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class AutoAbsentJob { + + private final AttendanceConfigRepository attendanceConfigRepository; + private final AttendanceService attendanceService; + + /** + * Daily at 23:30 server time (UTC). For IST-centric institutes that is + * ~05:00 local the next morning — yesterday is fully over and late manual + * marking has had the whole day to happen. + */ + @Scheduled(cron = "0 30 23 * * ?") + @SchedulerLock(name = "HrAutoAbsentJob", lockAtMostFor = "PT1H", lockAtLeastFor = "PT1M") + public void run() { + List configs; + try { + configs = attendanceConfigRepository.findAll(); + } catch (Exception e) { + log.error("[auto-absent] could not load configs — sweep aborted", e); + return; + } + + int marked = 0; + for (AttendanceConfig config : configs) { + try { + LocalDate yesterday = LocalDate.now(HrTimeUtil.resolveZone(config)).minusDays(1); + marked += attendanceService.autoMarkAbsentForInstitute(config, yesterday); + } catch (Exception e) { + // One institute's failure must never stop the others + log.error("[auto-absent] failed for institute {}", config.getInstituteId(), e); + } + } + if (marked > 0) { + log.info("[auto-absent] inserted {} ABSENT record(s) across {} institute(s)", + marked, configs.size()); + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/job/AutoCheckoutJob.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/job/AutoCheckoutJob.java new file mode 100644 index 0000000000..a22247da77 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/job/AutoCheckoutJob.java @@ -0,0 +1,64 @@ +package vacademy.io.admin_core_service.features.hr_attendance.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceConfig; +import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceConfigRepository; +import vacademy.io.admin_core_service.features.hr_attendance.service.AttendanceService; + +import java.util.List; + +/** + * Auto-checkout tick (every 30 minutes). + * + * hr_attendance_config always supported auto_checkout_enabled/auto_checkout_time + * but nothing ever acted on it: an employee who forgot to check out kept an + * open TIME_TRACKING session forever (no total hours, no overtime, payroll + * seeing a dangling day). Each tick, for every institute that opted in, closes + * today's (institute timezone) open check-ins at the configured time once that + * time has passed, reusing the exact checkout math the manual flow uses, with + * remarks "Auto checkout". Runs every 30 minutes rather than daily because + * each institute's cutoff falls at a different local time; ticks before the + * cutoff or with nothing open are a cheap no-op, so asking often is free. + * + *

{@code @SchedulerLock} is mandatory — admin_core runs 4 replicas; without + * it every open session would be raced by four writers per tick. + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class AutoCheckoutJob { + + private final AttendanceConfigRepository attendanceConfigRepository; + private final AttendanceService attendanceService; + + /** Every 30 minutes, on the hour and half hour. */ + @Scheduled(cron = "0 */30 * * * ?") + @SchedulerLock(name = "HrAutoCheckoutJob", lockAtMostFor = "PT25M", lockAtLeastFor = "PT30S") + public void run() { + List configs; + try { + configs = attendanceConfigRepository.findByAutoCheckoutEnabledTrue(); + } catch (Exception e) { + log.error("[auto-checkout] could not load configs — tick aborted", e); + return; + } + + int closed = 0; + for (AttendanceConfig config : configs) { + try { + closed += attendanceService.autoCheckoutInstitute(config); + } catch (Exception e) { + // One institute's failure must never stop the others + log.error("[auto-checkout] failed for institute {}", config.getInstituteId(), e); + } + } + if (closed > 0) { + log.info("[auto-checkout] closed {} open session(s) across {} institute(s)", + closed, configs.size()); + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/AttendanceConfigRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/AttendanceConfigRepository.java index 8c988e073c..456c7fb8f2 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/AttendanceConfigRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/AttendanceConfigRepository.java @@ -10,4 +10,7 @@ public interface AttendanceConfigRepository extends JpaRepository { Optional findByInstituteId(String instituteId); + + /** Institutes that opted into scheduled auto-checkout (AutoCheckoutJob fan-out). */ + java.util.List findByAutoCheckoutEnabledTrue(); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/AttendanceRecordRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/AttendanceRecordRepository.java index d423e370a5..599e0cf675 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/AttendanceRecordRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/AttendanceRecordRepository.java @@ -38,4 +38,18 @@ long countByEmployeeAndDateRangeAndStatus( List findByInstituteIdAndDate( @Param("instituteId") String instituteId, @Param("date") LocalDate date); + + /** Open sessions for auto-checkout: checked in on this date, never checked out. */ + @Query("SELECT a FROM AttendanceRecord a WHERE a.instituteId = :instituteId " + + "AND a.attendanceDate = :date AND a.checkInTime IS NOT NULL AND a.checkOutTime IS NULL") + List findOpenCheckIns( + @Param("instituteId") String instituteId, + @Param("date") LocalDate date); + + /** Employee ids that already have ANY record on this date (auto-absent exclusion set). */ + @Query("SELECT a.employee.id FROM AttendanceRecord a WHERE a.instituteId = :instituteId " + + "AND a.attendanceDate = :date") + List findEmployeeIdsWithRecordOnDate( + @Param("instituteId") String instituteId, + @Param("date") LocalDate date); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/AttendanceRegularizationRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/AttendanceRegularizationRepository.java index 5556b8453f..69388a4dac 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/AttendanceRegularizationRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/AttendanceRegularizationRepository.java @@ -12,4 +12,14 @@ public interface AttendanceRegularizationRepository extends JpaRepository findByEmployeeIdOrderByCreatedAtDesc(String employeeId); List findByApprovalStatusOrderByCreatedAtDesc(String approvalStatus); + + /** + * The institute's regularization queue, newest first. Scoped through the + * employee: the status-only finder above spans every tenant and must not + * back an API. + */ + List findByEmployee_InstituteIdOrderByCreatedAtDesc(String instituteId); + + List findByEmployee_InstituteIdAndApprovalStatusOrderByCreatedAtDesc( + String instituteId, String approvalStatus); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/EmployeeShiftMappingRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/EmployeeShiftMappingRepository.java index b98e762b3d..265be3997c 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/EmployeeShiftMappingRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/EmployeeShiftMappingRepository.java @@ -7,6 +7,7 @@ import vacademy.io.admin_core_service.features.hr_attendance.entity.EmployeeShiftMapping; import java.time.LocalDate; +import java.util.List; import java.util.Optional; @Repository @@ -15,4 +16,14 @@ public interface EmployeeShiftMappingRepository extends JpaRepository= :date)") Optional findActiveMapping(@Param("employeeId") String employeeId, @Param("date") LocalDate date); + + /** + * Mappings that are still open on or after the given date (no end date, or an + * end date on/after it). These must be closed before a new assignment becomes + * effective, otherwise {@link #findActiveMapping} finds more than one row and + * check-in fails with a NonUniqueResultException. + */ + @Query("SELECT m FROM EmployeeShiftMapping m WHERE m.employee.id = :employeeId " + + "AND (m.effectiveTo IS NULL OR m.effectiveTo >= :date)") + List findMappingsOpenOnOrAfter(@Param("employeeId") String employeeId, @Param("date") LocalDate date); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/HolidayRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/HolidayRepository.java index 6e26319185..be448de0ad 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/HolidayRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/repository/HolidayRepository.java @@ -23,6 +23,8 @@ List findByInstituteIdAndDateRange( boolean existsByInstituteIdAndDate(String instituteId, LocalDate date); + List findByInstituteIdAndDateIn(String instituteId, List dates); + @Query("SELECT COUNT(h) FROM Holiday h WHERE h.instituteId = :instituteId " + "AND h.date BETWEEN :startDate AND :endDate AND h.isOptional = false") long countMandatoryHolidays(@Param("instituteId") String instituteId, diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/AttendanceConfigService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/AttendanceConfigService.java index 96b06985a1..f2edaa7d2c 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/AttendanceConfigService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/AttendanceConfigService.java @@ -5,9 +5,12 @@ import org.springframework.transaction.annotation.Transactional; import vacademy.io.admin_core_service.features.hr_attendance.dto.AttendanceConfigDTO; import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceConfig; +import vacademy.io.admin_core_service.features.hr_attendance.enums.AttendanceMode; import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceConfigRepository; +import vacademy.io.admin_core_service.features.hr_attendance.util.HrTimeUtil; import vacademy.io.common.exceptions.VacademyException; +import java.time.ZoneId; import java.util.Optional; @Service @@ -16,19 +19,37 @@ public class AttendanceConfigService { @Autowired private AttendanceConfigRepository attendanceConfigRepository; + /** + * Upserts the config for the VALIDATED institute. The instituteId inside the + * DTO is deliberately ignored — trusting it allowed overwriting another + * institute's config (cross-tenant write). + */ @Transactional - public AttendanceConfigDTO saveConfig(AttendanceConfigDTO dto) { + public AttendanceConfigDTO saveConfig(AttendanceConfigDTO dto, String instituteId) { AttendanceConfig config; - Optional existing = attendanceConfigRepository.findByInstituteId(dto.getInstituteId()); + Optional existing = attendanceConfigRepository.findByInstituteId(instituteId); if (existing.isPresent()) { config = existing.get(); } else { config = new AttendanceConfig(); - config.setInstituteId(dto.getInstituteId()); + config.setInstituteId(instituteId); } config.setMode(dto.getMode()); + // Timezone: validate when supplied; keep the existing value (or the + // Asia/Kolkata default for new configs) when absent. + if (dto.getTimezone() != null && !dto.getTimezone().isBlank()) { + String tz = dto.getTimezone().trim(); + try { + ZoneId.of(tz); + } catch (Exception e) { + throw new VacademyException("Invalid timezone: " + tz); + } + config.setTimezone(tz); + } else if (config.getTimezone() == null) { + config.setTimezone(HrTimeUtil.DEFAULT_TIMEZONE); + } config.setAutoCheckoutEnabled(dto.getAutoCheckoutEnabled()); config.setAutoCheckoutTime(dto.getAutoCheckoutTime()); config.setGeoFenceEnabled(dto.getGeoFenceEnabled()); @@ -47,11 +68,28 @@ public AttendanceConfigDTO saveConfig(AttendanceConfigDTO dto) { return toDTO(saved); } + /** + * Never having configured attendance is a starting state, not an error, so + * an institute with no row gets the defaults the settings screen should open + * on. The absent {@code id} is what tells a caller it is unsaved. + */ @Transactional(readOnly = true) public AttendanceConfigDTO getConfig(String instituteId) { - AttendanceConfig config = attendanceConfigRepository.findByInstituteId(instituteId) - .orElseThrow(() -> new VacademyException("Attendance config not found for institute: " + instituteId)); - return toDTO(config); + return attendanceConfigRepository.findByInstituteId(instituteId) + .map(this::toDTO) + .orElseGet(() -> defaultConfig(instituteId)); + } + + private AttendanceConfigDTO defaultConfig(String instituteId) { + AttendanceConfigDTO dto = new AttendanceConfigDTO(); + dto.setInstituteId(instituteId); + dto.setMode(AttendanceMode.TIME_TRACKING.name()); + dto.setTimezone(HrTimeUtil.DEFAULT_TIMEZONE); + dto.setAutoCheckoutEnabled(false); + dto.setGeoFenceEnabled(false); + dto.setIpRestrictionEnabled(false); + dto.setOvertimeEnabled(false); + return dto; } private AttendanceConfigDTO toDTO(AttendanceConfig config) { @@ -59,6 +97,7 @@ private AttendanceConfigDTO toDTO(AttendanceConfig config) { dto.setId(config.getId()); dto.setInstituteId(config.getInstituteId()); dto.setMode(config.getMode()); + dto.setTimezone(config.getTimezone()); dto.setAutoCheckoutEnabled(config.getAutoCheckoutEnabled()); dto.setAutoCheckoutTime(config.getAutoCheckoutTime()); dto.setGeoFenceEnabled(config.getGeoFenceEnabled()); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/AttendanceService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/AttendanceService.java index b1a5094c09..b06b05aaf4 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/AttendanceService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/AttendanceService.java @@ -12,14 +12,18 @@ import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceConfig; import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceRecord; import vacademy.io.admin_core_service.features.hr_attendance.entity.EmployeeShiftMapping; +import vacademy.io.admin_core_service.features.hr_attendance.enums.AttendanceMode; import vacademy.io.admin_core_service.features.hr_attendance.enums.AttendanceSource; import vacademy.io.admin_core_service.features.hr_attendance.enums.AttendanceStatus; +import vacademy.io.admin_core_service.features.hr_attendance.util.HrTimeUtil; import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceConfigRepository; import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceRecordRepository; import vacademy.io.admin_core_service.features.hr_attendance.repository.EmployeeShiftMappingRepository; import vacademy.io.admin_core_service.features.hr_attendance.repository.HolidayRepository; import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; +import vacademy.io.admin_core_service.features.hr_leave.repository.LeaveApplicationRepository; +import vacademy.io.admin_core_service.features.hr_payroll.service.HrMonthLockService; import vacademy.io.common.auth.entity.User; import vacademy.io.common.auth.repository.UserRepository; import vacademy.io.common.exceptions.VacademyException; @@ -29,13 +33,17 @@ import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.YearMonth; +import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; @Service @@ -59,17 +67,27 @@ public class AttendanceService { @Autowired private UserRepository userRepository; - // TODO: BUG 6 — Currently any authenticated user can check in for any employeeId. - // Once role-based authorization infrastructure is in place, verify that dto.getEmployeeId() - // belongs to the authenticated user, or that the user has ADMIN/HR role to mark attendance for others. + @Autowired + private LeaveApplicationRepository leaveApplicationRepository; + + @Autowired + private HrMonthLockService hrMonthLockService; + + /** + * BUG 6 FIX: the employee is resolved and authorized by HrAccessGuard in the + * controller (self or HR staff, verified member of the validated institute), + * so this method never re-fetches by an unchecked id. + */ @Transactional - public String checkIn(CheckInDTO dto, String instituteId) { - EmployeeProfile employee = employeeProfileRepository.findById(dto.getEmployeeId()) - .orElseThrow(() -> new VacademyException("Employee not found with id: " + dto.getEmployeeId())); + public String checkIn(CheckInDTO dto, EmployeeProfile employee, String clientIp) { + String instituteId = employee.getInstituteId(); - // Fetch attendance config for geo-fence and IP restriction validation + // Fetch attendance config for mode, geo-fence and IP restriction validation AttendanceConfig config = attendanceConfigRepository.findByInstituteId(instituteId).orElse(null); + // Self check-in/out only exists in TIME_TRACKING mode + requireTimeTrackingMode(config); + // BUG 1 FIX: Enforce geo-fence validation if (config != null && Boolean.TRUE.equals(config.getGeoFenceEnabled())) { if (dto.getLatitude() == null || dto.getLongitude() == null) { @@ -82,17 +100,26 @@ public String checkIn(CheckInDTO dto, String instituteId) { } } - // BUG 2 FIX: Enforce IP restriction + // BUG 2 FIX: Enforce IP restriction. The IP is derived server-side by the + // controller (X-Forwarded-For / remote address) — the client-supplied + // dto.ipAddress is never trusted. Allowed entries may be bare IPs or + // IPv4 CIDR blocks ("a.b.c.d/nn"). if (config != null && Boolean.TRUE.equals(config.getIpRestrictionEnabled()) && config.getAllowedIps() != null) { - String clientIp = dto.getIpAddress(); - if (clientIp == null || !config.getAllowedIps().contains(clientIp)) { + if (clientIp == null || !isIpAllowed(clientIp, config.getAllowedIps())) { throw new VacademyException("Check-in not allowed from this IP address"); } } - LocalDate today = LocalDate.now(); + // Day-bucketing uses the institute's timezone (JVM stays UTC) + ZoneId zone = HrTimeUtil.resolveZone(config); + LocalDate today = LocalDate.now(zone); + + // Payroll month-lock: once the month's REGULAR run is past DRAFT, no + // new attendance may be written for it (rare around month boundaries). + hrMonthLockService.requireUnlocked(instituteId, today, "check in"); + Optional existingRecord = attendanceRecordRepository - .findByEmployeeIdAndAttendanceDate(dto.getEmployeeId(), today); + .findByEmployeeIdAndAttendanceDate(employee.getId(), today); if (existingRecord.isPresent() && existingRecord.get().getCheckInTime() != null) { throw new VacademyException("Employee has already checked in today"); @@ -110,13 +137,13 @@ record = new AttendanceRecord(); // Set shift if employee has an active shift mapping Optional shiftMapping = employeeShiftMappingRepository - .findActiveMapping(dto.getEmployeeId(), today); + .findActiveMapping(employee.getId(), today); shiftMapping.ifPresent(mapping -> record.setShift(mapping.getShift())); - record.setCheckInTime(LocalDateTime.now()); + record.setCheckInTime(LocalDateTime.now(zone)); record.setCheckInLat(dto.getLatitude()); record.setCheckInLng(dto.getLongitude()); - record.setCheckInIp(dto.getIpAddress()); + record.setCheckInIp(clientIp); record.setStatus(AttendanceStatus.PRESENT.name()); record.setRemarks(dto.getRemarks()); @@ -137,17 +164,26 @@ record = new AttendanceRecord(); } @Transactional - public String checkOut(CheckOutDTO dto, String instituteId) { - LocalDate today = LocalDate.now(); + public String checkOut(CheckOutDTO dto, EmployeeProfile employee, String clientIp) { + String instituteId = employee.getInstituteId(); + + AttendanceConfig config = attendanceConfigRepository.findByInstituteId(instituteId).orElse(null); + + // Self check-in/out only exists in TIME_TRACKING mode + requireTimeTrackingMode(config); + + // Day-bucketing uses the institute's timezone (JVM stays UTC) + ZoneId zone = HrTimeUtil.resolveZone(config); + LocalDate today = LocalDate.now(zone); // BUG 3 FIX: For night shifts, the employee may check out on the next day. // Try today first, then fall back to yesterday's record. Optional existingOpt = attendanceRecordRepository - .findByEmployeeIdAndAttendanceDate(dto.getEmployeeId(), today); + .findByEmployeeIdAndAttendanceDate(employee.getId(), today); if (existingOpt.isEmpty()) { // Try yesterday for night shift existingOpt = attendanceRecordRepository - .findByEmployeeIdAndAttendanceDate(dto.getEmployeeId(), today.minusDays(1)); + .findByEmployeeIdAndAttendanceDate(employee.getId(), today.minusDays(1)); } if (existingOpt.isEmpty()) { throw new VacademyException("No check-in record found for today"); @@ -162,17 +198,33 @@ public String checkOut(CheckOutDTO dto, String instituteId) { throw new VacademyException("Employee has already checked out today"); } - LocalDateTime checkOutTime = LocalDateTime.now(); + // Payroll month-lock on the record's own date (a night-shift checkout + // may land in the next month while the record's month is locked). + hrMonthLockService.requireUnlocked(instituteId, record.getAttendanceDate(), "check out"); + + LocalDateTime checkOutTime = LocalDateTime.now(zone); record.setCheckOutTime(checkOutTime); record.setCheckOutLat(dto.getLatitude()); record.setCheckOutLng(dto.getLongitude()); - record.setCheckOutIp(dto.getIpAddress()); + record.setCheckOutIp(clientIp); if (dto.getRemarks() != null) { record.setRemarks(dto.getRemarks()); } + applyCheckoutCalculations(record, config); + + attendanceRecordRepository.save(record); + return "Check-out recorded successfully. Total hours: " + record.getTotalHours(); + } + + /** + * Shared checkout math for self checkout and the auto-checkout job: derives + * total hours (minus the shift break), the half-day status and overtime + * from an already-set check-in/check-out pair. + */ + public void applyCheckoutCalculations(AttendanceRecord record, AttendanceConfig config) { // Calculate total hours worked - long minutesWorked = ChronoUnit.MINUTES.between(record.getCheckInTime(), checkOutTime); + long minutesWorked = ChronoUnit.MINUTES.between(record.getCheckInTime(), record.getCheckOutTime()); // Subtract break duration if shift is assigned if (record.getShift() != null && record.getShift().getBreakDurationMin() != null) { @@ -188,9 +240,7 @@ public String checkOut(CheckOutDTO dto, String instituteId) { record.setTotalHours(totalHours); // Check if half-day based on config - Optional configOpt = attendanceConfigRepository.findByInstituteId(instituteId); - if (configOpt.isPresent()) { - AttendanceConfig config = configOpt.get(); + if (config != null) { // Determine if half-day if (config.getHalfDayThresholdMin() != null && minutesWorked < config.getHalfDayThresholdMin()) { @@ -207,13 +257,31 @@ public String checkOut(CheckOutDTO dto, String instituteId) { } } } - - attendanceRecordRepository.save(record); - return "Check-out recorded successfully. Total hours: " + totalHours; } + /** + * Admin bulk mark. Records are always written against the VALIDATED + * instituteId (never the one in the request body), and every employee in + * the batch is verified to belong to that institute before anything is saved. + */ @Transactional - public String markBulkAttendance(BulkAttendanceMarkDTO dto) { + public String markBulkAttendance(BulkAttendanceMarkDTO dto, String instituteId) { + if (dto.getEntries() == null || dto.getEntries().isEmpty()) { + throw new VacademyException("No attendance entries provided"); + } + + // Payroll month-lock: the whole batch is written against dto.getDate() + hrMonthLockService.requireUnlocked(instituteId, dto.getDate(), "mark attendance"); + + // Batch-fetch and institute-check every employee in the list up front + List employeeIds = dto.getEntries().stream() + .map(BulkAttendanceMarkDTO.AttendanceMarkEntry::getEmployeeId) + .distinct() + .collect(Collectors.toList()); + Map employeeMap = employeeProfileRepository.findAllById(employeeIds).stream() + .filter(e -> instituteId.equals(e.getInstituteId())) + .collect(Collectors.toMap(EmployeeProfile::getId, e -> e)); + int successCount = 0; for (BulkAttendanceMarkDTO.AttendanceMarkEntry entry : dto.getEntries()) { @@ -224,8 +292,10 @@ public String markBulkAttendance(BulkAttendanceMarkDTO dto) { throw new VacademyException("Invalid attendance status: " + entry.getStatus()); } - EmployeeProfile employee = employeeProfileRepository.findById(entry.getEmployeeId()) - .orElseThrow(() -> new VacademyException("Employee not found with id: " + entry.getEmployeeId())); + EmployeeProfile employee = employeeMap.get(entry.getEmployeeId()); + if (employee == null) { + throw new VacademyException("Employee not found with id: " + entry.getEmployeeId()); + } Optional existingRecord = attendanceRecordRepository .findByEmployeeIdAndAttendanceDate(entry.getEmployeeId(), dto.getDate()); @@ -236,7 +306,7 @@ record = existingRecord.get(); } else { record = new AttendanceRecord(); record.setEmployee(employee); - record.setInstituteId(dto.getInstituteId()); + record.setInstituteId(instituteId); record.setAttendanceDate(dto.getDate()); } @@ -251,6 +321,131 @@ record = new AttendanceRecord(); return "Bulk attendance marked successfully for " + successCount + " employee(s)"; } + /** + * AutoCheckoutJob worker: closes today's forgotten open check-ins for one + * institute at the configured auto-checkout time. Transactional so the + * lazy shift association is readable from a scheduler thread and one + * institute's records commit or fail together. + * + * @return number of records auto-closed + */ + @Transactional + public int autoCheckoutInstitute(AttendanceConfig config) { + if (config == null || !Boolean.TRUE.equals(config.getAutoCheckoutEnabled()) + || config.getAutoCheckoutTime() == null) { + return 0; + } + // Auto-checkout only makes sense where employees clock themselves + if (AttendanceMode.DAY_LEVEL.name().equals(config.getMode())) { + return 0; + } + + ZoneId zone = HrTimeUtil.resolveZone(config); + LocalDate today = LocalDate.now(zone); + LocalTime now = LocalTime.now(zone); + if (now.isBefore(config.getAutoCheckoutTime())) { + return 0; + } + // Locked month (possible right around a month boundary): leave records alone + if (hrMonthLockService.isDateLocked(config.getInstituteId(), today)) { + return 0; + } + + List openRecords = attendanceRecordRepository + .findOpenCheckIns(config.getInstituteId(), today); + + int closed = 0; + for (AttendanceRecord record : openRecords) { + LocalDateTime checkOutAt = LocalDateTime.of(today, config.getAutoCheckoutTime()); + // Night shift / late check-in past the auto-checkout time: nothing + // sensible to close the session at — leave it open. + if (record.getCheckInTime() == null || !checkOutAt.isAfter(record.getCheckInTime())) { + continue; + } + record.setCheckOutTime(checkOutAt); + record.setRemarks("Auto checkout"); + applyCheckoutCalculations(record, config); + attendanceRecordRepository.save(record); + closed++; + } + return closed; + } + + /** + * AutoAbsentJob worker: for one institute and one (already elapsed) date, + * inserts an ABSENT record for every ACTIVE/PROBATION/NOTICE_PERIOD + * employee who has no attendance record and no approved leave that day. + * Weekends (per config), holidays, dates outside the employee's tenure and + * payroll-locked months are skipped. This removes the "no records = full + * pay" cliff: a day nobody marked becomes an explicit ABSENT row payroll + * can see. + * + * @return number of ABSENT records inserted + */ + @Transactional + public int autoMarkAbsentForInstitute(AttendanceConfig config, LocalDate date) { + if (config == null || date == null) { + return 0; + } + String instituteId = config.getInstituteId(); + + // Non-working day for the whole institute? + Set weekendDays = HrTimeUtil.resolveWeekendDays(config); + if (weekendDays.contains(date.getDayOfWeek())) { + return 0; + } + if (holidayRepository.existsByInstituteIdAndDate(instituteId, date)) { + return 0; + } + // Payroll already processed this month — never rewrite history + if (hrMonthLockService.isDateLocked(instituteId, date)) { + return 0; + } + + List employees = employeeProfileRepository.findActiveEmployees( + instituteId, Arrays.asList("ACTIVE", "PROBATION", "NOTICE_PERIOD")); + if (employees.isEmpty()) { + return 0; + } + + Set employeesWithRecord = new HashSet<>( + attendanceRecordRepository.findEmployeeIdsWithRecordOnDate(instituteId, date)); + + int marked = 0; + for (EmployeeProfile employee : employees) { + if (employeesWithRecord.contains(employee.getId())) { + continue; + } + // Not yet joined / already exited on that date + if (employee.getJoinDate() != null && date.isBefore(employee.getJoinDate())) { + continue; + } + if (employee.getLastWorkingDate() != null && date.isAfter(employee.getLastWorkingDate())) { + continue; + } + // Approved leave covering the day (normally already reflected as an + // ON_LEAVE record by the approval flow — this catches stragglers) + if (!leaveApplicationRepository.findApprovedLeavesInRange(employee.getId(), date, date).isEmpty()) { + continue; + } + + AttendanceRecord record = new AttendanceRecord(); + record.setEmployee(employee); + record.setInstituteId(instituteId); + record.setAttendanceDate(date); + record.setStatus(AttendanceStatus.ABSENT.name()); + record.setSource(AttendanceSource.ADMIN.name()); + record.setRemarks("Auto-marked absent"); + try { + attendanceRecordRepository.save(record); + marked++; + } catch (DataIntegrityViolationException e) { + // Raced with a concurrent write for the same (employee, date) — fine + } + } + return marked; + } + @Transactional(readOnly = true) public List getAttendanceRecords(String instituteId, String employeeId, Integer month, Integer year) { @@ -334,7 +529,9 @@ public List getAttendanceSummary(String instituteId, Integ summary.setHolidays(holidayCount); summary.setWeekends(weekendCount); - // Sum overtime hours from records + // Sum overtime hours from records. Safe in DAY_LEVEL mode too: + // admin-marked records have no check-in/out or overtime, and the + // null-filter below simply yields zero. List empRecords = attendanceRecordRepository .findByEmployeeIdAndAttendanceDateBetweenOrderByAttendanceDateAsc( employee.getId(), startDate, endDate); @@ -350,6 +547,71 @@ public List getAttendanceSummary(String instituteId, Integ return summaries; } + /** + * Self check-in/check-out is only meaningful in TIME_TRACKING mode. In + * DAY_LEVEL mode attendance is marked by admins (markBulkAttendance works + * in both modes). A missing config defaults to TIME_TRACKING (current behavior). + */ + private void requireTimeTrackingMode(AttendanceConfig config) { + if (config != null && AttendanceMode.DAY_LEVEL.name().equals(config.getMode())) { + throw new VacademyException("This institute uses day-level attendance; use admin marking"); + } + } + + /** + * Returns true if the client IP matches any allowed entry. Entries may be + * bare IPs (exact match, IPv4 or IPv6) or IPv4 CIDR blocks ("a.b.c.d/nn"). + * Malformed entries are skipped. + */ + private boolean isIpAllowed(String clientIp, List allowedEntries) { + for (String entry : allowedEntries) { + if (entry != null && matchesIpEntry(clientIp, entry.trim())) { + return true; + } + } + return false; + } + + private boolean matchesIpEntry(String clientIp, String entry) { + if (entry.isEmpty()) { + return false; + } + int slash = entry.indexOf('/'); + if (slash < 0) { + // Bare IP: exact match (covers IPv6 entries too) + return entry.equals(clientIp); + } + try { + long network = ipv4ToLong(entry.substring(0, slash)); + int prefix = Integer.parseInt(entry.substring(slash + 1)); + if (prefix < 0 || prefix > 32) { + return false; + } + long ip = ipv4ToLong(clientIp); + long mask = prefix == 0 ? 0L : (0xFFFFFFFFL << (32 - prefix)) & 0xFFFFFFFFL; + return (ip & mask) == (network & mask); + } catch (Exception e) { + // Malformed CIDR entry or non-IPv4 client IP — no match + return false; + } + } + + private long ipv4ToLong(String ip) { + String[] octets = ip.trim().split("\\."); + if (octets.length != 4) { + throw new IllegalArgumentException("Not an IPv4 address: " + ip); + } + long value = 0; + for (String octet : octets) { + int part = Integer.parseInt(octet); + if (part < 0 || part > 255) { + throw new IllegalArgumentException("Not an IPv4 address: " + ip); + } + value = (value << 8) | part; + } + return value; + } + /** * Calculates the distance in meters between two geographic coordinates * using the Haversine formula. diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/HolidayService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/HolidayService.java index 71f8727872..86d9d7241a 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/HolidayService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/HolidayService.java @@ -3,12 +3,16 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_attendance.dto.HolidayDTO; import vacademy.io.admin_core_service.features.hr_attendance.entity.Holiday; import vacademy.io.admin_core_service.features.hr_attendance.repository.HolidayRepository; import vacademy.io.common.exceptions.VacademyException; +import java.time.LocalDate; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; @Service @@ -17,26 +21,23 @@ public class HolidayService { @Autowired private HolidayRepository holidayRepository; - @Transactional - public String createHoliday(HolidayDTO dto) { - Holiday holiday = new Holiday(); - holiday.setInstituteId(dto.getInstituteId()); - holiday.setName(dto.getName()); - holiday.setDate(dto.getDate()); - holiday.setType(dto.getType()); - holiday.setIsOptional(dto.getIsOptional()); - holiday.setMaxOptionalAllowed(dto.getMaxOptionalAllowed()); - holiday.setYear(dto.getYear() != null ? dto.getYear() : dto.getDate().getYear()); - holiday.setDescription(dto.getDescription()); + @Autowired + private HrAccessGuard hrAccessGuard; - Holiday saved = holidayRepository.save(holiday); + @Transactional + public String createHoliday(HolidayDTO dto, String instituteId) { + if (dto.getDate() == null) { + throw new VacademyException("Holiday date is required"); + } + Holiday saved = holidayRepository.save(buildHoliday(dto, instituteId)); return saved.getId(); } @Transactional - public String updateHoliday(String id, HolidayDTO dto) { + public String updateHoliday(String id, HolidayDTO dto, String instituteId) { Holiday holiday = holidayRepository.findById(id) .orElseThrow(() -> new VacademyException("Holiday not found with id: " + id)); + hrAccessGuard.requireInstituteMatch(holiday.getInstituteId(), instituteId, "Holiday"); if (dto.getName() != null) holiday.setName(dto.getName()); if (dto.getDate() != null) { @@ -54,9 +55,10 @@ public String updateHoliday(String id, HolidayDTO dto) { } @Transactional - public void deleteHoliday(String id) { + public void deleteHoliday(String id, String instituteId) { Holiday holiday = holidayRepository.findById(id) .orElseThrow(() -> new VacademyException("Holiday not found with id: " + id)); + hrAccessGuard.requireInstituteMatch(holiday.getInstituteId(), instituteId, "Holiday"); holidayRepository.delete(holiday); } @@ -66,24 +68,61 @@ public List getHolidays(String instituteId, Integer year) { return holidays.stream().map(this::toDTO).collect(Collectors.toList()); } + /** + * Bulk create for the VALIDATED institute. Duplicate dates — within the batch + * or already existing for the institute — are skipped and reported instead of + * aborting mid-batch with a raw unique-constraint 500. + */ @Transactional - public String bulkCreateHolidays(List holidays) { + public String bulkCreateHolidays(List holidays, String instituteId) { + if (holidays == null || holidays.isEmpty()) { + throw new VacademyException("No holidays provided"); + } + + List dates = holidays.stream() + .map(HolidayDTO::getDate) + .filter(d -> d != null) + .distinct() + .collect(Collectors.toList()); + + Set existingDates = holidayRepository.findByInstituteIdAndDateIn(instituteId, dates).stream() + .map(Holiday::getDate) + .collect(Collectors.toSet()); + + Set seenDates = new HashSet<>(); int successCount = 0; - for (HolidayDTO dto : holidays) { - Holiday holiday = new Holiday(); - holiday.setInstituteId(dto.getInstituteId()); - holiday.setName(dto.getName()); - holiday.setDate(dto.getDate()); - holiday.setType(dto.getType()); - holiday.setIsOptional(dto.getIsOptional()); - holiday.setMaxOptionalAllowed(dto.getMaxOptionalAllowed()); - holiday.setYear(dto.getYear() != null ? dto.getYear() : dto.getDate().getYear()); - holiday.setDescription(dto.getDescription()); + int skippedCount = 0; - holidayRepository.save(holiday); + for (HolidayDTO dto : holidays) { + if (dto.getDate() == null) { + throw new VacademyException("Holiday date is required for every entry"); + } + if (existingDates.contains(dto.getDate()) || !seenDates.add(dto.getDate())) { + skippedCount++; + continue; + } + holidayRepository.save(buildHoliday(dto, instituteId)); successCount++; } - return successCount + " holiday(s) created successfully"; + + String result = successCount + " holiday(s) created successfully"; + if (skippedCount > 0) { + result += ", " + skippedCount + " duplicate(s) skipped"; + } + return result; + } + + private Holiday buildHoliday(HolidayDTO dto, String instituteId) { + Holiday holiday = new Holiday(); + holiday.setInstituteId(instituteId); + holiday.setName(dto.getName()); + holiday.setDate(dto.getDate()); + holiday.setType(dto.getType()); + holiday.setIsOptional(dto.getIsOptional()); + holiday.setMaxOptionalAllowed(dto.getMaxOptionalAllowed()); + holiday.setYear(dto.getYear() != null ? dto.getYear() : dto.getDate().getYear()); + holiday.setDescription(dto.getDescription()); + return holiday; } private HolidayDTO toDTO(Holiday holiday) { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/RegularizationService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/RegularizationService.java index 901017c51c..092a5a0ac7 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/RegularizationService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/RegularizationService.java @@ -3,21 +3,25 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_attendance.dto.RegularizationActionDTO; import vacademy.io.admin_core_service.features.hr_attendance.dto.RegularizationDTO; import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceConfig; import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceRecord; import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceRegularization; +import vacademy.io.admin_core_service.features.hr_attendance.enums.AttendanceStatus; import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceConfigRepository; import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceRecordRepository; import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceRegularizationRepository; import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; -import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; +import vacademy.io.admin_core_service.features.hr_employee.service.HrNotificationService; +import vacademy.io.admin_core_service.features.hr_payroll.service.HrMonthLockService; import vacademy.io.common.exceptions.VacademyException; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; +import java.util.List; @Service public class RegularizationService { @@ -29,18 +33,76 @@ public class RegularizationService { private AttendanceRecordRepository attendanceRecordRepository; @Autowired - private EmployeeProfileRepository employeeProfileRepository; + private AttendanceConfigRepository attendanceConfigRepository; @Autowired - private AttendanceConfigRepository attendanceConfigRepository; + private HrAccessGuard hrAccessGuard; + @Autowired + private HrMonthLockService hrMonthLockService; + + @Autowired + private HrNotificationService hrNotificationService; + + /** + * The institute's regularization requests, newest first, optionally narrowed + * to one approval status (PENDING for the approval queue). + */ + @Transactional(readOnly = true) + public List getRegularizations(String instituteId, String approvalStatus) { + List found = (approvalStatus == null || approvalStatus.isBlank()) + ? regularizationRepository.findByEmployee_InstituteIdOrderByCreatedAtDesc(instituteId) + : regularizationRepository.findByEmployee_InstituteIdAndApprovalStatusOrderByCreatedAtDesc( + instituteId, approvalStatus.trim().toUpperCase()); + return found.stream() + .map(this::toDTO) + .toList(); + } + + private RegularizationDTO toDTO(AttendanceRegularization entity) { + RegularizationDTO dto = new RegularizationDTO(); + dto.setId(entity.getId()); + AttendanceRecord record = entity.getAttendanceRecord(); + if (record != null) { + dto.setAttendanceId(record.getId()); + dto.setAttendanceDate(record.getAttendanceDate()); + } + EmployeeProfile employee = entity.getEmployee(); + if (employee != null) { + dto.setEmployeeId(employee.getId()); + dto.setEmployeeCode(employee.getEmployeeCode()); + } + dto.setOriginalStatus(entity.getOriginalStatus()); + dto.setRequestedStatus(entity.getRequestedStatus()); + dto.setOriginalCheckIn(entity.getOriginalCheckIn()); + dto.setOriginalCheckOut(entity.getOriginalCheckOut()); + dto.setRequestedCheckIn(entity.getRequestedCheckIn()); + dto.setRequestedCheckOut(entity.getRequestedCheckOut()); + dto.setReason(entity.getReason()); + dto.setApprovalStatus(entity.getApprovalStatus()); + dto.setApprovedBy(entity.getApprovedBy()); + dto.setApprovedAt(entity.getApprovedAt()); + dto.setRemarks(entity.getRemarks()); + return dto; + } + + /** + * The employee is resolved and authorized by HrAccessGuard in the controller + * (self or HR staff, member of the validated institute). The attendance + * record must belong to that SAME employee and institute — a request may + * never regularize someone else's record. + */ @Transactional - public String requestRegularization(RegularizationDTO dto) { + public String requestRegularization(RegularizationDTO dto, EmployeeProfile employee, String instituteId) { + validateRequestedTimes(dto.getRequestedCheckIn(), dto.getRequestedCheckOut()); + AttendanceRecord attendanceRecord = attendanceRecordRepository.findById(dto.getAttendanceId()) .orElseThrow(() -> new VacademyException("Attendance record not found with id: " + dto.getAttendanceId())); - - EmployeeProfile employee = employeeProfileRepository.findById(dto.getEmployeeId()) - .orElseThrow(() -> new VacademyException("Employee not found with id: " + dto.getEmployeeId())); + hrAccessGuard.requireInstituteMatch(attendanceRecord.getInstituteId(), instituteId, "Attendance record"); + if (attendanceRecord.getEmployee() == null + || !employee.getId().equals(attendanceRecord.getEmployee().getId())) { + throw new VacademyException("Attendance record does not belong to the specified employee"); + } AttendanceRegularization regularization = new AttendanceRegularization(); regularization.setAttendanceRecord(attendanceRecord); @@ -63,23 +125,41 @@ public String requestRegularization(RegularizationDTO dto) { } @Transactional - public String approveRejectRegularization(String id, RegularizationActionDTO actionDTO, String approverUserId) { + public String approveRejectRegularization(String id, RegularizationActionDTO actionDTO, + String approverUserId, String instituteId) { AttendanceRegularization regularization = regularizationRepository.findById(id) .orElseThrow(() -> new VacademyException("Regularization request not found with id: " + id)); + hrAccessGuard.requireInstituteMatch( + regularization.getAttendanceRecord() != null + ? regularization.getAttendanceRecord().getInstituteId() : null, + instituteId, "Regularization request"); if (!"PENDING".equals(regularization.getApprovalStatus())) { throw new VacademyException("Regularization request has already been processed"); } if (Boolean.TRUE.equals(actionDTO.getApproved())) { + // Payroll month-lock: approving a regularization rewrites the + // attendance record — refuse when its month is already processed. + AttendanceRecord lockedCheckRecord = regularization.getAttendanceRecord(); + if (lockedCheckRecord != null) { + hrMonthLockService.requireUnlocked(lockedCheckRecord.getInstituteId(), + lockedCheckRecord.getAttendanceDate(), "approve regularization"); + } + regularization.setApprovalStatus("APPROVED"); regularization.setApprovedBy(approverUserId); regularization.setApprovedAt(LocalDateTime.now()); regularization.setRemarks(actionDTO.getRemarks()); - // Update the original attendance record with the requested changes + // Update the original attendance record with the requested changes. + // When times were changed the final status is RE-DERIVED from the + // recalculated hours below instead of blindly trusting requestedStatus; + // a pure status-change request (no times) still applies requestedStatus. AttendanceRecord record = regularization.getAttendanceRecord(); - if (regularization.getRequestedStatus() != null) { + boolean timesChanged = regularization.getRequestedCheckIn() != null + || regularization.getRequestedCheckOut() != null; + if (!timesChanged && regularization.getRequestedStatus() != null) { record.setStatus(regularization.getRequestedStatus()); } if (regularization.getRequestedCheckIn() != null) { @@ -90,6 +170,15 @@ public String approveRejectRegularization(String id, RegularizationActionDTO act } record.setIsRegularized(true); + // The applied times must still form a valid interval (e.g. a requested + // check-out combined with the existing check-in) + if (record.getCheckInTime() != null && record.getCheckOutTime() != null + && !record.getCheckOutTime().isAfter(record.getCheckInTime())) { + throw new VacademyException("Regularized check-out time must be after check-in time"); + } + + AttendanceConfig config = attendanceConfigRepository.findByInstituteId(record.getInstituteId()).orElse(null); + // Recalculate total hours if both check-in and check-out are present if (record.getCheckInTime() != null && record.getCheckOutTime() != null) { long minutesWorked = java.time.temporal.ChronoUnit.MINUTES.between( @@ -97,12 +186,23 @@ public String approveRejectRegularization(String id, RegularizationActionDTO act if (record.getBreakDurationMin() != null) { minutesWorked -= record.getBreakDurationMin(); } + minutesWorked = Math.max(0, minutesWorked); record.setTotalHours(java.math.BigDecimal.valueOf(minutesWorked) .divide(java.math.BigDecimal.valueOf(60), 2, java.math.RoundingMode.HALF_UP)); + + // Re-derive status from the recalculated hours using the institute's + // half-day threshold (HALF_DAY below it, PRESENT otherwise). + if (timesChanged) { + if (config != null && config.getHalfDayThresholdMin() != null + && minutesWorked < config.getHalfDayThresholdMin()) { + record.setStatus(AttendanceStatus.HALF_DAY.name()); + } else { + record.setStatus(AttendanceStatus.PRESENT.name()); + } + } } // Recalculate overtime - AttendanceConfig config = attendanceConfigRepository.findByInstituteId(record.getInstituteId()).orElse(null); if (config != null && Boolean.TRUE.equals(config.getOvertimeEnabled()) && config.getOvertimeThresholdMin() != null) { if (record.getTotalHours() != null) { long totalMinutes = (long) (record.getTotalHours().doubleValue() * 60); @@ -124,8 +224,42 @@ public String approveRejectRegularization(String id, RegularizationActionDTO act } regularizationRepository.save(regularization); + + notifyRegularizationDecision(regularization, Boolean.TRUE.equals(actionDTO.getApproved()), instituteId); + return Boolean.TRUE.equals(actionDTO.getApproved()) ? "Regularization request approved successfully" : "Regularization request rejected"; } + + /** Best-effort employee email on a regularization decision (never breaks the operation). */ + private void notifyRegularizationDecision(AttendanceRegularization regularization, + boolean approved, String instituteId) { + try { + String date = regularization.getAttendanceRecord() != null + && regularization.getAttendanceRecord().getAttendanceDate() != null + ? regularization.getAttendanceRecord().getAttendanceDate().toString() : null; + String subject = approved + ? "Attendance regularization approved" + : "Attendance regularization rejected"; + String body = hrNotificationService.buildEmailBody(subject, + "Date", date, + "Status", approved ? "APPROVED" : "REJECTED", + "Remarks", regularization.getRemarks()); + EmployeeProfile employee = regularization.getEmployee(); + // The employee's profile may live outside the record's institute id; + // sends are attributed to the validated institute. + hrNotificationService.emailUser(employee != null ? employee.getUserId() : null, + instituteId, subject, body); + } catch (Exception e) { + // emailUser already swallows send failures; this guards lazy-load surprises + } + } + + private void validateRequestedTimes(LocalDateTime requestedCheckIn, LocalDateTime requestedCheckOut) { + if (requestedCheckIn != null && requestedCheckOut != null + && !requestedCheckOut.isAfter(requestedCheckIn)) { + throw new VacademyException("Requested check-out time must be after requested check-in time"); + } + } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/ShiftService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/ShiftService.java index ee24028484..39b4a96dc1 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/ShiftService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/service/ShiftService.java @@ -3,6 +3,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_attendance.dto.ShiftAssignDTO; import vacademy.io.admin_core_service.features.hr_attendance.dto.ShiftDTO; import vacademy.io.admin_core_service.features.hr_attendance.entity.EmployeeShiftMapping; @@ -13,7 +14,9 @@ import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; import vacademy.io.common.exceptions.VacademyException; +import java.time.LocalDate; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @Service @@ -28,10 +31,13 @@ public class ShiftService { @Autowired private EmployeeProfileRepository employeeProfileRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + @Transactional - public String createShift(ShiftDTO dto) { + public String createShift(ShiftDTO dto, String instituteId) { Shift shift = new Shift(); - shift.setInstituteId(dto.getInstituteId()); + shift.setInstituteId(instituteId); shift.setName(dto.getName()); shift.setCode(dto.getCode()); shift.setStartTime(dto.getStartTime()); @@ -49,9 +55,10 @@ public String createShift(ShiftDTO dto) { } @Transactional - public String updateShift(String id, ShiftDTO dto) { + public String updateShift(String id, ShiftDTO dto, String instituteId) { Shift shift = shiftRepository.findById(id) .orElseThrow(() -> new VacademyException("Shift not found with id: " + id)); + hrAccessGuard.requireInstituteMatch(shift.getInstituteId(), instituteId, "Shift"); if (dto.getName() != null) shift.setName(dto.getName()); if (dto.getCode() != null) shift.setCode(dto.getCode()); @@ -76,24 +83,51 @@ public List getShifts(String instituteId) { } @Transactional - public String assignShiftToEmployees(ShiftAssignDTO assignDTO) { + public String assignShiftToEmployees(ShiftAssignDTO assignDTO, String instituteId) { + if (assignDTO.getEmployeeIds() == null || assignDTO.getEmployeeIds().isEmpty()) { + throw new VacademyException("No employees provided for shift assignment"); + } + LocalDate effectiveFrom = assignDTO.getEffectiveFrom(); + if (effectiveFrom == null) { + throw new VacademyException("Effective from date is required for shift assignment"); + } + Shift shift = shiftRepository.findById(assignDTO.getShiftId()) .orElseThrow(() -> new VacademyException("Shift not found with id: " + assignDTO.getShiftId())); - - for (String employeeId : assignDTO.getEmployeeIds()) { - EmployeeProfile employee = employeeProfileRepository.findById(employeeId) - .orElseThrow(() -> new VacademyException("Employee not found with id: " + employeeId)); + hrAccessGuard.requireInstituteMatch(shift.getInstituteId(), instituteId, "Shift"); + + // Batch-fetch and institute-check every employee before writing anything + List employeeIds = assignDTO.getEmployeeIds().stream().distinct().collect(Collectors.toList()); + Map employeeMap = employeeProfileRepository.findAllById(employeeIds).stream() + .filter(e -> instituteId.equals(e.getInstituteId())) + .collect(Collectors.toMap(EmployeeProfile::getId, e -> e)); + + for (String employeeId : employeeIds) { + EmployeeProfile employee = employeeMap.get(employeeId); + if (employee == null) { + throw new VacademyException("Employee not found with id: " + employeeId); + } + + // Close any mapping still open on/after the new effective date, so exactly + // one mapping is active per day (findActiveMapping expects a single row; + // overlaps made check-in fail with a NonUniqueResultException). + List openMappings = employeeShiftMappingRepository + .findMappingsOpenOnOrAfter(employeeId, effectiveFrom); + for (EmployeeShiftMapping openMapping : openMappings) { + openMapping.setEffectiveTo(effectiveFrom.minusDays(1)); + employeeShiftMappingRepository.save(openMapping); + } EmployeeShiftMapping mapping = new EmployeeShiftMapping(); mapping.setEmployee(employee); mapping.setShift(shift); - mapping.setEffectiveFrom(assignDTO.getEffectiveFrom()); + mapping.setEffectiveFrom(effectiveFrom); mapping.setEffectiveTo(assignDTO.getEffectiveTo()); employeeShiftMappingRepository.save(mapping); } - return "Shift assigned to " + assignDTO.getEmployeeIds().size() + " employee(s) successfully"; + return "Shift assigned to " + employeeIds.size() + " employee(s) successfully"; } private ShiftDTO toDTO(Shift shift) { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/util/HrTimeUtil.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/util/HrTimeUtil.java new file mode 100644 index 0000000000..fecfe9c656 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_attendance/util/HrTimeUtil.java @@ -0,0 +1,65 @@ +package vacademy.io.admin_core_service.features.hr_attendance.util; + +import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceConfig; + +import java.time.DayOfWeek; +import java.time.ZoneId; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Shared date/time helpers for the HR attendance and leave features. + * + * The JVM stays in UTC (platform rule); every "today"/"now" day-bucketing + * decision must instead use the institute's configured timezone, falling back + * to Asia/Kolkata when the config or timezone is missing or invalid. + */ +public final class HrTimeUtil { + + public static final String DEFAULT_TIMEZONE = "Asia/Kolkata"; + + public static final List DEFAULT_WEEKEND_DAYS = List.of("SATURDAY", "SUNDAY"); + + private HrTimeUtil() { + } + + /** + * Resolves the institute's ZoneId from its attendance config. Falls back to + * Asia/Kolkata when the config is absent, the timezone is blank, or the + * value is not a valid zone id. + */ + public static ZoneId resolveZone(AttendanceConfig config) { + if (config != null && config.getTimezone() != null && !config.getTimezone().isBlank()) { + try { + return ZoneId.of(config.getTimezone().trim()); + } catch (Exception e) { + // Invalid timezone value stored — fall through to the default. + } + } + return ZoneId.of(DEFAULT_TIMEZONE); + } + + /** + * Resolves the institute's weekend days. A missing config or null list + * defaults to Saturday/Sunday; an explicitly configured empty list means + * "no weekend days". Malformed day names are skipped. + */ + public static Set resolveWeekendDays(AttendanceConfig config) { + List names = (config != null && config.getWeekendDays() != null) + ? config.getWeekendDays() + : DEFAULT_WEEKEND_DAYS; + Set weekendDays = new HashSet<>(); + for (String name : names) { + if (name == null) { + continue; + } + try { + weekendDays.add(DayOfWeek.valueOf(name.trim().toUpperCase())); + } catch (IllegalArgumentException e) { + // Skip malformed entries + } + } + return weekendDays; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/GulfProvisionController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/GulfProvisionController.java new file mode 100644 index 0000000000..7be32462e8 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/GulfProvisionController.java @@ -0,0 +1,66 @@ +package vacademy.io.admin_core_service.features.hr_compliance.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; +import vacademy.io.admin_core_service.features.hr_compliance.dto.EosbProvisionReportDTO; +import vacademy.io.admin_core_service.features.hr_compliance.service.EosbProvisionService; +import vacademy.io.common.auth.model.CustomUserDetails; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; + +/** + * Gulf payroll provisions (Phase E): end-of-service benefit (EOSB) provision + * report for UAE (Federal Decree-Law 33/2021 art. 51) and Saudi Arabia (Labor + * Law art. 84) institutes — the Gulf sibling of {@link ProvisionController}'s + * gratuity report. HR-staff; the CSV export is audited. + */ +@RestController +@RequestMapping("/admin-core-service/api/v1/hr/compliance") +public class GulfProvisionController { + + @Autowired + private EosbProvisionService eosbProvisionService; + + @Autowired + private HrAccessGuard hrAccessGuard; + + @GetMapping("/eosb-provision") + public ResponseEntity getEosbProvision( + @RequestParam("instituteId") String instituteId, + @RequestParam(value = "asOfDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate asOfDate, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + return ResponseEntity.ok(eosbProvisionService.buildReport(instituteId, asOfDate)); + } + + @GetMapping("/eosb-provision/download") + @Auditable(entityType = "HR_EOSB_PROVISION", action = "DOWNLOAD", + entityIdExpr = "#instituteId", + descriptionExpr = "'EOSB provision CSV exported as of ' + (#asOfDate != null ? #asOfDate : 'today')") + public ResponseEntity downloadEosbProvision( + @RequestParam("instituteId") String instituteId, + @RequestParam(value = "asOfDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate asOfDate, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + LocalDate asOf = asOfDate != null ? asOfDate : LocalDate.now(); + String csv = eosbProvisionService.buildReportCsv(instituteId, asOf); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"eosb-provision-" + asOf + ".csv\"") + .contentType(MediaType.parseMediaType("text/csv")) + .body(csv.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/ProvisionController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/ProvisionController.java new file mode 100644 index 0000000000..68e6e49175 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/ProvisionController.java @@ -0,0 +1,92 @@ +package vacademy.io.admin_core_service.features.hr_compliance.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; +import vacademy.io.admin_core_service.features.hr_compliance.dto.BonusComputationReportDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.BonusMaterializationResultDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.GratuityProvisionReportDTO; +import vacademy.io.admin_core_service.features.hr_compliance.service.GratuityProvisionService; +import vacademy.io.admin_core_service.features.hr_compliance.service.StatutoryBonusService; +import vacademy.io.common.auth.model.CustomUserDetails; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; + +/** + * India payroll provisions (Phase D): gratuity provision report (Payment of + * Gratuity Act, 1972) and statutory bonus computation/materialization + * (Payment of Bonus Act, 1965). Reports are HR-staff; materialization is + * HR-admin and audited. + */ +@RestController +@RequestMapping("/admin-core-service/api/v1/hr/compliance") +public class ProvisionController { + + @Autowired + private GratuityProvisionService gratuityProvisionService; + + @Autowired + private StatutoryBonusService statutoryBonusService; + + @Autowired + private HrAccessGuard hrAccessGuard; + + @GetMapping("/gratuity-provision") + public ResponseEntity getGratuityProvision( + @RequestParam("instituteId") String instituteId, + @RequestParam(value = "asOfDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate asOfDate, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + return ResponseEntity.ok(gratuityProvisionService.buildReport(instituteId, asOfDate)); + } + + @GetMapping("/gratuity-provision/download") + public ResponseEntity downloadGratuityProvision( + @RequestParam("instituteId") String instituteId, + @RequestParam(value = "asOfDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate asOfDate, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + LocalDate asOf = asOfDate != null ? asOfDate : LocalDate.now(); + String csv = gratuityProvisionService.buildReportCsv(instituteId, asOf); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"gratuity-provision-" + asOf + ".csv\"") + .contentType(MediaType.parseMediaType("text/csv")) + .body(csv.getBytes(StandardCharsets.UTF_8)); + } + + @GetMapping("/bonus-computation") + public ResponseEntity getBonusComputation( + @RequestParam("instituteId") String instituteId, + @RequestParam("financialYear") String financialYear, + @RequestParam(value = "bonusPct", required = false, defaultValue = "8.33") BigDecimal bonusPct, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + return ResponseEntity.ok(statutoryBonusService.computeBonus(instituteId, financialYear, bonusPct)); + } + + @PostMapping("/bonus-computation/materialize") + @Auditable(entityType = "HR_BONUS", action = "MATERIALIZE", + entityIdExpr = "#financialYear", + descriptionExpr = "'Statutory bonus FY ' + #financialYear + ' materialized for payout ' + #month + '/' + #year") + public ResponseEntity materializeBonus( + @RequestParam("instituteId") String instituteId, + @RequestParam("financialYear") String financialYear, + @RequestParam(value = "bonusPct", required = false, defaultValue = "8.33") BigDecimal bonusPct, + @RequestParam("month") Integer month, + @RequestParam("year") Integer year, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + return ResponseEntity.ok(statutoryBonusService.materialize( + instituteId, financialYear, bonusPct, month, year, user)); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/StatutoryReturnController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/StatutoryReturnController.java new file mode 100644 index 0000000000..a36494ecb7 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/StatutoryReturnController.java @@ -0,0 +1,148 @@ +package vacademy.io.admin_core_service.features.hr_compliance.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; +import vacademy.io.admin_core_service.features.hr_compliance.dto.EsiReturnResponseDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.PfEcrResponseDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.PtReturnResponseDTO; +import vacademy.io.admin_core_service.features.hr_compliance.service.EsiReturnService; +import vacademy.io.admin_core_service.features.hr_compliance.service.PfEcrService; +import vacademy.io.admin_core_service.features.hr_compliance.service.PtReturnService; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.exceptions.VacademyException; + +/** + * Statutory-scheme filings (Phase D): PF ECR, ESI monthly return, PT monthly + * return. HR-admin only — these expose bulk statutory identifiers (UAN, ESI + * IP numbers), so downloads are audited. + */ +@RestController +@RequestMapping("/admin-core-service/api/v1/hr/compliance") +public class StatutoryReturnController { + + @Autowired + private HrAccessGuard hrAccessGuard; + + @Autowired + private PfEcrService pfEcrService; + + @Autowired + private EsiReturnService esiReturnService; + + @Autowired + private PtReturnService ptReturnService; + + // ------------------------------------------------------------------ PF ECR + + @GetMapping("/pf-ecr") + public ResponseEntity getPfEcr( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") int month, + @RequestParam("year") int year, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + validateMonthYear(month, year); + return ResponseEntity.ok(pfEcrService.buildReturn(instituteId, month, year)); + } + + @GetMapping("/pf-ecr/download") + @Auditable(entityType = "HR_PF_ECR", action = "DOWNLOAD", + entityIdExpr = "#instituteId + ':' + #year + '-' + #month") + public ResponseEntity downloadPfEcr( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") int month, + @RequestParam("year") int year, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + validateMonthYear(month, year); + PfEcrResponseDTO response = pfEcrService.buildReturn(instituteId, month, year); + String file = pfEcrService.buildEcrFile(response); + return textDownload(file, MediaType.TEXT_PLAIN, + "ecr_" + month + "_" + year + ".txt"); + } + + // -------------------------------------------------------------- ESI return + + @GetMapping("/esi-return") + public ResponseEntity getEsiReturn( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") int month, + @RequestParam("year") int year, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + validateMonthYear(month, year); + return ResponseEntity.ok(esiReturnService.buildReturn(instituteId, month, year)); + } + + @GetMapping("/esi-return/download") + @Auditable(entityType = "HR_ESI_RETURN", action = "DOWNLOAD", + entityIdExpr = "#instituteId + ':' + #year + '-' + #month") + public ResponseEntity downloadEsiReturn( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") int month, + @RequestParam("year") int year, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + validateMonthYear(month, year); + EsiReturnResponseDTO response = esiReturnService.buildReturn(instituteId, month, year); + String file = esiReturnService.buildCsv(response); + return textDownload(file, new MediaType("text", "csv"), + "esi_return_" + month + "_" + year + ".csv"); + } + + // --------------------------------------------------------------- PT return + + @GetMapping("/pt-return") + public ResponseEntity getPtReturn( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") int month, + @RequestParam("year") int year, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + validateMonthYear(month, year); + return ResponseEntity.ok(ptReturnService.buildReturn(instituteId, month, year)); + } + + @GetMapping("/pt-return/download") + @Auditable(entityType = "HR_PT_RETURN", action = "DOWNLOAD", + entityIdExpr = "#instituteId + ':' + #year + '-' + #month") + public ResponseEntity downloadPtReturn( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") int month, + @RequestParam("year") int year, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + validateMonthYear(month, year); + PtReturnResponseDTO response = ptReturnService.buildReturn(instituteId, month, year); + String file = ptReturnService.buildCsv(response); + return textDownload(file, new MediaType("text", "csv"), + "pt_return_" + month + "_" + year + ".csv"); + } + + // ------------------------------------------------------------------ shared + + private static void validateMonthYear(int month, int year) { + if (month < 1 || month > 12) { + throw new VacademyException("month must be between 1 and 12"); + } + if (year < 2000 || year > 2100) { + throw new VacademyException("year must be between 2000 and 2100"); + } + } + + private static ResponseEntity textDownload(String body, MediaType mediaType, String filename) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(mediaType); + headers.set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\""); + return ResponseEntity.ok().headers(headers).body(body); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/TdsChallanController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/TdsChallanController.java new file mode 100644 index 0000000000..126219bd8c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/TdsChallanController.java @@ -0,0 +1,85 @@ +package vacademy.io.admin_core_service.features.hr_compliance.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; +import vacademy.io.admin_core_service.features.hr_compliance.entity.TdsChallan; +import vacademy.io.admin_core_service.features.hr_compliance.repository.TdsChallanRepository; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.exceptions.VacademyException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; + +/** + * TDS challan register (Phase D): deposits recorded here are mapped into the + * Form 24Q export. HR admin only. + */ +@RestController +@RequestMapping("/admin-core-service/api/v1/hr/compliance/challans") +public class TdsChallanController { + + private static final Set QUARTERS = Set.of("Q1", "Q2", "Q3", "Q4"); + + @Autowired + private TdsChallanRepository challanRepository; + + @Autowired + private HrAccessGuard hrAccessGuard; + + @PostMapping + @Auditable(entityType = "HR_TDS_CHALLAN", action = "CREATE", entityIdExpr = "#result?.body") + public ResponseEntity createChallan( + @RequestBody TdsChallan challan, + @RequestParam("instituteId") String instituteId, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + if (challan.getFinancialYear() == null || !challan.getFinancialYear().matches("\\d{4}-\\d{2}")) { + throw new VacademyException("financial_year must look like 2025-26"); + } + if (challan.getQuarter() == null || !QUARTERS.contains(challan.getQuarter().toUpperCase())) { + throw new VacademyException("quarter must be Q1..Q4 (FY quarters, Q1 = Apr-Jun)"); + } + if (challan.getDepositDate() == null) { + throw new VacademyException("deposit_date is required"); + } + if (challan.getAmount() == null || challan.getAmount().compareTo(BigDecimal.ZERO) <= 0) { + throw new VacademyException("amount must be positive"); + } + challan.setId(null); + challan.setInstituteId(instituteId); // never trust body institute + challan.setQuarter(challan.getQuarter().toUpperCase()); + challan.setCreatedBy(user.getUserId()); + return ResponseEntity.ok(challanRepository.save(challan).getId()); + } + + @GetMapping + public ResponseEntity> listChallans( + @RequestParam("instituteId") String instituteId, + @RequestParam("financialYear") String financialYear, + @RequestParam(value = "quarter", required = false) String quarter, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + List challans = quarter != null + ? challanRepository.findByInstituteIdAndFinancialYearAndQuarterOrderByDepositDateAsc( + instituteId, financialYear, quarter.toUpperCase()) + : challanRepository.findByInstituteIdAndFinancialYearOrderByDepositDateAsc(instituteId, financialYear); + return ResponseEntity.ok(challans); + } + + @DeleteMapping("/{id}") + @Auditable(entityType = "HR_TDS_CHALLAN", action = "DELETE", entityIdExpr = "#id") + public ResponseEntity deleteChallan( + @PathVariable("id") String id, + @RequestParam("instituteId") String instituteId, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + TdsChallan challan = challanRepository.findByIdAndInstituteId(id, instituteId) + .orElseThrow(() -> new VacademyException("Challan not found")); + challanRepository.delete(challan); + return ResponseEntity.ok(id); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/TdsFilingController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/TdsFilingController.java new file mode 100644 index 0000000000..b09d6cf75f --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/TdsFilingController.java @@ -0,0 +1,122 @@ +package vacademy.io.admin_core_service.features.hr_compliance.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; +import vacademy.io.admin_core_service.features.hr_compliance.dto.Form16DataDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.Form24QResponseDTO; +import vacademy.io.admin_core_service.features.hr_compliance.service.Form16Service; +import vacademy.io.admin_core_service.features.hr_compliance.service.Form24QService; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.exceptions.VacademyException; + +import java.nio.charset.StandardCharsets; + +/** + * TDS filings (Phase D): Form 16 Part B per employee, Form 24Q quarterly + * return data. Institute is ALWAYS the validated query param — never read from + * anywhere else. Form 16 is self-or-HR (an employee may fetch their own); + * Form 24Q is HR-admin only (bulk unmasked PANs). + */ +@RestController +@RequestMapping("/admin-core-service/api/v1/hr/compliance") +public class TdsFilingController { + + @Autowired + private Form16Service form16Service; + + @Autowired + private Form24QService form24QService; + + @Autowired + private HrAccessGuard hrAccessGuard; + + // ------------------------------------------------------------- Form 16 + + @GetMapping("/form16") + public ResponseEntity getForm16( + @RequestParam("instituteId") String instituteId, + @RequestParam("employeeId") String employeeId, + @RequestParam("financialYear") String financialYear, + @RequestAttribute("user") CustomUserDetails user) { + validateFinancialYear(financialYear); + EmployeeProfile employee = hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); + return ResponseEntity.ok(form16Service.buildForm16(employee, instituteId, financialYear)); + } + + @GetMapping("/form16/download") + @Auditable(entityType = "HR_FORM16", action = "DOWNLOAD", entityIdExpr = "#employeeId", + descriptionExpr = "'Form 16 Part B PDF for employee ' + #employeeId + ' FY ' + #financialYear") + public ResponseEntity downloadForm16( + @RequestParam("instituteId") String instituteId, + @RequestParam("employeeId") String employeeId, + @RequestParam("financialYear") String financialYear, + @RequestAttribute("user") CustomUserDetails user) { + validateFinancialYear(financialYear); + EmployeeProfile employee = hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); + Form16DataDTO data = form16Service.buildForm16(employee, instituteId, financialYear); + byte[] pdf = form16Service.renderForm16Pdf(data); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + headers.setContentDispositionFormData("attachment", + "form16_partB_" + safe(data.getEmployeeCode(), employeeId) + "_" + financialYear + ".pdf"); + return new ResponseEntity<>(pdf, headers, HttpStatus.OK); + } + + // ------------------------------------------------------------- Form 24Q + + @GetMapping("/24q") + public ResponseEntity getForm24Q( + @RequestParam("instituteId") String instituteId, + @RequestParam("financialYear") String financialYear, + @RequestParam("quarter") String quarter, + @RequestAttribute("user") CustomUserDetails user) { + validateFinancialYear(financialYear); + hrAccessGuard.requireHrAdmin(user, instituteId); + return ResponseEntity.ok(form24QService.buildForm24Q(instituteId, financialYear, quarter)); + } + + @GetMapping("/24q/download") + @Auditable(entityType = "HR_FORM24Q", action = "DOWNLOAD", entityIdExpr = "#instituteId", + descriptionExpr = "'Form 24Q CSV for FY ' + #financialYear + ' ' + #quarter") + public ResponseEntity downloadForm24Q( + @RequestParam("instituteId") String instituteId, + @RequestParam("financialYear") String financialYear, + @RequestParam("quarter") String quarter, + @RequestAttribute("user") CustomUserDetails user) { + validateFinancialYear(financialYear); + hrAccessGuard.requireHrAdmin(user, instituteId); + Form24QResponseDTO data = form24QService.buildForm24Q(instituteId, financialYear, quarter); + byte[] csv = form24QService.toCsv(data).getBytes(StandardCharsets.UTF_8); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(new MediaType("text", "csv", StandardCharsets.UTF_8)); + headers.setContentDispositionFormData("attachment", + "form24q_" + financialYear + "_" + data.getQuarter() + ".csv"); + return new ResponseEntity<>(csv, headers, HttpStatus.OK); + } + + // --------------------------------------------------------------- helpers + + private static void validateFinancialYear(String financialYear) { + if (financialYear == null || !financialYear.matches("\\d{4}-\\d{2}")) { + throw new VacademyException("financialYear must look like 2025-26"); + } + } + + private static String safe(String preferred, String fallback) { + String v = (preferred != null && !preferred.isBlank()) ? preferred : fallback; + return v.replaceAll("[^A-Za-z0-9_-]", "_"); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/WpsController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/WpsController.java new file mode 100644 index 0000000000..dcdd80ff0a --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/controller/WpsController.java @@ -0,0 +1,79 @@ +package vacademy.io.admin_core_service.features.hr_compliance.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; +import vacademy.io.admin_core_service.features.hr_compliance.dto.WpsExportResponseDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.WpsFileDTO; +import vacademy.io.admin_core_service.features.hr_compliance.service.WpsExportService; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.exceptions.VacademyException; + +/** + * Gulf WPS salary-file exports (Phase E): UAE MOHRE SIF and Saudi + * (Mudad-style) files, format resolved from the institute's Gulf tax + * configuration (countryCode ARE/UAE → UAE_SIF, SAU/KSA → SAUDI_WPS) with an + * optional {@code format} override. HR admin only — the file bulk-exposes + * IBANs and statutory person ids, so downloads are audited. + */ +@RestController +@RequestMapping("/admin-core-service/api/v1/hr/compliance/wps") +public class WpsController { + + @Autowired + private HrAccessGuard hrAccessGuard; + + @Autowired + private WpsExportService wpsExportService; + + /** JSON preview: rows + skipped (with reasons) + warnings + totals. */ + @GetMapping + public ResponseEntity getWpsExport( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") int month, + @RequestParam("year") int year, + @RequestParam(value = "format", required = false) String format, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + validateMonthYear(month, year); + return ResponseEntity.ok(wpsExportService.buildExport(instituteId, month, year, format)); + } + + /** The salary file itself (UAE .sif as text/plain, Saudi .csv as text/csv). */ + @GetMapping("/download") + @Auditable(entityType = "HR_WPS", action = "DOWNLOAD", + entityIdExpr = "#instituteId + ':' + #year + '-' + #month") + public ResponseEntity downloadWpsFile( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") int month, + @RequestParam("year") int year, + @RequestParam(value = "format", required = false) String format, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + validateMonthYear(month, year); + WpsExportResponseDTO response = wpsExportService.buildExport(instituteId, month, year, format); + WpsFileDTO file = wpsExportService.buildFile(response); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(file.getMediaType())); + headers.set(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + file.getFilename() + "\""); + return ResponseEntity.ok().headers(headers).body(file.getContent()); + } + + private static void validateMonthYear(int month, int year) { + if (month < 1 || month > 12) { + throw new VacademyException("month must be between 1 and 12"); + } + if (year < 2000 || year > 2100) { + throw new VacademyException("year must be between 2000 and 2100"); + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/BonusComputationReportDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/BonusComputationReportDTO.java new file mode 100644 index 0000000000..93099b8ca1 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/BonusComputationReportDTO.java @@ -0,0 +1,33 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** Institute-wide statutory bonus computation for one financial year. */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class BonusComputationReportDTO { + + private String instituteId; + /** e.g. 2025-26 (April to March). */ + private String financialYear; + private LocalDate fyStart; + private LocalDate fyEnd; + /** Applied rate, clamped to the Act's 8.33 (s.10 minimum) .. 20 (s.11 maximum). */ + private BigDecimal bonusPct; + private Integer eligibleCount; + private BigDecimal totalBonus; + private String currency; + private List rows; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/BonusComputationRowDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/BonusComputationRowDTO.java new file mode 100644 index 0000000000..4b5c5c0468 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/BonusComputationRowDTO.java @@ -0,0 +1,36 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * One employee's statutory bonus line (Payment of Bonus Act, 1965): + * bonus = min(monthly basic, 7,000) x eligible FY months x rate%. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class BonusComputationRowDTO { + + private String employeeId; + private String employeeCode; + private String employeeName; + private BigDecimal monthlyBasic; + private Boolean eligible; + /** Populated when eligible = false (wage above 21,000 ceiling, < 30 days service, ...). */ + private String ineligibleReason; + /** Months of eligible service within the FY (0..12). */ + private Integer eligibleMonths; + /** min(monthly basic, 7,000) — s.12 calculation ceiling. */ + private BigDecimal bonusWageBase; + private BigDecimal computedBonus; + private String currency; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/BonusMaterializationResultDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/BonusMaterializationResultDTO.java new file mode 100644 index 0000000000..e5859f7725 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/BonusMaterializationResultDTO.java @@ -0,0 +1,31 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** Result of materializing a statutory bonus run into payroll adjustments. */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class BonusMaterializationResultDTO { + + private String financialYear; + /** Payout period the adjustments were created for. */ + private Integer month; + private Integer year; + private BigDecimal bonusPct; + /** Adjustments created in this call. */ + private Integer createdCount; + /** Employees skipped because a STATUTORY_BONUS adjustment already exists for the period. */ + private Integer skippedExistingCount; + /** Total amount of the adjustments created in this call. */ + private BigDecimal totalAmount; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/EosbProvisionReportDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/EosbProvisionReportDTO.java new file mode 100644 index 0000000000..00e147837f --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/EosbProvisionReportDTO.java @@ -0,0 +1,40 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** + * Institute-wide end-of-service benefit (EOSB) provision report as of a date — + * the Gulf sibling of {@link GratuityProvisionReportDTO}. Only produced for + * institutes whose tax configuration is UAE (ARE) or Saudi Arabia (SAU). + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class EosbProvisionReportDTO { + + private String instituteId; + /** Normalized ISO-3 country the report was computed under: ARE | SAU. */ + private String countryCode; + private LocalDate asOfDate; + private Integer employeeCount; + /** Sum of per-row statutory liabilities (UAE rows under 1 year contribute 0). */ + private BigDecimal totalStatutoryLiability; + /** Sum of per-row day-one accounting accruals (no UAE 1-year floor). */ + private BigDecimal totalAccountingAccrual; + /** Sum of per-row current-band monthly run-rates. */ + private BigDecimal totalMonthlyRunRate; + /** Dominant currency of the underlying structures (rows carry their own). */ + private String currency; + private List rows; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/EosbProvisionRowDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/EosbProvisionRowDTO.java new file mode 100644 index 0000000000..c51798be57 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/EosbProvisionRowDTO.java @@ -0,0 +1,69 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * One employee's end-of-service benefit (EOSB) accrual line for a Gulf + * institute. + * + *

UAE (Federal Decree-Law 33/2021 art. 51): 21 days of basic per year for + * the first 5 years of service, 30 days/year beyond, pro-rated for fractional + * years (daily basic = monthly basic / 30); no statutory entitlement before + * 1 year of service; total capped at 2 years' pay. + * + *

Saudi Arabia (Labor Law art. 84): half a month's basic per year for the + * first 5 years, a full month per year beyond, pro-rated; no service floor. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class EosbProvisionRowDTO { + + private String employeeId; + private String employeeCode; + private String employeeName; + private String employmentStatus; + private LocalDate joinDate; + /** Service measurement end: asOfDate, or lastWorkingDate if earlier. */ + private LocalDate serviceEndDate; + /** True when the employee exited within the asOf month (still provisioned). */ + private Boolean exitedInAsOfMonth; + /** Decimal years of service (days / 365.25), 2dp. */ + private BigDecimal serviceYears; + private BigDecimal monthlyBasic; + /** BASIC_COMPONENT | GROSS_FALLBACK (50% of gross) | NONE (no ACTIVE structure). */ + private String basicSource; + /** + * EOSB payable if the employee exited on the service end date. UAE: zero + * before 1 year of service (art. 51 floor). Saudi: no floor. + */ + private BigDecimal statutoryLiability; + /** + * False only for UAE employees under 1 year of service — no statutory + * entitlement yet, though the books still carry {@code accountingAccrual}. + */ + private Boolean statutoryEligible; + /** + * Day-one accrual view (IAS 19 style): the same banded formula without the + * UAE 1-year floor. Equals {@code statutoryLiability} once eligible. + */ + private BigDecimal accountingAccrual; + /** UAE only: true when the accrual hit the 2-years'-pay cap (basic x 24). */ + private Boolean cappedAtTwoYearsPay; + /** + * Current band's monthly accrual — UAE: daily basic x (21|30)/12; + * Saudi: basic x (0.5|1)/12. + */ + private BigDecimal monthlyRunRate; + private String currency; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/EsiReturnResponseDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/EsiReturnResponseDTO.java new file mode 100644 index 0000000000..3be51a1c6c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/EsiReturnResponseDTO.java @@ -0,0 +1,47 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; + +/** + * JSON view of the monthly ESI return. The CSV download is generated from + * {@link #rows} only — {@link #skipped} employees (no IP number on file) are + * excluded from the file by design. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EsiReturnResponseDTO { + + private String instituteId; + private Integer month; + private Integer year; + + /** statutory_settings.esi_employer_code from the tax configuration; empty when missing. */ + private String esiEmployerCode; + + private List rows; + private List skipped; + private List warnings; + + private Integer ipCount; + private BigDecimal totalWages; + private BigDecimal totalIpContribution; + private BigDecimal totalEmployerContribution; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SkippedRow { + private String employeeCode; + private String employeeName; + private String reason; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/EsiReturnRowDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/EsiReturnRowDTO.java new file mode 100644 index 0000000000..61f2136628 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/EsiReturnRowDTO.java @@ -0,0 +1,38 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** One insured person's line of the monthly ESI return. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EsiReturnRowDTO { + + private String employeeCode; + + /** ESIC IP number — statutory_info key esi_number (fallback ip_number). */ + private String ipNumber; + + private String name; + + /** + * Paid days for the month = round HALF_UP of (days_present + days_on_leave): + * ESIC wants days for which wages were payable, and paid leave counts. + */ + private Integer daysWorked; + + /** Gross salary of the month (ESI wages). */ + private BigDecimal monthlyWage; + + /** Employee (IP) ESI contribution — the ESI component amount. */ + private BigDecimal ipContribution; + + /** Employer ESI contribution — the ESI_ER component amount. */ + private BigDecimal employerContribution; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form16DataDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form16DataDTO.java new file mode 100644 index 0000000000..c766b216e2 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form16DataDTO.java @@ -0,0 +1,73 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * Form 16 Part B (Annexure) data for one employee + financial year, assembled + * from the hr_tax_computation cumulative series. Part A (challan-wise TRACES + * certificate) is NOT produced here — this Part B is system-generated for + * verification against the TRACES download. + * + * PAN appears unmasked: the endpoint is guarded by requireSelfOrHrStaff, so + * only HR staff or the employee themselves can read it. + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Form16DataDTO { + + // Employee (deductee) + private String employeeId; + private String employeeName; + private String employeeCode; + private String employeePan; + + // Deductor (employer) — from hr_tax_configuration.statutory_settings + private String deductorName; + private String deductorTan; + private String deductorPan; + private String deductorAddress; + + private String financialYear; + private String regime; + + /** Gross salary paid in the FY = sum of the monthly cumulative deltas. */ + private BigDecimal grossSalaryPaid; + + // Exemptions (from the LAST computed month's computation_details) + private BigDecimal standardDeduction; + private BigDecimal hraExemption; + private BigDecimal totalExemptions; + + /** Chapter VI-A deductions from the engine breakdown (deduction80c, deduction80d, ...). */ + private Map chapterVIADeductions; + + // Tax on total income (annual figures from the last computed month) + private BigDecimal taxableIncome; + private BigDecimal slabTax; + private BigDecimal taxAfterRebate; + private BigDecimal surcharge; + private BigDecimal cess; + private BigDecimal totalTaxLiability; + + /** Total TDS actually deducted in the FY (last month's cumulative actual_tax_deducted). */ + private BigDecimal totalTdsDeducted; + + /** Month of the last computation present (FY order); annual figures are projections until March. */ + private Integer lastComputedMonth; + + private List monthlyDetails; + + /** Non-fatal issues: unconfigured statutory settings, missing PAN, incomplete FY, ... */ + private List warnings; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form16MonthlyRowDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form16MonthlyRowDTO.java new file mode 100644 index 0000000000..cb2523c05c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form16MonthlyRowDTO.java @@ -0,0 +1,26 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; + +/** One salary month inside a Form 16 Part B: amounts derived from cumulative deltas. */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Form16MonthlyRowDTO { + + private Integer month; + private Integer year; + private String monthName; + /** Income paid this month = cumulative actual_income_till_date minus previous month's cumulative. */ + private BigDecimal incomePaid; + /** TDS deducted this month = cumulative actual_tax_deducted minus previous month's cumulative. */ + private BigDecimal tdsDeducted; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form24QChallanDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form24QChallanDTO.java new file mode 100644 index 0000000000..61ffd14a23 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form24QChallanDTO.java @@ -0,0 +1,27 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** One TDS deposit (challan) mapped into the quarter's Form 24Q. */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Form24QChallanDTO { + + private String id; + private LocalDate depositDate; + private String bsrCode; + private String challanSerial; + private BigDecimal amount; + private BigDecimal interest; + private BigDecimal fee; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form24QDeducteeRowDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form24QDeducteeRowDTO.java new file mode 100644 index 0000000000..f813d4af8f --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form24QDeducteeRowDTO.java @@ -0,0 +1,37 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; + +/** + * One deductee annexure row of a Form 24Q: one employee + one salary month of + * the quarter with TDS > 0. Amounts are cumulative deltas from + * hr_tax_computation (income paid / TDS deducted that month). PAN unmasked — + * HR-admin-only output. + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Form24QDeducteeRowDTO { + + private String employeeId; + private String pan; + private String name; + private String employeeCode; + private Integer month; + private Integer year; + private String monthName; + /** Taxable income paid that month (cumulative delta). */ + private BigDecimal incomePaid; + /** TDS deducted that month (cumulative delta). */ + private BigDecimal tdsDeducted; + /** TDS section — salary TDS is always 192. */ + private String section; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form24QDeductorDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form24QDeductorDTO.java new file mode 100644 index 0000000000..78cab252a4 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form24QDeductorDTO.java @@ -0,0 +1,21 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** Deductor (employer) block of a Form 24Q — from hr_tax_configuration.statutory_settings. */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Form24QDeductorDTO { + + private String name; + private String tan; + private String pan; + private String address; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form24QResponseDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form24QResponseDTO.java new file mode 100644 index 0000000000..8490ae5a64 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/Form24QResponseDTO.java @@ -0,0 +1,37 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.util.List; + +/** Form 24Q quarterly return data (deductor + challans + deductee annexure + totals). */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Form24QResponseDTO { + + private String financialYear; + /** Q1..Q4 in FY terms (Q1 = Apr-Jun ... Q4 = Jan-Mar). */ + private String quarter; + + private Form24QDeductorDTO deductor; + private List challans; + private List deducteeRows; + + /** Sum of TDS deducted across the annexure rows. */ + private BigDecimal totalTdsDeducted; + /** Sum of challan amounts deposited for the quarter (amount only; interest/fee excluded). */ + private BigDecimal totalChallanAmount; + /** True when deducted TDS and deposited challan totals differ. */ + private boolean mismatch; + + /** Non-fatal issues: unconfigured statutory settings, missing PANs, no challans, ... */ + private List warnings; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/GratuityProvisionReportDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/GratuityProvisionReportDTO.java new file mode 100644 index 0000000000..3f017c9f9d --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/GratuityProvisionReportDTO.java @@ -0,0 +1,35 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** Institute-wide gratuity provision report as of a date. */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class GratuityProvisionReportDTO { + + private String instituteId; + private LocalDate asOfDate; + private Integer employeeCount; + private BigDecimal totalAccruedLiability; + /** Portion of the total for employees past 4y240d (payable if they exit now). */ + private BigDecimal vestedAccruedLiability; + /** Accounting provision carried for not-yet-vested employees. */ + private BigDecimal unvestedAccruedLiability; + /** Sum of per-employee 4.81%-of-basic monthly run-rates. */ + private BigDecimal totalMonthlyRunRate; + /** Dominant currency of the underlying structures (rows carry their own). */ + private String currency; + private List rows; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/GratuityProvisionRowDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/GratuityProvisionRowDTO.java new file mode 100644 index 0000000000..144d107b0f --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/GratuityProvisionRowDTO.java @@ -0,0 +1,52 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * One employee's gratuity accrual line (Payment of Gratuity Act, 1972 s.4): + * accrued liability = (15/26) x monthly basic x rounded years of service, + * capped at the Act's Rs 20,00,000 ceiling. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class GratuityProvisionRowDTO { + + private String employeeId; + private String employeeCode; + private String employeeName; + private String employmentStatus; + private LocalDate joinDate; + /** Service measurement end: asOfDate, or lastWorkingDate if earlier. */ + private LocalDate serviceEndDate; + /** True when the employee exited within the asOf month (still provisioned). */ + private Boolean exitedInAsOfMonth; + /** Decimal years of service (days / 365.2425), 2dp. */ + private BigDecimal rawYears; + /** + * Completed years, with a part in excess of six months rounded up to a full + * year once past the 5-year mark (s.4(2): "or part thereof in excess of six months"). + */ + private Integer roundedYears; + private BigDecimal monthlyBasic; + /** BASIC_COMPONENT | GROSS_FALLBACK (50% of gross) | NONE (no ACTIVE structure). */ + private String basicSource; + /** (15/26) x monthlyBasic x roundedYears, capped at 20,00,000. */ + private BigDecimal accruedLiability; + private Boolean cappedAtCeiling; + /** Service >= 4 years + 240 days (Mettur Beardsell, Madras HC). */ + private Boolean vested; + /** Monthly provision run-rate: 4.81% of monthly basic. */ + private BigDecimal monthlyRunRate; + private String currency; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PfEcrResponseDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PfEcrResponseDTO.java new file mode 100644 index 0000000000..77e94a1271 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PfEcrResponseDTO.java @@ -0,0 +1,48 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; + +/** + * JSON view of the monthly PF ECR. The downloadable ECR v2 text file is + * generated from {@link #rows} only — {@link #skipped} members (no UAN on + * file) are excluded from the file by design. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PfEcrResponseDTO { + + private String instituteId; + private Integer month; + private Integer year; + + /** statutory_settings.pf_establishment_id from the tax configuration; empty when missing. */ + private String pfEstablishmentId; + + private List rows; + private List skipped; + private List warnings; + + private Integer memberCount; + private BigDecimal totalEpfWages; + private BigDecimal totalEpfContri; + private BigDecimal totalEpsContri; + private BigDecimal totalEpfEpsDiff; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SkippedRow { + private String employeeCode; + private String employeeName; + private String reason; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PfEcrRowDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PfEcrRowDTO.java new file mode 100644 index 0000000000..9c67ef0fec --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PfEcrRowDTO.java @@ -0,0 +1,45 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * One member line of the EPFO ECR v2 file (all wage/contribution figures are + * whole rupees, as the portal expects). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PfEcrRowDTO { + + private String employeeCode; + private String uan; + private String memberName; + + /** Gross wages paid in the month (rupees, HALF_UP). */ + private BigDecimal grossWages; + + /** Recovered PF wage base = round(PF employee contribution / 0.12). */ + private BigDecimal epfWages; + private BigDecimal epsWages; + private BigDecimal edliWages; + + /** Employee 12% share — the PF component amount as deducted in payroll. */ + private BigDecimal epfContriRemitted; + + /** EPS 8.33% of the recovered wage base (HALF_UP rupee). */ + private BigDecimal epsContriRemitted; + + /** Employer 12% of the recovered base minus EPS. */ + private BigDecimal epfEpsDiffRemitted; + + /** Non-contributory period days = days_absent, rounded HALF_UP. */ + private Integer ncpDays; + + private BigDecimal refundOfAdvances; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PtReturnResponseDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PtReturnResponseDTO.java new file mode 100644 index 0000000000..cdc3e03e15 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PtReturnResponseDTO.java @@ -0,0 +1,34 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; + +/** JSON view of the monthly Professional Tax return; the CSV mirrors it. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PtReturnResponseDTO { + + private String instituteId; + private Integer month; + private Integer year; + + /** state_code from the tax configuration; empty when missing. */ + private String stateCode; + + /** statutory_settings.pt_registration_number; empty when missing. */ + private String ptRegistrationNumber; + + private List slabs; + private List rows; + private List warnings; + + private Integer employeeCount; + private BigDecimal grandTotalPt; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PtReturnRowDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PtReturnRowDTO.java new file mode 100644 index 0000000000..2ebde2a60c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PtReturnRowDTO.java @@ -0,0 +1,21 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** One employee's line of the monthly Professional Tax return. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PtReturnRowDTO { + + private String employeeCode; + private String name; + private BigDecimal grossSalary; + private BigDecimal ptAmount; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PtReturnSlabSummaryDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PtReturnSlabSummaryDTO.java new file mode 100644 index 0000000000..d6636370b6 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/PtReturnSlabSummaryDTO.java @@ -0,0 +1,24 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * Slab-wise PT summary line: every distinct PT deduction amount observed in + * the month with the number of employees at that amount and the resulting + * total. (State PT returns are slab-count based.) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PtReturnSlabSummaryDTO { + + private BigDecimal ptAmount; + private Integer employeeCount; + private BigDecimal totalAmount; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsEdrRowDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsEdrRowDTO.java new file mode 100644 index 0000000000..9e874d6242 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsEdrRowDTO.java @@ -0,0 +1,54 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * One UAE WPS SIF EDR (Employee Detail Record) — one per paid employee. + * The downloadable SIF line is generated from these fields; {@link #netPay} + * is JSON-only context (it feeds the SCR total but is not an EDR field). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WpsEdrRowDTO { + + private String employeeCode; + private String employeeName; + + /** statutory_info.mol_person_id when present (preferred), else employeeCode. */ + private String personId; + + /** statutory_info.wps_agent_id, falling back to bankAccount.routingNumber. */ + private String agentId; + + private String iban; + + /** Pay period start, first of month (YYYY-MM-DD). */ + private String payStartDate; + + /** Pay period end, last of month (YYYY-MM-DD). */ + private String payEndDate; + + /** Days in period — entry totalWorkingDays (max across the month's entries). */ + private Integer daysInPeriod; + + /** Fixed income — sum of totalEarnings. */ + private BigDecimal fixedIncome; + + /** Variable income — sum of otherEarnings + reimbursements. */ + private BigDecimal variableIncome; + + /** daysOnLeave rounded to whole days. */ + private Integer leaveDays; + + /** Sum of netPay across the employee's entries (feeds the SCR total). */ + private BigDecimal netPay; + + private String currency; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsExportResponseDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsExportResponseDTO.java new file mode 100644 index 0000000000..9acfcd1bab --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsExportResponseDTO.java @@ -0,0 +1,55 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; + +/** + * JSON view of a monthly WPS salary-file export. Exactly one of + * {@link #edrRows} (format UAE_SIF) / {@link #saudiRows} (format SAUDI_WPS) + * is populated; the downloadable file is generated from that list only — + * {@link #skipped} employees (no IBAN on file) are excluded by design. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WpsExportResponseDTO { + + /** UAE_SIF or SAUDI_WPS. */ + private String format; + + private String instituteId; + private Integer month; + private Integer year; + + /** Tax configuration countryCode the format was resolved from (may be null on explicit override). */ + private String countryCode; + + /** statutory_settings.mol_establishment_id; empty when missing (warned). */ + private String establishmentId; + + /** statutory_settings.employer_bank_code; empty when missing (warned). */ + private String employerBankCode; + + /** statutory_settings.wps_reference; optional, empty when missing (not warned). */ + private String wpsReference; + + private List edrRows; + private List saudiRows; + private List skipped; + private List warnings; + + /** Employees included in the file (skipped excluded). */ + private Integer employeeCount; + + /** Sum of netPay across included employees. */ + private BigDecimal totalNetPay; + + /** Payment currency of the file (expected AED for UAE, SAR for Saudi). */ + private String currency; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsFileDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsFileDTO.java new file mode 100644 index 0000000000..3a5cb8e6e2 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsFileDTO.java @@ -0,0 +1,24 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A rendered WPS salary file: content plus the filename / media type the + * download endpoint should serve it under. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WpsFileDTO { + + private String filename; + + /** e.g. "text/plain" (UAE .sif) or "text/csv" (Saudi .csv). */ + private String mediaType; + + private String content; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsSaudiRowDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsSaudiRowDTO.java new file mode 100644 index 0000000000..4d0e729c16 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsSaudiRowDTO.java @@ -0,0 +1,50 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * One Saudi WPS (Mudad-style, v1) salary-file row — one per paid employee. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WpsSaudiRowDTO { + + private String employeeCode; + private String employeeName; + + /** statutory_info.gosi_number when present (preferred), else employeeCode. */ + private String employeeId; + + private String iban; + + /** statutory_info.wps_agent_id, falling back to bankAccount.routingNumber. */ + private String bankCode; + + /** + * Basic salary — sum of the entry's BASIC component amounts when the + * structure defines one; falls back to totalEarnings (flagged in + * warnings) when no BASIC component exists for the employee. + */ + private BigDecimal basicSalary; + + /** Housing allowance — not separately tracked by payroll; 0 in v1. */ + private BigDecimal housingAllowance; + + /** otherEarnings + reimbursements. */ + private BigDecimal otherEarnings; + + /** totalDeductions. */ + private BigDecimal deductions; + + /** netPay. */ + private BigDecimal netSalary; + + private String currency; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsSkippedRowDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsSkippedRowDTO.java new file mode 100644 index 0000000000..450fafe1a2 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/dto/WpsSkippedRowDTO.java @@ -0,0 +1,21 @@ +package vacademy.io.admin_core_service.features.hr_compliance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Employee excluded from the WPS salary file (e.g. missing IBAN) with the + * reason — surfaced in the JSON view so HR can fix the data and re-export. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WpsSkippedRowDTO { + + private String employeeCode; + private String employeeName; + private String reason; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/entity/TdsChallan.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/entity/TdsChallan.java new file mode 100644 index 0000000000..03455c34bd --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/entity/TdsChallan.java @@ -0,0 +1,76 @@ +package vacademy.io.admin_core_service.features.hr_compliance.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.UuidGenerator; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** A TDS deposit (challan) against withheld salary TDS — mapped into Form 24Q (V483). */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "hr_tds_challan") +public class TdsChallan { + + @Id + @UuidGenerator + @Column(name = "id") + private String id; + + @Column(name = "institute_id", nullable = false) + private String instituteId; + + @Column(name = "financial_year", nullable = false, length = 10) + private String financialYear; + + /** Q1..Q4 in FY terms (Q1 = Apr–Jun). */ + @Column(name = "quarter", nullable = false, length = 2) + private String quarter; + + @Column(name = "month") + private Integer month; + + @Column(name = "year") + private Integer year; + + @Column(name = "deposit_date", nullable = false) + private LocalDate depositDate; + + @Column(name = "bsr_code", length = 10) + private String bsrCode; + + @Column(name = "challan_serial", length = 10) + private String challanSerial; + + @Column(name = "amount", nullable = false, precision = 15, scale = 2) + private BigDecimal amount; + + @Column(name = "interest", precision = 15, scale = 2) + private BigDecimal interest; + + @Column(name = "fee", precision = 15, scale = 2) + private BigDecimal fee; + + @Column(name = "notes", columnDefinition = "TEXT") + private String notes; + + @Column(name = "created_by") + private String createdBy; + + @Column(name = "created_at", insertable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", insertable = false) + private LocalDateTime updatedAt; + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/ComplianceProvisionQueryRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/ComplianceProvisionQueryRepository.java new file mode 100644 index 0000000000..0689bd430c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/ComplianceProvisionQueryRepository.java @@ -0,0 +1,39 @@ +package vacademy.io.admin_core_service.features.hr_compliance.repository; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollAdjustment; + +import java.util.List; + +/** + * Read-only query surface for the provision reports (Phase D: gratuity + * provisioning + statutory bonus). Deliberately extends the marker + * {@link Repository} rather than JpaRepository — no CRUD is exposed here; + * writes to adjustments go through PayrollAdjustmentService only. + */ +@org.springframework.stereotype.Repository +public interface ComplianceProvisionQueryRepository extends Repository { + + /** + * Every employee profile of the institute, exited or not; the services + * apply the report-specific status/date windows in code. + */ + @Query("SELECT e FROM EmployeeProfile e WHERE e.instituteId = :instituteId") + List findAllEmployeesByInstitute(@Param("instituteId") String instituteId); + + /** + * Employee ids that already carry an adjustment under {@code code} for the + * given payout period — consumed or not. Used for idempotent bonus + * materialization: a consumed adjustment means the bonus was already paid, + * an unconsumed one means it is already queued; both must be skipped. + */ + @Query("SELECT a.employeeId FROM PayrollAdjustment a WHERE a.instituteId = :instituteId " + + "AND a.year = :year AND a.month = :month AND a.code = :code") + List findAdjustmentEmployeeIdsForPeriod(@Param("instituteId") String instituteId, + @Param("year") Integer year, + @Param("month") Integer month, + @Param("code") String code); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/ComplianceStatutoryQueryRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/ComplianceStatutoryQueryRepository.java new file mode 100644 index 0000000000..f500af473f --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/ComplianceStatutoryQueryRepository.java @@ -0,0 +1,50 @@ +package vacademy.io.admin_core_service.features.hr_compliance.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntryComponent; + +import java.util.Collection; +import java.util.List; + +/** + * hr_compliance-owned read model over payroll data for statutory-scheme + * returns (PF ECR / ESI / PT). Deliberately its OWN repository — the + * hr_payroll repositories are not touched by Phase D. + * + *

Scope contract: every query is institute-scoped through the payroll + * run and only looks at filable runs (PROCESSED/APPROVED/PAID as passed by + * the caller). HELD entries never enter a statutory return — they were not + * paid out. + */ +@Repository +public interface ComplianceStatutoryQueryRepository extends JpaRepository { + + /** + * All statutory components (matched by salary-component code alias) for + * the institute's payroll runs of the given month/year. Fetch-joins the + * component, entry, run and employee so callers can read them without an + * open session per row (services are still readOnly-transactional). + */ + @Query(""" + SELECT c FROM PayrollEntryComponent c + JOIN FETCH c.component sc + JOIN FETCH c.payrollEntry e + JOIN FETCH e.payrollRun r + JOIN FETCH e.employee + WHERE r.instituteId = :instituteId + AND r.month = :month + AND r.year = :year + AND r.status IN (:runStatuses) + AND (e.status IS NULL OR e.status <> 'HELD') + AND UPPER(sc.code) IN (:codes) + """) + List findStatutoryComponents( + @Param("instituteId") String instituteId, + @Param("month") Integer month, + @Param("year") Integer year, + @Param("runStatuses") Collection runStatuses, + @Param("codes") Collection codes); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/ComplianceTaxQueryRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/ComplianceTaxQueryRepository.java new file mode 100644 index 0000000000..d93cdc435a --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/ComplianceTaxQueryRepository.java @@ -0,0 +1,31 @@ +package vacademy.io.admin_core_service.features.hr_compliance.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.hr_tax.entity.TaxComputation; + +import java.util.List; + +/** + * hr_compliance-owned read-only queries over {@link TaxComputation} (Phase D). + * Exists so TDS filing exports can run institute-wide aggregations without + * touching the hr_tax package's own repository. + */ +@Repository +public interface ComplianceTaxQueryRepository extends JpaRepository { + + /** + * Every tax computation row for an institute in a financial year, with the + * employee eagerly fetched (Form 24Q reads PAN/code/userId off each row). + * Ordering is by raw calendar month; callers must re-sort into FY order + * (Apr..Mar) before taking cumulative deltas. + */ + @Query("SELECT tc FROM TaxComputation tc JOIN FETCH tc.employee e " + + "WHERE e.instituteId = :instituteId AND tc.financialYear = :financialYear " + + "ORDER BY e.id, tc.month") + List findAllByInstituteAndFinancialYear( + @Param("instituteId") String instituteId, + @Param("financialYear") String financialYear); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/ComplianceWpsQueryRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/ComplianceWpsQueryRepository.java new file mode 100644 index 0000000000..e9b5078d59 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/ComplianceWpsQueryRepository.java @@ -0,0 +1,75 @@ +package vacademy.io.admin_core_service.features.hr_compliance.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntry; + +import java.util.Collection; +import java.util.List; + +/** + * hr_compliance-owned read model over payroll data for Gulf WPS (Wage + * Protection System) salary files — UAE SIF and Saudi (Mudad-style) exports + * (Phase E). Deliberately its OWN repository, mirroring + * {@link ComplianceStatutoryQueryRepository} — the hr_payroll repositories + * are not touched. + * + *

Scope contract: every query is institute-scoped through the payroll run + * and only looks at filable runs (PROCESSED/APPROVED/PAID as passed by the + * caller). HELD entries never enter a WPS file — they were not paid out. + */ +@Repository +public interface ComplianceWpsQueryRepository extends JpaRepository { + + /** + * All payable (non-HELD) payroll entries for the institute's runs of the + * given month/year. Fetch-joins the run, employee and (optional) bank + * account so callers can read them without an open session per row + * (services remain readOnly-transactional). Bank account is a LEFT join — + * an entry without one still surfaces so it can land in the skipped list + * with a reason instead of silently disappearing. + */ + @Query(""" + SELECT e FROM PayrollEntry e + JOIN FETCH e.payrollRun r + JOIN FETCH e.employee + LEFT JOIN FETCH e.bankAccount + WHERE r.instituteId = :instituteId + AND r.month = :month + AND r.year = :year + AND r.status IN (:runStatuses) + AND (e.status IS NULL OR e.status <> 'HELD') + """) + List findPayableEntries( + @Param("instituteId") String instituteId, + @Param("month") Integer month, + @Param("year") Integer year, + @Param("runStatuses") Collection runStatuses); + + /** + * (payrollEntryId, amount) pairs of the BASIC salary component for the + * same entry population — used by the Saudi file to report basic salary + * separately without loading every entry's component list. Component code + * "BASIC" is the platform-wide convention (see SalaryStructureService + * basic-component discovery and PayrollCalculationService). + */ + @Query(""" + SELECT c.payrollEntry.id, c.amount FROM PayrollEntryComponent c + JOIN c.component sc + JOIN c.payrollEntry e + JOIN e.payrollRun r + WHERE r.instituteId = :instituteId + AND r.month = :month + AND r.year = :year + AND r.status IN (:runStatuses) + AND (e.status IS NULL OR e.status <> 'HELD') + AND UPPER(sc.code) = 'BASIC' + """) + List findBasicAmountsByEntry( + @Param("instituteId") String instituteId, + @Param("month") Integer month, + @Param("year") Integer year, + @Param("runStatuses") Collection runStatuses); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/TdsChallanRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/TdsChallanRepository.java new file mode 100644 index 0000000000..b406a0ff8c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/repository/TdsChallanRepository.java @@ -0,0 +1,19 @@ +package vacademy.io.admin_core_service.features.hr_compliance.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.hr_compliance.entity.TdsChallan; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface TdsChallanRepository extends JpaRepository { + + List findByInstituteIdAndFinancialYearOrderByDepositDateAsc(String instituteId, String financialYear); + + List findByInstituteIdAndFinancialYearAndQuarterOrderByDepositDateAsc( + String instituteId, String financialYear, String quarter); + + Optional findByIdAndInstituteId(String id, String instituteId); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/EosbProvisionService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/EosbProvisionService.java new file mode 100644 index 0000000000..a3a6c357fe --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/EosbProvisionService.java @@ -0,0 +1,385 @@ +package vacademy.io.admin_core_service.features.hr_compliance.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.hr_compliance.dto.EosbProvisionReportDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.EosbProvisionRowDTO; +import vacademy.io.admin_core_service.features.hr_compliance.repository.ComplianceProvisionQueryRepository; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_salary.entity.EmployeeSalaryComponent; +import vacademy.io.admin_core_service.features.hr_salary.entity.EmployeeSalaryStructure; +import vacademy.io.admin_core_service.features.hr_salary.repository.EmployeeSalaryStructureRepository; +import vacademy.io.admin_core_service.features.hr_tax.entity.TaxConfiguration; +import vacademy.io.admin_core_service.features.hr_tax.repository.TaxConfigurationRepository; +import vacademy.io.common.auth.entity.User; +import vacademy.io.common.auth.repository.UserRepository; +import vacademy.io.common.exceptions.VacademyException; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * End-of-service benefit (EOSB) provision report (Phase E) — the Gulf sibling + * of {@link GratuityProvisionService}. Applies only to institutes configured + * for the UAE (ARE) or Saudi Arabia (SAU). + * + *

UAE — Federal Decree-Law 33/2021 art. 51: 21 days of basic wage per year + * of service for the first 5 years and 30 days/year beyond, with the daily + * basic taken as monthly basic / 30 and fractional years pro-rated across the + * bands. Statutory floor: no entitlement before 1 completed year of service + * (statutory liability 0, flagged not eligible) — but the books provision from + * day one (IAS 19 accrual view), so the same banded figure WITHOUT the floor is + * exposed separately as the accounting accrual. Statutory cap: the total + * gratuity may not exceed 2 years' pay — approximated here as basic x 24 and + * flagged when applied. + * + *

Saudi Arabia — Labor Law art. 84: half a month's basic per year for the + * first 5 years and a full month per year beyond, band-split pro-rated. No + * service floor, so statutory and accounting figures coincide. NOTE: art. 85's + * resignation reductions (one-third under 5 years, two-thirds between 5 and 10) + * are deliberately NOT modeled — the report provisions the full employer-side + * liability, the conservative accounting position. + * + *

Monthly run-rate = the CURRENT band's monthly accrual, matching what the + * payroll engines emit per month: UAE daily x (21|30)/12, KSA basic x (0.5|1)/12. + */ +@Service +public class EosbProvisionService { + + static final Set EXITED_STATUSES = Set.of("TERMINATED", "RELIEVED", "ABSCONDING"); + static final Set UAE_CODES = Set.of("ARE", "UAE"); + static final Set SAUDI_CODES = Set.of("SAU", "KSA"); + + /** Spec'd service-years denominator for this report (Gulf convention). */ + private static final BigDecimal DAYS_PER_YEAR = new BigDecimal("365.25"); + private static final BigDecimal FIVE = new BigDecimal("5"); + private static final BigDecimal TWELVE = new BigDecimal("12"); + private static final BigDecimal THIRTY = new BigDecimal("30"); + private static final BigDecimal HALF = new BigDecimal("0.50"); + + private static final BigDecimal UAE_DAYS_FIRST_BAND = new BigDecimal("21"); + private static final BigDecimal UAE_DAYS_SECOND_BAND = new BigDecimal("30"); + /** Art. 51(2): total EOSB may not exceed two years' pay (basic x 24 here). */ + private static final BigDecimal UAE_CAP_MONTHS = new BigDecimal("24"); + + private static final BigDecimal KSA_MONTHS_FIRST_BAND = new BigDecimal("0.5"); + private static final BigDecimal KSA_MONTHS_SECOND_BAND = BigDecimal.ONE; + + @Autowired + private ComplianceProvisionQueryRepository provisionQueryRepository; + + @Autowired + private EmployeeSalaryStructureRepository salaryStructureRepository; + + @Autowired + private TaxConfigurationRepository taxConfigurationRepository; + + @Autowired + private UserRepository userRepository; + + @Transactional(readOnly = true) + public EosbProvisionReportDTO buildReport(String instituteId, LocalDate asOfDate) { + LocalDate asOf = asOfDate != null ? asOfDate : LocalDate.now(); + String country = resolveGulfCountry(instituteId); + boolean uae = "ARE".equals(country); + + List employees = provisionQueryRepository.findAllEmployeesByInstitute(instituteId); + Map names = buildUserNameMap( + employees.stream().map(EmployeeProfile::getUserId).filter(Objects::nonNull) + .distinct().collect(Collectors.toList())); + + List rows = new ArrayList<>(); + BigDecimal totalStatutory = BigDecimal.ZERO; + BigDecimal totalAccounting = BigDecimal.ZERO; + BigDecimal totalRunRate = BigDecimal.ZERO; + String reportCurrency = null; + + for (EmployeeProfile e : employees) { + if (e.getJoinDate() == null || e.getJoinDate().isAfter(asOf)) { + continue; // not yet in service as of the report date + } + boolean exited = e.getEmploymentStatus() != null + && EXITED_STATUSES.contains(e.getEmploymentStatus().toUpperCase()); + boolean exitedInAsOfMonth = false; + if (exited) { + // Exited employees stay on the report only for their exit month, + // so the month-end provision movement (release on payout) is visible. + LocalDate lwd = e.getLastWorkingDate(); + if (lwd == null || !YearMonth.from(lwd).equals(YearMonth.from(asOf))) { + continue; + } + exitedInAsOfMonth = true; + } + + LocalDate serviceEnd = asOf; + if (e.getLastWorkingDate() != null && e.getLastWorkingDate().isBefore(asOf)) { + serviceEnd = e.getLastWorkingDate(); + } + if (serviceEnd.isBefore(e.getJoinDate())) { + serviceEnd = e.getJoinDate(); + } + + long serviceDays = ChronoUnit.DAYS.between(e.getJoinDate(), serviceEnd); + // High precision for the band math; the row shows 2dp. + BigDecimal yearsExact = new BigDecimal(serviceDays) + .divide(DAYS_PER_YEAR, 6, RoundingMode.HALF_UP); + BigDecimal serviceYears = yearsExact.setScale(2, RoundingMode.HALF_UP); + + GratuityProvisionService.BasicResolution basic = resolveMonthlyBasic(e.getId()); + if (reportCurrency == null && basic.currency() != null) { + reportCurrency = basic.currency(); + } + + BigDecimal accounting = BigDecimal.ZERO; + BigDecimal statutory = BigDecimal.ZERO; + BigDecimal runRate = BigDecimal.ZERO; + boolean eligible = true; + boolean capped = false; + + if (basic.amount() != null) { + if (uae) { + UaeAccrual a = computeUaeAccrual(basic.amount(), yearsExact); + accounting = a.accounting; + statutory = a.statutory; + eligible = a.eligible; + capped = a.capped; + runRate = a.runRate; + } else { + KsaAccrual a = computeKsaAccrual(basic.amount(), yearsExact); + accounting = a.accrual; + statutory = a.accrual; // no floor (art. 85 reductions not modeled) + runRate = a.runRate; + } + } + + totalStatutory = totalStatutory.add(statutory); + totalAccounting = totalAccounting.add(accounting); + totalRunRate = totalRunRate.add(runRate); + + rows.add(EosbProvisionRowDTO.builder() + .employeeId(e.getId()) + .employeeCode(e.getEmployeeCode()) + .employeeName(names.getOrDefault(e.getUserId(), "Unknown")) + .employmentStatus(e.getEmploymentStatus()) + .joinDate(e.getJoinDate()) + .serviceEndDate(serviceEnd) + .exitedInAsOfMonth(exitedInAsOfMonth) + .serviceYears(serviceYears) + .monthlyBasic(basic.amount()) + .basicSource(basic.source()) + .statutoryLiability(statutory) + .statutoryEligible(eligible) + .accountingAccrual(accounting) + .cappedAtTwoYearsPay(uae ? capped : Boolean.FALSE) + .monthlyRunRate(runRate) + .currency(basic.currency() != null ? basic.currency() : (uae ? "AED" : "SAR")) + .build()); + } + + rows.sort(Comparator.comparing(r -> r.getEmployeeCode() != null ? r.getEmployeeCode() : "", + String.CASE_INSENSITIVE_ORDER)); + + return EosbProvisionReportDTO.builder() + .instituteId(instituteId) + .countryCode(country) + .asOfDate(asOf) + .employeeCount(rows.size()) + .totalStatutoryLiability(totalStatutory) + .totalAccountingAccrual(totalAccounting) + .totalMonthlyRunRate(totalRunRate) + .currency(reportCurrency != null ? reportCurrency : (uae ? "AED" : "SAR")) + .rows(rows) + .build(); + } + + /** CSV rendering of the same report for download. */ + @Transactional(readOnly = true) + public String buildReportCsv(String instituteId, LocalDate asOfDate) { + EosbProvisionReportDTO report = buildReport(instituteId, asOfDate); + StringBuilder sb = new StringBuilder(); + sb.append("employee_code,employee_name,employment_status,join_date,service_end_date,") + .append("service_years,monthly_basic,basic_source,statutory_liability,") + .append("statutory_eligible,accounting_accrual,capped_at_two_years_pay,") + .append("monthly_run_rate,currency,exited_in_as_of_month\n"); + for (EosbProvisionRowDTO r : report.getRows()) { + sb.append(csv(r.getEmployeeCode())).append(',') + .append(csv(r.getEmployeeName())).append(',') + .append(csv(r.getEmploymentStatus())).append(',') + .append(csv(r.getJoinDate())).append(',') + .append(csv(r.getServiceEndDate())).append(',') + .append(csv(r.getServiceYears())).append(',') + .append(csv(r.getMonthlyBasic())).append(',') + .append(csv(r.getBasicSource())).append(',') + .append(csv(r.getStatutoryLiability())).append(',') + .append(csv(r.getStatutoryEligible())).append(',') + .append(csv(r.getAccountingAccrual())).append(',') + .append(csv(r.getCappedAtTwoYearsPay())).append(',') + .append(csv(r.getMonthlyRunRate())).append(',') + .append(csv(r.getCurrency())).append(',') + .append(csv(r.getExitedInAsOfMonth())).append('\n'); + } + sb.append("STATUTORY_TOTAL,,,,,,,,").append(report.getTotalStatutoryLiability()) + .append(",,,,,,\n"); + sb.append("ACCOUNTING_TOTAL,,,,,,,,,,").append(report.getTotalAccountingAccrual()) + .append(",,,,\n"); + sb.append("RUN_RATE_TOTAL,,,,,,,,,,,,").append(report.getTotalMonthlyRunRate()).append(',') + .append(report.getCurrency()).append(",\n"); + return sb.toString(); + } + + // ================================================================== + // Accrual math + // ================================================================== + + /** + * UAE art. 51 band-split accrual: daily basic (basic/30) x + * [min(years,5) x 21 + max(0, years-5) x 30], capped at basic x 24; + * statutory figure floored to zero under 1 year of service. + */ + private static UaeAccrual computeUaeAccrual(BigDecimal basic, BigDecimal yearsExact) { + BigDecimal daily = basic.divide(THIRTY, 10, RoundingMode.HALF_UP); + BigDecimal firstBandYears = yearsExact.min(FIVE); + BigDecimal secondBandYears = yearsExact.subtract(FIVE).max(BigDecimal.ZERO); + + BigDecimal accounting = firstBandYears.multiply(UAE_DAYS_FIRST_BAND) + .add(secondBandYears.multiply(UAE_DAYS_SECOND_BAND)) + .multiply(daily) + .setScale(2, RoundingMode.HALF_UP); + + BigDecimal cap = basic.multiply(UAE_CAP_MONTHS).setScale(2, RoundingMode.HALF_UP); + boolean capped = false; + if (accounting.compareTo(cap) > 0) { + accounting = cap; + capped = true; + } + + boolean eligible = yearsExact.compareTo(BigDecimal.ONE) >= 0; + BigDecimal statutory = eligible ? accounting : BigDecimal.ZERO; + + BigDecimal bandDays = yearsExact.compareTo(FIVE) < 0 ? UAE_DAYS_FIRST_BAND : UAE_DAYS_SECOND_BAND; + BigDecimal runRate = daily.multiply(bandDays).divide(TWELVE, 2, RoundingMode.HALF_UP); + + return new UaeAccrual(statutory, accounting, eligible, capped, runRate); + } + + /** + * KSA art. 84 band-split accrual: basic x [min(years,5) x 0.5 + + * max(0, years-5) x 1]. No floor, no cap; art. 85 resignation reductions + * intentionally not modeled (full liability provisioned). + */ + private static KsaAccrual computeKsaAccrual(BigDecimal basic, BigDecimal yearsExact) { + BigDecimal firstBandYears = yearsExact.min(FIVE); + BigDecimal secondBandYears = yearsExact.subtract(FIVE).max(BigDecimal.ZERO); + + BigDecimal accrual = firstBandYears.multiply(HALF) + .add(secondBandYears) + .multiply(basic) + .setScale(2, RoundingMode.HALF_UP); + + BigDecimal bandMonths = yearsExact.compareTo(FIVE) < 0 ? KSA_MONTHS_FIRST_BAND : KSA_MONTHS_SECOND_BAND; + BigDecimal runRate = basic.multiply(bandMonths).divide(TWELVE, 2, RoundingMode.HALF_UP); + + return new KsaAccrual(accrual, runRate); + } + + // ================================================================== + // Lookups + // ================================================================== + + /** + * The institute's Gulf country from its tax configuration: ARE (UAE) or + * SAU (KSA), accepting the common aliases. Any other (or missing) + * configuration is a clean client error — this report is Gulf-only. + */ + private String resolveGulfCountry(String instituteId) { + List configs = taxConfigurationRepository + .findAllByInstituteIdAndStatus(instituteId, "ACTIVE"); + if (configs.isEmpty()) { + configs = taxConfigurationRepository.findAllByInstituteId(instituteId); + } + for (TaxConfiguration c : configs) { + String code = c.getCountryCode() != null ? c.getCountryCode().trim().toUpperCase() : ""; + if (UAE_CODES.contains(code)) { + return "ARE"; + } + if (SAUDI_CODES.contains(code)) { + return "SAU"; + } + } + throw new VacademyException( + "The EOSB provision report applies to UAE (ARE) and Saudi Arabia (SAU) institutes only. " + + "This institute's tax configuration is " + + (configs.isEmpty() ? "not set up" : "for a different country") + + " — for India, use the gratuity provision report instead."); + } + + /** + * Monthly basic pay from the latest ACTIVE salary structure: the BASIC + * component's monthly amount, falling back to 50% of gross monthly when no + * BASIC component exists — the same resolution the gratuity report uses. + */ + private GratuityProvisionService.BasicResolution resolveMonthlyBasic(String employeeId) { + Optional structureOpt = salaryStructureRepository + .findFirstByEmployee_IdAndStatusOrderByEffectiveFromDesc(employeeId, "ACTIVE"); + if (structureOpt.isEmpty()) { + return new GratuityProvisionService.BasicResolution(null, "NONE", null); + } + EmployeeSalaryStructure structure = structureOpt.get(); + String currency = structure.getCurrency(); + if (structure.getComponents() != null) { + for (EmployeeSalaryComponent c : structure.getComponents()) { + if (c.getComponent() != null && "BASIC".equalsIgnoreCase(c.getComponent().getCode()) + && c.getMonthlyAmount() != null) { + return new GratuityProvisionService.BasicResolution( + c.getMonthlyAmount().setScale(2, RoundingMode.HALF_UP), + "BASIC_COMPONENT", currency); + } + } + } + if (structure.getGrossMonthly() != null) { + return new GratuityProvisionService.BasicResolution( + structure.getGrossMonthly().multiply(HALF).setScale(2, RoundingMode.HALF_UP), + "GROSS_FALLBACK", currency); + } + return new GratuityProvisionService.BasicResolution(null, "NONE", currency); + } + + private Map buildUserNameMap(List userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + List users = userRepository.findByIdIn(userIds); + return users.stream().collect(Collectors.toMap( + User::getId, + u -> u.getFullName() != null ? u.getFullName() : u.getUsername(), + (a, b) -> a)); + } + + private static String csv(Object value) { + if (value == null) return ""; + String s = String.valueOf(value); + if (s.contains(",") || s.contains("\"") || s.contains("\n")) { + return '"' + s.replace("\"", "\"\"") + '"'; + } + return s; + } + + private record UaeAccrual(BigDecimal statutory, BigDecimal accounting, + boolean eligible, boolean capped, BigDecimal runRate) { + } + + private record KsaAccrual(BigDecimal accrual, BigDecimal runRate) { + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/EsiReturnService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/EsiReturnService.java new file mode 100644 index 0000000000..1ca76e8504 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/EsiReturnService.java @@ -0,0 +1,249 @@ +package vacademy.io.admin_core_service.features.hr_compliance.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.hr_compliance.dto.EsiReturnResponseDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.EsiReturnRowDTO; +import vacademy.io.admin_core_service.features.hr_compliance.repository.ComplianceStatutoryQueryRepository; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntry; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntryComponent; +import vacademy.io.admin_core_service.features.hr_tax.entity.TaxConfiguration; +import vacademy.io.admin_core_service.features.hr_tax.repository.TaxConfigurationRepository; +import vacademy.io.common.auth.entity.User; +import vacademy.io.common.auth.repository.UserRepository; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Monthly ESIC contribution return builder (v1 — CSV mirroring the portal's + * monthly-contribution columns; not yet validated against an ESIC portal + * upload). + * + *

"Days worked" judgment call: ESIC asks for the number of days for which + * wages were PAYABLE, which includes paid leave. PayrollEntry keeps + * days_present / days_on_leave / days_absent separately, so paid days = + * round HALF_UP(days_present + days_on_leave). Unpaid absence (days_absent) + * is what reduced the wage and is correctly excluded. + */ +@Service +public class EsiReturnService { + + /** Employee-side ESI code aliases (PayrollCalculationService.STATUTORY_ALIASES). */ + private static final Set ESI_EMPLOYEE_CODES = Set.of("ESI", "ESI_EMP"); + + /** Employer-side aliases: the engine writes _ER-suffixed twins. */ + private static final Set ESI_EMPLOYER_CODES = Set.of("ESI_ER", "ESI_EMP_ER"); + + private static final List FILABLE_RUN_STATUSES = List.of("PROCESSED", "APPROVED", "PAID"); + + @Autowired + private ComplianceStatutoryQueryRepository statutoryQueryRepository; + + @Autowired + private TaxConfigurationRepository taxConfigurationRepository; + + @Autowired + private UserRepository userRepository; + + @Transactional(readOnly = true) + public EsiReturnResponseDTO buildReturn(String instituteId, int month, int year) { + List warnings = new ArrayList<>(); + String employerCode = resolveEmployerCode(instituteId, warnings); + + Set allCodes = Stream.concat(ESI_EMPLOYEE_CODES.stream(), ESI_EMPLOYER_CODES.stream()) + .collect(Collectors.toSet()); + List components = statutoryQueryRepository.findStatutoryComponents( + instituteId, month, year, FILABLE_RUN_STATUSES, allCodes); + + // Aggregate per employee across the month's filable runs. Contribution + // amounts and gross SUM across entries; paid days take the MAX across + // entries (attendance is a per-month fact repeated per entry that + // carries it — summing would double-count off-cycle runs). + Map byEmployee = new LinkedHashMap<>(); + boolean sawProcessedRun = false; + for (PayrollEntryComponent component : components) { + PayrollEntry entry = component.getPayrollEntry(); + EmployeeProfile employee = entry.getEmployee(); + if ("PROCESSED".equals(entry.getPayrollRun().getStatus())) { + sawProcessedRun = true; + } + IpAgg agg = byEmployee.computeIfAbsent(employee.getId(), k -> new IpAgg(employee)); + String code = component.getComponent().getCode() == null + ? "" : component.getComponent().getCode().toUpperCase(); + if (ESI_EMPLOYEE_CODES.contains(code)) { + agg.employeeContribution = agg.employeeContribution.add(nvl(component.getAmount())); + agg.hasEmployeeSide = true; + } else if (ESI_EMPLOYER_CODES.contains(code)) { + agg.employerContribution = agg.employerContribution.add(nvl(component.getAmount())); + } + if (agg.seenEntryIds.add(entry.getId())) { + agg.gross = agg.gross.add(nvl(entry.getGrossSalary())); + BigDecimal paidDays = nvl(entry.getDaysPresent()).add(nvl(entry.getDaysOnLeave())); + if (paidDays.compareTo(agg.paidDays) > 0) { + agg.paidDays = paidDays; + } + } + } + if (sawProcessedRun) { + warnings.add("Includes payroll run(s) still in PROCESSED status (not yet approved); " + + "re-generate after approval before filing."); + } + + Map nameMap = buildUserNameMap(byEmployee.values().stream() + .map(a -> a.employee.getUserId()).distinct().collect(Collectors.toList())); + + List rows = new ArrayList<>(); + List skipped = new ArrayList<>(); + for (IpAgg agg : byEmployee.values()) { + if (!agg.hasEmployeeSide) { + // Employer-side component with no IP deduction — not an insured + // person's contribution line for this month. + continue; + } + String name = nameMap.getOrDefault(agg.employee.getUserId(), ""); + String ipNumber = resolveIpNumber(agg.employee); + if (ipNumber == null || ipNumber.isBlank()) { + skipped.add(EsiReturnResponseDTO.SkippedRow.builder() + .employeeCode(agg.employee.getEmployeeCode()) + .employeeName(name) + .reason("Missing ESI IP number (statutory_info esi_number/ip_number) — excluded from return file") + .build()); + continue; + } + rows.add(EsiReturnRowDTO.builder() + .employeeCode(agg.employee.getEmployeeCode()) + .ipNumber(ipNumber.trim()) + .name(name) + .daysWorked(agg.paidDays.setScale(0, RoundingMode.HALF_UP).intValue()) + .monthlyWage(agg.gross.setScale(2, RoundingMode.HALF_UP)) + .ipContribution(agg.employeeContribution.setScale(2, RoundingMode.HALF_UP)) + .employerContribution(agg.employerContribution.setScale(2, RoundingMode.HALF_UP)) + .build()); + } + rows.sort(Comparator.comparing(r -> r.getEmployeeCode() == null ? "" : r.getEmployeeCode())); + + return EsiReturnResponseDTO.builder() + .instituteId(instituteId) + .month(month) + .year(year) + .esiEmployerCode(employerCode) + .rows(rows) + .skipped(skipped) + .warnings(warnings) + .ipCount(rows.size()) + .totalWages(sum(rows, EsiReturnRowDTO::getMonthlyWage)) + .totalIpContribution(sum(rows, EsiReturnRowDTO::getIpContribution)) + .totalEmployerContribution(sum(rows, EsiReturnRowDTO::getEmployerContribution)) + .build(); + } + + /** CSV download: skipped employees (no IP number) are excluded from the file. */ + public String buildCsv(EsiReturnResponseDTO response) { + StringBuilder sb = new StringBuilder(); + sb.append("IP Number,IP Name,No Of Days,Monthly Wage,IP Contribution,Employer Contribution\r\n"); + for (EsiReturnRowDTO row : response.getRows()) { + sb.append(String.join(",", + csv(row.getIpNumber()), + csv(row.getName()), + String.valueOf(row.getDaysWorked() == null ? 0 : row.getDaysWorked()), + plain(row.getMonthlyWage()), + plain(row.getIpContribution()), + plain(row.getEmployerContribution()))); + sb.append("\r\n"); + } + return sb.toString(); + } + + /** IP number lives in the decrypted statutory_info map: esi_number, fallback ip_number. */ + private static String resolveIpNumber(EmployeeProfile employee) { + Map info = employee.getStatutoryInfo(); + if (info == null) { + return null; + } + Object value = info.get("esi_number"); + if (value == null || value.toString().isBlank()) { + value = info.get("ip_number"); + } + return value == null ? null : value.toString(); + } + + private String resolveEmployerCode(String instituteId, List warnings) { + TaxConfiguration config = taxConfigurationRepository + .findByInstituteIdAndCountryCode(instituteId, "IN").orElse(null); + if (config == null) { + warnings.add("No India (IN) tax configuration found for this institute; " + + "ESI employer code is blank."); + return ""; + } + Map settings = config.getStatutorySettings(); + Object value = settings == null ? null : settings.get("esi_employer_code"); + if (value == null || value.toString().isBlank()) { + warnings.add("statutory_settings.esi_employer_code is not configured; " + + "set it in the tax configuration before filing."); + return ""; + } + return value.toString().trim(); + } + + private Map buildUserNameMap(List userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + List users = userRepository.findByIdIn(userIds); + return users.stream().collect(Collectors.toMap( + User::getId, + u -> u.getFullName() != null ? u.getFullName() : u.getUsername(), + (a, b) -> a)); + } + + private static String csv(String value) { + if (value == null) { + return ""; + } + String cleaned = value.replace("\r", " ").replace("\n", " "); + if (cleaned.contains(",") || cleaned.contains("\"")) { + return "\"" + cleaned.replace("\"", "\"\"") + "\""; + } + return cleaned; + } + + private static String plain(BigDecimal value) { + return value == null ? "0.00" : value.toPlainString(); + } + + private static BigDecimal nvl(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + private static BigDecimal sum(List rows, + java.util.function.Function getter) { + return rows.stream().map(getter).filter(java.util.Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private static final class IpAgg { + private final EmployeeProfile employee; + private final Set seenEntryIds = new HashSet<>(); + private boolean hasEmployeeSide = false; + private BigDecimal employeeContribution = BigDecimal.ZERO; + private BigDecimal employerContribution = BigDecimal.ZERO; + private BigDecimal gross = BigDecimal.ZERO; + private BigDecimal paidDays = BigDecimal.ZERO; + + private IpAgg(EmployeeProfile employee) { + this.employee = employee; + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/Form16Service.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/Form16Service.java new file mode 100644 index 0000000000..2f009c002a --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/Form16Service.java @@ -0,0 +1,323 @@ +package vacademy.io.admin_core_service.features.hr_compliance.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.features.hr_compliance.dto.Form16DataDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.Form16MonthlyRowDTO; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_payslip.service.HrFileStorageService; +import vacademy.io.admin_core_service.features.hr_tax.entity.TaxComputation; +import vacademy.io.admin_core_service.features.hr_tax.entity.TaxConfiguration; +import vacademy.io.admin_core_service.features.hr_tax.repository.TaxComputationRepository; +import vacademy.io.admin_core_service.features.hr_tax.repository.TaxConfigurationRepository; +import vacademy.io.admin_core_service.features.hr_tax.repository.TaxDeclarationRepository; +import vacademy.io.common.auth.entity.User; +import vacademy.io.common.auth.repository.UserRepository; +import vacademy.io.common.exceptions.VacademyException; + +import java.math.BigDecimal; +import java.text.DecimalFormat; +import java.time.Month; +import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Form 16 Part B (Annexure) assembly (Phase D). + * + * Data derivation: hr_tax_computation stores CUMULATIVE actual_income_till_date + * and actual_tax_deducted per employee/FY/month. Monthly amounts are the deltas + * between consecutive present months in FY order (Apr..Mar); the first present + * month's delta is its cumulative itself. Months with no computation row are + * simply absent. Annual figures (taxable income, slab tax, cess, ...) come from + * the LAST computed month's computation_details — the engine's projection, + * which equals actuals once March is computed. + */ +@Service +public class Form16Service { + + private static final DecimalFormat MONEY = new DecimalFormat("#,##0.00"); + + @Autowired + private TaxComputationRepository taxComputationRepository; + + @Autowired + private TaxConfigurationRepository taxConfigurationRepository; + + @Autowired + private TaxDeclarationRepository taxDeclarationRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private HrFileStorageService hrFileStorageService; + + /** The validated employee comes from HrAccessGuard.requireSelfOrHrStaff — never re-fetched here. */ + public Form16DataDTO buildForm16(EmployeeProfile employee, String instituteId, String financialYear) { + List computations = taxComputationRepository + .findByEmployee_IdAndFinancialYearOrderByMonthAsc(employee.getId(), financialYear); + if (computations.isEmpty()) { + throw new VacademyException("No tax computations found for this employee in FY " + financialYear + + " — Form 16 cannot be generated"); + } + + // Repository orders by raw calendar month (1..12); re-sort into FY order Apr..Mar. + computations.sort(Comparator.comparingInt(c -> fyIndex(c.getMonth()))); + TaxComputation last = computations.get(computations.size() - 1); + + List warnings = new ArrayList<>(); + + // Monthly actuals from cumulative deltas + List monthlyRows = new ArrayList<>(); + BigDecimal prevIncome = BigDecimal.ZERO; + BigDecimal prevTds = BigDecimal.ZERO; + BigDecimal grossSalaryPaid = BigDecimal.ZERO; + for (TaxComputation c : computations) { + BigDecimal cumIncome = nvl(c.getActualIncomeTillDate()); + BigDecimal cumTds = nvl(c.getActualTaxDeducted()); + BigDecimal incomePaid = cumIncome.subtract(prevIncome); + BigDecimal tdsDeducted = cumTds.subtract(prevTds); + monthlyRows.add(Form16MonthlyRowDTO.builder() + .month(c.getMonth()) + .year(c.getYear()) + .monthName(monthName(c.getMonth())) + .incomePaid(incomePaid) + .tdsDeducted(tdsDeducted) + .build()); + grossSalaryPaid = grossSalaryPaid.add(incomePaid); + prevIncome = cumIncome; + prevTds = cumTds; + } + + // Annual figures from the last computed month's engine breakdown + Map details = last.getComputationDetails() != null + ? last.getComputationDetails() : Map.of(); + + Map chapterVIA = new LinkedHashMap<>(); + for (Map.Entry e : details.entrySet()) { + if (e.getKey().startsWith("deduction")) { + BigDecimal v = toBigDecimal(e.getValue()); + if (v != null) chapterVIA.put(e.getKey(), v); + } + } + + String regime = details.get("regime") instanceof String s && StringUtils.hasText(s) + ? s + : taxDeclarationRepository.findByEmployee_IdAndFinancialYear(employee.getId(), financialYear) + .map(d -> d.getRegime() != null ? d.getRegime() : "").orElse(""); + + if (fyIndex(last.getMonth()) < 11) { + warnings.add("FY " + financialYear + " is computed only through " + monthName(last.getMonth()) + + " — annual figures (taxable income, tax liability) are projections, not final actuals"); + } + if (!StringUtils.hasText(employee.getPanNumber())) { + warnings.add("Employee PAN is not on record — Form 16 requires the deductee PAN"); + } + + Map deductor = loadDeductorSettings(instituteId, warnings); + + return Form16DataDTO.builder() + .employeeId(employee.getId()) + .employeeName(resolveEmployeeName(employee.getUserId())) + .employeeCode(nvlStr(employee.getEmployeeCode())) + .employeePan(nvlStr(employee.getPanNumber())) + .deductorName(deductor.get("deductor_name")) + .deductorTan(deductor.get("tan")) + .deductorPan(deductor.get("employer_pan")) + .deductorAddress(deductor.get("deductor_address")) + .financialYear(financialYear) + .regime(regime) + .grossSalaryPaid(grossSalaryPaid) + .standardDeduction(toBigDecimal(details.get("standardDeduction"))) + .hraExemption(toBigDecimal(details.get("hraExemption"))) + .totalExemptions(nvl(last.getTotalExemptions())) + .chapterVIADeductions(chapterVIA) + .taxableIncome(toBigDecimal(details.get("taxableIncome"))) + .slabTax(toBigDecimal(details.get("slabTax"))) + .taxAfterRebate(toBigDecimal(details.get("taxAfterRebate"))) + .surcharge(toBigDecimal(details.get("surcharge"))) + .cess(toBigDecimal(details.get("cess"))) + .totalTaxLiability(nvl(last.getProjectedAnnualTax())) + .totalTdsDeducted(nvl(last.getActualTaxDeducted())) + .lastComputedMonth(last.getMonth()) + .monthlyDetails(monthlyRows) + .warnings(warnings) + .build(); + } + + /** Renders the Part B data as a printable PDF (openhtmltopdf via HrFileStorageService). */ + public byte[] renderForm16Pdf(Form16DataDTO data) { + return hrFileStorageService.htmlToPdf(buildForm16Html(data)); + } + + // ------------------------------------------------------------------ html + + private String buildForm16Html(Form16DataDTO d) { + StringBuilder sb = new StringBuilder(); + sb.append(""); + + sb.append("

FORM 16 - PART B (Annexure)

"); + sb.append("
Part A of Form 16 is issued from TRACES. This Part B is " + + "system-generated from payroll tax computations for verification purposes.
"); + + sb.append("

Deductor / Deductee

"); + row2(sb, "Deductor (Employer)", esc(d.getDeductorName()), "Employee Name", esc(d.getEmployeeName())); + row2(sb, "TAN", esc(d.getDeductorTan()), "Employee Code", esc(d.getEmployeeCode())); + row2(sb, "Employer PAN", esc(d.getDeductorPan()), "Employee PAN", esc(d.getEmployeePan())); + row2(sb, "Address", esc(d.getDeductorAddress()), "Financial Year", esc(d.getFinancialYear())); + row2(sb, "Tax Regime", esc(d.getRegime()), "", ""); + sb.append("
"); + + sb.append("

1. Gross Salary and Monthly Detail

") + .append(""); + if (d.getMonthlyDetails() != null) { + for (Form16MonthlyRowDTO m : d.getMonthlyDetails()) { + sb.append(""); + } + } + sb.append("") + .append("
MonthSalary Paid (Rs.)TDS Deducted (Rs.)
").append(esc(m.getMonthName())).append(" ").append(m.getYear()) + .append("").append(money(m.getIncomePaid())) + .append("").append(money(m.getTdsDeducted())) + .append("
Total").append(money(d.getGrossSalaryPaid())) + .append("").append(money(d.getTotalTdsDeducted())).append("
"); + + sb.append("

2. Exemptions and Deductions

"); + moneyRow(sb, "Standard Deduction (Sec 16)", d.getStandardDeduction()); + moneyRow(sb, "HRA Exemption (Sec 10(13A))", d.getHraExemption()); + if (d.getChapterVIADeductions() != null) { + for (Map.Entry e : d.getChapterVIADeductions().entrySet()) { + moneyRow(sb, esc(chapterVIALabel(e.getKey())), e.getValue()); + } + } + moneyRow(sb, "Total Exemptions and Deductions", d.getTotalExemptions()); + sb.append("
"); + + sb.append("

3. Tax on Total Income

"); + moneyRow(sb, "Taxable Income", d.getTaxableIncome()); + moneyRow(sb, "Tax on Taxable Income (slab)", d.getSlabTax()); + moneyRow(sb, "Tax after Rebate (Sec 87A)", d.getTaxAfterRebate()); + moneyRow(sb, "Surcharge", d.getSurcharge()); + moneyRow(sb, "Health and Education Cess (4%)", d.getCess()); + moneyRow(sb, "Total Tax Liability", d.getTotalTaxLiability()); + moneyRow(sb, "Total TDS Deducted (Sec 192)", d.getTotalTdsDeducted()); + sb.append("
"); + + if (d.getWarnings() != null && !d.getWarnings().isEmpty()) { + sb.append("
"); + for (String w : d.getWarnings()) { + sb.append("⚠ ").append(esc(w)).append("
"); + } + sb.append("
"); + } + + sb.append(""); + return sb.toString(); + } + + private static void row2(StringBuilder sb, String l1, String v1, String l2, String v2) { + sb.append("").append(l1).append("").append(v1) + .append("").append(l2).append("").append(v2) + .append(""); + } + + private static void moneyRow(StringBuilder sb, String label, BigDecimal value) { + sb.append("").append(label).append("") + .append(money(value)).append(""); + } + + private static String chapterVIALabel(String key) { + return switch (key) { + case "deduction80c" -> "Deduction under Sec 80C"; + case "deduction80d" -> "Deduction under Sec 80D"; + case "deduction80ccd2" -> "Deduction under Sec 80CCD(2)"; + default -> key; + }; + } + + // --------------------------------------------------------------- helpers + + private Map loadDeductorSettings(String instituteId, List warnings) { + Map settings = taxConfigurationRepository + .findByInstituteIdAndCountryCode(instituteId, "IN") + .or(() -> taxConfigurationRepository.findAllByInstituteIdAndStatus(instituteId, "ACTIVE") + .stream().findFirst()) + .map(TaxConfiguration::getStatutorySettings) + .orElse(null); + + Map out = new LinkedHashMap<>(); + for (String key : List.of("deductor_name", "deductor_address", "employer_pan", "tan")) { + Object v = settings != null ? settings.get(key) : null; + String s = v != null ? v.toString().trim() : ""; + out.put(key, s); + if (s.isEmpty()) { + warnings.add("Statutory setting '" + key + + "' is not configured in tax configuration statutory_settings"); + } + } + return out; + } + + private String resolveEmployeeName(String userId) { + if (!StringUtils.hasText(userId)) return ""; + List users = userRepository.findByIdIn(List.of(userId)); + if (users.isEmpty()) return ""; + User u = users.get(0); + return u.getFullName() != null ? u.getFullName() : nvlStr(u.getUsername()); + } + + /** FY position of a calendar month: Apr=0 ... Mar=11 (TDS months belong to the FY Apr-Mar). */ + static int fyIndex(Integer month) { + int m = month != null ? month : 1; + return m >= 4 ? m - 4 : m + 8; + } + + static String monthName(Integer month) { + if (month == null || month < 1 || month > 12) return ""; + return Month.of(month).getDisplayName(TextStyle.SHORT, Locale.ENGLISH); + } + + static BigDecimal toBigDecimal(Object value) { + if (value == null) return null; + if (value instanceof BigDecimal bd) return bd; + try { + return new BigDecimal(value.toString()); + } catch (NumberFormatException e) { + return null; + } + } + + private static BigDecimal nvl(BigDecimal v) { + return v != null ? v : BigDecimal.ZERO; + } + + private static String nvlStr(String v) { + return v != null ? v : ""; + } + + private static String money(BigDecimal v) { + return v != null ? MONEY.format(v) : "-"; + } + + private static String esc(String s) { + if (s == null) return ""; + return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/Form24QService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/Form24QService.java new file mode 100644 index 0000000000..3c3eea3d36 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/Form24QService.java @@ -0,0 +1,312 @@ +package vacademy.io.admin_core_service.features.hr_compliance.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.features.hr_compliance.dto.Form24QChallanDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.Form24QDeducteeRowDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.Form24QDeductorDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.Form24QResponseDTO; +import vacademy.io.admin_core_service.features.hr_compliance.entity.TdsChallan; +import vacademy.io.admin_core_service.features.hr_compliance.repository.ComplianceTaxQueryRepository; +import vacademy.io.admin_core_service.features.hr_compliance.repository.TdsChallanRepository; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_tax.entity.TaxComputation; +import vacademy.io.admin_core_service.features.hr_tax.entity.TaxConfiguration; +import vacademy.io.admin_core_service.features.hr_tax.repository.TaxConfigurationRepository; +import vacademy.io.common.auth.entity.User; +import vacademy.io.common.auth.repository.UserRepository; +import vacademy.io.common.exceptions.VacademyException; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Form 24Q quarterly TDS return assembly (Phase D). + * + * Data derivation: monthly income / TDS amounts are cumulative deltas over the + * FULL FY series of hr_tax_computation rows (per employee, FY order Apr..Mar), + * because e.g. July's monthly figure = July cumulative - June cumulative even + * though only Jul/Aug/Sep land in Q2's annexure. One annexure row is emitted + * per employee per quarter month with TDS > 0, under section 192. + */ +@Service +public class Form24QService { + + /** Salary TDS section for every annexure row. */ + private static final String SECTION_192 = "192"; + + /** FY quarters: Q1 = Apr-Jun ... Q4 = Jan-Mar (calendar year = FY start year + 1). */ + private static final Map QUARTER_MONTHS = Map.of( + "Q1", new int[]{4, 5, 6}, + "Q2", new int[]{7, 8, 9}, + "Q3", new int[]{10, 11, 12}, + "Q4", new int[]{1, 2, 3}); + + @Autowired + private ComplianceTaxQueryRepository complianceTaxQueryRepository; + + @Autowired + private TdsChallanRepository tdsChallanRepository; + + @Autowired + private TaxConfigurationRepository taxConfigurationRepository; + + @Autowired + private UserRepository userRepository; + + public Form24QResponseDTO buildForm24Q(String instituteId, String financialYear, String quarter) { + String q = quarter != null ? quarter.toUpperCase() : ""; + int[] months = QUARTER_MONTHS.get(q); + if (months == null) { + throw new VacademyException("quarter must be Q1..Q4 (FY quarters, Q1 = Apr-Jun)"); + } + + List warnings = new ArrayList<>(); + Form24QDeductorDTO deductor = loadDeductor(instituteId, warnings); + + // ---- deductee annexure: cumulative deltas over each employee's FY series + List allComputations = complianceTaxQueryRepository + .findAllByInstituteAndFinancialYear(instituteId, financialYear); + + Map> byEmployee = allComputations.stream() + .collect(Collectors.groupingBy(c -> c.getEmployee().getId(), LinkedHashMap::new, + Collectors.toList())); + + List rows = new ArrayList<>(); + BigDecimal totalTds = BigDecimal.ZERO; + for (List series : byEmployee.values()) { + series.sort(Comparator.comparingInt(c -> Form16Service.fyIndex(c.getMonth()))); + EmployeeProfile employee = series.get(0).getEmployee(); + + BigDecimal prevIncome = BigDecimal.ZERO; + BigDecimal prevTds = BigDecimal.ZERO; + for (TaxComputation c : series) { + BigDecimal cumIncome = nvl(c.getActualIncomeTillDate()); + BigDecimal cumTds = nvl(c.getActualTaxDeducted()); + BigDecimal incomePaid = cumIncome.subtract(prevIncome); + BigDecimal tdsDeducted = cumTds.subtract(prevTds); + prevIncome = cumIncome; + prevTds = cumTds; + + if (!inQuarter(months, c.getMonth()) || tdsDeducted.signum() <= 0) { + continue; + } + rows.add(Form24QDeducteeRowDTO.builder() + .employeeId(employee.getId()) + .pan(nvlStr(employee.getPanNumber())) + .employeeCode(nvlStr(employee.getEmployeeCode())) + .month(c.getMonth()) + .year(c.getYear() != null ? c.getYear() : calendarYearFor(financialYear, c.getMonth())) + .monthName(Form16Service.monthName(c.getMonth())) + .incomePaid(incomePaid) + .tdsDeducted(tdsDeducted) + .section(SECTION_192) + .build()); + totalTds = totalTds.add(tdsDeducted); + + if (!StringUtils.hasText(employee.getPanNumber())) { + String code = StringUtils.hasText(employee.getEmployeeCode()) + ? employee.getEmployeeCode() : employee.getId(); + String w = "PAN missing for employee " + code + " — 24Q annexure rows need a valid PAN"; + if (!warnings.contains(w)) warnings.add(w); + } + } + } + + // Fill names in one batch, then sort the annexure by name + FY month + Map nameByUserId = buildUserNameMap(byEmployee.values().stream() + .map(s -> s.get(0).getEmployee().getUserId()).distinct().collect(Collectors.toList())); + Map userIdByEmployeeId = byEmployee.values().stream() + .collect(Collectors.toMap(s -> s.get(0).getEmployee().getId(), + s -> nvlStr(s.get(0).getEmployee().getUserId()), (a, b) -> a)); + for (Form24QDeducteeRowDTO row : rows) { + row.setName(nameByUserId.getOrDefault(userIdByEmployeeId.getOrDefault(row.getEmployeeId(), ""), "")); + } + rows.sort(Comparator.comparing(Form24QDeducteeRowDTO::getName) + .thenComparing(r -> Form16Service.fyIndex(r.getMonth()))); + + // ---- challans for the quarter + List challans = tdsChallanRepository + .findByInstituteIdAndFinancialYearAndQuarterOrderByDepositDateAsc(instituteId, financialYear, q); + BigDecimal challanTotal = challans.stream() + .map(c -> nvl(c.getAmount())) + .reduce(BigDecimal.ZERO, BigDecimal::add); + if (challans.isEmpty() && totalTds.signum() > 0) { + warnings.add("No TDS challans recorded for " + q + " FY " + financialYear + + " — record deposits in the challan register before filing"); + } + + boolean mismatch = totalTds.compareTo(challanTotal) != 0; + if (mismatch) { + warnings.add("Quarter TDS deducted (" + totalTds.toPlainString() + ") does not match challan deposits (" + + challanTotal.toPlainString() + ")"); + } + + return Form24QResponseDTO.builder() + .financialYear(financialYear) + .quarter(q) + .deductor(deductor) + .challans(challans.stream().map(this::toChallanDTO).collect(Collectors.toList())) + .deducteeRows(rows) + .totalTdsDeducted(totalTds) + .totalChallanAmount(challanTotal) + .mismatch(mismatch) + .warnings(warnings) + .build(); + } + + /** + * CSV rendering of the 24Q data. v1 PREPARER-INPUT format: a commented + * header block, a challan section and a deductee annexure section. This is + * NOT the FVU e-TDS binary — the CSV feeds a return preparer / RPU-style + * utility which produces the actual e-TDS file. + */ + public String toCsv(Form24QResponseDTO data) { + StringBuilder sb = new StringBuilder(); + sb.append("# FORM 24Q PREPARER INPUT (v1) - feed to a TDS return preparer utility;" + + " this file is NOT the FVU e-TDS format\n"); + sb.append("# Financial Year: ").append(data.getFinancialYear()) + .append(" Quarter: ").append(data.getQuarter()).append("\n"); + if (data.getWarnings() != null) { + for (String w : data.getWarnings()) { + sb.append("# WARNING: ").append(w.replace("\n", " ")).append("\n"); + } + } + sb.append("\n[DEDUCTOR]\n"); + sb.append(csv("Deductor Name", "TAN", "PAN", "Address", "Financial Year", "Quarter")).append("\n"); + Form24QDeductorDTO d = data.getDeductor(); + sb.append(csv(d.getName(), d.getTan(), d.getPan(), d.getAddress(), + data.getFinancialYear(), data.getQuarter())).append("\n"); + + sb.append("\n[CHALLANS]\n"); + sb.append(csv("Sr No", "Deposit Date", "BSR Code", "Challan Serial", "Amount", "Interest", "Fee")).append("\n"); + int sr = 1; + for (Form24QChallanDTO c : data.getChallans()) { + sb.append(csv(String.valueOf(sr++), + c.getDepositDate() != null ? c.getDepositDate().toString() : "", + nvlStr(c.getBsrCode()), nvlStr(c.getChallanSerial()), + plain(c.getAmount()), plain(c.getInterest()), plain(c.getFee()))).append("\n"); + } + + sb.append("\n[DEDUCTEE ANNEXURE]\n"); + sb.append(csv("Sr No", "PAN", "Name", "Employee Code", "Month", "Year", "Section", + "Income Paid", "TDS Deducted")).append("\n"); + sr = 1; + for (Form24QDeducteeRowDTO r : data.getDeducteeRows()) { + sb.append(csv(String.valueOf(sr++), nvlStr(r.getPan()), nvlStr(r.getName()), + nvlStr(r.getEmployeeCode()), nvlStr(r.getMonthName()), + r.getYear() != null ? String.valueOf(r.getYear()) : "", + nvlStr(r.getSection()), plain(r.getIncomePaid()), plain(r.getTdsDeducted()))).append("\n"); + } + + sb.append("\n[TOTALS]\n"); + sb.append(csv("Quarter TDS Deducted", "Challan Deposits", "Mismatch")).append("\n"); + sb.append(csv(plain(data.getTotalTdsDeducted()), plain(data.getTotalChallanAmount()), + String.valueOf(data.isMismatch()))).append("\n"); + return sb.toString(); + } + + // --------------------------------------------------------------- helpers + + private Form24QDeductorDTO loadDeductor(String instituteId, List warnings) { + Map settings = taxConfigurationRepository + .findByInstituteIdAndCountryCode(instituteId, "IN") + .or(() -> taxConfigurationRepository.findAllByInstituteIdAndStatus(instituteId, "ACTIVE") + .stream().findFirst()) + .map(TaxConfiguration::getStatutorySettings) + .orElse(null); + + Map values = new LinkedHashMap<>(); + for (String key : List.of("deductor_name", "deductor_address", "employer_pan", "tan")) { + Object v = settings != null ? settings.get(key) : null; + String s = v != null ? v.toString().trim() : ""; + values.put(key, s); + if (s.isEmpty()) { + warnings.add("Statutory setting '" + key + + "' is not configured in tax configuration statutory_settings"); + } + } + return Form24QDeductorDTO.builder() + .name(values.get("deductor_name")) + .address(values.get("deductor_address")) + .pan(values.get("employer_pan")) + .tan(values.get("tan")) + .build(); + } + + private Form24QChallanDTO toChallanDTO(TdsChallan c) { + return Form24QChallanDTO.builder() + .id(c.getId()) + .depositDate(c.getDepositDate()) + .bsrCode(c.getBsrCode()) + .challanSerial(c.getChallanSerial()) + .amount(c.getAmount()) + .interest(c.getInterest()) + .fee(c.getFee()) + .build(); + } + + private Map buildUserNameMap(List userIds) { + List ids = userIds.stream().filter(StringUtils::hasText).collect(Collectors.toList()); + if (ids.isEmpty()) return Map.of(); + List users = userRepository.findByIdIn(ids); + return users.stream().collect(Collectors.toMap( + User::getId, + u -> u.getFullName() != null ? u.getFullName() : nvlStr(u.getUsername()), + (a, b) -> a)); + } + + private static boolean inQuarter(int[] months, Integer month) { + if (month == null) return false; + for (int m : months) { + if (m == month) return true; + } + return false; + } + + /** Months 4-12 fall in the FY start year; months 1-3 in start year + 1. */ + static int calendarYearFor(String financialYear, Integer month) { + int startYear; + try { + startYear = Integer.parseInt(financialYear.substring(0, 4)); + } catch (Exception e) { + return 0; + } + return (month != null && month >= 4) ? startYear : startYear + 1; + } + + private static String csv(String... cells) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < cells.length; i++) { + if (i > 0) sb.append(','); + sb.append(escapeCsv(cells[i])); + } + return sb.toString(); + } + + private static String escapeCsv(String s) { + if (s == null) return ""; + if (s.contains(",") || s.contains("\"") || s.contains("\n") || s.contains("\r")) { + return "\"" + s.replace("\"", "\"\"") + "\""; + } + return s; + } + + private static String plain(BigDecimal v) { + return v != null ? v.toPlainString() : "0"; + } + + private static BigDecimal nvl(BigDecimal v) { + return v != null ? v : BigDecimal.ZERO; + } + + private static String nvlStr(String v) { + return v != null ? v : ""; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/GratuityProvisionService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/GratuityProvisionService.java new file mode 100644 index 0000000000..bdd9592847 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/GratuityProvisionService.java @@ -0,0 +1,278 @@ +package vacademy.io.admin_core_service.features.hr_compliance.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.hr_compliance.dto.GratuityProvisionReportDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.GratuityProvisionRowDTO; +import vacademy.io.admin_core_service.features.hr_compliance.repository.ComplianceProvisionQueryRepository; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_salary.entity.EmployeeSalaryComponent; +import vacademy.io.admin_core_service.features.hr_salary.entity.EmployeeSalaryStructure; +import vacademy.io.admin_core_service.features.hr_salary.repository.EmployeeSalaryStructureRepository; +import vacademy.io.common.auth.entity.User; +import vacademy.io.common.auth.repository.UserRepository; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.Period; +import java.time.YearMonth; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Gratuity provision report (Phase D) under the Payment of Gratuity Act, 1972. + * + *

Statutory basis, s.4: gratuity = (15/26) x last drawn monthly wages + * (basic + DA) x completed years of service, where a part of a year in excess + * of six months counts as a full year, capped at Rs 20,00,000 (ceiling per the + * 2018 amendment + MoLE notification S.O.1420(E)). + * + *

Vesting: payable after 5 years of continuous service (s.4(1)), which + * judicial precedent (Mettur Beardsell Ltd. v. RLC, Madras HC) reads as + * 4 years + 240 days. Employees short of vesting still carry an accounting + * provision (AS-15/Ind AS-19 accrual view) and are reported flagged unvested. + * + *

Monthly run-rate: 4.81% of monthly basic — the payroll-costing + * approximation of (15/26)/12. + */ +@Service +public class GratuityProvisionService { + + /** Rs 20,00,000 statutory ceiling on gratuity payable. */ + static final BigDecimal GRATUITY_CEILING = new BigDecimal("2000000.00"); + /** Monthly accrual run-rate: 4.81% of monthly basic. */ + static final BigDecimal MONTHLY_RUN_RATE_PCT = new BigDecimal("4.81"); + static final Set EXITED_STATUSES = Set.of("TERMINATED", "RELIEVED", "ABSCONDING"); + private static final BigDecimal FIFTEEN = new BigDecimal("15"); + private static final BigDecimal TWENTY_SIX = new BigDecimal("26"); + private static final BigDecimal DAYS_PER_YEAR = new BigDecimal("365.2425"); + private static final BigDecimal HALF = new BigDecimal("0.50"); + private static final BigDecimal HUNDRED = new BigDecimal("100"); + + @Autowired + private ComplianceProvisionQueryRepository provisionQueryRepository; + + @Autowired + private EmployeeSalaryStructureRepository salaryStructureRepository; + + @Autowired + private UserRepository userRepository; + + @Transactional(readOnly = true) + public GratuityProvisionReportDTO buildReport(String instituteId, LocalDate asOfDate) { + LocalDate asOf = asOfDate != null ? asOfDate : LocalDate.now(); + List employees = provisionQueryRepository.findAllEmployeesByInstitute(instituteId); + + Map names = buildUserNameMap( + employees.stream().map(EmployeeProfile::getUserId).filter(java.util.Objects::nonNull) + .distinct().collect(Collectors.toList())); + + List rows = new ArrayList<>(); + BigDecimal totalAccrued = BigDecimal.ZERO; + BigDecimal vestedAccrued = BigDecimal.ZERO; + BigDecimal unvestedAccrued = BigDecimal.ZERO; + BigDecimal totalRunRate = BigDecimal.ZERO; + String reportCurrency = null; + + for (EmployeeProfile e : employees) { + if (e.getJoinDate() == null || e.getJoinDate().isAfter(asOf)) { + continue; // not yet in service as of the report date + } + boolean exited = e.getEmploymentStatus() != null + && EXITED_STATUSES.contains(e.getEmploymentStatus().toUpperCase()); + boolean exitedInAsOfMonth = false; + if (exited) { + // Exited employees stay on the report only for their exit month, + // so the month-end provision movement (release on payout) is visible. + LocalDate lwd = e.getLastWorkingDate(); + if (lwd == null || !YearMonth.from(lwd).equals(YearMonth.from(asOf))) { + continue; + } + exitedInAsOfMonth = true; + } + + LocalDate serviceEnd = asOf; + if (e.getLastWorkingDate() != null && e.getLastWorkingDate().isBefore(asOf)) { + serviceEnd = e.getLastWorkingDate(); + } + if (serviceEnd.isBefore(e.getJoinDate())) { + serviceEnd = e.getJoinDate(); + } + + long serviceDays = ChronoUnit.DAYS.between(e.getJoinDate(), serviceEnd); + BigDecimal rawYears = new BigDecimal(serviceDays) + .divide(DAYS_PER_YEAR, 2, RoundingMode.HALF_UP); + + Period p = Period.between(e.getJoinDate(), serviceEnd); + int completedYears = p.getYears(); + // s.4(2): a part of a year "in excess of six months" rounds up to a + // full year — applied once past the 5-year vesting threshold, per + // the reporting convention for this pack. + boolean partExceedsSixMonths = p.getMonths() > 6 || (p.getMonths() == 6 && p.getDays() > 0); + int roundedYears = completedYears + ((completedYears >= 5 && partExceedsSixMonths) ? 1 : 0); + + // Vesting: 4 years + 240 days of continuous service. + boolean vested = !e.getJoinDate().plusYears(4).plusDays(240).isAfter(serviceEnd); + + BasicResolution basic = resolveMonthlyBasic(e.getId()); + if (reportCurrency == null && basic.currency != null) { + reportCurrency = basic.currency; + } + + BigDecimal accrued = BigDecimal.ZERO; + BigDecimal runRate = BigDecimal.ZERO; + boolean capped = false; + if (basic.amount != null) { + accrued = basic.amount.multiply(FIFTEEN) + .divide(TWENTY_SIX, 10, RoundingMode.HALF_UP) + .multiply(new BigDecimal(roundedYears)) + .setScale(2, RoundingMode.HALF_UP); + if (accrued.compareTo(GRATUITY_CEILING) > 0) { + accrued = GRATUITY_CEILING; + capped = true; + } + runRate = basic.amount.multiply(MONTHLY_RUN_RATE_PCT) + .divide(HUNDRED, 2, RoundingMode.HALF_UP); + } + + totalAccrued = totalAccrued.add(accrued); + if (vested) { + vestedAccrued = vestedAccrued.add(accrued); + } else { + unvestedAccrued = unvestedAccrued.add(accrued); + } + totalRunRate = totalRunRate.add(runRate); + + rows.add(GratuityProvisionRowDTO.builder() + .employeeId(e.getId()) + .employeeCode(e.getEmployeeCode()) + .employeeName(names.getOrDefault(e.getUserId(), "Unknown")) + .employmentStatus(e.getEmploymentStatus()) + .joinDate(e.getJoinDate()) + .serviceEndDate(serviceEnd) + .exitedInAsOfMonth(exitedInAsOfMonth) + .rawYears(rawYears) + .roundedYears(roundedYears) + .monthlyBasic(basic.amount) + .basicSource(basic.source) + .accruedLiability(accrued) + .cappedAtCeiling(capped) + .vested(vested) + .monthlyRunRate(runRate) + .currency(basic.currency != null ? basic.currency : "INR") + .build()); + } + + rows.sort(Comparator.comparing(r -> r.getEmployeeCode() != null ? r.getEmployeeCode() : "", + String.CASE_INSENSITIVE_ORDER)); + + return GratuityProvisionReportDTO.builder() + .instituteId(instituteId) + .asOfDate(asOf) + .employeeCount(rows.size()) + .totalAccruedLiability(totalAccrued) + .vestedAccruedLiability(vestedAccrued) + .unvestedAccruedLiability(unvestedAccrued) + .totalMonthlyRunRate(totalRunRate) + .currency(reportCurrency != null ? reportCurrency : "INR") + .rows(rows) + .build(); + } + + /** CSV rendering of the same report for download. */ + @Transactional(readOnly = true) + public String buildReportCsv(String instituteId, LocalDate asOfDate) { + GratuityProvisionReportDTO report = buildReport(instituteId, asOfDate); + StringBuilder sb = new StringBuilder(); + sb.append("employee_code,employee_name,employment_status,join_date,service_end_date,") + .append("raw_years,rounded_years,monthly_basic,basic_source,accrued_liability,") + .append("capped_at_ceiling,vested,monthly_run_rate,currency,exited_in_as_of_month\n"); + for (GratuityProvisionRowDTO r : report.getRows()) { + sb.append(csv(r.getEmployeeCode())).append(',') + .append(csv(r.getEmployeeName())).append(',') + .append(csv(r.getEmploymentStatus())).append(',') + .append(csv(r.getJoinDate())).append(',') + .append(csv(r.getServiceEndDate())).append(',') + .append(csv(r.getRawYears())).append(',') + .append(csv(r.getRoundedYears())).append(',') + .append(csv(r.getMonthlyBasic())).append(',') + .append(csv(r.getBasicSource())).append(',') + .append(csv(r.getAccruedLiability())).append(',') + .append(csv(r.getCappedAtCeiling())).append(',') + .append(csv(r.getVested())).append(',') + .append(csv(r.getMonthlyRunRate())).append(',') + .append(csv(r.getCurrency())).append(',') + .append(csv(r.getExitedInAsOfMonth())).append('\n'); + } + sb.append("TOTAL,,,,,,,,,").append(report.getTotalAccruedLiability()).append(",,,") + .append(report.getTotalMonthlyRunRate()).append(',') + .append(report.getCurrency()).append(",\n"); + sb.append("VESTED_TOTAL,,,,,,,,,").append(report.getVestedAccruedLiability()).append(",,,,,\n"); + sb.append("UNVESTED_TOTAL,,,,,,,,,").append(report.getUnvestedAccruedLiability()).append(",,,,,\n"); + return sb.toString(); + } + + /** + * Monthly basic pay from the latest ACTIVE salary structure: the BASIC + * component's monthly amount, falling back to 50% of gross monthly when no + * BASIC component exists. (DA, where an institute pays it, is expected to + * be merged into BASIC for statutory wage purposes — see StatutoryBonusService.) + */ + private BasicResolution resolveMonthlyBasic(String employeeId) { + Optional structureOpt = salaryStructureRepository + .findFirstByEmployee_IdAndStatusOrderByEffectiveFromDesc(employeeId, "ACTIVE"); + if (structureOpt.isEmpty()) { + return new BasicResolution(null, "NONE", null); + } + EmployeeSalaryStructure structure = structureOpt.get(); + String currency = structure.getCurrency(); + if (structure.getComponents() != null) { + for (EmployeeSalaryComponent c : structure.getComponents()) { + if (c.getComponent() != null && "BASIC".equalsIgnoreCase(c.getComponent().getCode()) + && c.getMonthlyAmount() != null) { + return new BasicResolution( + c.getMonthlyAmount().setScale(2, RoundingMode.HALF_UP), + "BASIC_COMPONENT", currency); + } + } + } + if (structure.getGrossMonthly() != null) { + return new BasicResolution( + structure.getGrossMonthly().multiply(HALF).setScale(2, RoundingMode.HALF_UP), + "GROSS_FALLBACK", currency); + } + return new BasicResolution(null, "NONE", currency); + } + + private Map buildUserNameMap(List userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + List users = userRepository.findByIdIn(userIds); + return users.stream().collect(Collectors.toMap( + User::getId, + u -> u.getFullName() != null ? u.getFullName() : u.getUsername(), + (a, b) -> a)); + } + + private static String csv(Object value) { + if (value == null) return ""; + String s = String.valueOf(value); + if (s.contains(",") || s.contains("\"") || s.contains("\n")) { + return '"' + s.replace("\"", "\"\"") + '"'; + } + return s; + } + + /** Resolved monthly basic + provenance. */ + record BasicResolution(BigDecimal amount, String source, String currency) { + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/PfEcrService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/PfEcrService.java new file mode 100644 index 0000000000..617b9630b4 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/PfEcrService.java @@ -0,0 +1,246 @@ +package vacademy.io.admin_core_service.features.hr_compliance.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.hr_compliance.dto.PfEcrResponseDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.PfEcrRowDTO; +import vacademy.io.admin_core_service.features.hr_compliance.repository.ComplianceStatutoryQueryRepository; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntry; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntryComponent; +import vacademy.io.admin_core_service.features.hr_tax.entity.TaxConfiguration; +import vacademy.io.admin_core_service.features.hr_tax.repository.TaxConfigurationRepository; +import vacademy.io.common.auth.entity.User; +import vacademy.io.common.auth.repository.UserRepository; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Monthly EPFO ECR (Electronic Challan cum Return) builder — v1, PENDING + * EPFO PORTAL VALIDATION: the line layout follows the published ECR v2 + * text-file spec ({@code #~#}-separated, one line per member) but has not yet + * been round-tripped through the unified portal's file validator. + * + *

Wage-base recovery: payroll stores only the deducted PF amount per + * entry. The engine computes employee PF as 12% of min(basic, 15,000) + * rounded HALF_UP to the rupee, so the base is recovered as + * {@code wageBase = round(pfAmount / 0.12)} and the EPS (8.33%) / employer + * EPF (12% − EPS) split is re-derived from that base with the engine's own + * rounding (HALF_UP to the rupee, EPS rate 0.0833). + */ +@Service +public class PfEcrService { + + private static final BigDecimal PF_RATE = new BigDecimal("0.12"); + private static final BigDecimal EPS_RATE = new BigDecimal("0.0833"); + + /** Employee-side PF component code aliases (PayrollCalculationService.STATUTORY_ALIASES). */ + private static final Set PF_EMPLOYEE_CODES = Set.of("PF", "EPF", "PF_EMP", "PROVIDENT_FUND"); + + /** Filable run statuses: money is determined once a run is PROCESSED. */ + private static final List FILABLE_RUN_STATUSES = List.of("PROCESSED", "APPROVED", "PAID"); + + @Autowired + private ComplianceStatutoryQueryRepository statutoryQueryRepository; + + @Autowired + private TaxConfigurationRepository taxConfigurationRepository; + + @Autowired + private UserRepository userRepository; + + @Transactional(readOnly = true) + public PfEcrResponseDTO buildReturn(String instituteId, int month, int year) { + List warnings = new ArrayList<>(); + String establishmentId = resolveEstablishmentId(instituteId, warnings); + + List components = statutoryQueryRepository.findStatutoryComponents( + instituteId, month, year, FILABLE_RUN_STATUSES, PF_EMPLOYEE_CODES); + + // Aggregate per employee across the month's filable runs (regular + + // off-cycle). Amounts and gross SUM across entries; NCP days take the + // MAX across entries because attendance is a per-month fact repeated + // on each entry that carries it — summing would double-count. + Map byEmployee = new LinkedHashMap<>(); + boolean sawProcessedRun = false; + for (PayrollEntryComponent component : components) { + PayrollEntry entry = component.getPayrollEntry(); + EmployeeProfile employee = entry.getEmployee(); + if ("PROCESSED".equals(entry.getPayrollRun().getStatus())) { + sawProcessedRun = true; + } + MemberAgg agg = byEmployee.computeIfAbsent(employee.getId(), k -> new MemberAgg(employee)); + agg.pfAmount = agg.pfAmount.add(nvl(component.getAmount())); + if (agg.seenEntryIds.add(entry.getId())) { + agg.gross = agg.gross.add(nvl(entry.getGrossSalary())); + BigDecimal absent = nvl(entry.getDaysAbsent()); + if (absent.compareTo(agg.daysAbsent) > 0) { + agg.daysAbsent = absent; + } + } + } + if (sawProcessedRun) { + warnings.add("Includes payroll run(s) still in PROCESSED status (not yet approved); " + + "re-generate after approval before filing."); + } + + Map nameMap = buildUserNameMap(byEmployee.values().stream() + .map(a -> a.employee.getUserId()).distinct().collect(Collectors.toList())); + + List rows = new ArrayList<>(); + List skipped = new ArrayList<>(); + for (MemberAgg agg : byEmployee.values()) { + String name = nameMap.getOrDefault(agg.employee.getUserId(), ""); + String uan = agg.employee.getUanNumber(); // decrypted by the entity converter + if (uan == null || uan.isBlank()) { + skipped.add(PfEcrResponseDTO.SkippedRow.builder() + .employeeCode(agg.employee.getEmployeeCode()) + .employeeName(name) + .reason("Missing UAN — excluded from ECR file") + .build()); + continue; + } + // Recover the PF wage base from the deducted amount and re-derive + // the EPS / employer-EPF split with the engine's rounding. + BigDecimal epfContri = agg.pfAmount.setScale(0, RoundingMode.HALF_UP); + BigDecimal wageBase = agg.pfAmount.divide(PF_RATE, 0, RoundingMode.HALF_UP); + BigDecimal eps = wageBase.multiply(EPS_RATE).setScale(0, RoundingMode.HALF_UP); + BigDecimal employerTotal = wageBase.multiply(PF_RATE).setScale(0, RoundingMode.HALF_UP); + BigDecimal diff = employerTotal.subtract(eps); + + rows.add(PfEcrRowDTO.builder() + .employeeCode(agg.employee.getEmployeeCode()) + .uan(uan.trim()) + .memberName(name) + .grossWages(agg.gross.setScale(0, RoundingMode.HALF_UP)) + .epfWages(wageBase) + .epsWages(wageBase) + .edliWages(wageBase) + .epfContriRemitted(epfContri) + .epsContriRemitted(eps) + .epfEpsDiffRemitted(diff) + .ncpDays(agg.daysAbsent.setScale(0, RoundingMode.HALF_UP).intValue()) + .refundOfAdvances(BigDecimal.ZERO) + .build()); + } + rows.sort(Comparator.comparing(r -> nvlStr(r.getEmployeeCode()))); + + return PfEcrResponseDTO.builder() + .instituteId(instituteId) + .month(month) + .year(year) + .pfEstablishmentId(establishmentId) + .rows(rows) + .skipped(skipped) + .warnings(warnings) + .memberCount(rows.size()) + .totalEpfWages(sum(rows, PfEcrRowDTO::getEpfWages)) + .totalEpfContri(sum(rows, PfEcrRowDTO::getEpfContriRemitted)) + .totalEpsContri(sum(rows, PfEcrRowDTO::getEpsContriRemitted)) + .totalEpfEpsDiff(sum(rows, PfEcrRowDTO::getEpfEpsDiffRemitted)) + .build(); + } + + /** + * ECR v2 text file: one {@code #~#}-separated line per member with a UAN. + * Members in the skipped list are NOT written — the portal rejects lines + * without a valid UAN. + */ + public String buildEcrFile(PfEcrResponseDTO response) { + StringBuilder sb = new StringBuilder(); + for (PfEcrRowDTO row : response.getRows()) { + sb.append(String.join("#~#", + row.getUan(), + sanitizeEcrField(row.getMemberName()), + plain(row.getGrossWages()), + plain(row.getEpfWages()), + plain(row.getEpsWages()), + plain(row.getEdliWages()), + plain(row.getEpfContriRemitted()), + plain(row.getEpsContriRemitted()), + plain(row.getEpfEpsDiffRemitted()), + String.valueOf(row.getNcpDays() == null ? 0 : row.getNcpDays()), + plain(row.getRefundOfAdvances()))); + sb.append("\r\n"); + } + return sb.toString(); + } + + private String resolveEstablishmentId(String instituteId, List warnings) { + TaxConfiguration config = taxConfigurationRepository + .findByInstituteIdAndCountryCode(instituteId, "IN").orElse(null); + if (config == null) { + warnings.add("No India (IN) tax configuration found for this institute; " + + "PF establishment id is blank."); + return ""; + } + Map settings = config.getStatutorySettings(); + Object value = settings == null ? null : settings.get("pf_establishment_id"); + if (value == null || value.toString().isBlank()) { + warnings.add("statutory_settings.pf_establishment_id is not configured; " + + "set it in the tax configuration before filing."); + return ""; + } + return value.toString().trim(); + } + + private Map buildUserNameMap(List userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + List users = userRepository.findByIdIn(userIds); + return users.stream().collect(Collectors.toMap( + User::getId, + u -> u.getFullName() != null ? u.getFullName() : u.getUsername(), + (a, b) -> a)); + } + + /** ECR fields must not contain the #~# separator or line breaks. */ + private static String sanitizeEcrField(String value) { + if (value == null) { + return ""; + } + return value.replace("#", " ").replace("~", " ") + .replace("\r", " ").replace("\n", " ").trim(); + } + + private static String plain(BigDecimal value) { + return value == null ? "0" : value.toPlainString(); + } + + private static BigDecimal nvl(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + private static String nvlStr(String value) { + return value == null ? "" : value; + } + + private static BigDecimal sum(List rows, + java.util.function.Function getter) { + return rows.stream().map(getter).filter(java.util.Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private static final class MemberAgg { + private final EmployeeProfile employee; + private final Set seenEntryIds = new HashSet<>(); + private BigDecimal pfAmount = BigDecimal.ZERO; + private BigDecimal gross = BigDecimal.ZERO; + private BigDecimal daysAbsent = BigDecimal.ZERO; + + private MemberAgg(EmployeeProfile employee) { + this.employee = employee; + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/PtReturnService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/PtReturnService.java new file mode 100644 index 0000000000..66569ca49e --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/PtReturnService.java @@ -0,0 +1,226 @@ +package vacademy.io.admin_core_service.features.hr_compliance.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.hr_compliance.dto.PtReturnResponseDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.PtReturnRowDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.PtReturnSlabSummaryDTO; +import vacademy.io.admin_core_service.features.hr_compliance.repository.ComplianceStatutoryQueryRepository; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntry; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntryComponent; +import vacademy.io.admin_core_service.features.hr_tax.entity.TaxConfiguration; +import vacademy.io.admin_core_service.features.hr_tax.repository.TaxConfigurationRepository; +import vacademy.io.common.auth.entity.User; +import vacademy.io.common.auth.repository.UserRepository; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * Monthly Professional Tax return builder (v1 CSV — state PT return formats + * differ; this produces the slab-count summary every state form asks for plus + * an employee annexure). + * + *

Slabs are derived empirically: PT is a flat slab amount per employee per + * month, so grouping the month's distinct PT deduction amounts IS the slab + * summary (count of employees + total per amount) without re-encoding every + * state's slab table here. + */ +@Service +public class PtReturnService { + + /** PT code aliases (PayrollCalculationService.STATUTORY_ALIASES). */ + private static final Set PT_CODES = Set.of("PT", "PROF_TAX", "PROFESSIONAL_TAX"); + + private static final List FILABLE_RUN_STATUSES = List.of("PROCESSED", "APPROVED", "PAID"); + + @Autowired + private ComplianceStatutoryQueryRepository statutoryQueryRepository; + + @Autowired + private TaxConfigurationRepository taxConfigurationRepository; + + @Autowired + private UserRepository userRepository; + + @Transactional(readOnly = true) + public PtReturnResponseDTO buildReturn(String instituteId, int month, int year) { + List warnings = new ArrayList<>(); + TaxConfiguration config = taxConfigurationRepository + .findByInstituteIdAndCountryCode(instituteId, "IN").orElse(null); + String stateCode = ""; + String registrationNumber = ""; + if (config == null) { + warnings.add("No India (IN) tax configuration found for this institute; " + + "state code and PT registration number are blank."); + } else { + stateCode = config.getStateCode() == null ? "" : config.getStateCode().trim(); + if (stateCode.isBlank()) { + warnings.add("state_code is not set on the tax configuration."); + } + Map settings = config.getStatutorySettings(); + Object reg = settings == null ? null : settings.get("pt_registration_number"); + if (reg == null || reg.toString().isBlank()) { + warnings.add("statutory_settings.pt_registration_number is not configured; " + + "set it in the tax configuration before filing."); + } else { + registrationNumber = reg.toString().trim(); + } + } + + List components = statutoryQueryRepository.findStatutoryComponents( + instituteId, month, year, FILABLE_RUN_STATUSES, PT_CODES); + + // Aggregate per employee across the month's filable runs (PT is a flat + // monthly amount; multiple entries for the same employee — e.g. an + // off-cycle run that also deducted PT — sum, matching what was + // actually deducted and must be remitted). + Map byEmployee = new LinkedHashMap<>(); + boolean sawProcessedRun = false; + for (PayrollEntryComponent component : components) { + PayrollEntry entry = component.getPayrollEntry(); + EmployeeProfile employee = entry.getEmployee(); + if ("PROCESSED".equals(entry.getPayrollRun().getStatus())) { + sawProcessedRun = true; + } + PtAgg agg = byEmployee.computeIfAbsent(employee.getId(), k -> new PtAgg(employee)); + agg.ptAmount = agg.ptAmount.add(nvl(component.getAmount())); + if (agg.seenEntryIds.add(entry.getId())) { + agg.gross = agg.gross.add(nvl(entry.getGrossSalary())); + } + } + if (sawProcessedRun) { + warnings.add("Includes payroll run(s) still in PROCESSED status (not yet approved); " + + "re-generate after approval before filing."); + } + + Map nameMap = buildUserNameMap(byEmployee.values().stream() + .map(a -> a.employee.getUserId()).distinct().collect(Collectors.toList())); + + List rows = new ArrayList<>(); + for (PtAgg agg : byEmployee.values()) { + rows.add(PtReturnRowDTO.builder() + .employeeCode(agg.employee.getEmployeeCode()) + .name(nameMap.getOrDefault(agg.employee.getUserId(), "")) + .grossSalary(agg.gross.setScale(2, RoundingMode.HALF_UP)) + .ptAmount(agg.ptAmount.setScale(2, RoundingMode.HALF_UP)) + .build()); + } + rows.sort(Comparator.comparing(r -> r.getEmployeeCode() == null ? "" : r.getEmployeeCode())); + + // Slab-wise summary: distinct PT amounts, ascending. + Map> byAmount = rows.stream() + .collect(Collectors.groupingBy(PtReturnRowDTO::getPtAmount, TreeMap::new, Collectors.toList())); + List slabs = byAmount.entrySet().stream() + .map(e -> PtReturnSlabSummaryDTO.builder() + .ptAmount(e.getKey()) + .employeeCount(e.getValue().size()) + .totalAmount(e.getKey().multiply(BigDecimal.valueOf(e.getValue().size())) + .setScale(2, RoundingMode.HALF_UP)) + .build()) + .collect(Collectors.toList()); + + BigDecimal grandTotal = rows.stream().map(PtReturnRowDTO::getPtAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP); + + return PtReturnResponseDTO.builder() + .instituteId(instituteId) + .month(month) + .year(year) + .stateCode(stateCode) + .ptRegistrationNumber(registrationNumber) + .slabs(slabs) + .rows(rows) + .warnings(warnings) + .employeeCount(rows.size()) + .grandTotalPt(grandTotal) + .build(); + } + + /** CSV: header block (state + registration), slab summary, employee annexure, grand total. */ + public String buildCsv(PtReturnResponseDTO response) { + StringBuilder sb = new StringBuilder(); + sb.append("Professional Tax Return,").append(response.getMonth()) + .append("/").append(response.getYear()).append("\r\n"); + sb.append("State Code,").append(csv(response.getStateCode())).append("\r\n"); + sb.append("PT Registration Number,").append(csv(response.getPtRegistrationNumber())).append("\r\n"); + sb.append("\r\n"); + + sb.append("Slab Summary\r\n"); + sb.append("PT Amount,Employee Count,Total\r\n"); + for (PtReturnSlabSummaryDTO slab : response.getSlabs()) { + sb.append(String.join(",", + plain(slab.getPtAmount()), + String.valueOf(slab.getEmployeeCount() == null ? 0 : slab.getEmployeeCount()), + plain(slab.getTotalAmount()))); + sb.append("\r\n"); + } + sb.append("\r\n"); + + sb.append("Employee Details\r\n"); + sb.append("Employee Code,Name,Gross Salary,PT Amount\r\n"); + for (PtReturnRowDTO row : response.getRows()) { + sb.append(String.join(",", + csv(row.getEmployeeCode()), + csv(row.getName()), + plain(row.getGrossSalary()), + plain(row.getPtAmount()))); + sb.append("\r\n"); + } + sb.append("\r\n"); + sb.append("Grand Total,,,").append(plain(response.getGrandTotalPt())).append("\r\n"); + return sb.toString(); + } + + private Map buildUserNameMap(List userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + List users = userRepository.findByIdIn(userIds); + return users.stream().collect(Collectors.toMap( + User::getId, + u -> u.getFullName() != null ? u.getFullName() : u.getUsername(), + (a, b) -> a)); + } + + private static String csv(String value) { + if (value == null) { + return ""; + } + String cleaned = value.replace("\r", " ").replace("\n", " "); + if (cleaned.contains(",") || cleaned.contains("\"")) { + return "\"" + cleaned.replace("\"", "\"\"") + "\""; + } + return cleaned; + } + + private static String plain(BigDecimal value) { + return value == null ? "0.00" : value.toPlainString(); + } + + private static BigDecimal nvl(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + private static final class PtAgg { + private final EmployeeProfile employee; + private final Set seenEntryIds = new HashSet<>(); + private BigDecimal ptAmount = BigDecimal.ZERO; + private BigDecimal gross = BigDecimal.ZERO; + + private PtAgg(EmployeeProfile employee) { + this.employee = employee; + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/StatutoryBonusService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/StatutoryBonusService.java new file mode 100644 index 0000000000..49dc9f0a9c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/StatutoryBonusService.java @@ -0,0 +1,330 @@ +package vacademy.io.admin_core_service.features.hr_compliance.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.hr_compliance.dto.BonusComputationReportDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.BonusComputationRowDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.BonusMaterializationResultDTO; +import vacademy.io.admin_core_service.features.hr_compliance.repository.ComplianceProvisionQueryRepository; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_payroll.dto.PayrollAdjustmentDTO; +import vacademy.io.admin_core_service.features.hr_payroll.service.PayrollAdjustmentService; +import vacademy.io.admin_core_service.features.hr_salary.entity.EmployeeSalaryComponent; +import vacademy.io.admin_core_service.features.hr_salary.entity.EmployeeSalaryStructure; +import vacademy.io.admin_core_service.features.hr_salary.repository.EmployeeSalaryStructureRepository; +import vacademy.io.common.auth.entity.User; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.auth.repository.UserRepository; +import vacademy.io.common.exceptions.VacademyException; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Statutory bonus computation (Phase D) under the Payment of Bonus Act, 1965. + * + *

Statutory basis: + *

    + *
  • Eligibility — s.2(13) + s.8: employees drawing salary/wage (basic + DA) + * up to Rs 21,000/month who worked at least 30 working days in the + * accounting year.
  • + *
  • Calculation ceiling — s.12: where salary/wage exceeds Rs 7,000/month + * (or the scheduled-employment minimum wage, if higher — see note below), + * bonus is computed as if it were Rs 7,000.
  • + *
  • Rate — s.10 minimum 8.33%, s.11 maximum 20% of salary/wage earned in + * the accounting year.
  • + *
+ * + *

Wage basis note: statutory "salary or wage" is basic + dearness allowance. + * This platform's salary structures carry no separate DA component — institutes + * are expected to merge DA into the BASIC component, so the BASIC monthly + * amount is used as the bonus wage. The s.12 alternative floor (minimum wage + * for the scheduled employment, if above 7,000) is not modeled — states' + * minimum wages are not configured in the platform; flagged for a later phase. + * + *

Working-days assumption: attendance-level working-day counts are not + * consulted; an employee employed during the FY is assumed to satisfy the + * 30-working-day threshold unless joinDate/lastWorkingDate constrain the + * employment window to fewer than 30 calendar days of overlap with the FY. + */ +@Service +public class StatutoryBonusService { + + /** s.2(13) eligibility wage ceiling (2015 amendment). */ + static final BigDecimal ELIGIBILITY_WAGE_CEILING = new BigDecimal("21000"); + /** s.12 calculation wage ceiling (2015 amendment). */ + static final BigDecimal CALCULATION_WAGE_CEILING = new BigDecimal("7000"); + /** s.10 statutory minimum bonus rate (%). */ + static final BigDecimal MIN_BONUS_PCT = new BigDecimal("8.33"); + /** s.11 statutory maximum bonus rate (%). */ + static final BigDecimal MAX_BONUS_PCT = new BigDecimal("20"); + /** Component code created for materialized bonus adjustments. */ + static final String BONUS_CODE = "STATUTORY_BONUS"; + /** s.8: minimum working days in the accounting year. */ + private static final long MIN_WORKING_DAYS = 30; + /** + * A calendar month counts as an eligible service month when the employee + * was employed for at least this many of its days (half-month convention). + */ + private static final long MIN_DAYS_FOR_MONTH = 15; + private static final BigDecimal HALF = new BigDecimal("0.50"); + private static final BigDecimal HUNDRED = new BigDecimal("100"); + + @Autowired + private ComplianceProvisionQueryRepository provisionQueryRepository; + + @Autowired + private EmployeeSalaryStructureRepository salaryStructureRepository; + + @Autowired + private PayrollAdjustmentService payrollAdjustmentService; + + @Autowired + private UserRepository userRepository; + + @Transactional(readOnly = true) + public BonusComputationReportDTO computeBonus(String instituteId, String financialYear, BigDecimal bonusPct) { + LocalDate fyStart = parseFyStart(financialYear); + LocalDate fyEnd = fyStart.plusYears(1).minusDays(1); // Mar 31 + BigDecimal pct = clampPct(bonusPct); + + List employees = provisionQueryRepository.findAllEmployeesByInstitute(instituteId); + Map names = buildUserNameMap( + employees.stream().map(EmployeeProfile::getUserId).filter(Objects::nonNull) + .distinct().collect(Collectors.toList())); + + List rows = new ArrayList<>(); + BigDecimal totalBonus = BigDecimal.ZERO; + int eligibleCount = 0; + String reportCurrency = null; + + for (EmployeeProfile e : employees) { + if (e.getJoinDate() == null || e.getJoinDate().isAfter(fyEnd)) { + continue; // never in service during this FY + } + LocalDate serviceEnd = e.getLastWorkingDate() != null ? e.getLastWorkingDate() : fyEnd; + if (serviceEnd.isBefore(fyStart)) { + continue; // exited before the FY began + } + + LocalDate overlapStart = e.getJoinDate().isAfter(fyStart) ? e.getJoinDate() : fyStart; + LocalDate overlapEnd = serviceEnd.isBefore(fyEnd) ? serviceEnd : fyEnd; + long overlapDays = ChronoUnit.DAYS.between(overlapStart, overlapEnd) + 1; + + BasicResolution basic = resolveMonthlyBasic(e.getId()); + if (reportCurrency == null && basic.currency != null) { + reportCurrency = basic.currency; + } + + String ineligibleReason = null; + if (basic.amount == null) { + ineligibleReason = "No ACTIVE salary structure / BASIC not resolvable"; + } else if (basic.amount.compareTo(ELIGIBILITY_WAGE_CEILING) > 0) { + ineligibleReason = "Monthly wage above Rs 21,000 eligibility ceiling (s.2(13))"; + } else if (overlapDays < MIN_WORKING_DAYS) { + ineligibleReason = "Fewer than 30 days of service in the FY (s.8)"; + } + boolean eligible = ineligibleReason == null; + + int eligibleMonths = eligible ? countEligibleMonths(fyStart, overlapStart, overlapEnd) : 0; + BigDecimal bonusWage = eligible ? basic.amount.min(CALCULATION_WAGE_CEILING) : null; + BigDecimal bonus = BigDecimal.ZERO; + if (eligible && eligibleMonths > 0) { + bonus = bonusWage.multiply(new BigDecimal(eligibleMonths)) + .multiply(pct).divide(HUNDRED, 2, RoundingMode.HALF_UP); + } + + if (eligible) { + eligibleCount++; + totalBonus = totalBonus.add(bonus); + } + + rows.add(BonusComputationRowDTO.builder() + .employeeId(e.getId()) + .employeeCode(e.getEmployeeCode()) + .employeeName(names.getOrDefault(e.getUserId(), "Unknown")) + .monthlyBasic(basic.amount) + .eligible(eligible) + .ineligibleReason(ineligibleReason) + .eligibleMonths(eligibleMonths) + .bonusWageBase(bonusWage) + .computedBonus(bonus) + .currency(basic.currency != null ? basic.currency : "INR") + .build()); + } + + rows.sort(Comparator.comparing(r -> r.getEmployeeCode() != null ? r.getEmployeeCode() : "", + String.CASE_INSENSITIVE_ORDER)); + + return BonusComputationReportDTO.builder() + .instituteId(instituteId) + .financialYear(financialYear) + .fyStart(fyStart) + .fyEnd(fyEnd) + .bonusPct(pct) + .eligibleCount(eligibleCount) + .totalBonus(totalBonus) + .currency(reportCurrency != null ? reportCurrency : "INR") + .rows(rows) + .build(); + } + + /** + * Materializes the computed bonus as one BONUS-scope payroll adjustment per + * eligible employee for the given payout month/year. Idempotent: employees + * that already carry a STATUTORY_BONUS adjustment for that period — + * consumed by a run or still pending — are skipped, so re-running after a + * partial failure only fills the gaps. + */ + @Transactional + public BonusMaterializationResultDTO materialize(String instituteId, String financialYear, + BigDecimal bonusPct, Integer month, Integer year, + CustomUserDetails user) { + if (month == null || month < 1 || month > 12 || year == null || year < 2000 || year > 2100) { + throw new VacademyException("Valid payout month and year are required"); + } + BonusComputationReportDTO report = computeBonus(instituteId, financialYear, bonusPct); + + Set alreadyMaterialized = new HashSet<>( + provisionQueryRepository.findAdjustmentEmployeeIdsForPeriod(instituteId, year, month, BONUS_CODE)); + + int created = 0; + int skipped = 0; + BigDecimal totalAmount = BigDecimal.ZERO; + for (BonusComputationRowDTO row : report.getRows()) { + if (!Boolean.TRUE.equals(row.getEligible()) + || row.getComputedBonus() == null + || row.getComputedBonus().compareTo(BigDecimal.ZERO) <= 0) { + continue; + } + if (alreadyMaterialized.contains(row.getEmployeeId())) { + skipped++; + continue; + } + PayrollAdjustmentDTO dto = PayrollAdjustmentDTO.builder() + .employeeId(row.getEmployeeId()) + .month(month) + .year(year) + .type("EARNING") + .code(BONUS_CODE) + .label("Statutory Bonus FY " + financialYear) + .amount(row.getComputedBonus()) + .currency(row.getCurrency()) + .runScope("BONUS") + .notes("Payment of Bonus Act computation @ " + report.getBonusPct() + + "% for " + row.getEligibleMonths() + " eligible month(s), wage base " + + row.getBonusWageBase()) + .build(); + payrollAdjustmentService.createAdjustment(dto, instituteId, user, "SYSTEM"); + created++; + totalAmount = totalAmount.add(row.getComputedBonus()); + } + + return BonusMaterializationResultDTO.builder() + .financialYear(financialYear) + .month(month) + .year(year) + .bonusPct(report.getBonusPct()) + .createdCount(created) + .skippedExistingCount(skipped) + .totalAmount(totalAmount) + .build(); + } + + /** "2025-26" -> 2025-04-01; validates format and year continuity. */ + static LocalDate parseFyStart(String financialYear) { + if (financialYear == null || !financialYear.matches("\\d{4}-\\d{2}")) { + throw new VacademyException("financialYear must look like 2025-26"); + } + int startYear = Integer.parseInt(financialYear.substring(0, 4)); + int endTwoDigit = Integer.parseInt(financialYear.substring(5)); + if ((startYear + 1) % 100 != endTwoDigit) { + throw new VacademyException("financialYear years must be consecutive, e.g. 2025-26"); + } + return LocalDate.of(startYear, 4, 1); + } + + /** Clamp the requested rate into the Act's [8.33, 20] band; default 8.33. */ + static BigDecimal clampPct(BigDecimal requested) { + if (requested == null) return MIN_BONUS_PCT; + if (requested.compareTo(MIN_BONUS_PCT) < 0) return MIN_BONUS_PCT; + if (requested.compareTo(MAX_BONUS_PCT) > 0) return MAX_BONUS_PCT; + return requested; + } + + /** + * Number of FY calendar months in which the employee was employed for at + * least 15 days (half-month convention; documented judgment call — the Act + * prorates on salary "earned", which monthly proration approximates). + */ + private int countEligibleMonths(LocalDate fyStart, LocalDate overlapStart, LocalDate overlapEnd) { + int months = 0; + for (int i = 0; i < 12; i++) { + YearMonth ym = YearMonth.from(fyStart.plusMonths(i)); + LocalDate monthStart = ym.atDay(1); + LocalDate monthEnd = ym.atEndOfMonth(); + LocalDate s = overlapStart.isAfter(monthStart) ? overlapStart : monthStart; + LocalDate t = overlapEnd.isBefore(monthEnd) ? overlapEnd : monthEnd; + if (!s.isAfter(t) && ChronoUnit.DAYS.between(s, t) + 1 >= MIN_DAYS_FOR_MONTH) { + months++; + } + } + return months; + } + + /** + * Bonus wage (basic + merged DA) from the latest ACTIVE structure's BASIC + * component, falling back to 50% of gross monthly when absent. + */ + private BasicResolution resolveMonthlyBasic(String employeeId) { + Optional structureOpt = salaryStructureRepository + .findFirstByEmployee_IdAndStatusOrderByEffectiveFromDesc(employeeId, "ACTIVE"); + if (structureOpt.isEmpty()) { + return new BasicResolution(null, null); + } + EmployeeSalaryStructure structure = structureOpt.get(); + if (structure.getComponents() != null) { + for (EmployeeSalaryComponent c : structure.getComponents()) { + if (c.getComponent() != null && "BASIC".equalsIgnoreCase(c.getComponent().getCode()) + && c.getMonthlyAmount() != null) { + return new BasicResolution( + c.getMonthlyAmount().setScale(2, RoundingMode.HALF_UP), + structure.getCurrency()); + } + } + } + if (structure.getGrossMonthly() != null) { + return new BasicResolution( + structure.getGrossMonthly().multiply(HALF).setScale(2, RoundingMode.HALF_UP), + structure.getCurrency()); + } + return new BasicResolution(null, structure.getCurrency()); + } + + private Map buildUserNameMap(List userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + List users = userRepository.findByIdIn(userIds); + return users.stream().collect(Collectors.toMap( + User::getId, + u -> u.getFullName() != null ? u.getFullName() : u.getUsername(), + (a, b) -> a)); + } + + record BasicResolution(BigDecimal amount, String currency) { + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/WpsExportService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/WpsExportService.java new file mode 100644 index 0000000000..fe995d2988 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_compliance/service/WpsExportService.java @@ -0,0 +1,595 @@ +package vacademy.io.admin_core_service.features.hr_compliance.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.hr_compliance.dto.WpsEdrRowDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.WpsExportResponseDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.WpsFileDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.WpsSaudiRowDTO; +import vacademy.io.admin_core_service.features.hr_compliance.dto.WpsSkippedRowDTO; +import vacademy.io.admin_core_service.features.hr_compliance.repository.ComplianceWpsQueryRepository; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeBankDetail; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntry; +import vacademy.io.admin_core_service.features.hr_tax.entity.TaxConfiguration; +import vacademy.io.admin_core_service.features.hr_tax.repository.TaxConfigurationRepository; +import vacademy.io.common.auth.entity.User; +import vacademy.io.common.auth.repository.UserRepository; +import vacademy.io.common.exceptions.VacademyException; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import java.util.stream.Collectors; + +/** + * Gulf WPS (Wage Protection System) salary-file builder — Phase E, v1 + * layouts PENDING PORTAL VALIDATION: + * + *

    + *
  • UAE_SIF — MOHRE Salary Information File: comma-separated EDR + * records (one per paid employee) plus one trailing SCR employer + * record. Not yet round-tripped through a bank/MOHRE portal validator.
  • + *
  • SAUDI_WPS — Mudad-style CSV with a header block. Not yet + * validated against the Mudad portal.
  • + *
+ * + *

Institute-level identifiers come from the tax configuration's + * {@code statutory_settings} JSONB: + *

    + *
  • {@code mol_establishment_id} — the employer's Ministry-of-Labour + * establishment id (UAE: MOHRE establishment ID; Saudi: MOL/Mudad + * establishment id).
  • + *
  • {@code employer_bank_code} — the employer's WPS agent / bank routing + * code (the bank that debits salaries).
  • + *
  • {@code wps_reference} — optional free-form employer reference.
  • + *
+ * Missing keys produce warnings and empty strings in the file — never a hard + * failure, so HR can preview while configuration is being completed. + * + *

Per-employee routing: the EDR agent id is the employee's + * {@code statutory_info.wps_agent_id} (their bank's WPS agent code), falling + * back to {@code bankAccount.routingNumber}. The account identifier is + * {@code bankAccount.iban}; employees without an IBAN are moved to the + * skipped list and excluded from the file. + * + *

Population: non-HELD entries of the month's PROCESSED/APPROVED/PAID + * runs (a warning flags PROCESSED — i.e. not yet approved — inclusions). + * Multiple entries per employee (regular + off-cycle) are aggregated: + * amounts SUM; attendance facts (working days, leave days) take the MAX + * because they are per-month facts repeated on each entry that carries them. + */ +@Service +public class WpsExportService { + + public static final String FORMAT_UAE_SIF = "UAE_SIF"; + public static final String FORMAT_SAUDI_WPS = "SAUDI_WPS"; + + /** Filable run statuses: money is determined once a run is PROCESSED. */ + private static final List FILABLE_RUN_STATUSES = List.of("PROCESSED", "APPROVED", "PAID"); + + private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter TIME_HHMM = DateTimeFormatter.ofPattern("HHmm"); + + @Autowired + private ComplianceWpsQueryRepository wpsQueryRepository; + + @Autowired + private TaxConfigurationRepository taxConfigurationRepository; + + @Autowired + private UserRepository userRepository; + + // ------------------------------------------------------------------ build + + @Transactional(readOnly = true) + public WpsExportResponseDTO buildExport(String instituteId, int month, int year, String formatOverride) { + List warnings = new ArrayList<>(); + + TaxConfiguration config = resolveConfig(instituteId, formatOverride, warnings); + String format = resolveFormat(config, formatOverride); + String expectedCurrency = FORMAT_UAE_SIF.equals(format) ? "AED" : "SAR"; + + Map settings = config == null ? null : config.getStatutorySettings(); + String establishmentId = requiredSetting(settings, "mol_establishment_id", + "employer establishment id (MOHRE/MOL)", warnings); + String employerBankCode = requiredSetting(settings, "employer_bank_code", + "employer WPS agent/bank routing code", warnings); + String wpsReference = optionalSetting(settings, "wps_reference"); + + List entries = wpsQueryRepository.findPayableEntries( + instituteId, month, year, FILABLE_RUN_STATUSES); + Map basicByEntry = FORMAT_SAUDI_WPS.equals(format) + ? loadBasicByEntry(instituteId, month, year) + : Map.of(); + + // ---- aggregate per employee across the month's filable runs + Map byEmployee = new LinkedHashMap<>(); + boolean sawProcessedRun = false; + boolean sawMixedCurrency = false; + for (PayrollEntry entry : entries) { + EmployeeProfile employee = entry.getEmployee(); + if ("PROCESSED".equals(entry.getPayrollRun().getStatus())) { + sawProcessedRun = true; + } + EmpAgg agg = byEmployee.computeIfAbsent(employee.getId(), k -> new EmpAgg(employee)); + agg.fixedIncome = agg.fixedIncome.add(nvl(entry.getTotalEarnings())); + agg.variableIncome = agg.variableIncome + .add(nvl(entry.getOtherEarnings())) + .add(nvl(entry.getReimbursements())); + agg.deductions = agg.deductions.add(nvl(entry.getTotalDeductions())); + agg.netPay = agg.netPay.add(nvl(entry.getNetPay())); + agg.basic = agg.basic.add(nvl(basicByEntry.get(entry.getId()))); + + Integer workingDays = entry.getTotalWorkingDays(); + if (workingDays != null && (agg.workingDays == null || workingDays > agg.workingDays)) { + agg.workingDays = workingDays; + } + BigDecimal leave = nvl(entry.getDaysOnLeave()); + if (leave.compareTo(agg.leaveDays) > 0) { + agg.leaveDays = leave; + } + if (agg.bankAccount == null) { + agg.bankAccount = entry.getBankAccount(); + } + String entryCurrency = firstNonBlank(entry.getCurrency(), entry.getPayrollRun().getCurrency()); + if (entryCurrency != null) { + if (agg.currency == null) { + agg.currency = entryCurrency; + } else if (!agg.currency.equalsIgnoreCase(entryCurrency)) { + sawMixedCurrency = true; + } + } + } + if (sawProcessedRun) { + warnings.add("Includes payroll run(s) still in PROCESSED status (not yet approved); " + + "re-generate after approval before submitting to the bank/portal."); + } + + Map nameMap = buildUserNameMap(byEmployee.values().stream() + .map(a -> a.employee.getUserId()).distinct().collect(Collectors.toList())); + + LocalDate periodStart = LocalDate.of(year, month, 1); + LocalDate periodEnd = periodStart.withDayOfMonth(periodStart.lengthOfMonth()); + + List edrRows = new ArrayList<>(); + List saudiRows = new ArrayList<>(); + List skipped = new ArrayList<>(); + TreeSet missingPersonId = new TreeSet<>(); + TreeSet missingGosi = new TreeSet<>(); + TreeSet missingAgent = new TreeSet<>(); + TreeSet basicFallback = new TreeSet<>(); + String fileCurrency = null; + boolean currencyMismatch = false; + + for (EmpAgg agg : byEmployee.values()) { + String employeeCode = nvlStr(agg.employee.getEmployeeCode()); + String name = nameMap.getOrDefault(agg.employee.getUserId(), ""); + String iban = agg.bankAccount == null ? null : agg.bankAccount.getIban(); + if (iban == null || iban.isBlank()) { + skipped.add(WpsSkippedRowDTO.builder() + .employeeCode(employeeCode) + .employeeName(name) + .reason(agg.bankAccount == null + ? "No bank account on payroll entry — excluded from WPS file" + : "Missing IBAN on bank account — excluded from WPS file") + .build()); + continue; + } + iban = iban.trim(); + + // statutory_info is decrypted by the entity converter. + Map statutory = agg.employee.getStatutoryInfo(); + String agentId = firstNonBlank(str(statutory, "wps_agent_id"), + agg.bankAccount.getRoutingNumber()); + if (agentId == null) { + agentId = ""; + missingAgent.add(labelOf(employeeCode, name)); + } + + String rowCurrency = agg.currency; + if (rowCurrency != null) { + if (fileCurrency == null) { + fileCurrency = rowCurrency.toUpperCase(); + } + if (!expectedCurrency.equalsIgnoreCase(rowCurrency)) { + currencyMismatch = true; + } + } + + if (FORMAT_UAE_SIF.equals(format)) { + String personId = str(statutory, "mol_person_id"); + if (personId == null) { + personId = employeeCode; + missingPersonId.add(labelOf(employeeCode, name)); + } + edrRows.add(WpsEdrRowDTO.builder() + .employeeCode(employeeCode) + .employeeName(name) + .personId(personId) + .agentId(agentId) + .iban(iban) + .payStartDate(ISO_DATE.format(periodStart)) + .payEndDate(ISO_DATE.format(periodEnd)) + .daysInPeriod(agg.workingDays != null ? agg.workingDays : periodStart.lengthOfMonth()) + .fixedIncome(scale2(agg.fixedIncome)) + .variableIncome(scale2(agg.variableIncome)) + .leaveDays(agg.leaveDays.setScale(0, RoundingMode.HALF_UP).intValue()) + .netPay(scale2(agg.netPay)) + .currency(rowCurrency) + .build()); + } else { + String employeeId = str(statutory, "gosi_number"); + if (employeeId == null) { + employeeId = employeeCode; + missingGosi.add(labelOf(employeeCode, name)); + } + // Basic salary is recovered from the entry's BASIC component; + // when the structure defines none, totalEarnings stands in + // (documented on WpsSaudiRowDTO) and the employee is flagged. + BigDecimal basic = agg.basic; + if (basic.compareTo(BigDecimal.ZERO) == 0) { + basic = agg.fixedIncome; + basicFallback.add(labelOf(employeeCode, name)); + } + saudiRows.add(WpsSaudiRowDTO.builder() + .employeeCode(employeeCode) + .employeeName(name) + .employeeId(employeeId) + .iban(iban) + .bankCode(agentId) + .basicSalary(scale2(basic)) + .housingAllowance(scale2(BigDecimal.ZERO)) + .otherEarnings(scale2(agg.variableIncome)) + .deductions(scale2(agg.deductions)) + .netSalary(scale2(agg.netPay)) + .currency(rowCurrency) + .build()); + } + } + edrRows.sort(Comparator.comparing(r -> nvlStr(r.getEmployeeCode()))); + saudiRows.sort(Comparator.comparing(r -> nvlStr(r.getEmployeeCode()))); + + if (!missingPersonId.isEmpty()) { + warnings.add("statutory_info.mol_person_id missing for " + missingPersonId.size() + + " employee(s) — employeeCode used as the EDR person id: " + + String.join(", ", missingPersonId)); + } + if (!missingGosi.isEmpty()) { + warnings.add("statutory_info.gosi_number missing for " + missingGosi.size() + + " employee(s) — employeeCode used as the employee id: " + + String.join(", ", missingGosi)); + } + if (!missingAgent.isEmpty()) { + warnings.add("No WPS agent/routing code (statutory_info.wps_agent_id or bank routing number) for " + + missingAgent.size() + " employee(s) — blank in file: " + + String.join(", ", missingAgent)); + } + if (!basicFallback.isEmpty()) { + warnings.add("No BASIC salary component found for " + basicFallback.size() + + " employee(s) — totalEarnings reported as basic salary: " + + String.join(", ", basicFallback)); + } + if (sawMixedCurrency) { + warnings.add("Entries with differing currencies were aggregated for at least one employee; " + + "verify the payroll run currency setup."); + } + if (currencyMismatch) { + warnings.add("Payroll currency differs from the expected " + expectedCurrency + + " for format " + format + " — the WPS portal/bank may reject the file."); + } + if (fileCurrency == null) { + fileCurrency = expectedCurrency; + } + + int included = FORMAT_UAE_SIF.equals(format) ? edrRows.size() : saudiRows.size(); + BigDecimal totalNet = FORMAT_UAE_SIF.equals(format) + ? sum(edrRows.stream().map(WpsEdrRowDTO::getNetPay).collect(Collectors.toList())) + : sum(saudiRows.stream().map(WpsSaudiRowDTO::getNetSalary).collect(Collectors.toList())); + + return WpsExportResponseDTO.builder() + .format(format) + .instituteId(instituteId) + .month(month) + .year(year) + .countryCode(config == null ? null : config.getCountryCode()) + .establishmentId(establishmentId) + .employerBankCode(employerBankCode) + .wpsReference(wpsReference) + .edrRows(FORMAT_UAE_SIF.equals(format) ? edrRows : null) + .saudiRows(FORMAT_SAUDI_WPS.equals(format) ? saudiRows : null) + .skipped(skipped) + .warnings(warnings) + .employeeCount(included) + .totalNetPay(scale2(totalNet)) + .currency(fileCurrency) + .build(); + } + + // ------------------------------------------------------------------- files + + /** Renders the downloadable salary file for an already-built export. */ + public WpsFileDTO buildFile(WpsExportResponseDTO response) { + if (FORMAT_UAE_SIF.equals(response.getFormat())) { + return buildUaeSifFile(response); + } + return buildSaudiFile(response); + } + + /** + * UAE SIF v1 (pending MOHRE/bank portal validation). CRLF-terminated, + * comma-separated records: + *

+     * EDR,<personId>,<agentId>,<IBAN>,<payStart YYYY-MM-DD>,<payEnd YYYY-MM-DD>,
+     *     <daysInPeriod>,<fixedIncome 2dp>,<variableIncome 2dp>,<leaveDays>
+     * SCR,<molEstablishmentId>,<employerBankCode>,<creationDate YYYY-MM-DD>,
+     *     <creationTime HHmm>,<salaryMonth MMYYYY>,<edrCount>,<totalSalary 2dp>,<currency>
+     * 
+ * WPS reconciles the SALARY actually paid, so the SCR total is the sum of + * net pay across the EDR records. + */ + private WpsFileDTO buildUaeSifFile(WpsExportResponseDTO response) { + StringBuilder sb = new StringBuilder(); + for (WpsEdrRowDTO row : response.getEdrRows()) { + sb.append(String.join(",", + "EDR", + csv(row.getPersonId()), + csv(row.getAgentId()), + csv(row.getIban()), + csv(row.getPayStartDate()), + csv(row.getPayEndDate()), + String.valueOf(row.getDaysInPeriod() == null ? 0 : row.getDaysInPeriod()), + amount(row.getFixedIncome()), + amount(row.getVariableIncome()), + String.valueOf(row.getLeaveDays() == null ? 0 : row.getLeaveDays()))); + sb.append("\r\n"); + } + LocalDateTime now = LocalDateTime.now(); + String salaryMonth = String.format("%02d%04d", response.getMonth(), response.getYear()); + sb.append(String.join(",", + "SCR", + csv(response.getEstablishmentId()), + csv(response.getEmployerBankCode()), + ISO_DATE.format(now.toLocalDate()), + TIME_HHMM.format(now), + salaryMonth, + String.valueOf(response.getEdrRows().size()), + amount(response.getTotalNetPay()), + csv(response.getCurrency()))); + sb.append("\r\n"); + + return WpsFileDTO.builder() + .filename("sif_" + filenameToken(response.getEstablishmentId()) + "_" + salaryMonth + ".sif") + .mediaType("text/plain") + .content(sb.toString()) + .build(); + } + + /** + * Saudi WPS v1, Mudad-style CSV (pending Mudad validation): a commented + * header block (establishment id + salary month), then a column-header + * line, then one row per employee. + */ + private WpsFileDTO buildSaudiFile(WpsExportResponseDTO response) { + String salaryMonth = String.format("%02d%04d", response.getMonth(), response.getYear()); + StringBuilder sb = new StringBuilder(); + sb.append("#FORMAT,SAUDI_WPS_V1 (pending Mudad validation)\r\n"); + sb.append("#ESTABLISHMENT_ID,").append(csv(response.getEstablishmentId())).append("\r\n"); + sb.append("#EMPLOYER_BANK_CODE,").append(csv(response.getEmployerBankCode())).append("\r\n"); + sb.append("#SALARY_MONTH,").append(String.format("%02d-%04d", response.getMonth(), response.getYear())) + .append("\r\n"); + sb.append("#GENERATED_AT,").append(ISO_DATE.format(LocalDate.now())).append("\r\n"); + sb.append("EmployeeId,EmployeeName,IBAN,BankCode,BasicSalary,HousingAllowance,") + .append("OtherEarnings,Deductions,NetSalary\r\n"); + for (WpsSaudiRowDTO row : response.getSaudiRows()) { + sb.append(String.join(",", + csv(row.getEmployeeId()), + csv(row.getEmployeeName()), + csv(row.getIban()), + csv(row.getBankCode()), + amount(row.getBasicSalary()), + amount(row.getHousingAllowance()), + amount(row.getOtherEarnings()), + amount(row.getDeductions()), + amount(row.getNetSalary()))); + sb.append("\r\n"); + } + return WpsFileDTO.builder() + .filename("wps_" + filenameToken(response.getEstablishmentId()) + "_" + salaryMonth + ".csv") + .mediaType("text/csv") + .content(sb.toString()) + .build(); + } + + // ------------------------------------------------------- format resolution + + /** + * Picks the Gulf tax configuration the export is driven by. With an + * explicit format override the matching config is used if present (else + * null + warning); without one, exactly one of the institute's configs + * must be a Gulf country (ARE/SAU after alias normalization). + */ + private TaxConfiguration resolveConfig(String instituteId, String formatOverride, List warnings) { + List configs = taxConfigurationRepository.findAllByInstituteId(instituteId); + TaxConfiguration uae = null; + TaxConfiguration saudi = null; + for (TaxConfiguration config : configs) { + String code = normalizeCountry(config.getCountryCode()); + if ("ARE".equals(code)) uae = config; + if ("SAU".equals(code)) saudi = config; + } + String override = normalizeFormat(formatOverride); + if (override != null) { + TaxConfiguration match = FORMAT_UAE_SIF.equals(override) ? uae : saudi; + if (match == null) { + warnings.add("No " + (FORMAT_UAE_SIF.equals(override) ? "UAE (ARE)" : "Saudi (SAU)") + + " tax configuration found for this institute; employer identifiers are blank."); + } + return match; + } + if (uae != null && saudi != null) { + throw new VacademyException("Institute has both UAE and Saudi tax configurations; " + + "pass format=UAE_SIF or format=SAUDI_WPS"); + } + if (uae == null && saudi == null) { + throw new VacademyException("No UAE (ARE/UAE) or Saudi (SAU/KSA) tax configuration found " + + "for this institute; configure one or pass format=UAE_SIF / format=SAUDI_WPS"); + } + return uae != null ? uae : saudi; + } + + private String resolveFormat(TaxConfiguration config, String formatOverride) { + String override = normalizeFormat(formatOverride); + if (override != null) { + return override; + } + // resolveConfig guarantees a Gulf config when no override is given. + return "ARE".equals(normalizeCountry(config.getCountryCode())) ? FORMAT_UAE_SIF : FORMAT_SAUDI_WPS; + } + + /** Accepts the documented values plus common aliases; null when absent. */ + private static String normalizeFormat(String format) { + if (format == null || format.isBlank()) { + return null; + } + return switch (format.trim().toUpperCase()) { + case "UAE_SIF", "SIF", "UAE", "AE", "ARE", "MOHRE" -> FORMAT_UAE_SIF; + case "SAUDI_WPS", "SAUDI", "KSA", "SA", "SAU", "MUDAD" -> FORMAT_SAUDI_WPS; + default -> throw new VacademyException("Unknown WPS format '" + format + + "' — use UAE_SIF or SAUDI_WPS"); + }; + } + + /** Same alias normalization as hr_tax's TaxRegimeFactory (institutes configure aliases). */ + private static String normalizeCountry(String countryCode) { + if (countryCode == null) return ""; + return switch (countryCode.trim().toUpperCase()) { + case "UAE", "AE" -> "ARE"; + case "KSA", "SA", "SAUDI" -> "SAU"; + default -> countryCode.trim().toUpperCase(); + }; + } + + // ---------------------------------------------------------------- helpers + + private Map loadBasicByEntry(String instituteId, int month, int year) { + Map basicByEntry = new HashMap<>(); + for (Object[] row : wpsQueryRepository.findBasicAmountsByEntry( + instituteId, month, year, FILABLE_RUN_STATUSES)) { + String entryId = (String) row[0]; + BigDecimal amount = nvl((BigDecimal) row[1]); + basicByEntry.merge(entryId, amount, BigDecimal::add); + } + return basicByEntry; + } + + private Map buildUserNameMap(List userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + List users = userRepository.findByIdIn(userIds); + return users.stream().collect(Collectors.toMap( + User::getId, + u -> u.getFullName() != null ? u.getFullName() : u.getUsername(), + (a, b) -> a)); + } + + private static String requiredSetting(Map settings, String key, + String label, List warnings) { + Object value = settings == null ? null : settings.get(key); + if (value == null || value.toString().isBlank()) { + warnings.add("statutory_settings." + key + " (" + label + + ") is not configured; blank in the file — set it before submitting."); + return ""; + } + return value.toString().trim(); + } + + private static String optionalSetting(Map settings, String key) { + Object value = settings == null ? null : settings.get(key); + return value == null ? "" : value.toString().trim(); + } + + /** Trimmed string value of a statutory_info key; null when absent/blank. */ + private static String str(Map map, String key) { + Object value = map == null ? null : map.get(key); + if (value == null) return null; + String s = value.toString().trim(); + return s.isEmpty() ? null : s; + } + + private static String firstNonBlank(String a, String b) { + if (a != null && !a.isBlank()) return a.trim(); + if (b != null && !b.isBlank()) return b.trim(); + return null; + } + + private static String labelOf(String employeeCode, String name) { + return employeeCode != null && !employeeCode.isBlank() ? employeeCode + : (name == null || name.isBlank() ? "(unknown)" : name); + } + + /** CSV field: no commas or line breaks (WPS records are comma-separated). */ + private static String csv(String value) { + if (value == null) { + return ""; + } + return value.replace(",", " ").replace("\r", " ").replace("\n", " ").trim(); + } + + /** Amounts as plain 2-decimal strings (no grouping, no exponent). */ + private static String amount(BigDecimal value) { + return nvl(value).setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + + /** Filename-safe token from a configured id (may be blank). */ + private static String filenameToken(String value) { + String token = value == null ? "" : value.replaceAll("[^A-Za-z0-9_-]", ""); + return token.isEmpty() ? "UNSET" : token; + } + + private static BigDecimal scale2(BigDecimal value) { + return nvl(value).setScale(2, RoundingMode.HALF_UP); + } + + private static BigDecimal sum(List values) { + return values.stream().filter(java.util.Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private static BigDecimal nvl(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + private static String nvlStr(String value) { + return value == null ? "" : value; + } + + /** Per-employee aggregation across the month's filable payroll entries. */ + private static final class EmpAgg { + private final EmployeeProfile employee; + private EmployeeBankDetail bankAccount; + private BigDecimal fixedIncome = BigDecimal.ZERO; + private BigDecimal variableIncome = BigDecimal.ZERO; + private BigDecimal deductions = BigDecimal.ZERO; + private BigDecimal netPay = BigDecimal.ZERO; + private BigDecimal basic = BigDecimal.ZERO; + private Integer workingDays; + private BigDecimal leaveDays = BigDecimal.ZERO; + private String currency; + + private EmpAgg(EmployeeProfile employee) { + this.employee = employee; + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/controller/DepartmentController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/controller/DepartmentController.java index c5fdad8737..388e3c4a9c 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/controller/DepartmentController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/controller/DepartmentController.java @@ -3,7 +3,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; import vacademy.io.admin_core_service.features.hr_employee.dto.DepartmentDTO; import vacademy.io.admin_core_service.features.hr_employee.service.DepartmentService; import vacademy.io.common.auth.model.CustomUserDetails; @@ -18,14 +19,19 @@ public class DepartmentController { private DepartmentService departmentService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; @PostMapping + @Auditable( + entityType = "HR_DEPARTMENT", + action = "CREATE", + entityIdExpr = "#result?.body", + descriptionExpr = "'created department ' + (#dto?.name ?: '')") public ResponseEntity addDepartment( @RequestBody DepartmentDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrAdmin(user, instituteId); String id = departmentService.addDepartment(dto, instituteId); return ResponseEntity.ok(id); } @@ -34,29 +40,39 @@ public ResponseEntity addDepartment( public ResponseEntity> getDepartments( @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrStaff(user, instituteId); List departments = departmentService.getDepartments(instituteId); return ResponseEntity.ok(departments); } @PutMapping("/{id}") + @Auditable( + entityType = "HR_DEPARTMENT", + action = "UPDATE", + entityIdExpr = "#id", + descriptionExpr = "'updated department ' + #id") public ResponseEntity updateDepartment( @PathVariable("id") String id, @RequestBody DepartmentDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String updatedId = departmentService.updateDepartment(id, dto); + hrAccessGuard.requireHrAdmin(user, instituteId); + String updatedId = departmentService.updateDepartment(id, dto, instituteId); return ResponseEntity.ok(updatedId); } @DeleteMapping("/{id}") + @Auditable( + entityType = "HR_DEPARTMENT", + action = "DEACTIVATE", + entityIdExpr = "#id", + descriptionExpr = "'deactivated department ' + #id") public ResponseEntity deactivateDepartment( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - departmentService.deactivateDepartment(id); + hrAccessGuard.requireHrAdmin(user, instituteId); + departmentService.deactivateDepartment(id, instituteId); return ResponseEntity.ok().build(); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/controller/DesignationController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/controller/DesignationController.java index 62c2936693..3783838265 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/controller/DesignationController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/controller/DesignationController.java @@ -3,7 +3,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; import vacademy.io.admin_core_service.features.hr_employee.dto.DesignationDTO; import vacademy.io.admin_core_service.features.hr_employee.service.DesignationService; import vacademy.io.common.auth.model.CustomUserDetails; @@ -18,14 +19,19 @@ public class DesignationController { private DesignationService designationService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; @PostMapping + @Auditable( + entityType = "HR_DESIGNATION", + action = "CREATE", + entityIdExpr = "#result?.body", + descriptionExpr = "'created designation ' + (#dto?.name ?: '')") public ResponseEntity addDesignation( @RequestBody DesignationDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrAdmin(user, instituteId); String id = designationService.addDesignation(dto, instituteId); return ResponseEntity.ok(id); } @@ -34,19 +40,24 @@ public ResponseEntity addDesignation( public ResponseEntity> getDesignations( @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrStaff(user, instituteId); List designations = designationService.getDesignations(instituteId); return ResponseEntity.ok(designations); } @PutMapping("/{id}") + @Auditable( + entityType = "HR_DESIGNATION", + action = "UPDATE", + entityIdExpr = "#id", + descriptionExpr = "'updated designation ' + #id") public ResponseEntity updateDesignation( @PathVariable("id") String id, @RequestBody DesignationDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String updatedId = designationService.updateDesignation(id, dto); + hrAccessGuard.requireHrAdmin(user, instituteId); + String updatedId = designationService.updateDesignation(id, dto, instituteId); return ResponseEntity.ok(updatedId); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/controller/EmployeeController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/controller/EmployeeController.java index 848fbb819e..98a24cb9a5 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/controller/EmployeeController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/controller/EmployeeController.java @@ -4,11 +4,14 @@ import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; import vacademy.io.admin_core_service.features.hr_employee.dto.*; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; import vacademy.io.admin_core_service.features.hr_employee.service.EmployeeBankService; import vacademy.io.admin_core_service.features.hr_employee.service.EmployeeDocumentService; import vacademy.io.admin_core_service.features.hr_employee.service.EmployeeService; +import vacademy.io.admin_core_service.features.hr_employee.service.StaffUnificationService; import vacademy.io.common.auth.model.CustomUserDetails; import java.util.List; @@ -27,28 +30,58 @@ public class EmployeeController { private EmployeeDocumentService employeeDocumentService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; + + @Autowired + private StaffUnificationService staffUnificationService; // ======================== Employee Profile ======================== @PostMapping + @Auditable( + entityType = "HR_EMPLOYEE", + action = "CREATE", + entityIdExpr = "#result?.body", + descriptionExpr = "'created employee profile ' + (#dto?.employeeCode ?: '')") public ResponseEntity createEmployee( @RequestBody EmployeeProfileDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrAdmin(user, instituteId); String id = employeeService.createEmployee(dto, instituteId); return ResponseEntity.ok(id); } + /** + * The caller's OWN employee profile in this institute. + * + * Every other read here is keyed on an employee id, which a self-service + * user does not have and cannot discover: the list endpoint is HR-gated on + * purpose. Without this, an employee has no way to reach their own payslips, + * leave balance or tax declaration. Institute membership is the only + * requirement — the guard resolves the profile from the JWT user id, so a + * caller can never address anyone else's record through it, and 404s for a + * staff member who has no HR profile yet. + */ + @GetMapping("/me") + public ResponseEntity getMyEmployeeProfile( + @RequestParam("instituteId") String instituteId, + @RequestAttribute("user") CustomUserDetails user) { + EmployeeProfile self = hrAccessGuard.resolveSelfEmployee(user, instituteId); + // Sensitive statutory fields stay masked: this is the employee's own + // view, not an HR-admin one. + return ResponseEntity.ok(employeeService.getEmployeeById(self.getId(), instituteId, false)); + } + @GetMapping public ResponseEntity> getEmployees( @RequestParam("instituteId") String instituteId, @RequestParam(defaultValue = "0") int pageNo, @RequestParam(defaultValue = "10") int pageSize, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - Page employees = employeeService.getEmployees(instituteId, null, pageNo, pageSize); + hrAccessGuard.requireHrStaff(user, instituteId); + Page employees = employeeService.getEmployees( + instituteId, null, pageNo, pageSize, hrAccessGuard.isHrAdmin(user)); return ResponseEntity.ok(employees); } @@ -59,8 +92,9 @@ public ResponseEntity> getEmployeesWithFilter( @RequestParam(defaultValue = "0") int pageNo, @RequestParam(defaultValue = "10") int pageSize, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - Page employees = employeeService.getEmployees(instituteId, filterDTO, pageNo, pageSize); + hrAccessGuard.requireHrStaff(user, instituteId); + Page employees = employeeService.getEmployees( + instituteId, filterDTO, pageNo, pageSize, hrAccessGuard.isHrAdmin(user)); return ResponseEntity.ok(employees); } @@ -69,30 +103,42 @@ public ResponseEntity getEmployeeById( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - EmployeeProfileDTO employee = employeeService.getEmployeeById(id); + // HR staff may view anyone in the institute; an employee may view their own profile + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, id); + EmployeeProfileDTO employee = employeeService.getEmployeeById( + id, instituteId, hrAccessGuard.isHrAdmin(user)); return ResponseEntity.ok(employee); } @PutMapping("/{id}") + @Auditable( + entityType = "HR_EMPLOYEE", + action = "UPDATE", + entityIdExpr = "#id", + descriptionExpr = "'updated employee profile ' + #id") public ResponseEntity updateEmployee( @PathVariable("id") String id, @RequestBody EmployeeProfileDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String updatedId = employeeService.updateEmployee(id, dto); + hrAccessGuard.requireHrAdmin(user, instituteId); + String updatedId = employeeService.updateEmployee(id, dto, instituteId); return ResponseEntity.ok(updatedId); } @PutMapping("/{id}/status") + @Auditable( + entityType = "HR_EMPLOYEE", + action = "STATUS_CHANGE", + entityIdExpr = "#id", + descriptionExpr = "'changed employee ' + #id + ' status to ' + #statusUpdateDTO?.status") public ResponseEntity updateEmployeeStatus( @PathVariable("id") String id, @RequestBody EmployeeStatusUpdateDTO statusUpdateDTO, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String updatedId = employeeService.updateEmployeeStatus(id, statusUpdateDTO); + hrAccessGuard.requireHrAdmin(user, instituteId); + String updatedId = employeeService.updateEmployeeStatus(id, statusUpdateDTO, instituteId); return ResponseEntity.ok(updatedId); } @@ -101,21 +147,58 @@ public ResponseEntity> getOrgChart( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - List directReports = employeeService.getOrgChart(id); + hrAccessGuard.requireHrStaff(user, instituteId); + List directReports = employeeService.getOrgChart( + id, instituteId, hrAccessGuard.isHrAdmin(user)); return ResponseEntity.ok(directReports); } + // ======================== Staff ↔ HR bridge (Phase F1) ======================== + + @GetMapping("/staff-bridge") + public ResponseEntity getStaffBridge( + @RequestParam("instituteId") String instituteId, + @RequestParam(value = "role", required = false) String role, + @RequestParam(value = "search", required = false) String search, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + StaffBridgeResponseDTO bridge = staffUnificationService.getStaffBridge( + instituteId, role, search, page, size); + return ResponseEntity.ok(bridge); + } + + @PostMapping("/from-staff") + @Auditable( + entityType = "HR_EMPLOYEE", + action = "CREATE_FROM_STAFF", + entityIdExpr = "#result?.body", + descriptionExpr = "'created employee profile from staff user ' + (#dto?.userId ?: '')") + public ResponseEntity createEmployeeFromStaff( + @RequestBody EmployeeProfileDTO dto, + @RequestParam("instituteId") String instituteId, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + String id = staffUnificationService.createEmployeeFromStaff(dto, instituteId); + return ResponseEntity.ok(id); + } + // ======================== Bank Details ======================== @PostMapping("/{id}/bank-details") + @Auditable( + entityType = "HR_EMPLOYEE_BANK", + action = "CREATE", + entityIdExpr = "#result?.body", + descriptionExpr = "'added bank detail for employee ' + #id") public ResponseEntity addBankDetail( @PathVariable("id") String id, @RequestBody EmployeeBankDetailDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String bankId = employeeBankService.addBankDetail(id, dto); + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, id); + String bankId = employeeBankService.addBankDetail(id, dto, instituteId); return ResponseEntity.ok(bankId); } @@ -124,33 +207,43 @@ public ResponseEntity> getBankDetails( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - List bankDetails = employeeBankService.getBankDetails(id); + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, id); + List bankDetails = employeeBankService.getBankDetails(id, instituteId); return ResponseEntity.ok(bankDetails); } @PutMapping("/{id}/bank-details/{bid}") + @Auditable( + entityType = "HR_EMPLOYEE_BANK", + action = "UPDATE", + entityIdExpr = "#bid", + descriptionExpr = "'updated bank detail ' + #bid + ' for employee ' + #id") public ResponseEntity updateBankDetail( @PathVariable("id") String id, @PathVariable("bid") String bid, @RequestBody EmployeeBankDetailDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String updatedId = employeeBankService.updateBankDetail(id, bid, dto); + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, id); + String updatedId = employeeBankService.updateBankDetail(id, bid, dto, instituteId); return ResponseEntity.ok(updatedId); } // ======================== Documents ======================== @PostMapping("/{id}/documents") + @Auditable( + entityType = "HR_EMPLOYEE_DOCUMENT", + action = "CREATE", + entityIdExpr = "#result?.body", + descriptionExpr = "'uploaded document ' + (#dto?.documentName ?: '') + ' for employee ' + #id") public ResponseEntity addDocument( @PathVariable("id") String id, @RequestBody EmployeeDocumentDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String docId = employeeDocumentService.addDocument(id, dto); + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, id); + String docId = employeeDocumentService.addDocument(id, dto, instituteId); return ResponseEntity.ok(docId); } @@ -159,19 +252,24 @@ public ResponseEntity> getDocuments( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - List documents = employeeDocumentService.getDocuments(id); + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, id); + List documents = employeeDocumentService.getDocuments(id, instituteId); return ResponseEntity.ok(documents); } @DeleteMapping("/{id}/documents/{did}") + @Auditable( + entityType = "HR_EMPLOYEE_DOCUMENT", + action = "DELETE", + entityIdExpr = "#did", + descriptionExpr = "'deleted document ' + #did + ' of employee ' + #id") public ResponseEntity deleteDocument( @PathVariable("id") String id, @PathVariable("did") String did, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - employeeDocumentService.deleteDocument(id, did); + hrAccessGuard.requireHrAdmin(user, instituteId); + employeeDocumentService.deleteDocument(id, did, instituteId); return ResponseEntity.ok().build(); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/dto/StaffBridgeResponseDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/dto/StaffBridgeResponseDTO.java new file mode 100644 index 0000000000..90e34caa7f --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/dto/StaffBridgeResponseDTO.java @@ -0,0 +1,40 @@ +package vacademy.io.admin_core_service.features.hr_employee.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Paged staff↔HR bridge roster plus the institute-wide coverage counts an + * admin needs to onboard payroll. The summary counts always describe the FULL + * staff roster of the institute, regardless of the role/search filter applied + * to the page rows. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class StaffBridgeResponseDTO { + + private List rows; + + private int page; + private int size; + /** Rows matching the current role/search filter (across all pages). */ + private long totalElements; + + // ---- coverage summary (unfiltered, whole institute) ---- + + /** Distinct users holding any staff role (ADMIN/TEACHER/EVALUATOR/COUNSELLOR) in the institute. */ + private long totalStaff; + /** Of those, how many already have an HR employee profile in this institute. */ + private long withHrProfile; + /** Staff with an ACTIVE teaching assignment but no HR profile yet — the payroll gap. */ + private long teachingWithoutProfile; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/dto/StaffBridgeRowDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/dto/StaffBridgeRowDTO.java new file mode 100644 index 0000000000..734707014b --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/dto/StaffBridgeRowDTO.java @@ -0,0 +1,48 @@ +package vacademy.io.admin_core_service.features.hr_employee.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * One institute staff member (ADMIN/TEACHER/EVALUATOR/COUNSELLOR user_role + * holder) with their HR linkage state — the row of the staff↔HR unification + * bridge (Phase F1). + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class StaffBridgeRowDTO { + + private String userId; + private String fullName; + private String email; + private String mobileNumber; + + /** Staff roles this user holds in the institute (subset of ADMIN/TEACHER/EVALUATOR/COUNSELLOR). */ + private List roles; + + /** ACTIVE when any staff user_role row is ACTIVE, else INVITED. */ + private String status; + + /** HR linkage: set when an EmployeeProfile exists for (userId, institute). */ + private String employeeId; + private String employeeCode; + + /** True when the user has an ACTIVE teaching assignment (faculty mapping, suborg IS NULL). */ + private boolean teaches; + + /** + * Set when NO profile exists here but the user already has one in another + * institute — hr_employee_profile.user_id is globally unique, so creating a + * profile for them in this institute is impossible. Null otherwise. + */ + private String blockedReason; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/entity/EmployeeBankDetail.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/entity/EmployeeBankDetail.java index 1affb9063f..d1f0d93970 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/entity/EmployeeBankDetail.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/entity/EmployeeBankDetail.java @@ -29,7 +29,8 @@ public class EmployeeBankDetail { @Column(name = "account_holder_name") private String accountHolderName; - @Column(name = "account_number", nullable = false, length = 50) + @Convert(converter = vacademy.io.admin_core_service.core.crypto.EncryptedStringConverter.class) + @Column(name = "account_number", nullable = false, length = 512) private String accountNumber; @Column(name = "bank_name") diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/entity/EmployeeProfile.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/entity/EmployeeProfile.java index 2de123c460..98e47621e5 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/entity/EmployeeProfile.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/entity/EmployeeProfile.java @@ -92,17 +92,21 @@ public class EmployeeProfile { @Column(name = "marital_status", length = 20) private String maritalStatus; - @Column(name = "pan_number", length = 20) + @Convert(converter = vacademy.io.admin_core_service.core.crypto.EncryptedStringConverter.class) + @Column(name = "pan_number", length = 512) private String panNumber; @Column(name = "tax_id_number", length = 50) private String taxIdNumber; - @Column(name = "uan_number", length = 20) + @Convert(converter = vacademy.io.admin_core_service.core.crypto.EncryptedStringConverter.class) + @Column(name = "uan_number", length = 512) private String uanNumber; - @JdbcTypeCode(SqlTypes.JSON) - @Column(name = "statutory_info", columnDefinition = "jsonb") + // Encrypted at rest as TEXT (V480 converted jsonb -> text); legacy plaintext + // JSON rows read through the converter unchanged. + @Convert(converter = vacademy.io.admin_core_service.core.crypto.EncryptedJsonMapConverter.class) + @Column(name = "statutory_info", columnDefinition = "TEXT") private Map statutoryInfo; @JdbcTypeCode(SqlTypes.JSON) diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/job/DocumentExpiryJob.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/job/DocumentExpiryJob.java new file mode 100644 index 0000000000..26724fa33c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/job/DocumentExpiryJob.java @@ -0,0 +1,107 @@ +package vacademy.io.admin_core_service.features.hr_employee.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceConfig; +import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceConfigRepository; +import vacademy.io.admin_core_service.features.hr_attendance.util.HrTimeUtil; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeDocument; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeDocumentRepository; +import vacademy.io.admin_core_service.features.hr_employee.service.HrNotificationService; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Daily employee-document expiry reminder. + * + * Emails the institute's HR (HR_ADMIN role holders, falling back to ADMINs and + * then the employee's reporting manager) when an employee document (visa, + * contract, certification, …) expires in exactly 30 or exactly 7 days — + * two nudges per document, no daily spam. Like ProbationEndJob, the once-only + * guarantee is structural: the exact-days-remaining test (in the institute's + * timezone) can only match a given document on those two days. Candidates are + * fetched in a broad UTC window with the employee fetch-joined, since the job + * runs on a scheduler thread with no open session. + * + *

{@code @SchedulerLock} is mandatory — admin_core runs 4 replicas, and this + * job has no other dedup: without the lock HR would get four copies of every + * reminder. + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class DocumentExpiryJob { + + private static final Set EXITED_STATUSES = Set.of("RELIEVED", "TERMINATED", "ABSCONDING"); + + /** Days-remaining marks at which a reminder is sent. */ + private static final Set REMINDER_DAYS = Set.of(30L, 7L); + + private final EmployeeDocumentRepository employeeDocumentRepository; + private final AttendanceConfigRepository attendanceConfigRepository; + private final HrNotificationService hrNotificationService; + + /** Daily at 03:15 server time (UTC), after the probation reminder. */ + @Scheduled(cron = "0 15 3 * * ?") + @SchedulerLock(name = "HrDocumentExpiryJob", lockAtMostFor = "PT30M", lockAtLeastFor = "PT1M") + public void run() { + LocalDate utcToday = LocalDate.now(ZoneId.of("UTC")); + List candidates; + try { + // Broad window covering both the 7- and 30-day marks ±1 day of + // timezone skew; the exact match below uses each institute's zone. + candidates = employeeDocumentRepository.findExpiringBetweenWithEmployee( + utcToday.plusDays(6), utcToday.plusDays(31)); + } catch (Exception e) { + log.error("[doc-expiry] could not load candidates — tick aborted", e); + return; + } + + Map todayByInstitute = new HashMap<>(); + int sent = 0; + for (EmployeeDocument document : candidates) { + try { + EmployeeProfile employee = document.getEmployee(); + if (employee == null || (employee.getEmploymentStatus() != null + && EXITED_STATUSES.contains(employee.getEmploymentStatus()))) { + continue; + } + LocalDate today = todayByInstitute.computeIfAbsent(employee.getInstituteId(), + id -> LocalDate.now(HrTimeUtil.resolveZone( + attendanceConfigRepository.findByInstituteId(id).orElse((AttendanceConfig) null)))); + long daysLeft = ChronoUnit.DAYS.between(today, document.getExpiryDate()); + // Fire only at the exact 30/7-day marks — this IS the resend guard + if (!REMINDER_DAYS.contains(daysLeft)) { + continue; + } + + String name = hrNotificationService.resolveUserName(employee.getUserId()); + String subject = "Employee document expiring in " + daysLeft + " days: " + name; + String body = hrNotificationService.buildEmailBody(subject, + "Employee", name, + "Employee code", employee.getEmployeeCode(), + "Document", document.getDocumentName(), + "Type", document.getDocumentType(), + "Expires on", document.getExpiryDate().toString(), + "Action", "Collect a renewed document before it lapses."); + hrNotificationService.emailInstituteHr(employee.getInstituteId(), employee.getId(), subject, body); + sent++; + } catch (Exception e) { + log.warn("[doc-expiry] failed for document {}: {}", document.getId(), e.getMessage()); + } + } + if (sent > 0) { + log.info("[doc-expiry] sent {} reminder(s)", sent); + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/job/ProbationEndJob.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/job/ProbationEndJob.java new file mode 100644 index 0000000000..1aaaaf2cd6 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/job/ProbationEndJob.java @@ -0,0 +1,99 @@ +package vacademy.io.admin_core_service.features.hr_employee.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceConfig; +import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceConfigRepository; +import vacademy.io.admin_core_service.features.hr_attendance.util.HrTimeUtil; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; +import vacademy.io.admin_core_service.features.hr_employee.service.HrNotificationService; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Daily probation-end reminder. + * + * Emails the institute's HR (HR_ADMIN role holders, falling back to ADMINs and + * then the employee's reporting manager) exactly once per employee, 7 days + * before probation_end_date. The once-only guarantee needs no state: the mail + * fires only on the single day where probation_end_date − 7 == today in the + * institute's timezone, so tomorrow's run can never re-match the same employee. + * Candidates are fetched in a broad UTC window and the exact-day test is done + * per institute zone. + * + *

{@code @SchedulerLock} is mandatory — admin_core runs 4 replicas, and this + * job has no other dedup: without the lock HR would get four copies of every + * reminder. + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class ProbationEndJob { + + /** Statuses whose probation reminders are pointless (already out the door). */ + private static final Set EXITED_STATUSES = Set.of("RELIEVED", "TERMINATED", "ABSCONDING"); + + private final EmployeeProfileRepository employeeProfileRepository; + private final AttendanceConfigRepository attendanceConfigRepository; + private final HrNotificationService hrNotificationService; + + /** Daily at 03:00 server time (UTC). */ + @Scheduled(cron = "0 0 3 * * ?") + @SchedulerLock(name = "HrProbationEndJob", lockAtMostFor = "PT30M", lockAtLeastFor = "PT1M") + public void run() { + LocalDate utcToday = LocalDate.now(ZoneId.of("UTC")); + List candidates; + try { + // Broad window around utcToday+7; the exact match below uses each + // institute's own timezone (which can put "today" ±1 day from UTC). + candidates = employeeProfileRepository.findByProbationEndDateBetween( + utcToday.plusDays(6), utcToday.plusDays(8)); + } catch (Exception e) { + log.error("[probation-end] could not load candidates — tick aborted", e); + return; + } + + Map todayByInstitute = new HashMap<>(); + int sent = 0; + for (EmployeeProfile employee : candidates) { + try { + if (employee.getEmploymentStatus() != null + && EXITED_STATUSES.contains(employee.getEmploymentStatus())) { + continue; + } + LocalDate today = todayByInstitute.computeIfAbsent(employee.getInstituteId(), + id -> LocalDate.now(HrTimeUtil.resolveZone( + attendanceConfigRepository.findByInstituteId(id).orElse((AttendanceConfig) null)))); + // Fire only on the single day exactly 7 days out — this IS the resend guard + if (!employee.getProbationEndDate().minusDays(7).equals(today)) { + continue; + } + + String name = hrNotificationService.resolveUserName(employee.getUserId()); + String subject = "Probation ending soon: " + name; + String body = hrNotificationService.buildEmailBody(subject, + "Employee", name, + "Employee code", employee.getEmployeeCode(), + "Probation ends", employee.getProbationEndDate().toString(), + "Joined", employee.getJoinDate() != null ? employee.getJoinDate().toString() : null, + "Action", "Confirm the employee or extend probation before this date."); + hrNotificationService.emailInstituteHr(employee.getInstituteId(), employee.getId(), subject, body); + sent++; + } catch (Exception e) { + log.warn("[probation-end] failed for employee {}: {}", employee.getId(), e.getMessage()); + } + } + if (sent > 0) { + log.info("[probation-end] sent {} reminder(s)", sent); + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/repository/EmployeeDocumentRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/repository/EmployeeDocumentRepository.java index 26aed15518..e294e0f6fb 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/repository/EmployeeDocumentRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/repository/EmployeeDocumentRepository.java @@ -12,4 +12,16 @@ public interface EmployeeDocumentRepository extends JpaRepository findByEmployeeIdOrderByCreatedAtDesc(String employeeId); List findByEmployeeIdAndDocumentType(String employeeId, String documentType); + + /** + * Document-expiry reminder sweep (DocumentExpiryJob): candidates in a broad + * window, exact 30/7-day matching done per institute TZ by the job. The + * employee is fetch-joined because the job runs outside any session and + * must read employee/institute fields. + */ + @org.springframework.data.jpa.repository.Query( + "SELECT d FROM EmployeeDocument d JOIN FETCH d.employee WHERE d.expiryDate BETWEEN :start AND :end") + List findExpiringBetweenWithEmployee( + @org.springframework.data.repository.query.Param("start") java.time.LocalDate start, + @org.springframework.data.repository.query.Param("end") java.time.LocalDate end); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/repository/EmployeeProfileRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/repository/EmployeeProfileRepository.java index 194d81abd0..fa3098428f 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/repository/EmployeeProfileRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/repository/EmployeeProfileRepository.java @@ -44,4 +44,15 @@ Page findByFilters( List findActiveEmployees(@Param("instituteId") String instituteId, @Param("statuses") List statuses); boolean existsByUserIdAndInstituteId(String userId, String instituteId); + + /** Probation-end reminder sweep (ProbationEndJob): candidates in a broad window, filtered per institute TZ by the job. */ + List findByProbationEndDateBetween(java.time.LocalDate start, java.time.LocalDate end); + + /** + * The auth userId of an employee's reporting manager, without initializing + * the lazy association (safe to call from scheduler threads with no open + * session). Empty when the employee has no reporting manager. + */ + @Query("SELECT e.reportingManager.userId FROM EmployeeProfile e WHERE e.id = :employeeId") + Optional findReportingManagerUserId(@Param("employeeId") String employeeId); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/DepartmentService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/DepartmentService.java index 03066d4dc4..e1217434b0 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/DepartmentService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/DepartmentService.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_employee.dto.DepartmentDTO; import vacademy.io.admin_core_service.features.hr_employee.entity.Department; import vacademy.io.admin_core_service.features.hr_employee.repository.DepartmentRepository; @@ -23,6 +24,9 @@ public class DepartmentService { @Autowired private DepartmentRepository departmentRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + @Transactional public String addDepartment(DepartmentDTO dto, String instituteId) { if (!StringUtils.hasText(dto.getName())) { @@ -44,6 +48,7 @@ public String addDepartment(DepartmentDTO dto, String instituteId) { if (StringUtils.hasText(dto.getParentId())) { Department parent = departmentRepository.findById(dto.getParentId()) .orElseThrow(() -> new VacademyException("Parent department not found")); + hrAccessGuard.requireInstituteMatch(parent.getInstituteId(), instituteId, "Parent department"); department.setParent(parent); } @@ -52,9 +57,10 @@ public String addDepartment(DepartmentDTO dto, String instituteId) { } @Transactional - public String updateDepartment(String id, DepartmentDTO dto) { + public String updateDepartment(String id, DepartmentDTO dto, String instituteId) { Department department = departmentRepository.findById(id) .orElseThrow(() -> new VacademyException("Department not found")); + hrAccessGuard.requireInstituteMatch(department.getInstituteId(), instituteId, "Department"); if (StringUtils.hasText(dto.getName())) { department.setName(dto.getName()); @@ -79,6 +85,7 @@ public String updateDepartment(String id, DepartmentDTO dto) { validateNoCycle(id, dto.getParentId()); Department parent = departmentRepository.findById(dto.getParentId()) .orElseThrow(() -> new VacademyException("Parent department not found")); + hrAccessGuard.requireInstituteMatch(parent.getInstituteId(), instituteId, "Parent department"); department.setParent(parent); } } @@ -144,9 +151,10 @@ private void validateNoCycle(String departmentId, String newParentId) { } @Transactional - public void deactivateDepartment(String id) { + public void deactivateDepartment(String id, String instituteId) { Department department = departmentRepository.findById(id) .orElseThrow(() -> new VacademyException("Department not found")); + hrAccessGuard.requireInstituteMatch(department.getInstituteId(), instituteId, "Department"); department.setStatus("INACTIVE"); departmentRepository.save(department); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/DesignationService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/DesignationService.java index a28754ffaf..ebb263b698 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/DesignationService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/DesignationService.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_employee.dto.DesignationDTO; import vacademy.io.admin_core_service.features.hr_employee.entity.Designation; import vacademy.io.admin_core_service.features.hr_employee.repository.DesignationRepository; @@ -18,6 +19,9 @@ public class DesignationService { @Autowired private DesignationRepository designationRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + @Transactional public String addDesignation(DesignationDTO dto, String instituteId) { if (!StringUtils.hasText(dto.getName())) { @@ -42,9 +46,10 @@ public String addDesignation(DesignationDTO dto, String instituteId) { } @Transactional - public String updateDesignation(String id, DesignationDTO dto) { + public String updateDesignation(String id, DesignationDTO dto, String instituteId) { Designation designation = designationRepository.findById(id) .orElseThrow(() -> new VacademyException("Designation not found")); + hrAccessGuard.requireInstituteMatch(designation.getInstituteId(), instituteId, "Designation"); if (StringUtils.hasText(dto.getName())) { designation.setName(dto.getName()); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/EmployeeBankService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/EmployeeBankService.java index c466ba3fce..2bd3f44a6b 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/EmployeeBankService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/EmployeeBankService.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_employee.dto.EmployeeBankDetailDTO; import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeBankDetail; import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; @@ -24,14 +25,21 @@ public class EmployeeBankService { @Autowired private EmployeeProfileRepository employeeProfileRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + @Transactional - public String addBankDetail(String employeeId, EmployeeBankDetailDTO dto) { + public String addBankDetail(String employeeId, EmployeeBankDetailDTO dto, String instituteId) { EmployeeProfile employee = employeeProfileRepository.findById(employeeId) .orElseThrow(() -> new VacademyException("Employee not found")); + hrAccessGuard.requireInstituteMatch(employee.getInstituteId(), instituteId, "Employee"); if (!StringUtils.hasText(dto.getAccountNumber())) { throw new VacademyException("Account number is required"); } + if (dto.getAccountNumber().contains("*")) { + throw new VacademyException("Account number appears masked; please provide the full account number"); + } EmployeeBankDetail bankDetail = new EmployeeBankDetail(); bankDetail.setEmployee(employee); @@ -67,9 +75,10 @@ public String addBankDetail(String employeeId, EmployeeBankDetailDTO dto) { } @Transactional - public String updateBankDetail(String employeeId, String id, EmployeeBankDetailDTO dto) { + public String updateBankDetail(String employeeId, String id, EmployeeBankDetailDTO dto, String instituteId) { EmployeeBankDetail bankDetail = employeeBankDetailRepository.findById(id) .orElseThrow(() -> new VacademyException("Bank detail not found")); + hrAccessGuard.requireInstituteMatch(bankDetail.getEmployee().getInstituteId(), instituteId, "Bank detail"); if (!bankDetail.getEmployee().getId().equals(employeeId)) { throw new VacademyException("Bank detail does not belong to this employee"); } @@ -78,6 +87,9 @@ public String updateBankDetail(String employeeId, String id, EmployeeBankDetailD bankDetail.setAccountHolderName(dto.getAccountHolderName()); } if (StringUtils.hasText(dto.getAccountNumber())) { + if (dto.getAccountNumber().contains("*")) { + throw new VacademyException("Account number appears masked; please provide the full account number"); + } bankDetail.setAccountNumber(dto.getAccountNumber()); } if (dto.getBankName() != null) { @@ -122,7 +134,11 @@ public String updateBankDetail(String employeeId, String id, EmployeeBankDetailD } @Transactional(readOnly = true) - public List getBankDetails(String employeeId) { + public List getBankDetails(String employeeId, String instituteId) { + EmployeeProfile employee = employeeProfileRepository.findById(employeeId) + .orElseThrow(() -> new VacademyException("Employee not found")); + hrAccessGuard.requireInstituteMatch(employee.getInstituteId(), instituteId, "Employee"); + List bankDetails = employeeBankDetailRepository.findByEmployeeId(employeeId); return bankDetails.stream() diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/EmployeeDocumentService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/EmployeeDocumentService.java index 487f21c4a2..c37b16c63a 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/EmployeeDocumentService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/EmployeeDocumentService.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_employee.dto.EmployeeDocumentDTO; import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeDocument; import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; @@ -24,10 +25,14 @@ public class EmployeeDocumentService { @Autowired private EmployeeProfileRepository employeeProfileRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + @Transactional - public String addDocument(String employeeId, EmployeeDocumentDTO dto) { + public String addDocument(String employeeId, EmployeeDocumentDTO dto, String instituteId) { EmployeeProfile employee = employeeProfileRepository.findById(employeeId) .orElseThrow(() -> new VacademyException("Employee not found")); + hrAccessGuard.requireInstituteMatch(employee.getInstituteId(), instituteId, "Employee"); if (!StringUtils.hasText(dto.getDocumentName())) { throw new VacademyException("Document name is required"); @@ -55,7 +60,11 @@ public String addDocument(String employeeId, EmployeeDocumentDTO dto) { } @Transactional(readOnly = true) - public List getDocuments(String employeeId) { + public List getDocuments(String employeeId, String instituteId) { + EmployeeProfile employee = employeeProfileRepository.findById(employeeId) + .orElseThrow(() -> new VacademyException("Employee not found")); + hrAccessGuard.requireInstituteMatch(employee.getInstituteId(), instituteId, "Employee"); + List documents = employeeDocumentRepository.findByEmployeeIdOrderByCreatedAtDesc(employeeId); return documents.stream() @@ -64,9 +73,10 @@ public List getDocuments(String employeeId) { } @Transactional - public void deleteDocument(String employeeId, String documentId) { + public void deleteDocument(String employeeId, String documentId, String instituteId) { EmployeeDocument document = employeeDocumentRepository.findById(documentId) .orElseThrow(() -> new VacademyException("Document not found")); + hrAccessGuard.requireInstituteMatch(document.getEmployee().getInstituteId(), instituteId, "Document"); if (!document.getEmployee().getId().equals(employeeId)) { throw new VacademyException("Document does not belong to this employee"); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/EmployeeService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/EmployeeService.java index 1d0da5abcb..ad5984fac3 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/EmployeeService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/EmployeeService.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_employee.dto.EmployeeFilterDTO; import vacademy.io.admin_core_service.features.hr_employee.dto.EmployeeProfileDTO; import vacademy.io.admin_core_service.features.hr_employee.dto.EmployeeStatusUpdateDTO; @@ -19,14 +20,19 @@ import vacademy.io.admin_core_service.features.hr_employee.repository.DepartmentRepository; import vacademy.io.admin_core_service.features.hr_employee.repository.DesignationRepository; import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; +import vacademy.io.admin_core_service.features.workflow.enums.WorkflowTriggerEvent; +import vacademy.io.admin_core_service.features.workflow.service.WorkflowTriggerService; import vacademy.io.common.exceptions.VacademyException; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +@lombok.extern.slf4j.Slf4j @Service public class EmployeeService { @@ -39,16 +45,30 @@ public class EmployeeService { @Autowired private DesignationRepository designationRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + + @Autowired + private WorkflowTriggerService workflowTriggerService; + @Transactional public String createEmployee(EmployeeProfileDTO dto, String instituteId) { if (!StringUtils.hasText(dto.getUserId())) { throw new VacademyException("User ID is required"); } + if (dto.getJoinDate() == null) { + throw new VacademyException("Join date is required"); + } if (employeeProfileRepository.existsByUserIdAndInstituteId(dto.getUserId(), instituteId)) { throw new VacademyException("Employee profile already exists for this user in this institute"); } + if (StringUtils.hasText(dto.getEmployeeCode()) + && employeeProfileRepository.findByInstituteIdAndEmployeeCode(instituteId, dto.getEmployeeCode()).isPresent()) { + throw new VacademyException("Employee code already exists for this institute"); + } + if (dto.getEmploymentType() != null) { try { EmploymentType.valueOf(dto.getEmploymentType()); @@ -91,18 +111,21 @@ public String createEmployee(EmployeeProfileDTO dto, String instituteId) { if (StringUtils.hasText(dto.getDepartmentId())) { Department department = departmentRepository.findById(dto.getDepartmentId()) .orElseThrow(() -> new VacademyException("Department not found")); + hrAccessGuard.requireInstituteMatch(department.getInstituteId(), instituteId, "Department"); employee.setDepartment(department); } if (StringUtils.hasText(dto.getDesignationId())) { Designation designation = designationRepository.findById(dto.getDesignationId()) .orElseThrow(() -> new VacademyException("Designation not found")); + hrAccessGuard.requireInstituteMatch(designation.getInstituteId(), instituteId, "Designation"); employee.setDesignation(designation); } if (StringUtils.hasText(dto.getReportingManagerId())) { EmployeeProfile manager = employeeProfileRepository.findById(dto.getReportingManagerId()) .orElseThrow(() -> new VacademyException("Reporting manager not found")); + hrAccessGuard.requireInstituteMatch(manager.getInstituteId(), instituteId, "Reporting manager"); employee.setReportingManager(manager); } @@ -111,9 +134,10 @@ public String createEmployee(EmployeeProfileDTO dto, String instituteId) { } @Transactional - public String updateEmployee(String id, EmployeeProfileDTO dto) { + public String updateEmployee(String id, EmployeeProfileDTO dto, String instituteId) { EmployeeProfile employee = employeeProfileRepository.findById(id) .orElseThrow(() -> new VacademyException("Employee not found")); + hrAccessGuard.requireInstituteMatch(employee.getInstituteId(), instituteId, "Employee"); if (dto.getEmployeeCode() != null) { employee.setEmployeeCode(dto.getEmployeeCode()); @@ -173,13 +197,15 @@ public String updateEmployee(String id, EmployeeProfileDTO dto) { if (dto.getMaritalStatus() != null) { employee.setMaritalStatus(dto.getMaritalStatus()); } - if (dto.getPanNumber() != null) { + // Masked round-trip guard: these fields are masked in toDTO, so a value + // containing '*' is a re-submitted mask, not new data — keep the stored value. + if (dto.getPanNumber() != null && !isMaskedValue(dto.getPanNumber())) { employee.setPanNumber(dto.getPanNumber()); } - if (dto.getTaxIdNumber() != null) { + if (dto.getTaxIdNumber() != null && !isMaskedValue(dto.getTaxIdNumber())) { employee.setTaxIdNumber(dto.getTaxIdNumber()); } - if (dto.getUanNumber() != null) { + if (dto.getUanNumber() != null && !isMaskedValue(dto.getUanNumber())) { employee.setUanNumber(dto.getUanNumber()); } if (dto.getStatutoryInfo() != null) { @@ -195,6 +221,7 @@ public String updateEmployee(String id, EmployeeProfileDTO dto) { } else { Department department = departmentRepository.findById(dto.getDepartmentId()) .orElseThrow(() -> new VacademyException("Department not found")); + hrAccessGuard.requireInstituteMatch(department.getInstituteId(), instituteId, "Department"); employee.setDepartment(department); } } @@ -205,6 +232,7 @@ public String updateEmployee(String id, EmployeeProfileDTO dto) { } else { Designation designation = designationRepository.findById(dto.getDesignationId()) .orElseThrow(() -> new VacademyException("Designation not found")); + hrAccessGuard.requireInstituteMatch(designation.getInstituteId(), instituteId, "Designation"); employee.setDesignation(designation); } } @@ -216,6 +244,7 @@ public String updateEmployee(String id, EmployeeProfileDTO dto) { validateNoReportingCycle(id, dto.getReportingManagerId()); EmployeeProfile manager = employeeProfileRepository.findById(dto.getReportingManagerId()) .orElseThrow(() -> new VacademyException("Reporting manager not found")); + hrAccessGuard.requireInstituteMatch(manager.getInstituteId(), instituteId, "Reporting manager"); employee.setReportingManager(manager); } } @@ -225,15 +254,16 @@ public String updateEmployee(String id, EmployeeProfileDTO dto) { } @Transactional(readOnly = true) - public EmployeeProfileDTO getEmployeeById(String id) { + public EmployeeProfileDTO getEmployeeById(String id, String instituteId, boolean includeSensitive) { EmployeeProfile employee = employeeProfileRepository.findById(id) .orElseThrow(() -> new VacademyException("Employee not found")); + hrAccessGuard.requireInstituteMatch(employee.getInstituteId(), instituteId, "Employee"); - return toDTO(employee); + return toDTO(employee, includeSensitive); } @Transactional(readOnly = true) - public Page getEmployees(String instituteId, EmployeeFilterDTO filterDTO, int pageNo, int pageSize) { + public Page getEmployees(String instituteId, EmployeeFilterDTO filterDTO, int pageNo, int pageSize, boolean includeSensitive) { String status = (filterDTO != null && StringUtils.hasText(filterDTO.getStatus())) ? filterDTO.getStatus() : null; String departmentId = (filterDTO != null && StringUtils.hasText(filterDTO.getDepartmentId())) ? filterDTO.getDepartmentId() : null; String designationId = (filterDTO != null && StringUtils.hasText(filterDTO.getDesignationId())) ? filterDTO.getDesignationId() : null; @@ -244,13 +274,14 @@ public Page getEmployees(String instituteId, EmployeeFilterD Page employeePage = employeeProfileRepository.findByFilters( instituteId, status, departmentId, designationId, employmentType, pageable); - return employeePage.map(this::toDTO); + return employeePage.map(employee -> toDTO(employee, includeSensitive)); } @Transactional - public String updateEmployeeStatus(String id, EmployeeStatusUpdateDTO statusUpdateDTO) { + public String updateEmployeeStatus(String id, EmployeeStatusUpdateDTO statusUpdateDTO, String instituteId) { EmployeeProfile employee = employeeProfileRepository.findById(id) .orElseThrow(() -> new VacademyException("Employee not found")); + hrAccessGuard.requireInstituteMatch(employee.getInstituteId(), instituteId, "Employee"); if (!StringUtils.hasText(statusUpdateDTO.getStatus())) { throw new VacademyException("Status is required"); @@ -261,6 +292,7 @@ public String updateEmployeeStatus(String id, EmployeeStatusUpdateDTO statusUpda throw new VacademyException("Invalid employment status: " + statusUpdateDTO.getStatus()); } + String oldStatus = employee.getEmploymentStatus(); employee.setEmploymentStatus(statusUpdateDTO.getStatus()); if (statusUpdateDTO.getResignationDate() != null) { @@ -274,16 +306,45 @@ public String updateEmployeeStatus(String id, EmployeeStatusUpdateDTO statusUpda } employeeProfileRepository.save(employee); + + // Phase F5: HR_EMPLOYEE_STATUS_CHANGED workflow trigger on an actual + // transition (emit-and-forget — a workflow failure must never break the + // status update itself) + if (!statusUpdateDTO.getStatus().equals(oldStatus)) { + try { + Map contextData = new HashMap<>(); + contextData.put("employeeId", employee.getId()); + contextData.put("userId", employee.getUserId()); + contextData.put("oldStatus", oldStatus); + contextData.put("newStatus", employee.getEmploymentStatus()); + if (employee.getLastWorkingDate() != null) { + contextData.put("lastWorkingDate", employee.getLastWorkingDate().toString()); + } + workflowTriggerService.handleTriggerEvents( + WorkflowTriggerEvent.HR_EMPLOYEE_STATUS_CHANGED.name(), + employee.getId(), + instituteId, + contextData); + } catch (Exception e) { + log.warn("Failed to trigger HR_EMPLOYEE_STATUS_CHANGED workflow", e); + } + } + return employee.getId(); } @Transactional(readOnly = true) - public List getOrgChart(String employeeId) { + public List getOrgChart(String employeeId, String instituteId, boolean includeSensitive) { + EmployeeProfile employee = employeeProfileRepository.findById(employeeId) + .orElseThrow(() -> new VacademyException("Employee not found")); + hrAccessGuard.requireInstituteMatch(employee.getInstituteId(), instituteId, "Employee"); + // Get direct reports for the given employee List directReports = employeeProfileRepository.findByReportingManagerId(employeeId); return directReports.stream() - .map(this::toDTO) + .filter(report -> instituteId.equals(report.getInstituteId())) + .map(report -> toDTO(report, includeSensitive)) .collect(Collectors.toList()); } @@ -306,7 +367,12 @@ private String maskSensitive(String value) { return "****" + value.substring(value.length() - 4); } - private EmployeeProfileDTO toDTO(EmployeeProfile employee) { + /** True when the value is a round-tripped mask produced by {@link #maskSensitive}. */ + private boolean isMaskedValue(String value) { + return value != null && value.contains("*"); + } + + private EmployeeProfileDTO toDTO(EmployeeProfile employee, boolean includeSensitive) { EmployeeProfileDTO dto = EmployeeProfileDTO.builder() .id(employee.getId()) .userId(employee.getUserId()) @@ -330,7 +396,8 @@ private EmployeeProfileDTO toDTO(EmployeeProfile employee) { .panNumber(maskSensitive(employee.getPanNumber())) .taxIdNumber(maskSensitive(employee.getTaxIdNumber())) .uanNumber(maskSensitive(employee.getUanNumber())) - .statutoryInfo(employee.getStatutoryInfo()) + // statutoryInfo may hold raw PAN/PF/ESI values — HR admins only + .statutoryInfo(includeSensitive ? employee.getStatutoryInfo() : null) .customFields(employee.getCustomFields()) .build(); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/HrNotificationService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/HrNotificationService.java new file mode 100644 index 0000000000..5266ee6c44 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/HrNotificationService.java @@ -0,0 +1,178 @@ +package vacademy.io.admin_core_service.features.hr_employee.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; +import vacademy.io.admin_core_service.features.notification_service.service.NotificationService; +import vacademy.io.common.auth.entity.User; +import vacademy.io.common.auth.repository.UserRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Best-effort email dispatch for HR flows (leave/comp-off/regularization + * decisions, loans, reimbursements, lifecycle reminders). + * + * Every send is wrapped so a notification-service blip can NEVER fail or roll + * back the business operation that triggered it — callers just call, no + * try/catch needed on their side. + * + * Employee emails are resolved through the same user-identity join the rest of + * hr_attendance uses (EmployeeProfile.userId → common auth users table). + * + * "The institute's HR" recipients are the users holding an ACTIVE HR_ADMIN + * role for the institute (falling back to ADMIN when no HR_ADMIN exists, and + * finally to the employee's reporting manager) — resolved via the same + * user_role join scheduled reporting already uses. + */ +@Slf4j +@Service +public class HrNotificationService { + + private static final String EMAIL_TYPE = "UTILITY_EMAIL"; + + @Autowired + private NotificationService notificationService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private EmployeeProfileRepository employeeProfileRepository; + + /** Email the employee behind this profile. Silently no-ops when no email is on file. */ + public void emailEmployee(EmployeeProfile employee, String subject, String bodyHtml) { + if (employee == null) { + return; + } + emailUser(employee.getUserId(), employee.getInstituteId(), subject, bodyHtml); + } + + /** Email a user by auth userId. Failures are logged, never thrown. */ + public void emailUser(String userId, String instituteId, String subject, String bodyHtml) { + try { + String email = resolveUserEmail(userId); + if (email == null || email.isBlank()) { + log.warn("[hr-notify] no email on file for user {} — '{}' not sent", userId, subject); + return; + } + notificationService.sendHtmlEmailViaUnified( + email, subject, bodyHtml, instituteId, null, null, EMAIL_TYPE); + } catch (Exception e) { + log.warn("[hr-notify] failed to send '{}' to user {}: {}", subject, userId, e.getMessage()); + } + } + + /** + * Email the institute's HR: ACTIVE HR_ADMIN role holders, else ACTIVE + * ADMINs, else the given employee's reporting manager (when provided). + * Failures are logged, never thrown. + */ + public void emailInstituteHr(String instituteId, String fallbackEmployeeId, String subject, String bodyHtml) { + try { + List recipients = resolveHrEmails(instituteId, fallbackEmployeeId); + if (recipients.isEmpty()) { + log.warn("[hr-notify] no HR recipient resolvable for institute {} — '{}' not sent", + instituteId, subject); + return; + } + for (String email : recipients) { + try { + notificationService.sendHtmlEmailViaUnified( + email, subject, bodyHtml, instituteId, null, null, EMAIL_TYPE); + } catch (Exception e) { + log.warn("[hr-notify] failed to send '{}' to {}: {}", subject, email, e.getMessage()); + } + } + } catch (Exception e) { + log.warn("[hr-notify] failed to resolve HR recipients for institute {}: {}", + instituteId, e.getMessage()); + } + } + + /** Display name for a user id ("Unknown" fallback) — for use in email bodies. */ + public String resolveUserName(String userId) { + if (userId == null) { + return "Unknown"; + } + try { + return userRepository.findById(userId) + .map(u -> u.getFullName() != null ? u.getFullName() : u.getUsername()) + .orElse("Unknown"); + } catch (Exception e) { + return "Unknown"; + } + } + + /** + * Renders a short, clean HTML mail body: a title line followed by + * label/value rows. Values are HTML-escaped (they carry user-entered text + * like rejection reasons). + */ + public String buildEmailBody(String title, String... labelValuePairs) { + StringBuilder rows = new StringBuilder(); + for (int i = 0; i + 1 < labelValuePairs.length; i += 2) { + if (labelValuePairs[i + 1] == null) { + continue; + } + rows.append("") + .append(escapeHtml(labelValuePairs[i])) + .append("") + .append(escapeHtml(labelValuePairs[i + 1])) + .append(""); + } + return "

" + + "

" + escapeHtml(title) + "

" + + "" + rows + "
" + + "

" + + "This is an automated notification from your HR system.

" + + "
"; + } + + private String resolveUserEmail(String userId) { + if (userId == null) { + return null; + } + return userRepository.findById(userId).map(User::getEmail).orElse(null); + } + + private List resolveHrEmails(String instituteId, String fallbackEmployeeId) { + List emails = emailsForRole(instituteId, "HR_ADMIN"); + if (emails.isEmpty()) { + emails = emailsForRole(instituteId, "ADMIN"); + } + if (emails.isEmpty() && fallbackEmployeeId != null) { + Optional managerUserId = + employeeProfileRepository.findReportingManagerUserId(fallbackEmployeeId); + managerUserId.map(this::resolveUserEmail) + .filter(e -> e != null && !e.isBlank()) + .ifPresent(emails::add); + } + return emails; + } + + private List emailsForRole(String instituteId, String roleName) { + List emails = new ArrayList<>(); + for (User user : userRepository.findByInstituteAndRoleNames( + instituteId, List.of(roleName), List.of("ACTIVE"))) { + if (user.getEmail() != null && !user.getEmail().isBlank() + && !emails.contains(user.getEmail())) { + emails.add(user.getEmail()); + } + } + return emails; + } + + private String escapeHtml(String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/StaffUnificationService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/StaffUnificationService.java new file mode 100644 index 0000000000..0e89b27ec6 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_employee/service/StaffUnificationService.java @@ -0,0 +1,264 @@ +package vacademy.io.admin_core_service.features.hr_employee.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.features.common.enums.StatusEnum; +import vacademy.io.admin_core_service.features.faculty.repository.FacultySubjectPackageSessionMappingRepository; +import vacademy.io.admin_core_service.features.hr_employee.dto.EmployeeProfileDTO; +import vacademy.io.admin_core_service.features.hr_employee.dto.StaffBridgeResponseDTO; +import vacademy.io.admin_core_service.features.hr_employee.dto.StaffBridgeRowDTO; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; +import vacademy.io.admin_core_service.features.auth_service.service.AuthService; +import vacademy.io.common.auth.dto.UserDTO; +import vacademy.io.common.exceptions.VacademyException; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Phase F1 of staff↔HR-employee unification: bridges the canonical "people who + * work here" source — user_role rows (ADMIN/TEACHER/EVALUATOR/COUNSELLOR, + * institute-scoped, status ACTIVE/INVITED; the legacy `staff` table is dead) — + * to hr_employee_profile. + * + *

Roster source is auth_service over HTTP — ALL of it. admin_core's database + * contains none of the auth tables: not {@code users}, and not {@code user_role} + * or {@code roles} either. admin_core does inject the common_service + * {@code UserRoleRepository}, which makes those queries compile and pass review, + * but any of them that actually reaches the database fails at runtime with + * {@code relation "user_role" does not exist}. There is no in-process way to ask + * "who works here"; {@link AuthService} is the only answer. + * + *

Two consequences worth knowing when reading these rows: + *

    + *
  • auth_service returns ACTIVE memberships only, so INVITED staff do not + * appear in the bridge and every row's status is ACTIVE.
  • + *
  • {@code UserDTO.roles} lists a user's roles across ALL their institutes, + * so it is intersected with {@link #STAFF_ROLES} before display.
  • + *
+ */ +@Service +public class StaffUnificationService { + + /** The staff roles that constitute the institute roster. */ + public static final List STAFF_ROLES = List.of("ADMIN", "TEACHER", "EVALUATOR", "COUNSELLOR"); + + private static final String CROSS_INSTITUTE_BLOCKED_REASON = + "User already has an HR employee profile in another institute; hr_employee_profile.user_id " + + "is globally unique, so a second profile cannot be created for this user here."; + + @Autowired + private AuthService authService; + + @Autowired + private FacultySubjectPackageSessionMappingRepository facultyMappingRepository; + + @Autowired + private EmployeeProfileRepository employeeProfileRepository; + + @Autowired + private EmployeeService employeeService; + + // ======================== Roster (GET /staff-bridge) ======================== + + @Transactional(readOnly = true) + public StaffBridgeResponseDTO getStaffBridge(String instituteId, String role, String search, int page, int size) { + String roleFilter = normalizeRoleFilter(role); + int safePage = Math.max(page, 0); + int safeSize = Math.min(Math.max(size, 1), 100); + + // Full (unfiltered) staff roster — the summary counts must describe the + // whole institute even when the page rows are role/search filtered. + Map roster = fetchStaffRoster(instituteId); + + Map profileByUserId = employeeProfileRepository + .findByFilters(instituteId, null, null, null, null, Pageable.unpaged()) + .getContent().stream() + .collect(Collectors.toMap(EmployeeProfile::getUserId, Function.identity(), (a, b) -> a)); + + Set teachingUserIds = facultyMappingRepository + .findUserIdsByFilters(instituteId, List.of(StatusEnum.ACTIVE.name())); + + long withHrProfile = roster.keySet().stream().filter(profileByUserId::containsKey).count(); + long teachingWithoutProfile = roster.keySet().stream() + .filter(teachingUserIds::contains) + .filter(userId -> !profileByUserId.containsKey(userId)) + .count(); + + // Filter + stable sort + page slice. + List filtered = roster.values().stream() + .filter(e -> roleFilter == null || e.roles.contains(roleFilter)) + .filter(e -> matchesSearch(e, search)) + .sorted(Comparator + .comparing((RosterEntry e) -> e.fullName == null ? "" : e.fullName.toLowerCase(Locale.ROOT)) + .thenComparing(e -> e.userId)) + .collect(Collectors.toList()); + + int from = Math.min(safePage * safeSize, filtered.size()); + int to = Math.min(from + safeSize, filtered.size()); + + List rows = new ArrayList<>(); + for (RosterEntry entry : filtered.subList(from, to)) { + EmployeeProfile profile = profileByUserId.get(entry.userId); + String blockedReason = null; + if (profile == null) { + // Local profile absent — surface the global-unique constraint + // honestly when the user is already an employee elsewhere. + blockedReason = employeeProfileRepository.findByUserId(entry.userId) + .map(other -> CROSS_INSTITUTE_BLOCKED_REASON) + .orElse(null); + } + rows.add(StaffBridgeRowDTO.builder() + .userId(entry.userId) + .fullName(entry.fullName) + .email(entry.email) + .mobileNumber(entry.mobileNumber) + .roles(new ArrayList<>(entry.roles)) + .status(entry.anyActive ? "ACTIVE" : "INVITED") + .employeeId(profile != null ? profile.getId() : null) + .employeeCode(profile != null ? profile.getEmployeeCode() : null) + .teaches(teachingUserIds.contains(entry.userId)) + .blockedReason(blockedReason) + .build()); + } + + return StaffBridgeResponseDTO.builder() + .rows(rows) + .page(safePage) + .size(safeSize) + .totalElements(filtered.size()) + .totalStaff(roster.size()) + .withHrProfile(withHrProfile) + .teachingWithoutProfile(teachingWithoutProfile) + .build(); + } + + // ======================== Create (POST /from-staff) ======================== + + /** + * Creates a minimal EmployeeProfile for an existing staff user via the + * canonical {@link EmployeeService#createEmployee} path (its duplicate-code, + * department/designation institute-match and enum validations all apply). + * Only the bridge-relevant fields are forwarded; join_date defaults to today. + */ + @Transactional + public String createEmployeeFromStaff(EmployeeProfileDTO dto, String instituteId) { + if (dto == null || !StringUtils.hasText(dto.getUserId())) { + throw new VacademyException("user_id is required"); + } + String userId = dto.getUserId(); + + // user_id is globally unique on hr_employee_profile — one lookup + // distinguishes "already onboarded here" from "blocked by another institute". + Optional existing = employeeProfileRepository.findByUserId(userId); + if (existing.isPresent()) { + if (instituteId.equals(existing.get().getInstituteId())) { + throw new VacademyException( + "User already has an HR employee profile in this institute (employee id " + + existing.get().getId() + ")"); + } + throw new VacademyException(CROSS_INSTITUTE_BLOCKED_REASON); + } + + // Same constraint as the roster: user_role is not in this database, so + // "is this user staff here?" is an auth_service question. + boolean isStaffHere = authService.requireUsersByInstituteAndRoles(instituteId, STAFF_ROLES) + .stream() + .anyMatch(u -> u != null && userId.equals(u.getId())); + if (!isStaffHere) { + throw new VacademyException( + "User holds no staff role (ADMIN/TEACHER/EVALUATOR/COUNSELLOR) in this institute"); + } + + EmployeeProfileDTO minimal = EmployeeProfileDTO.builder() + .userId(userId) + .employeeCode(StringUtils.hasText(dto.getEmployeeCode()) ? dto.getEmployeeCode() : null) + .joinDate(dto.getJoinDate() != null ? dto.getJoinDate() : LocalDate.now()) + .departmentId(dto.getDepartmentId()) + .designationId(dto.getDesignationId()) + .build(); + + return employeeService.createEmployee(minimal, instituteId); + } + + // ======================== internals ======================== + + /** + * Distinct staff users of the institute, from auth_service. Uses the + * throwing variant: an unreachable auth_service must surface as an error, + * not as an institute that appears to employ nobody. + */ + private Map fetchStaffRoster(String instituteId) { + Map byUserId = new LinkedHashMap<>(); + for (UserDTO user : authService.requireUsersByInstituteAndRoles(instituteId, STAFF_ROLES)) { + if (user == null || !StringUtils.hasText(user.getId())) { + continue; + } + RosterEntry entry = byUserId.computeIfAbsent(user.getId(), RosterEntry::new); + entry.fullName = user.getFullName(); + entry.email = user.getEmail(); + entry.mobileNumber = user.getMobileNumber(); + entry.anyActive = true; + if (user.getRoles() != null) { + user.getRoles().stream() + .filter(StringUtils::hasText) + .map(r -> r.trim().toUpperCase(Locale.ROOT)) + .filter(STAFF_ROLES::contains) + .forEach(entry.roles::add); + } + } + return byUserId; + } + + private String normalizeRoleFilter(String role) { + if (!StringUtils.hasText(role)) { + return null; + } + String normalized = role.trim().toUpperCase(Locale.ROOT); + if (!STAFF_ROLES.contains(normalized)) { + throw new VacademyException("Invalid role filter: " + role + ". Allowed: " + STAFF_ROLES); + } + return normalized; + } + + private boolean matchesSearch(RosterEntry entry, String search) { + if (!StringUtils.hasText(search)) { + return true; + } + String needle = search.trim().toLowerCase(Locale.ROOT); + return (entry.fullName != null && entry.fullName.toLowerCase(Locale.ROOT).contains(needle)) + || (entry.email != null && entry.email.toLowerCase(Locale.ROOT).contains(needle)); + } + + /** + * In-memory merge of one user's staff user_role rows. Only {@code userId} and + * the role grants come from this service's schema; the identity fields stay + * null until {@link #decorateWithIdentity} fills them from auth_service. + */ + private static final class RosterEntry { + final String userId; + final Set roles = new LinkedHashSet<>(); + String fullName; + String email; + String mobileNumber; + boolean anyActive; + + RosterEntry(String userId) { + this.userId = userId; + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/controller/CrmIncentiveController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/controller/CrmIncentiveController.java new file mode 100644 index 0000000000..e9aa9fd71b --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/controller/CrmIncentiveController.java @@ -0,0 +1,71 @@ +package vacademy.io.admin_core_service.features.hr_incentive.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; +import vacademy.io.admin_core_service.features.hr_incentive.dto.IncentiveMaterializeResultDTO; +import vacademy.io.admin_core_service.features.hr_incentive.dto.IncentivePreviewDTO; +import vacademy.io.admin_core_service.features.hr_incentive.service.CrmIncentiveService; +import vacademy.io.common.auth.model.CustomUserDetails; + +import java.math.BigDecimal; + +/** + * CRM incentives → payroll (Phase F3): preview per-counsellor sales incentives computed + * from collected revenue (canonical attribution reproduced from the Reports Center's + * RevenueReportService), then materialize them as CRM_INCENTIVE payroll adjustments + * consumed by the REGULAR run of the payout period. + * + *

Earning window = calendar month in Asia/Kolkata, converted to UTC half-open bounds. + * Preview is HR staff; materialization is HR admin and audited. + */ +@RestController +@RequestMapping("/admin-core-service/api/v1/hr/incentives") +public class CrmIncentiveController { + + @Autowired + private CrmIncentiveService crmIncentiveService; + + @Autowired + private HrAccessGuard hrAccessGuard; + + @GetMapping("/preview") + public ResponseEntity preview( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") Integer month, + @RequestParam("year") Integer year, + @RequestParam(value = "commissionPct", required = false) BigDecimal commissionPct, + @RequestParam(value = "fixedPerConversion", required = false) BigDecimal fixedPerConversion, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + return ResponseEntity.ok(crmIncentiveService.preview( + instituteId, month, year, commissionPct, fixedPerConversion)); + } + + @PostMapping("/materialize") + @Auditable(entityType = "HR_INCENTIVE", action = "MATERIALIZE", + descriptionExpr = "'CRM incentives ' + #month + '/' + #year + ' -> payout '" + + " + #payoutMonth + '/' + #payoutYear + ': created '" + + " + #result?.body?.createdCount + ', total ' + #result?.body?.totalAmount") + public ResponseEntity materialize( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") Integer month, + @RequestParam("year") Integer year, + @RequestParam(value = "commissionPct", required = false) BigDecimal commissionPct, + @RequestParam(value = "fixedPerConversion", required = false) BigDecimal fixedPerConversion, + @RequestParam("payoutMonth") Integer payoutMonth, + @RequestParam("payoutYear") Integer payoutYear, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + return ResponseEntity.ok(crmIncentiveService.materialize( + instituteId, month, year, commissionPct, fixedPerConversion, + payoutMonth, payoutYear, user)); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/dto/IncentiveMaterializeResultDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/dto/IncentiveMaterializeResultDTO.java new file mode 100644 index 0000000000..ca099224a4 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/dto/IncentiveMaterializeResultDTO.java @@ -0,0 +1,75 @@ +package vacademy.io.admin_core_service.features.hr_incentive.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; + +/** Outcome of materializing CRM incentives into payroll adjustments. */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class IncentiveMaterializeResultDTO { + + /** Earning period the incentives were computed over. */ + private Integer month; + private Integer year; + /** Payroll period the adjustments were written to. */ + private Integer payoutMonth; + private Integer payoutYear; + + private List created; + private List skipped; + /** Counsellors with revenue but no EmployeeProfile in this institute — nothing created. */ + private List unlinkedCounsellors; + + /** Sum of the adjustment amounts actually created in this call. */ + private BigDecimal totalAmount; + private int createdCount; + private int skippedCount; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public static class CreatedItem { + private String adjustmentId; + private String employeeId; + private String counsellorUserId; + private String counsellorName; + private BigDecimal amount; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public static class SkippedItem { + private String employeeId; + private String counsellorUserId; + private String counsellorName; + /** already_materialized | zero_incentive */ + private String reason; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public static class UnlinkedCounsellor { + private String counsellorUserId; + private String counsellorName; + /** What the incentive would have been, for follow-up once a profile exists. */ + private BigDecimal incentive; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/dto/IncentivePreviewDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/dto/IncentivePreviewDTO.java new file mode 100644 index 0000000000..47acc05dcc --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/dto/IncentivePreviewDTO.java @@ -0,0 +1,40 @@ +package vacademy.io.admin_core_service.features.hr_incentive.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; + +/** CRM incentive preview: per-counsellor rows + totals for one earning month. */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class IncentivePreviewDTO { + + /** Earning period (calendar month in Asia/Kolkata). */ + private Integer month; + private Integer year; + private BigDecimal commissionPct; + private BigDecimal fixedPerConversion; + + /** UTC window actually queried (payment_log.created_at ∈ [from, to)), for transparency. */ + private String windowFromUtc; + private String windowToUtc; + + private List rows; + + private BigDecimal totalRevenue; + private long totalPayingLeads; + private long totalPayments; + private BigDecimal totalIncentive; + private int counsellorCount; + private int linkedCounsellorCount; + private int unlinkedCounsellorCount; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/dto/IncentiveRowDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/dto/IncentiveRowDTO.java new file mode 100644 index 0000000000..b80e4244e8 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/dto/IncentiveRowDTO.java @@ -0,0 +1,42 @@ +package vacademy.io.admin_core_service.features.hr_incentive.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * One counsellor's collected revenue + computed incentive for the earning period. + * Employee linkage: {@code employeeId} when the counsellor's auth userId maps to an + * HR EmployeeProfile in this institute, else {@code noEmployeeProfile = true} + * (listed and flagged, but skipped from materialization). + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class IncentiveRowDTO { + + private String counsellorUserId; + private String counsellorName; + private String employeeId; + private boolean noEmployeeProfile; + + /** Collected revenue attributed to this counsellor in the window (PAID payments of CONVERTED leads). */ + private BigDecimal revenue; + /** Distinct paying converted leads = "conversions" for the fixed-per-conversion component. */ + private long payingLeads; + private long payments; + + /** revenue × commissionPct / 100 (0 when commissionPct absent). */ + private BigDecimal commissionComponent; + /** fixedPerConversion × payingLeads (0 when fixedPerConversion absent). */ + private BigDecimal fixedComponent; + /** commissionComponent + fixedComponent, 2dp HALF_UP. */ + private BigDecimal incentive; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/repository/CrmIncentiveEmployeeRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/repository/CrmIncentiveEmployeeRepository.java new file mode 100644 index 0000000000..8f275949ec --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/repository/CrmIncentiveEmployeeRepository.java @@ -0,0 +1,17 @@ +package vacademy.io.admin_core_service.features.hr_incentive.repository; + +import org.springframework.data.repository.Repository; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; + +import java.util.Collection; +import java.util.List; + +/** + * Batch counsellor-userId → EmployeeProfile resolution for CRM incentives. + * Own read-only interface (the shared {@code EmployeeProfileRepository} has no + * IN-batch variant of findByUserIdAndInstituteId and stays untouched). + */ +public interface CrmIncentiveEmployeeRepository extends Repository { + + List findByInstituteIdAndUserIdIn(String instituteId, Collection userIds); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/repository/CrmIncentiveRevenueRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/repository/CrmIncentiveRevenueRepository.java new file mode 100644 index 0000000000..f749e3714c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/repository/CrmIncentiveRevenueRepository.java @@ -0,0 +1,114 @@ +package vacademy.io.admin_core_service.features.hr_incentive.repository; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollAdjustment; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.util.List; + +/** + * Read-only revenue attribution query for CRM incentives (Phase F3). + * + *

The SQL is a faithful reproduction of the canonical per-counsellor collected-revenue + * query in {@code features/audience/service/RevenueReportService.java} (CONV_CTE + PAID_CTE + + * REVENUE_BY_COUNSELLOR_SQL) — that file stays the single narrative source for the + * revenue-recognition product decision (a payment_log row counts only when + * payment_status='PAID' AND the paying user is an institute lead whose + * user_lead_profile.conversion_status='CONVERTED'; counsellor resolved as the latest + * ENQUIRY linked_users row for the lead's representative response, falling back to + * assigned_counselor_id). Two mechanical deviations, semantics unchanged: + *

    + *
  • {@code :scopeCsv} is wrapped in {@code CAST(... AS text)} so PostgreSQL can type the + * always-null bind coming through Hibernate (HR staff see the whole institute, so this + * feature always passes {@code null} = no counsellor filter).
  • + *
  • Result columns are aliased camelCase for the Spring Data interface projection.
  • + *
+ * + *

Declared over {@link PayrollAdjustment} only to satisfy Spring Data's domain-type + * requirement; the native query never touches that table. + */ +public interface CrmIncentiveRevenueRepository extends Repository { + + // Copied from RevenueReportService.CONV_CTE (see class javadoc for the two mechanical tweaks). + String CONV_CTE = """ + conv AS ( + SELECT ulp.user_id, + COALESCE(ulp.best_source_type, ar.source_type, 'UNKNOWN') AS source_type, + COALESCE(lu.user_id, ulp.assigned_counselor_id) AS counsellor_id + FROM user_lead_profile ulp + LEFT JOIN audience_response ar ON ar.id = ulp.best_score_response_id + LEFT JOIN LATERAL ( + SELECT lu2.user_id FROM linked_users lu2 + WHERE lu2.source = 'ENQUIRY' AND lu2.source_id = ar.enquiry_id + ORDER BY lu2.created_at DESC LIMIT 1 + ) lu ON true + WHERE ulp.institute_id = :instituteId + AND ulp.conversion_status = 'CONVERTED' + AND (CAST(:scopeCsv AS text) IS NULL OR COALESCE(lu.user_id, ulp.assigned_counselor_id) = ANY(STRING_TO_ARRAY(CAST(:scopeCsv AS text), ','))) + ) + """; + + // Copied from RevenueReportService.PAID_CTE. + String PAID_CTE = """ + paid AS ( + SELECT c.user_id, c.source_type, c.counsellor_id, + pl.payment_amount, pl.created_at + FROM payment_log pl + JOIN conv c ON c.user_id = pl.user_id + WHERE pl.payment_status = 'PAID' + AND pl.payment_amount IS NOT NULL + AND pl.created_at >= :fromTs AND pl.created_at < :toTs + ) + """; + + // Copied from RevenueReportService.REVENUE_BY_COUNSELLOR_SQL (camelCase aliases for projection). + String REVENUE_BY_COUNSELLOR_SQL = "WITH " + CONV_CTE + ", " + PAID_CTE + """ + SELECT paid.counsellor_id AS counsellorId, + COALESCE(SUM(paid.payment_amount), 0) AS revenue, + COUNT(DISTINCT paid.user_id) AS payingLeads, + COUNT(*) AS payments + FROM paid + WHERE paid.counsellor_id IS NOT NULL + GROUP BY paid.counsellor_id + ORDER BY revenue DESC + """; + + /** + * Per-counsellor collected revenue for a [fromTs, toTs) UTC window. + * Pass {@code scopeCsv = null} (no counsellor filter — institute-wide). + */ + @Query(value = REVENUE_BY_COUNSELLOR_SQL, nativeQuery = true) + List findCounsellorRevenue( + @Param("instituteId") String instituteId, + @Param("fromTs") Timestamp fromTs, + @Param("toTs") Timestamp toTs, + @Param("scopeCsv") String scopeCsv); + + /** + * Idempotency probe for materialization: employees already holding a CRM_INCENTIVE + * adjustment for the payout period — matched on source OR code so a manually keyed + * CRM_INCENTIVE row also blocks a double payout. Deliberately ignores + * payrollEntryId (consumed state): consumed means already paid, so it must + * still block re-materialization. + */ + @Query("SELECT DISTINCT a.employeeId FROM PayrollAdjustment a " + + "WHERE a.instituteId = :instituteId AND a.year = :year AND a.month = :month " + + "AND (a.source = 'CRM_INCENTIVE' OR a.code = 'CRM_INCENTIVE')") + List findEmployeeIdsWithCrmIncentive( + @Param("instituteId") String instituteId, + @Param("year") Integer year, + @Param("month") Integer month); + + interface CounsellorRevenueProjection { + String getCounsellorId(); + + BigDecimal getRevenue(); + + Long getPayingLeads(); + + Long getPayments(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/service/CrmIncentiveService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/service/CrmIncentiveService.java new file mode 100644 index 0000000000..6e919906cf --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_incentive/service/CrmIncentiveService.java @@ -0,0 +1,335 @@ +package vacademy.io.admin_core_service.features.hr_incentive.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_incentive.dto.IncentiveMaterializeResultDTO; +import vacademy.io.admin_core_service.features.hr_incentive.dto.IncentivePreviewDTO; +import vacademy.io.admin_core_service.features.hr_incentive.dto.IncentiveRowDTO; +import vacademy.io.admin_core_service.features.hr_incentive.repository.CrmIncentiveEmployeeRepository; +import vacademy.io.admin_core_service.features.hr_incentive.repository.CrmIncentiveRevenueRepository; +import vacademy.io.admin_core_service.features.hr_payroll.dto.PayrollAdjustmentDTO; +import vacademy.io.admin_core_service.features.hr_payroll.service.PayrollAdjustmentService; +import vacademy.io.common.auth.entity.User; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.auth.repository.UserRepository; +import vacademy.io.common.exceptions.VacademyException; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.Month; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * CRM incentives → payroll (Phase F3): computes per-counsellor sales incentives from + * collected revenue and materializes them as CRM_INCENTIVE payroll adjustments, which + * the REGULAR payroll run for the payout period then consumes as variable pay. + * + *

Revenue attribution reuses the canonical query from + * {@code features/audience/service/RevenueReportService.java} (reproduced verbatim in + * {@link CrmIncentiveRevenueRepository}): PAID payment_log rows of this institute's + * CONVERTED leads, grouped by resolved counsellor auth-userId. + * + *

Month bounds: the earning period is the calendar month in Asia/Kolkata, + * converted to UTC wall-clock timestamps and applied half-open to + * {@code payment_log.created_at} — the same local-date→UTC convention as + * {@code RevenueReportService.toUtc} ({@code atStartOfDay(zone) → withZoneSameInstant(UTC)}; + * columns store UTC in timestamp-without-time-zone). RevenueReportService derives its zone + * from the institute's report settings with an Asia/Kolkata fallback; this feature pins + * Asia/Kolkata per the F3 spec, so previews match the Reports Center for IST institutes. + * + *

Formula: incentive = revenue × commissionPct/100 + fixedPerConversion × payingLeads + * (distinct paying converted leads = "conversions"). Either parameter is optional; at least + * one is required; commissionPct is capped at 0..50. + */ +@Slf4j +@Service +public class CrmIncentiveService { + + private static final ZoneId EARNING_ZONE = ZoneId.of("Asia/Kolkata"); + private static final BigDecimal MAX_COMMISSION_PCT = BigDecimal.valueOf(50); + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + + static final String ADJUSTMENT_CODE = "CRM_INCENTIVE"; + static final String ADJUSTMENT_SOURCE = "CRM_INCENTIVE"; + + @Autowired + private CrmIncentiveRevenueRepository revenueRepository; + + @Autowired + private CrmIncentiveEmployeeRepository employeeRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PayrollAdjustmentService payrollAdjustmentService; + + @Transactional(readOnly = true) + public IncentivePreviewDTO preview(String instituteId, Integer month, Integer year, + BigDecimal commissionPct, BigDecimal fixedPerConversion) { + validateParams(month, year, commissionPct, fixedPerConversion); + return compute(instituteId, month, year, commissionPct, fixedPerConversion); + } + + /** + * Creates one CRM_INCENTIVE EARNING adjustment per linked counsellor-employee with + * incentive > 0, dated to the payout period. Idempotent: employees already holding a + * CRM_INCENTIVE adjustment for the payout period are skipped regardless of consumed + * state (consumed = already paid). All-or-nothing: any failure rolls back every + * adjustment created in this call. + */ + @Transactional + public IncentiveMaterializeResultDTO materialize(String instituteId, Integer month, Integer year, + BigDecimal commissionPct, BigDecimal fixedPerConversion, + Integer payoutMonth, Integer payoutYear, + CustomUserDetails user) { + validateParams(month, year, commissionPct, fixedPerConversion); + validateMonthYear(payoutMonth, payoutYear, "payout"); + + IncentivePreviewDTO preview = compute(instituteId, month, year, commissionPct, fixedPerConversion); + + Set alreadyMaterialized = new HashSet<>( + revenueRepository.findEmployeeIdsWithCrmIncentive(instituteId, payoutYear, payoutMonth)); + + String label = "Sales Incentive " + monthName(month) + " " + year; + List created = new ArrayList<>(); + List skipped = new ArrayList<>(); + List unlinked = new ArrayList<>(); + BigDecimal totalAmount = BigDecimal.ZERO; + + for (IncentiveRowDTO row : preview.getRows()) { + if (row.isNoEmployeeProfile()) { + unlinked.add(IncentiveMaterializeResultDTO.UnlinkedCounsellor.builder() + .counsellorUserId(row.getCounsellorUserId()) + .counsellorName(row.getCounsellorName()) + .incentive(row.getIncentive()) + .build()); + continue; + } + if (row.getIncentive() == null || row.getIncentive().compareTo(BigDecimal.ZERO) <= 0) { + skipped.add(skippedItem(row, "zero_incentive")); + continue; + } + if (alreadyMaterialized.contains(row.getEmployeeId())) { + skipped.add(skippedItem(row, "already_materialized")); + continue; + } + + PayrollAdjustmentDTO dto = PayrollAdjustmentDTO.builder() + .employeeId(row.getEmployeeId()) + .month(payoutMonth) + .year(payoutYear) + .type("EARNING") + .code(ADJUSTMENT_CODE) + .label(label) + .amount(row.getIncentive()) + .runScope("REGULAR") + .notes(buildNotes(row, month, year, commissionPct, fixedPerConversion)) + .build(); + String adjustmentId = payrollAdjustmentService.createAdjustment( + dto, instituteId, user, ADJUSTMENT_SOURCE); + + created.add(IncentiveMaterializeResultDTO.CreatedItem.builder() + .adjustmentId(adjustmentId) + .employeeId(row.getEmployeeId()) + .counsellorUserId(row.getCounsellorUserId()) + .counsellorName(row.getCounsellorName()) + .amount(row.getIncentive()) + .build()); + totalAmount = totalAmount.add(row.getIncentive()); + } + + log.info("[CrmIncentive] materialized {} adjustments (skipped {}, unlinked {}) " + + "for institute {} earning {}/{} payout {}/{}", + created.size(), skipped.size(), unlinked.size(), + instituteId, month, year, payoutMonth, payoutYear); + + return IncentiveMaterializeResultDTO.builder() + .month(month).year(year) + .payoutMonth(payoutMonth).payoutYear(payoutYear) + .created(created) + .skipped(skipped) + .unlinkedCounsellors(unlinked) + .totalAmount(totalAmount.setScale(2, RoundingMode.HALF_UP)) + .createdCount(created.size()) + .skippedCount(skipped.size()) + .build(); + } + + // ───────────────────────────────────────────────────────────────────── + // Computation + // ───────────────────────────────────────────────────────────────────── + + private IncentivePreviewDTO compute(String instituteId, Integer month, Integer year, + BigDecimal commissionPct, BigDecimal fixedPerConversion) { + LocalDate firstDay = LocalDate.of(year, month, 1); + Timestamp fromTs = toUtc(firstDay); + Timestamp toTs = toUtc(firstDay.plusMonths(1)); + + List revenueRows = + revenueRepository.findCounsellorRevenue(instituteId, fromTs, toTs, null); + + List counsellorIds = revenueRows.stream() + .map(CrmIncentiveRevenueRepository.CounsellorRevenueProjection::getCounsellorId) + .filter(id -> id != null && !id.isBlank()) + .distinct() + .collect(Collectors.toList()); + + Map nameMap = buildUserNameMap(counsellorIds); + Map employeeByUserId = counsellorIds.isEmpty() + ? Map.of() + : employeeRepository.findByInstituteIdAndUserIdIn(instituteId, counsellorIds).stream() + .collect(Collectors.toMap(EmployeeProfile::getUserId, Function.identity(), (a, b) -> a)); + + List rows = new ArrayList<>(); + BigDecimal totalRevenue = BigDecimal.ZERO; + BigDecimal totalIncentive = BigDecimal.ZERO; + long totalPayingLeads = 0; + long totalPayments = 0; + int linked = 0; + + for (CrmIncentiveRevenueRepository.CounsellorRevenueProjection r : revenueRows) { + BigDecimal revenue = r.getRevenue() == null ? BigDecimal.ZERO + : r.getRevenue().setScale(2, RoundingMode.HALF_UP); + long payingLeads = r.getPayingLeads() == null ? 0 : r.getPayingLeads(); + long payments = r.getPayments() == null ? 0 : r.getPayments(); + + BigDecimal commissionComponent = commissionPct == null ? BigDecimal.ZERO + : revenue.multiply(commissionPct).divide(HUNDRED, 2, RoundingMode.HALF_UP); + BigDecimal fixedComponent = fixedPerConversion == null ? BigDecimal.ZERO + : fixedPerConversion.multiply(BigDecimal.valueOf(payingLeads)) + .setScale(2, RoundingMode.HALF_UP); + BigDecimal incentive = commissionComponent.add(fixedComponent); + + EmployeeProfile employee = employeeByUserId.get(r.getCounsellorId()); + if (employee != null) linked++; + + rows.add(IncentiveRowDTO.builder() + .counsellorUserId(r.getCounsellorId()) + .counsellorName(nameMap.getOrDefault(r.getCounsellorId(), r.getCounsellorId())) + .employeeId(employee != null ? employee.getId() : null) + .noEmployeeProfile(employee == null) + .revenue(revenue) + .payingLeads(payingLeads) + .payments(payments) + .commissionComponent(commissionComponent) + .fixedComponent(fixedComponent) + .incentive(incentive) + .build()); + + totalRevenue = totalRevenue.add(revenue); + totalIncentive = totalIncentive.add(incentive); + totalPayingLeads += payingLeads; + totalPayments += payments; + } + + return IncentivePreviewDTO.builder() + .month(month).year(year) + .commissionPct(commissionPct) + .fixedPerConversion(fixedPerConversion) + .windowFromUtc(fromTs.toInstant().toString()) + .windowToUtc(toTs.toInstant().toString()) + .rows(rows) + .totalRevenue(totalRevenue.setScale(2, RoundingMode.HALF_UP)) + .totalPayingLeads(totalPayingLeads) + .totalPayments(totalPayments) + .totalIncentive(totalIncentive.setScale(2, RoundingMode.HALF_UP)) + .counsellorCount(rows.size()) + .linkedCounsellorCount(linked) + .unlinkedCounsellorCount(rows.size() - linked) + .build(); + } + + // ───────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────── + + private void validateParams(Integer month, Integer year, + BigDecimal commissionPct, BigDecimal fixedPerConversion) { + validateMonthYear(month, year, "earning"); + if (commissionPct == null && fixedPerConversion == null) { + throw new VacademyException("At least one of commissionPct or fixedPerConversion is required"); + } + if (commissionPct != null + && (commissionPct.compareTo(BigDecimal.ZERO) < 0 + || commissionPct.compareTo(MAX_COMMISSION_PCT) > 0)) { + throw new VacademyException("commissionPct must be between 0 and 50"); + } + if (fixedPerConversion != null && fixedPerConversion.compareTo(BigDecimal.ZERO) < 0) { + throw new VacademyException("fixedPerConversion must not be negative"); + } + } + + private void validateMonthYear(Integer month, Integer year, String which) { + if (month == null || month < 1 || month > 12 + || year == null || year < 2000 || year > 2100) { + throw new VacademyException("Valid " + which + " month (1-12) and year are required"); + } + } + + /** Calendar-month start in Asia/Kolkata as a UTC wall-clock Timestamp (RevenueReportService.toUtc convention). */ + private static Timestamp toUtc(LocalDate localDate) { + return Timestamp.valueOf(localDate.atStartOfDay(EARNING_ZONE) + .withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()); + } + + private static String monthName(int month) { + return Month.of(month).getDisplayName(TextStyle.FULL, Locale.ENGLISH); + } + + private String buildNotes(IncentiveRowDTO row, Integer month, Integer year, + BigDecimal commissionPct, BigDecimal fixedPerConversion) { + StringBuilder sb = new StringBuilder("CRM incentive for ") + .append(monthName(month)).append(' ').append(year) + .append(": revenue ").append(row.getRevenue()); + if (commissionPct != null) { + sb.append(" x ").append(commissionPct).append("% = ").append(row.getCommissionComponent()); + } + if (fixedPerConversion != null) { + sb.append(commissionPct != null ? " + " : ", ") + .append(fixedPerConversion).append(" x ").append(row.getPayingLeads()) + .append(" paying leads = ").append(row.getFixedComponent()); + } + sb.append("; total ").append(row.getIncentive()) + .append(" (counsellor ").append(row.getCounsellorUserId()).append(')'); + return sb.toString(); + } + + /** Same pattern as hr_attendance/service/AttendanceService.buildUserNameMap. */ + private Map buildUserNameMap(List userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + List users = userRepository.findByIdIn(userIds); + return users.stream() + .collect(Collectors.toMap( + User::getId, + u -> u.getFullName() != null ? u.getFullName() : u.getUsername(), + (a, b) -> a + )); + } + + private static IncentiveMaterializeResultDTO.SkippedItem skippedItem(IncentiveRowDTO row, String reason) { + return IncentiveMaterializeResultDTO.SkippedItem.builder() + .employeeId(row.getEmployeeId()) + .counsellorUserId(row.getCounsellorUserId()) + .counsellorName(row.getCounsellorName()) + .reason(reason) + .build(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/controller/LeaveApplicationController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/controller/LeaveApplicationController.java index 9b3c07e060..a54d43ac05 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/controller/LeaveApplicationController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/controller/LeaveApplicationController.java @@ -4,7 +4,7 @@ import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; import vacademy.io.admin_core_service.features.hr_leave.dto.*; import vacademy.io.admin_core_service.features.hr_leave.service.CompOffService; import vacademy.io.admin_core_service.features.hr_leave.service.LeaveApplicationService; @@ -13,6 +13,11 @@ import java.util.List; +/** + * Authorization is enforced inside the services via {@code HrAccessGuard}: + * every method validates institute membership, role, and entity-to-institute + * ownership before touching data — controllers stay thin. + */ @RestController @RequestMapping("/admin-core-service/api/v1/hr/leaves") public class LeaveApplicationController { @@ -26,17 +31,13 @@ public class LeaveApplicationController { @Autowired private CompOffService compOffService; - @Autowired - private InstituteAccessValidator instituteAccessValidator; - // --- Leave Application endpoints --- @PostMapping("/apply") public ResponseEntity applyLeave(@RequestBody LeaveApplyDTO dto, @RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String id = leaveApplicationService.applyLeave(dto, instituteId); + String id = leaveApplicationService.applyLeave(dto, instituteId, user); return ResponseEntity.ok(id); } @@ -48,9 +49,8 @@ public ResponseEntity> getLeaveApplications( @RequestParam(defaultValue = "0") int pageNo, @RequestParam(defaultValue = "20") int pageSize, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); Page page = leaveApplicationService.getLeaveApplications( - instituteId, status, employeeId, pageNo, pageSize); + instituteId, status, employeeId, pageNo, pageSize, user); return ResponseEntity.ok(page); } @@ -59,8 +59,7 @@ public ResponseEntity approveRejectLeave(@PathVariable String id, @RequestBody LeaveActionDTO actionDTO, @RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = leaveApplicationService.approveRejectLeave(id, actionDTO, user.getUserId()); + String resultId = leaveApplicationService.approveRejectLeave(id, actionDTO, instituteId, user); return ResponseEntity.ok(resultId); } @@ -68,56 +67,67 @@ public ResponseEntity approveRejectLeave(@PathVariable String id, public ResponseEntity cancelLeave(@PathVariable String id, @RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = leaveApplicationService.cancelLeave(id); + String resultId = leaveApplicationService.cancelLeave(id, instituteId, user); return ResponseEntity.ok(resultId); } @GetMapping("/applications/pending") public ResponseEntity> getPendingForManager( @RequestParam String instituteId, + @RequestParam(required = false) String approverId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - List pending = leaveApplicationService.getPendingForManager(user.getUserId()); + List pending = leaveApplicationService.getPendingForManager( + instituteId, approverId, user); return ResponseEntity.ok(pending); } // --- Leave Balance endpoints --- @GetMapping("/balances") - public ResponseEntity> getBalances(@RequestParam String employeeId, + public ResponseEntity> getBalances(@RequestParam(required = false) String employeeId, @RequestParam Integer year, @RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - List balances = leaveBalanceService.getBalances(employeeId, year); + List balances = leaveBalanceService.getBalances(employeeId, year, instituteId, user); return ResponseEntity.ok(balances); } @PutMapping("/balances/{id}/adjust") + @Auditable( + entityType = "HR_LEAVE_BALANCE", + action = "ADJUST", + entityIdExpr = "#id", + descriptionExpr = "'adjusted leave balance by ' + #dto?.adjustment + (#dto?.reason != null ? ' (' + #dto.reason + ')' : '')") public ResponseEntity adjustBalance(@PathVariable String id, @RequestBody LeaveBalanceAdjustDTO dto, @RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = leaveBalanceService.adjustBalance(id, dto); + String resultId = leaveBalanceService.adjustBalance(id, dto, instituteId, user); return ResponseEntity.ok(resultId); } @PostMapping("/accrue") + @Auditable( + entityType = "HR_LEAVE", + action = "ACCRUE", + entityIdExpr = "#instituteId", + descriptionExpr = "'triggered monthly leave accrual'") public ResponseEntity accrueLeaves(@RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String result = leaveBalanceService.accrueLeaves(instituteId); + String result = leaveBalanceService.accrueLeaves(instituteId, user); return ResponseEntity.ok(result); } @PostMapping("/year-end-process") + @Auditable( + entityType = "HR_LEAVE", + action = "YEAR_END", + entityIdExpr = "#instituteId", + descriptionExpr = "'ran year-end leave process for year ' + #year") public ResponseEntity yearEndProcess(@RequestParam String instituteId, @RequestParam Integer year, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String result = leaveBalanceService.yearEndProcess(instituteId, year); + String result = leaveBalanceService.yearEndProcess(instituteId, year, user); return ResponseEntity.ok(result); } @@ -127,8 +137,7 @@ public ResponseEntity yearEndProcess(@RequestParam String instituteId, public ResponseEntity requestCompOff(@RequestBody CompOffDTO dto, @RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String id = compOffService.requestCompOff(dto, instituteId); + String id = compOffService.requestCompOff(dto, instituteId, user); return ResponseEntity.ok(id); } @@ -137,8 +146,7 @@ public ResponseEntity approveRejectCompOff(@PathVariable String id, @RequestBody CompOffActionDTO actionDTO, @RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = compOffService.approveRejectCompOff(id, actionDTO, user.getUserId()); + String resultId = compOffService.approveRejectCompOff(id, actionDTO, instituteId, user); return ResponseEntity.ok(resultId); } @@ -146,8 +154,7 @@ public ResponseEntity approveRejectCompOff(@PathVariable String id, public ResponseEntity> getCompOffs(@RequestParam String employeeId, @RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - List compOffs = compOffService.getCompOffs(employeeId); + List compOffs = compOffService.getCompOffs(employeeId, instituteId, user); return ResponseEntity.ok(compOffs); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/controller/LeavePolicyController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/controller/LeavePolicyController.java index e96c07c076..f3660b076a 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/controller/LeavePolicyController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/controller/LeavePolicyController.java @@ -3,13 +3,16 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; import vacademy.io.admin_core_service.features.hr_leave.dto.LeavePolicyDTO; import vacademy.io.admin_core_service.features.hr_leave.service.LeavePolicyService; import vacademy.io.common.auth.model.CustomUserDetails; import java.util.List; +/** + * Authorization is enforced inside the service via {@code HrAccessGuard}: + * create/update are HR-admin only, listing is open to institute members. + */ @RestController @RequestMapping("/admin-core-service/api/v1/hr/leaves/policies") public class LeavePolicyController { @@ -17,23 +20,18 @@ public class LeavePolicyController { @Autowired private LeavePolicyService leavePolicyService; - @Autowired - private InstituteAccessValidator instituteAccessValidator; - @PostMapping public ResponseEntity createLeavePolicy(@RequestBody LeavePolicyDTO dto, @RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String id = leavePolicyService.createLeavePolicy(dto, instituteId); + String id = leavePolicyService.createLeavePolicy(dto, instituteId, user); return ResponseEntity.ok(id); } @GetMapping public ResponseEntity> getLeavePolicies(@RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - List policies = leavePolicyService.getLeavePolicies(instituteId); + List policies = leavePolicyService.getLeavePolicies(instituteId, user); return ResponseEntity.ok(policies); } @@ -42,8 +40,7 @@ public ResponseEntity updateLeavePolicy(@PathVariable String id, @RequestBody LeavePolicyDTO dto, @RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String updatedId = leavePolicyService.updateLeavePolicy(id, dto); + String updatedId = leavePolicyService.updateLeavePolicy(id, dto, instituteId, user); return ResponseEntity.ok(updatedId); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/controller/LeaveTypeController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/controller/LeaveTypeController.java index da718025a6..3a537fb0fb 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/controller/LeaveTypeController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/controller/LeaveTypeController.java @@ -3,13 +3,16 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; import vacademy.io.admin_core_service.features.hr_leave.dto.LeaveTypeDTO; import vacademy.io.admin_core_service.features.hr_leave.service.LeaveTypeService; import vacademy.io.common.auth.model.CustomUserDetails; import java.util.List; +/** + * Authorization is enforced inside the service via {@code HrAccessGuard}: + * create/update are HR-admin only, listing is open to institute members. + */ @RestController @RequestMapping("/admin-core-service/api/v1/hr/leaves/types") public class LeaveTypeController { @@ -17,23 +20,18 @@ public class LeaveTypeController { @Autowired private LeaveTypeService leaveTypeService; - @Autowired - private InstituteAccessValidator instituteAccessValidator; - @PostMapping public ResponseEntity createLeaveType(@RequestBody LeaveTypeDTO dto, @RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String id = leaveTypeService.createLeaveType(dto, instituteId); + String id = leaveTypeService.createLeaveType(dto, instituteId, user); return ResponseEntity.ok(id); } @GetMapping public ResponseEntity> getLeaveTypes(@RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - List leaveTypes = leaveTypeService.getLeaveTypes(instituteId); + List leaveTypes = leaveTypeService.getLeaveTypes(instituteId, user); return ResponseEntity.ok(leaveTypes); } @@ -42,8 +40,7 @@ public ResponseEntity updateLeaveType(@PathVariable String id, @RequestBody LeaveTypeDTO dto, @RequestParam String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String updatedId = leaveTypeService.updateLeaveType(id, dto); + String updatedId = leaveTypeService.updateLeaveType(id, dto, instituteId, user); return ResponseEntity.ok(updatedId); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/entity/LeaveAccrualTxn.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/entity/LeaveAccrualTxn.java new file mode 100644 index 0000000000..66b0354d2d --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/entity/LeaveAccrualTxn.java @@ -0,0 +1,60 @@ +package vacademy.io.admin_core_service.features.hr_leave.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.UuidGenerator; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Ledger of leave accrual events. One row per (employee, leave type, period) + * — the unique constraint on (employee_id, leave_type_id, period_key) makes + * every accrual, pro-rata grant and carry-forward idempotent. + * + * period_key formats: MONTHLY "YYYY-MM", QUARTERLY "YYYY-Qn", YEARLY "YYYY", + * year-end carry-forward marker "CARRY-YYYY". + */ +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "hr_leave_accrual_txn") +public class LeaveAccrualTxn { + + @Id + @UuidGenerator + @Column(name = "id") + private String id; + + @Column(name = "employee_id", nullable = false) + private String employeeId; + + @Column(name = "leave_type_id", nullable = false) + private String leaveTypeId; + + @Column(name = "policy_id") + private String policyId; + + @Column(name = "year", nullable = false) + private Integer year; + + @Column(name = "period_key", nullable = false, length = 20) + private String periodKey; + + @Column(name = "amount", precision = 5, scale = 2) + private BigDecimal amount; + + @Column(name = "source", length = 30) + private String source; + + @Column(name = "created_at", insertable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/entity/LeaveBalance.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/entity/LeaveBalance.java index 79aa15a1ac..ee7e1a0508 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/entity/LeaveBalance.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/entity/LeaveBalance.java @@ -53,6 +53,10 @@ public class LeaveBalance { @Column(name = "encashed", precision = 5, scale = 1) private BigDecimal encashed; + @Version + @Column(name = "version") + private Long version; + @Column(name = "created_at", insertable = false, updatable = false) private LocalDateTime createdAt; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/job/CompOffExpiryJob.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/job/CompOffExpiryJob.java new file mode 100644 index 0000000000..ca98f7704c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/job/CompOffExpiryJob.java @@ -0,0 +1,45 @@ +package vacademy.io.admin_core_service.features.hr_leave.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import vacademy.io.admin_core_service.features.hr_leave.service.CompOffService; + +/** + * Daily comp-off expiry sweep. + * + * APPROVED comp-offs carry an optional expiry_date, but nothing ever enforced + * it — expired credits stayed spendable forever through the COMP_OFF leave + * balance. This job marks APPROVED comp-offs past their expiry date (per the + * owning institute's timezone) as EXPIRED and, when the credited days are still + * unspent, deducts min(days, available) from the balance's adjustment so the + * closing balance can never go negative. The status transition itself makes + * re-runs idempotent: an EXPIRED row is never a candidate again. + * + *

{@code @SchedulerLock} is mandatory — admin_core runs 4 replicas; without + * it the same comp-off could be double-deducted by two replicas racing between + * the candidate fetch and the status flip. + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class CompOffExpiryJob { + + private final CompOffService compOffService; + + /** Daily at 02:30 server time (UTC), after the accrual tick. */ + @Scheduled(cron = "0 30 2 * * ?") + @SchedulerLock(name = "HrCompOffExpiryJob", lockAtMostFor = "PT30M", lockAtLeastFor = "PT1M") + public void run() { + try { + int expired = compOffService.expireOverdueCompOffs(); + if (expired > 0) { + log.info("[comp-off-expiry] expired {} comp-off(s)", expired); + } + } catch (Exception e) { + log.error("[comp-off-expiry] sweep failed", e); + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/job/LeaveAccrualJob.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/job/LeaveAccrualJob.java new file mode 100644 index 0000000000..1a550c90da --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/job/LeaveAccrualJob.java @@ -0,0 +1,64 @@ +package vacademy.io.admin_core_service.features.hr_leave.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import vacademy.io.admin_core_service.features.hr_leave.repository.LeavePolicyRepository; +import vacademy.io.admin_core_service.features.hr_leave.service.LeaveBalanceService; + +import java.util.List; + +/** + * Daily leave accrual tick. + * + * Before this job existed, accrual only happened when an HR admin remembered to + * hit the accrual endpoint — a forgotten month silently under-credited every + * employee. This job runs {@link LeaveBalanceService#accrueLeavesInternal} for + * every institute that has at least one ACTIVE leave policy. Running DAILY is + * safe: the hr_leave_accrual_txn ledger's unique (employee, leave type, + * period_key) constraint means each MONTHLY/QUARTERLY/YEARLY period is credited + * exactly once, no matter how often the job asks. + * + *

{@code @SchedulerLock} is mandatory — admin_core runs 4 replicas, and while + * the ledger constraint makes concurrent runs merely wasteful rather than + * incorrect, four replicas racing over every institute every night is pointless + * load and log noise. + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class LeaveAccrualJob { + + private final LeavePolicyRepository leavePolicyRepository; + private final LeaveBalanceService leaveBalanceService; + + /** Daily at 02:00 server time (UTC); per-institute "today" is derived inside the accrual. */ + @Scheduled(cron = "0 0 2 * * ?") + @SchedulerLock(name = "HrLeaveAccrualJob", lockAtMostFor = "PT1H", lockAtLeastFor = "PT1M") + public void run() { + List instituteIds; + try { + instituteIds = leavePolicyRepository.findDistinctInstituteIdsWithActivePolicies(); + } catch (Exception e) { + log.error("[leave-accrual] could not enumerate institutes — tick aborted", e); + return; + } + if (instituteIds.isEmpty()) { + return; + } + + int failed = 0; + for (String instituteId : instituteIds) { + try { + leaveBalanceService.accrueLeavesInternal(instituteId); + } catch (Exception e) { + // One institute's bad policy must never stop the others + failed++; + log.error("[leave-accrual] accrual failed for institute {}", instituteId, e); + } + } + log.info("[leave-accrual] tick done: {} institute(s), {} failed", instituteIds.size(), failed); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/CompensatoryOffRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/CompensatoryOffRepository.java index 0ef5ff5f5c..63bdc19a1e 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/CompensatoryOffRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/CompensatoryOffRepository.java @@ -12,4 +12,7 @@ public interface CompensatoryOffRepository extends JpaRepository findByEmployee_IdAndStatusOrderByWorkedOnDateDesc(String employeeId, String status); List findByEmployee_IdAndUsedFalseAndStatusOrderByExpiryDateAsc(String employeeId, String status); + + /** Expiry sweep candidates (CompOffExpiryJob): rows in this status whose expiry date is before the cutoff. */ + List findByStatusAndExpiryDateLessThan(String status, java.time.LocalDate cutoff); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeaveAccrualTxnRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeaveAccrualTxnRepository.java new file mode 100644 index 0000000000..6212c8be95 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeaveAccrualTxnRepository.java @@ -0,0 +1,11 @@ +package vacademy.io.admin_core_service.features.hr_leave.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.hr_leave.entity.LeaveAccrualTxn; + +@Repository +public interface LeaveAccrualTxnRepository extends JpaRepository { + + boolean existsByEmployeeIdAndLeaveTypeIdAndPeriodKey(String employeeId, String leaveTypeId, String periodKey); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeaveApplicationRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeaveApplicationRepository.java index f9fc482f22..670c147031 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeaveApplicationRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeaveApplicationRepository.java @@ -37,6 +37,8 @@ List findOverlappingLeaves(@Param("employeeId") String employe @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate); - @Query("SELECT la FROM LeaveApplication la WHERE la.appliedTo = :managerId AND la.status = 'PENDING' ORDER BY la.createdAt DESC") - List findPendingForManager(@Param("managerId") String managerId); + @Query("SELECT la FROM LeaveApplication la WHERE la.appliedTo = :managerId AND la.instituteId = :instituteId " + + "AND la.status = 'PENDING' ORDER BY la.createdAt DESC") + List findPendingForManagerInInstitute(@Param("managerId") String managerId, + @Param("instituteId") String instituteId); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeaveBalanceRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeaveBalanceRepository.java index f4a0ce2492..49ea7f8abd 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeaveBalanceRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeaveBalanceRepository.java @@ -18,6 +18,9 @@ public interface LeaveBalanceRepository extends JpaRepository findByEmployee_Id(String employeeId); + /** Every employee's balances for one year — the HR-wide balance view. */ + List findByEmployee_InstituteIdAndYear(String instituteId, Integer year); + @Query("SELECT CASE WHEN COUNT(lb) > 0 THEN true ELSE false END FROM LeaveBalance lb " + "WHERE lb.employee.id IN :employeeIds AND lb.year = :year") boolean existsByEmployeeIdsAndYear(@Param("employeeIds") List employeeIds, @Param("year") Integer year); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeavePolicyRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeavePolicyRepository.java index 41452bff8f..05e971a668 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeavePolicyRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/repository/LeavePolicyRepository.java @@ -19,4 +19,12 @@ public interface LeavePolicyRepository extends JpaRepository findActivePolicies(@Param("instituteId") String instituteId, @Param("date") LocalDate date); List findByLeaveType_Id(String leaveTypeId); + + /** + * Every institute that has at least one ACTIVE leave policy — the fan-out + * set for the daily accrual job (LeaveAccrualJob). Effective-date filtering + * happens inside the accrual itself, per institute timezone. + */ + @Query("SELECT DISTINCT p.instituteId FROM LeavePolicy p WHERE p.status = 'ACTIVE'") + List findDistinctInstituteIdsWithActivePolicies(); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/CompOffService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/CompOffService.java index e755532ef5..02c7286848 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/CompOffService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/CompOffService.java @@ -4,8 +4,16 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceConfig; +import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceRecord; +import vacademy.io.admin_core_service.features.hr_attendance.enums.AttendanceStatus; +import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceConfigRepository; +import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceRecordRepository; +import vacademy.io.admin_core_service.features.hr_attendance.repository.HolidayRepository; +import vacademy.io.admin_core_service.features.hr_attendance.util.HrTimeUtil; import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; -import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; +import vacademy.io.admin_core_service.features.hr_employee.service.HrNotificationService; import vacademy.io.admin_core_service.features.hr_leave.dto.CompOffActionDTO; import vacademy.io.admin_core_service.features.hr_leave.dto.CompOffDTO; import vacademy.io.admin_core_service.features.hr_leave.entity.CompensatoryOff; @@ -15,23 +23,38 @@ import vacademy.io.admin_core_service.features.hr_leave.repository.CompensatoryOffRepository; import vacademy.io.admin_core_service.features.hr_leave.repository.LeaveBalanceRepository; import vacademy.io.admin_core_service.features.hr_leave.repository.LeaveTypeRepository; +import vacademy.io.admin_core_service.features.workflow.enums.WorkflowTriggerEvent; +import vacademy.io.admin_core_service.features.workflow.service.WorkflowTriggerService; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.exceptions.ForbiddenException; import vacademy.io.common.exceptions.VacademyException; import java.math.BigDecimal; +import java.time.DayOfWeek; import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneId; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; +@lombok.extern.slf4j.Slf4j @Service public class CompOffService { - @Autowired - private CompensatoryOffRepository compensatoryOffRepository; + /** + * Terminal status for an APPROVED comp-off whose expiry date has passed + * without being spent. Stored in the same String status column as the + * LeaveStatus values (kept as a literal so entities/enums stay untouched). + */ + public static final String STATUS_EXPIRED = "EXPIRED"; @Autowired - private EmployeeProfileRepository employeeProfileRepository; + private CompensatoryOffRepository compensatoryOffRepository; @Autowired private LeaveTypeRepository leaveTypeRepository; @@ -39,20 +62,69 @@ public class CompOffService { @Autowired private LeaveBalanceRepository leaveBalanceRepository; + @Autowired + private AttendanceConfigRepository attendanceConfigRepository; + + @Autowired + private AttendanceRecordRepository attendanceRecordRepository; + + @Autowired + private HolidayRepository holidayRepository; + + @Autowired + private HrAccessGuard hrAccessGuard; + + @Autowired + private HrNotificationService hrNotificationService; + + @Autowired + private WorkflowTriggerService workflowTriggerService; + @Transactional - public String requestCompOff(CompOffDTO dto, String instituteId) { + public String requestCompOff(CompOffDTO dto, String instituteId, CustomUserDetails user) { if (!StringUtils.hasText(dto.getEmployeeId())) { throw new VacademyException("Employee ID is required"); } if (dto.getWorkedOnDate() == null) { throw new VacademyException("Worked on date is required"); } - if (dto.getEarnedDays() == null || dto.getEarnedDays().compareTo(BigDecimal.ZERO) <= 0) { - throw new VacademyException("Earned days must be greater than zero"); + // Sanity clamp: the client-supplied earnedDays is never trusted beyond + // a plausible single-request range. + if (dto.getEarnedDays() == null || dto.getEarnedDays().compareTo(BigDecimal.ZERO) <= 0 + || dto.getEarnedDays().compareTo(new BigDecimal("2")) > 0) { + throw new VacademyException("Earned days must be greater than zero and at most 2"); } - EmployeeProfile employee = employeeProfileRepository.findById(dto.getEmployeeId()) - .orElseThrow(() -> new VacademyException("Employee not found")); + // Non-HR callers may only request comp-off for themselves; the employee + // is verified to belong to the validated institute. + EmployeeProfile employee = hrAccessGuard.requireSelfOrHrStaff(user, instituteId, dto.getEmployeeId()); + + // Comp-off is only earned for work on a non-working day: the worked + // date must be a configured weekend day or a holiday. + AttendanceConfig config = attendanceConfigRepository.findByInstituteId(instituteId).orElse(null); + Set weekendDays = HrTimeUtil.resolveWeekendDays(config); + boolean isWeekend = weekendDays.contains(dto.getWorkedOnDate().getDayOfWeek()); + boolean isHoliday = holidayRepository.existsByInstituteIdAndDate(instituteId, dto.getWorkedOnDate()); + if (!isWeekend && !isHoliday) { + throw new VacademyException("Compensatory off can only be requested for work done on a weekend or holiday"); + } + + // If the employee has attendance records for that month at all, require + // a PRESENT/HALF_DAY record on the worked date as proof of presence. + // (Institutes not tracking attendance have no records — skip the check.) + YearMonth workedMonth = YearMonth.from(dto.getWorkedOnDate()); + List monthRecords = attendanceRecordRepository + .findByEmployeeIdAndAttendanceDateBetweenOrderByAttendanceDateAsc( + employee.getId(), workedMonth.atDay(1), workedMonth.atEndOfMonth()); + if (!monthRecords.isEmpty()) { + boolean workedThatDay = monthRecords.stream() + .anyMatch(r -> r.getAttendanceDate().isEqual(dto.getWorkedOnDate()) + && (AttendanceStatus.PRESENT.name().equals(r.getStatus()) + || AttendanceStatus.HALF_DAY.name().equals(r.getStatus()))); + if (!workedThatDay) { + throw new VacademyException("No attendance record found showing you worked on " + dto.getWorkedOnDate()); + } + } CompensatoryOff compOff = new CompensatoryOff(); compOff.setEmployee(employee); @@ -67,9 +139,18 @@ public String requestCompOff(CompOffDTO dto, String instituteId) { } @Transactional - public String approveRejectCompOff(String id, CompOffActionDTO actionDTO, String approverUserId) { + public String approveRejectCompOff(String id, CompOffActionDTO actionDTO, String instituteId, CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + CompensatoryOff compOff = compensatoryOffRepository.findById(id) .orElseThrow(() -> new VacademyException("Compensatory off request not found")); + hrAccessGuard.requireInstituteMatch(compOff.getEmployee().getInstituteId(), instituteId, "Compensatory off request"); + + // No self-approval: even HR staff must not decide their own comp-off. + String requesterUserId = compOff.getEmployee().getUserId(); + if (requesterUserId != null && requesterUserId.equals(user.getUserId())) { + throw new ForbiddenException("You cannot approve or reject your own compensatory off request"); + } if (!LeaveStatus.PENDING.name().equals(compOff.getStatus())) { throw new VacademyException("Only pending compensatory off requests can be approved or rejected"); @@ -81,7 +162,7 @@ public String approveRejectCompOff(String id, CompOffActionDTO actionDTO, String if (Boolean.TRUE.equals(actionDTO.getApproved())) { compOff.setStatus(LeaveStatus.APPROVED.name()); - compOff.setApprovedBy(approverUserId); + compOff.setApprovedBy(user.getUserId()); // BUG 6 FIX: Credit approved comp-off to leave balance creditCompOffToLeaveBalance(compOff); @@ -90,11 +171,137 @@ public String approveRejectCompOff(String id, CompOffActionDTO actionDTO, String } compensatoryOffRepository.save(compOff); + + notifyCompOffDecision(compOff, Boolean.TRUE.equals(actionDTO.getApproved())); + + // Phase F5: HR_COMP_OFF_DECIDED workflow trigger (emit-and-forget — a + // workflow failure must never break the decision itself) + try { + Map contextData = new HashMap<>(); + contextData.put("compOffId", compOff.getId()); + contextData.put("employeeId", compOff.getEmployee().getId()); + contextData.put("employeeUserId", compOff.getEmployee().getUserId()); + contextData.put("workedOnDate", compOff.getWorkedOnDate() != null + ? compOff.getWorkedOnDate().toString() : null); + contextData.put("earnedDays", compOff.getEarnedDays() != null + ? compOff.getEarnedDays().toPlainString() : null); + contextData.put("expiryDate", compOff.getExpiryDate() != null + ? compOff.getExpiryDate().toString() : null); + contextData.put("status", compOff.getStatus()); + contextData.put("approvedBy", compOff.getApprovedBy()); + workflowTriggerService.handleTriggerEvents( + WorkflowTriggerEvent.HR_COMP_OFF_DECIDED.name(), + compOff.getId(), + instituteId, + contextData); + } catch (Exception e) { + log.warn("Failed to trigger HR_COMP_OFF_DECIDED workflow", e); + } + return compOff.getId(); } + /** + * CompOffExpiryJob worker: marks APPROVED comp-offs whose expiry date has + * passed (per the owning institute's timezone) as EXPIRED, and — when the + * credited days were never spent — removes them from the COMP_OFF balance's + * adjustment so they stop being spendable. Conservative: the deduction is + * capped at the balance still available, so an expiry can never drive the + * closing balance negative. + * + * @return number of comp-offs expired + */ + @Transactional + public int expireOverdueCompOffs() { + // Broad candidate fetch using the platform default zone plus a day of + // slack; the exact "is it past expiry" test below uses each owning + // institute's own timezone. + LocalDate broadCutoff = LocalDate.now(ZoneId.of(HrTimeUtil.DEFAULT_TIMEZONE)).plusDays(1); + List candidates = compensatoryOffRepository + .findByStatusAndExpiryDateLessThan(LeaveStatus.APPROVED.name(), broadCutoff); + + Map configCache = new HashMap<>(); + int expired = 0; + + for (CompensatoryOff compOff : candidates) { + try { + EmployeeProfile employee = compOff.getEmployee(); + String instituteId = employee.getInstituteId(); + AttendanceConfig config = configCache.computeIfAbsent(instituteId, + id -> attendanceConfigRepository.findByInstituteId(id).orElse(null)); + LocalDate today = LocalDate.now(HrTimeUtil.resolveZone(config)); + if (compOff.getExpiryDate() == null || !compOff.getExpiryDate().isBefore(today)) { + continue; // not yet past expiry in the institute's zone + } + + compOff.setStatus(STATUS_EXPIRED); + + // Claw back only credits that are still unspent + if (!Boolean.TRUE.equals(compOff.getUsed()) && compOff.getEarnedDays() != null + && compOff.getEarnedDays().compareTo(BigDecimal.ZERO) > 0) { + deductExpiredDaysFromBalance(employee, instituteId, compOff.getEarnedDays(), + compOff.getExpiryDate(), today); + } + + compensatoryOffRepository.save(compOff); + expired++; + } catch (Exception e) { + log.warn("[comp-off-expiry] failed to expire comp-off {}: {}", compOff.getId(), e.getMessage()); + } + } + return expired; + } + + /** + * Deducts min(days, available) from the COMP_OFF balance's adjustment. + * The balance is looked up for the current year first, then the expiry + * year (a comp-off credited late in December can expire in January). + */ + private void deductExpiredDaysFromBalance(EmployeeProfile employee, String instituteId, + BigDecimal days, LocalDate expiryDate, LocalDate today) { + Optional compOffType = leaveTypeRepository.findByInstituteIdAndCode(instituteId, "COMP_OFF"); + if (compOffType.isEmpty()) { + return; // nothing was ever credited + } + LeaveBalance balance = leaveBalanceRepository + .findByEmployee_IdAndLeaveType_IdAndYear(employee.getId(), compOffType.get().getId(), today.getYear()) + .or(() -> leaveBalanceRepository.findByEmployee_IdAndLeaveType_IdAndYear( + employee.getId(), compOffType.get().getId(), expiryDate.getYear())) + .orElse(null); + if (balance == null) { + return; + } + + BigDecimal available = balance.getClosingBalance(); + if (available.compareTo(BigDecimal.ZERO) <= 0) { + return; // already spent (or over-spent) — never push it negative + } + BigDecimal deduct = days.min(available); + BigDecimal currentAdjustment = balance.getAdjustment() != null ? balance.getAdjustment() : BigDecimal.ZERO; + balance.setAdjustment(currentAdjustment.subtract(deduct)); + leaveBalanceRepository.save(balance); + } + + /** Best-effort employee email on a comp-off decision (never breaks the operation). */ + private void notifyCompOffDecision(CompensatoryOff compOff, boolean approved) { + try { + String subject = approved + ? "Your compensatory off was approved" + : "Your compensatory off was rejected"; + String body = hrNotificationService.buildEmailBody(subject, + "Worked on", compOff.getWorkedOnDate() != null ? compOff.getWorkedOnDate().toString() : null, + "Earned days", compOff.getEarnedDays() != null ? compOff.getEarnedDays().toPlainString() : null, + "Expires on", compOff.getExpiryDate() != null ? compOff.getExpiryDate().toString() : null, + "Status", compOff.getStatus()); + hrNotificationService.emailEmployee(compOff.getEmployee(), subject, body); + } catch (Exception e) { + // emailEmployee already swallows send failures; this guards lazy-load surprises + } + } + @Transactional(readOnly = true) - public List getCompOffs(String employeeId) { + public List getCompOffs(String employeeId, String instituteId, CustomUserDetails user) { + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); // Fetch all comp offs for the employee (all statuses), ordered by worked on date desc List compOffs = compensatoryOffRepository .findByEmployee_IdAndStatusOrderByWorkedOnDateDesc(employeeId, LeaveStatus.APPROVED.name()); @@ -120,7 +327,9 @@ public List getCompOffs(String employeeId) { private void creditCompOffToLeaveBalance(CompensatoryOff compOff) { EmployeeProfile employee = compOff.getEmployee(); String instituteId = employee.getInstituteId(); - int currentYear = LocalDate.now().getYear(); + // Year derivation uses the institute's timezone (JVM stays UTC) + AttendanceConfig config = attendanceConfigRepository.findByInstituteId(instituteId).orElse(null); + int currentYear = LocalDate.now(HrTimeUtil.resolveZone(config)).getYear(); // Find or create COMP_OFF leave type for the institute LeaveType compOffLeaveType = leaveTypeRepository.findByInstituteIdAndCode(instituteId, "COMP_OFF") diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeaveApplicationService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeaveApplicationService.java index f56f80b78c..a56349436e 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeaveApplicationService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeaveApplicationService.java @@ -7,10 +7,19 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceConfig; +import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceRecord; import vacademy.io.admin_core_service.features.hr_attendance.entity.Holiday; +import vacademy.io.admin_core_service.features.hr_attendance.enums.AttendanceSource; +import vacademy.io.admin_core_service.features.hr_attendance.enums.AttendanceStatus; +import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceConfigRepository; +import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceRecordRepository; import vacademy.io.admin_core_service.features.hr_attendance.repository.HolidayRepository; +import vacademy.io.admin_core_service.features.hr_attendance.util.HrTimeUtil; import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; -import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; +import vacademy.io.admin_core_service.features.hr_employee.service.HrNotificationService; +import vacademy.io.admin_core_service.features.hr_payroll.service.HrMonthLockService; import vacademy.io.admin_core_service.features.hr_leave.dto.LeaveActionDTO; import vacademy.io.admin_core_service.features.hr_leave.dto.LeaveApplicationDTO; import vacademy.io.admin_core_service.features.hr_leave.dto.LeaveApplyDTO; @@ -22,16 +31,26 @@ import vacademy.io.admin_core_service.features.hr_leave.repository.LeaveApplicationRepository; import vacademy.io.admin_core_service.features.hr_leave.repository.LeaveBalanceRepository; import vacademy.io.admin_core_service.features.hr_leave.repository.LeaveTypeRepository; +import vacademy.io.admin_core_service.features.workflow.enums.WorkflowTriggerEvent; +import vacademy.io.admin_core_service.features.workflow.service.WorkflowTriggerService; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.exceptions.ForbiddenException; import vacademy.io.common.exceptions.VacademyException; import java.math.BigDecimal; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +@lombok.extern.slf4j.Slf4j @Service public class LeaveApplicationService { @@ -45,13 +64,28 @@ public class LeaveApplicationService { private LeaveBalanceRepository leaveBalanceRepository; @Autowired - private EmployeeProfileRepository employeeProfileRepository; + private HolidayRepository holidayRepository; @Autowired - private HolidayRepository holidayRepository; + private AttendanceConfigRepository attendanceConfigRepository; + + @Autowired + private AttendanceRecordRepository attendanceRecordRepository; + + @Autowired + private HrAccessGuard hrAccessGuard; + + @Autowired + private HrMonthLockService hrMonthLockService; + + @Autowired + private HrNotificationService hrNotificationService; + + @Autowired + private WorkflowTriggerService workflowTriggerService; @Transactional - public String applyLeave(LeaveApplyDTO dto, String instituteId) { + public String applyLeave(LeaveApplyDTO dto, String instituteId, CustomUserDetails user) { if (!StringUtils.hasText(dto.getEmployeeId())) { throw new VacademyException("Employee ID is required"); } @@ -75,29 +109,40 @@ public String applyLeave(LeaveApplyDTO dto, String instituteId) { throw new VacademyException("Half-day leave can only be applied for a single day"); } - EmployeeProfile employee = employeeProfileRepository.findById(dto.getEmployeeId()) - .orElseThrow(() -> new VacademyException("Employee not found")); + // Non-HR callers may only apply for themselves; the employee is + // verified to belong to the validated institute. + EmployeeProfile employee = hrAccessGuard.requireSelfOrHrStaff(user, instituteId, dto.getEmployeeId()); LeaveType leaveType = leaveTypeRepository.findById(dto.getLeaveTypeId()) .orElseThrow(() -> new VacademyException("Leave type not found")); + hrAccessGuard.requireInstituteMatch(leaveType.getInstituteId(), instituteId, "Leave type"); + + // Inactive leave types cannot be applied for + if (leaveType.getStatus() != null && !"ACTIVE".equals(leaveType.getStatus())) { + throw new VacademyException("This leave type is not active"); + } // BUG 1 FIX: Check for overlapping leaves (PENDING or APPROVED) List overlapping = leaveApplicationRepository.findOverlappingLeaves( - dto.getEmployeeId(), dto.getFromDate(), dto.getToDate()); + employee.getId(), dto.getFromDate(), dto.getToDate()); if (!overlapping.isEmpty()) { throw new VacademyException("Leave application overlaps with an existing leave"); } - // Calculate working days (exclude weekends and holidays) - BigDecimal calculatedDays = calculateWorkingDays( - dto.getFromDate(), dto.getToDate(), instituteId); + // Calculate working days (exclude the institute's configured weekend + // days and mandatory holidays) + List workingDates = getWorkingDates(dto.getFromDate(), dto.getToDate(), instituteId); + BigDecimal calculatedDays = new BigDecimal(workingDates.size()); - // If half day, count as 0.5 if (Boolean.TRUE.equals(dto.getIsHalfDay())) { + // The half-day date must itself be a working day — previously the + // 0.5 override ran before the working-days check, letting a + // half-day on a weekend/holiday slip through. + if (!workingDates.contains(dto.getFromDate())) { + throw new VacademyException("Half-day leave cannot be applied on a weekend or holiday"); + } calculatedDays = new BigDecimal("0.5"); - } - - if (calculatedDays.compareTo(BigDecimal.ZERO) <= 0) { + } else if (calculatedDays.compareTo(BigDecimal.ZERO) <= 0) { throw new VacademyException("No working days in the selected date range"); } @@ -109,20 +154,23 @@ public String applyLeave(LeaveApplyDTO dto, String instituteId) { } } - // Validate leave balance - int year = dto.getFromDate().getYear(); - LeaveBalance balance = leaveBalanceRepository - .findByEmployee_IdAndLeaveType_IdAndYear(dto.getEmployeeId(), dto.getLeaveTypeId(), year) - .orElse(null); - - if (balance != null) { - BigDecimal availableBalance = balance.getClosingBalance(); - if (availableBalance.compareTo(calculatedDays) < 0) { - throw new VacademyException("Insufficient leave balance. Available: " - + availableBalance + ", Requested: " + calculatedDays); + // Validate leave balance. UNPAID leave types (isPaid=false) are LOP: + // they require no balance and payroll handles them separately. + if (isPaidLeaveType(leaveType)) { + int year = dto.getFromDate().getYear(); + LeaveBalance balance = leaveBalanceRepository + .findByEmployee_IdAndLeaveType_IdAndYear(employee.getId(), leaveType.getId(), year) + .orElse(null); + + if (balance != null) { + BigDecimal availableBalance = balance.getClosingBalance(); + if (availableBalance.compareTo(calculatedDays) < 0) { + throw new VacademyException("Insufficient leave balance. Available: " + + availableBalance + ", Requested: " + calculatedDays); + } + } else { + throw new VacademyException("No leave balance found for the selected leave type and year"); } - } else { - throw new VacademyException("No leave balance found for the selected leave type and year"); } // Validate halfDayType enum @@ -156,13 +204,59 @@ public String applyLeave(LeaveApplyDTO dto, String instituteId) { application.setAppliedTo(appliedTo); application = leaveApplicationRepository.save(application); + + // Best-effort heads-up to the reporting manager the request is addressed to + if (appliedTo != null) { + String applicantName = hrNotificationService.resolveUserName(employee.getUserId()); + hrNotificationService.emailUser(appliedTo, instituteId, + "Leave application awaiting your review", + hrNotificationService.buildEmailBody("Leave application awaiting your review", + "Employee", applicantName, + "Leave type", leaveType.getName(), + "From", dto.getFromDate().toString(), + "To", dto.getToDate().toString(), + "Days", calculatedDays.toPlainString(), + "Reason", dto.getReason())); + } + + // Phase F5: HR_LEAVE_REQUESTED workflow trigger (emit-and-forget — a + // workflow failure must never break the leave application itself) + try { + Map contextData = new HashMap<>(); + contextData.put("applicationId", application.getId()); + contextData.put("employeeId", employee.getId()); + contextData.put("employeeUserId", employee.getUserId()); + contextData.put("leaveTypeId", leaveType.getId()); + contextData.put("leaveTypeName", leaveType.getName()); + contextData.put("fromDate", dto.getFromDate().toString()); + contextData.put("toDate", dto.getToDate().toString()); + contextData.put("totalDays", calculatedDays.toPlainString()); + contextData.put("appliedTo", appliedTo); + workflowTriggerService.handleTriggerEvents( + WorkflowTriggerEvent.HR_LEAVE_REQUESTED.name(), + application.getId(), + instituteId, + contextData); + } catch (Exception e) { + log.warn("Failed to trigger HR_LEAVE_REQUESTED workflow", e); + } + return application.getId(); } @Transactional - public String approveRejectLeave(String id, LeaveActionDTO actionDTO, String approverUserId) { + public String approveRejectLeave(String id, LeaveActionDTO actionDTO, String instituteId, CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + LeaveApplication application = leaveApplicationRepository.findById(id) .orElseThrow(() -> new VacademyException("Leave application not found")); + hrAccessGuard.requireInstituteMatch(application.getInstituteId(), instituteId, "Leave application"); + + // No self-approval: even HR staff must not decide their own leave. + String applicantUserId = application.getEmployee().getUserId(); + if (applicantUserId != null && applicantUserId.equals(user.getUserId())) { + throw new ForbiddenException("You cannot approve or reject your own leave application"); + } if (!LeaveStatus.PENDING.name().equals(application.getStatus())) { throw new VacademyException("Only pending leave applications can be approved or rejected"); @@ -178,22 +272,40 @@ public String approveRejectLeave(String id, LeaveActionDTO actionDTO, String app } if (LeaveStatus.APPROVED.name().equals(action)) { - // Deduct from leave balance - int year = application.getFromDate().getYear(); - LeaveBalance balance = leaveBalanceRepository - .findByEmployee_IdAndLeaveType_IdAndYear( - application.getEmployee().getId(), - application.getLeaveType().getId(), - year) - .orElseThrow(() -> new VacademyException("Leave balance not found")); + // Payroll month-lock: approving writes balances + attendance for the + // leave's dates — refuse when any month it touches is already processed. + requireLeaveMonthsUnlocked(application, "approve leave"); + + // Deduct from leave balance — only for PAID leave types. UNPAID + // leave is LOP with no balance to deduct (payroll handles it). + if (isPaidLeaveType(application.getLeaveType())) { + int year = application.getFromDate().getYear(); + LeaveBalance balance = leaveBalanceRepository + .findByEmployee_IdAndLeaveType_IdAndYear( + application.getEmployee().getId(), + application.getLeaveType().getId(), + year) + .orElseThrow(() -> new VacademyException("Leave balance not found")); + + // Re-validate at approval time: the balance may have changed since the + // application was submitted (other approvals, adjustments, encashment). + BigDecimal availableBalance = balance.getClosingBalance(); + if (availableBalance.compareTo(application.getTotalDays()) < 0) { + throw new VacademyException("Insufficient leave balance to approve. Available: " + + availableBalance + ", Requested: " + application.getTotalDays()); + } - BigDecimal currentUsed = balance.getUsed() != null ? balance.getUsed() : BigDecimal.ZERO; - balance.setUsed(currentUsed.add(application.getTotalDays())); - leaveBalanceRepository.save(balance); + BigDecimal currentUsed = balance.getUsed() != null ? balance.getUsed() : BigDecimal.ZERO; + balance.setUsed(currentUsed.add(application.getTotalDays())); + leaveBalanceRepository.save(balance); + } application.setStatus(LeaveStatus.APPROVED.name()); - application.setApprovedBy(approverUserId); + application.setApprovedBy(user.getUserId()); application.setApprovedAt(LocalDateTime.now()); + + // Reflect the approved leave on the attendance calendar + markAttendanceForApprovedLeave(application); } else { // Rejected if (!StringUtils.hasText(actionDTO.getRejectionReason())) { @@ -204,36 +316,111 @@ public String approveRejectLeave(String id, LeaveActionDTO actionDTO, String app } leaveApplicationRepository.save(application); + + notifyLeaveDecision(application); + + // Phase F5: HR_LEAVE_DECIDED workflow trigger (emit-and-forget — a + // workflow failure must never break the decision itself) + try { + Map contextData = new HashMap<>(); + contextData.put("applicationId", application.getId()); + contextData.put("employeeId", application.getEmployee().getId()); + contextData.put("employeeUserId", application.getEmployee().getUserId()); + contextData.put("leaveTypeId", application.getLeaveType().getId()); + contextData.put("fromDate", application.getFromDate().toString()); + contextData.put("toDate", application.getToDate().toString()); + contextData.put("totalDays", application.getTotalDays() != null + ? application.getTotalDays().toPlainString() : null); + contextData.put("status", application.getStatus()); + contextData.put("approvedBy", application.getApprovedBy()); + contextData.put("rejectionReason", application.getRejectionReason()); + workflowTriggerService.handleTriggerEvents( + WorkflowTriggerEvent.HR_LEAVE_DECIDED.name(), + application.getId(), + instituteId, + contextData); + } catch (Exception e) { + log.warn("Failed to trigger HR_LEAVE_DECIDED workflow", e); + } + return application.getId(); } + /** + * Payroll month-lock across the leave's whole range: any month the + * from→to span touches must still be open (cross-year applications are + * rejected at apply time, so the span is at most 12 months). + */ + private void requireLeaveMonthsUnlocked(LeaveApplication application, String actionLabel) { + YearMonth from = YearMonth.from(application.getFromDate()); + YearMonth to = YearMonth.from(application.getToDate()); + for (YearMonth ym = from; !ym.isAfter(to); ym = ym.plusMonths(1)) { + hrMonthLockService.requireUnlocked(application.getInstituteId(), ym.atDay(1), actionLabel); + } + } + + /** Best-effort employee email on a leave decision (never breaks the operation). */ + private void notifyLeaveDecision(LeaveApplication application) { + try { + boolean approved = LeaveStatus.APPROVED.name().equals(application.getStatus()); + String subject = approved ? "Your leave application was approved" + : "Your leave application was rejected"; + String body = hrNotificationService.buildEmailBody(subject, + "Leave type", application.getLeaveType().getName(), + "From", application.getFromDate().toString(), + "To", application.getToDate().toString(), + "Days", application.getTotalDays() != null + ? application.getTotalDays().toPlainString() : null, + "Status", application.getStatus(), + "Reason", approved ? null : application.getRejectionReason()); + hrNotificationService.emailEmployee(application.getEmployee(), subject, body); + } catch (Exception e) { + // emailEmployee already swallows send failures; this guards lazy-load surprises + } + } + @Transactional - public String cancelLeave(String id) { + public String cancelLeave(String id, String instituteId, CustomUserDetails user) { LeaveApplication application = leaveApplicationRepository.findById(id) .orElseThrow(() -> new VacademyException("Leave application not found")); + // Membership + institute scope + only the applicant themselves or HR + // staff may cancel. + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, application.getEmployee().getId()); + hrAccessGuard.requireInstituteMatch(application.getInstituteId(), instituteId, "Leave application"); + String currentStatus = application.getStatus(); if (!LeaveStatus.PENDING.name().equals(currentStatus) && !LeaveStatus.APPROVED.name().equals(currentStatus)) { throw new VacademyException("Only pending or approved leave applications can be cancelled"); } - // If it was approved, restore the leave balance + // If it was approved, restore the leave balance (paid types only — no + // balance was deducted for UNPAID/LOP leave) and revert the attendance + // records the approval wrote. if (LeaveStatus.APPROVED.name().equals(currentStatus)) { - int year = application.getFromDate().getYear(); - LeaveBalance balance = leaveBalanceRepository - .findByEmployee_IdAndLeaveType_IdAndYear( - application.getEmployee().getId(), - application.getLeaveType().getId(), - year) - .orElse(null); - - if (balance != null) { - BigDecimal currentUsed = balance.getUsed() != null ? balance.getUsed() : BigDecimal.ZERO; - BigDecimal restoredUsed = currentUsed.subtract(application.getTotalDays()); - balance.setUsed(restoredUsed.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : restoredUsed); - leaveBalanceRepository.save(balance); + // Payroll month-lock: cancelling an approved leave rewrites balances + // and attendance for its dates — refuse once payroll is processed. + requireLeaveMonthsUnlocked(application, "cancel approved leave"); + + if (isPaidLeaveType(application.getLeaveType())) { + int year = application.getFromDate().getYear(); + LeaveBalance balance = leaveBalanceRepository + .findByEmployee_IdAndLeaveType_IdAndYear( + application.getEmployee().getId(), + application.getLeaveType().getId(), + year) + .orElse(null); + + if (balance != null) { + BigDecimal currentUsed = balance.getUsed() != null ? balance.getUsed() : BigDecimal.ZERO; + BigDecimal restoredUsed = currentUsed.subtract(application.getTotalDays()); + balance.setUsed(restoredUsed.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : restoredUsed); + leaveBalanceRepository.save(balance); + } } + + revertAttendanceForCancelledLeave(application); } application.setStatus(LeaveStatus.CANCELLED.name()); @@ -243,7 +430,15 @@ public String cancelLeave(String id) { @Transactional(readOnly = true) public Page getLeaveApplications(String instituteId, String status, - String employeeId, int pageNo, int pageSize) { + String employeeId, int pageNo, int pageSize, + CustomUserDetails user) { + if (StringUtils.hasText(employeeId)) { + // Employee-scoped listing: the employee themselves or HR staff. + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); + } else { + // Institute-wide listing is HR staff only. + hrAccessGuard.requireHrStaff(user, instituteId); + } Pageable pageable = PageRequest.of(pageNo, pageSize); Page page = leaveApplicationRepository.findByFilters( instituteId, status, employeeId, pageable); @@ -251,18 +446,30 @@ public Page getLeaveApplications(String instituteId, String } @Transactional(readOnly = true) - public List getPendingForManager(String managerUserId) { - List applications = leaveApplicationRepository.findPendingForManager(managerUserId); + public List getPendingForManager(String instituteId, String approverId, CustomUserDetails user) { + hrAccessGuard.validateMember(user, instituteId); + // Non-HR callers only ever see the queue addressed to themselves; + // HR staff may inspect another approver's queue. + String managerUserId = user.getUserId(); + if (StringUtils.hasText(approverId) && hrAccessGuard.isHrStaff(user)) { + managerUserId = approverId; + } + List applications = leaveApplicationRepository + .findPendingForManagerInInstitute(managerUserId, instituteId); return applications.stream() .map(this::toDTO) .collect(Collectors.toList()); } /** - * Calculates the number of working days between two dates (inclusive), - * excluding weekends (Saturday and Sunday) and holidays. + * Returns the working dates between two dates (inclusive), excluding the + * institute's configured weekend days (default Saturday/Sunday) and + * mandatory holidays. */ - private BigDecimal calculateWorkingDays(LocalDate fromDate, LocalDate toDate, String instituteId) { + private List getWorkingDates(LocalDate fromDate, LocalDate toDate, String instituteId) { + AttendanceConfig config = attendanceConfigRepository.findByInstituteId(instituteId).orElse(null); + Set weekendDays = HrTimeUtil.resolveWeekendDays(config); + // Fetch holidays in the date range List holidays = holidayRepository.findByInstituteIdAndDateRange(instituteId, fromDate, toDate); Set holidayDates = holidays.stream() @@ -270,20 +477,104 @@ private BigDecimal calculateWorkingDays(LocalDate fromDate, LocalDate toDate, St .map(Holiday::getDate) .collect(Collectors.toSet()); - int workingDays = 0; + List workingDates = new ArrayList<>(); LocalDate current = fromDate; while (!current.isAfter(toDate)) { - DayOfWeek dayOfWeek = current.getDayOfWeek(); - boolean isWeekend = dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY; - boolean isHoliday = holidayDates.contains(current); - - if (!isWeekend && !isHoliday) { - workingDays++; + if (!weekendDays.contains(current.getDayOfWeek()) && !holidayDates.contains(current)) { + workingDates.add(current); } current = current.plusDays(1); } + return workingDates; + } + + /** + * UNPAID leave types (isPaid=false) are loss-of-pay: no balance is + * required, deducted or restored for them. A null isPaid is treated as + * paid (the previous behavior). + */ + private boolean isPaidLeaveType(LeaveType leaveType) { + return !Boolean.FALSE.equals(leaveType.getIsPaid()); + } + + /** + * Leave → attendance link: on approval, upsert an hr_attendance_record row + * for each working day of the leave. Existing rows for a day are UPDATED + * (the unique (employee, date) constraint forbids a second insert). + * + * Full-day leave marks the day ON_LEAVE (source ADMIN), overwriting any + * prior status — approving a leave is the authoritative statement that the + * employee is on leave that day. Half-day leave marks the day HALF_DAY + * only when the day has no PRESENT record: a day already clocked PRESENT + * is left untouched (simplest correct behavior — presence wins over a + * half-day marking). + */ + private void markAttendanceForApprovedLeave(LeaveApplication application) { + List workingDates = getWorkingDates( + application.getFromDate(), application.getToDate(), application.getInstituteId()); + boolean halfDay = Boolean.TRUE.equals(application.getIsHalfDay()); + + for (LocalDate date : workingDates) { + Optional existingOpt = attendanceRecordRepository + .findByEmployeeIdAndAttendanceDate(application.getEmployee().getId(), date); + + if (halfDay && existingOpt.isPresent() + && AttendanceStatus.PRESENT.name().equals(existingOpt.get().getStatus())) { + continue; + } - return new BigDecimal(workingDays); + AttendanceRecord record; + if (existingOpt.isPresent()) { + record = existingOpt.get(); + } else { + record = new AttendanceRecord(); + record.setEmployee(application.getEmployee()); + record.setInstituteId(application.getInstituteId()); + record.setAttendanceDate(date); + } + record.setStatus(halfDay ? AttendanceStatus.HALF_DAY.name() : AttendanceStatus.ON_LEAVE.name()); + record.setSource(AttendanceSource.ADMIN.name()); + attendanceRecordRepository.save(record); + } + } + + /** + * On cancel of an APPROVED leave, revert the attendance rows the approval + * wrote. Only rows still carrying the leave marking are touched: + * - full-day: rows still ON_LEAVE — deleted when created by the approval + * (no check-in), restored to PRESENT when the employee had clocked in + * before the leave overwrote the day; + * - half-day: the admin-sourced HALF_DAY row with no check-in is deleted; + * a HALF_DAY row with clock data is a genuine short day and is kept. + */ + private void revertAttendanceForCancelledLeave(LeaveApplication application) { + List workingDates = getWorkingDates( + application.getFromDate(), application.getToDate(), application.getInstituteId()); + boolean halfDay = Boolean.TRUE.equals(application.getIsHalfDay()); + + for (LocalDate date : workingDates) { + Optional existingOpt = attendanceRecordRepository + .findByEmployeeIdAndAttendanceDate(application.getEmployee().getId(), date); + if (existingOpt.isEmpty()) { + continue; + } + AttendanceRecord record = existingOpt.get(); + + if (halfDay) { + if (AttendanceStatus.HALF_DAY.name().equals(record.getStatus()) + && AttendanceSource.ADMIN.name().equals(record.getSource()) + && record.getCheckInTime() == null) { + attendanceRecordRepository.delete(record); + } + } else if (AttendanceStatus.ON_LEAVE.name().equals(record.getStatus())) { + if (record.getCheckInTime() != null) { + record.setStatus(AttendanceStatus.PRESENT.name()); + attendanceRecordRepository.save(record); + } else { + attendanceRecordRepository.delete(record); + } + } + } } private LeaveApplicationDTO toDTO(LeaveApplication entity) { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeaveBalanceService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeaveBalanceService.java index 0fa001fe73..5d04e93dc0 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeaveBalanceService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeaveBalanceService.java @@ -1,21 +1,33 @@ package vacademy.io.admin_core_service.features.hr_leave.service; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceConfig; +import vacademy.io.admin_core_service.features.hr_attendance.repository.AttendanceConfigRepository; +import vacademy.io.admin_core_service.features.hr_attendance.util.HrTimeUtil; import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; import vacademy.io.admin_core_service.features.hr_leave.dto.LeaveBalanceAdjustDTO; import vacademy.io.admin_core_service.features.hr_leave.dto.LeaveBalanceDTO; +import vacademy.io.admin_core_service.features.hr_leave.entity.LeaveAccrualTxn; import vacademy.io.admin_core_service.features.hr_leave.entity.LeaveBalance; import vacademy.io.admin_core_service.features.hr_leave.entity.LeavePolicy; import vacademy.io.admin_core_service.features.hr_leave.entity.LeaveType; +import vacademy.io.admin_core_service.features.hr_leave.enums.AccrualType; +import vacademy.io.admin_core_service.features.hr_leave.repository.LeaveAccrualTxnRepository; import vacademy.io.admin_core_service.features.hr_leave.repository.LeaveBalanceRepository; import vacademy.io.admin_core_service.features.hr_leave.repository.LeavePolicyRepository; +import vacademy.io.common.auth.model.CustomUserDetails; import vacademy.io.common.exceptions.VacademyException; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; +import java.time.YearMonth; +import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -33,22 +45,46 @@ public class LeaveBalanceService { @Autowired private EmployeeProfileRepository employeeProfileRepository; + @Autowired + private LeaveAccrualTxnRepository leaveAccrualTxnRepository; + + @Autowired + private AttendanceConfigRepository attendanceConfigRepository; + + @Autowired + private HrAccessGuard hrAccessGuard; + + /** + * Balances for one employee, or — when no employeeId is given — for every + * employee in the institute, which is what the HR balance dashboard needs. + * The institute-wide form is HR-staff only; the per-employee form still lets + * an employee read their own. + */ @Transactional(readOnly = true) - public List getBalances(String employeeId, Integer year) { - List balances = leaveBalanceRepository.findByEmployee_IdAndYear(employeeId, year); + public List getBalances(String employeeId, Integer year, String instituteId, CustomUserDetails user) { + List balances; + if (employeeId == null || employeeId.isBlank()) { + hrAccessGuard.requireHrStaff(user, instituteId); + balances = leaveBalanceRepository.findByEmployee_InstituteIdAndYear(instituteId, year); + } else { + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); + balances = leaveBalanceRepository.findByEmployee_IdAndYear(employeeId, year); + } return balances.stream() .map(this::toDTO) .collect(Collectors.toList()); } @Transactional - public String adjustBalance(String balanceId, LeaveBalanceAdjustDTO dto) { + public String adjustBalance(String balanceId, LeaveBalanceAdjustDTO dto, String instituteId, CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); if (dto.getAdjustment() == null) { throw new VacademyException("Adjustment amount is required"); } LeaveBalance balance = leaveBalanceRepository.findById(balanceId) .orElseThrow(() -> new VacademyException("Leave balance not found")); + hrAccessGuard.requireInstituteMatch(balance.getEmployee().getInstituteId(), instituteId, "Leave balance"); BigDecimal currentAdjustment = balance.getAdjustment() != null ? balance.getAdjustment() : BigDecimal.ZERO; balance.setAdjustment(currentAdjustment.add(dto.getAdjustment())); @@ -58,23 +94,35 @@ public String adjustBalance(String balanceId, LeaveBalanceAdjustDTO dto) { } /** - * Monthly accrual process: for each active employee with an active leave policy - * that has MONTHLY accrual type, find or create the leave balance for the current year - * and add the accrual amount. + * Accrual process: for each active policy × eligible employee, records a + * ledger row (hr_leave_accrual_txn) for the CURRENT period — MONTHLY + * "YYYY-MM", QUARTERLY "YYYY-Qn", YEARLY "YYYY" — and adds the accrual + * amount to the balance's accrued. The unique (employee, leave type, + * period_key) constraint on the ledger makes the process idempotent per + * period, replacing the old "accrued >= amount * month" heuristic. */ @Transactional - public String accrueLeaves(String instituteId) { - LocalDate today = LocalDate.now(); + public String accrueLeaves(String instituteId, CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + return accrueLeavesInternal(instituteId); + } + + /** + * Guard-free accrual core, shared by the guarded admin endpoint above and + * the daily LeaveAccrualJob (which runs with no CustomUserDetails). The + * accrual ledger's unique (employee, leave type, period_key) constraint + * makes calling this daily safe — each period is credited exactly once. + */ + @Transactional + public String accrueLeavesInternal(String instituteId) { + // "Today" in the institute's timezone (JVM stays UTC) + AttendanceConfig attendanceConfig = attendanceConfigRepository.findByInstituteId(instituteId).orElse(null); + LocalDate today = LocalDate.now(HrTimeUtil.resolveZone(attendanceConfig)); int currentYear = today.getYear(); - // Get all active leave policies with MONTHLY accrual for this institute List activePolicies = leavePolicyRepository.findActivePolicies(instituteId, today); - List monthlyPolicies = activePolicies.stream() - .filter(p -> "MONTHLY".equals(p.getAccrualType())) - .collect(Collectors.toList()); - - if (monthlyPolicies.isEmpty()) { - return "No monthly accrual policies found"; + if (activePolicies.isEmpty()) { + return "No active accrual policies found"; } // Get all active employees @@ -84,7 +132,13 @@ public String accrueLeaves(String instituteId) { int accruedCount = 0; for (EmployeeProfile employee : activeEmployees) { - for (LeavePolicy policy : monthlyPolicies) { + for (LeavePolicy policy : activePolicies) { + AccrualType accrualType = parseAccrualType(policy.getAccrualType()); + if (accrualType == null) { + // Missing/unknown accrual type — nothing to accrue + continue; + } + // Check if the policy is applicable to this employee's employment type if (policy.getApplicableEmploymentTypes() != null && !policy.getApplicableEmploymentTypes().isEmpty() @@ -94,13 +148,82 @@ public String accrueLeaves(String instituteId) { // Check if applicable after days condition is met if (policy.getApplicableAfterDays() != null && policy.getApplicableAfterDays() > 0) { - long daysSinceJoining = java.time.temporal.ChronoUnit.DAYS.between( + long daysSinceJoining = ChronoUnit.DAYS.between( employee.getJoinDate(), today); if (daysSinceJoining < policy.getApplicableAfterDays()) { continue; } } + // Current period bounds + ledger key + LocalDate periodStart; + LocalDate periodEnd; + String periodKey; + switch (accrualType) { + case MONTHLY -> { + YearMonth yearMonth = YearMonth.from(today); + periodStart = yearMonth.atDay(1); + periodEnd = yearMonth.atEndOfMonth(); + periodKey = String.format("%d-%02d", currentYear, today.getMonthValue()); + } + case QUARTERLY -> { + int quarter = (today.getMonthValue() - 1) / 3 + 1; + periodStart = LocalDate.of(currentYear, (quarter - 1) * 3 + 1, 1); + periodEnd = periodStart.plusMonths(3).minusDays(1); + periodKey = currentYear + "-Q" + quarter; + } + default -> { + periodStart = LocalDate.of(currentYear, 1, 1); + periodEnd = LocalDate.of(currentYear, 12, 31); + periodKey = String.valueOf(currentYear); + } + } + + // Ledger idempotency: skip if this period was already accrued + if (leaveAccrualTxnRepository.existsByEmployeeIdAndLeaveTypeIdAndPeriodKey( + employee.getId(), policy.getLeaveType().getId(), periodKey)) { + continue; + } + + BigDecimal accrualAmount = policy.getAccrualAmount(); + if (accrualAmount == null) { + accrualAmount = accrualType == AccrualType.YEARLY + ? policy.getAnnualQuota() + : BigDecimal.ZERO; + } + + // Pro-rata: a mid-period joiner's FIRST period is scaled by + // remaining-days-in-period / total-days-in-period (1 decimal). + String source = "ACCRUAL"; + if (Boolean.TRUE.equals(policy.getProRataEnabled()) + && employee.getJoinDate() != null + && employee.getJoinDate().isAfter(periodStart) + && !employee.getJoinDate().isAfter(periodEnd)) { + long totalDays = ChronoUnit.DAYS.between(periodStart, periodEnd) + 1; + long remainingDays = ChronoUnit.DAYS.between(employee.getJoinDate(), periodEnd) + 1; + accrualAmount = accrualAmount + .multiply(BigDecimal.valueOf(remainingDays)) + .divide(BigDecimal.valueOf(totalDays), 1, RoundingMode.HALF_UP); + source = "PRO_RATA"; + } + + // Record the ledger row FIRST — the unique constraint is the + // real guard against a concurrent double-accrual. + LeaveAccrualTxn txn = new LeaveAccrualTxn(); + txn.setEmployeeId(employee.getId()); + txn.setLeaveTypeId(policy.getLeaveType().getId()); + txn.setPolicyId(policy.getId()); + txn.setYear(currentYear); + txn.setPeriodKey(periodKey); + txn.setAmount(accrualAmount); + txn.setSource(source); + try { + leaveAccrualTxnRepository.saveAndFlush(txn); + } catch (DataIntegrityViolationException e) { + // A concurrent run already accrued this period — skip + continue; + } + // Find or create leave balance Optional existingBalance = leaveBalanceRepository .findByEmployee_IdAndLeaveType_IdAndYear( @@ -124,24 +247,10 @@ public String accrueLeaves(String instituteId) { balance.setEncashed(BigDecimal.ZERO); } - // Add accrual amount + // Add accrual amount, capped to the annual quota BigDecimal currentAccrued = balance.getAccrued() != null ? balance.getAccrued() : BigDecimal.ZERO; - BigDecimal accrualAmount = policy.getAccrualAmount() != null - ? policy.getAccrualAmount() - : BigDecimal.ZERO; - - // BUG 4 FIX: Idempotency guard — prevent double-accrual in the same month. - // Expected accrued by end of current month = accrualAmount * currentMonthNumber. - // If already accrued >= expected, skip this policy for this balance. - int currentMonth = today.getMonthValue(); - BigDecimal expectedAccruedForMonth = accrualAmount.multiply(new BigDecimal(currentMonth)); - if (currentAccrued.compareTo(expectedAccruedForMonth) >= 0) { - continue; - } - - // Cap total accrued to annual quota BigDecimal newAccrued = currentAccrued.add(accrualAmount); - if (newAccrued.compareTo(policy.getAnnualQuota()) > 0) { + if (policy.getAnnualQuota() != null && newAccrued.compareTo(policy.getAnnualQuota()) > 0) { newAccrued = policy.getAnnualQuota(); } @@ -154,26 +263,37 @@ public String accrueLeaves(String instituteId) { return "Accrual completed for " + accruedCount + " employee-policy combinations"; } + private AccrualType parseAccrualType(String value) { + if (value == null) { + return null; + } + try { + return AccrualType.valueOf(value); + } catch (IllegalArgumentException e) { + return null; + } + } + /** * Year-end process: for each employee's leave balance of the closing year, * calculate closing balance and handle carry forward and encashment. + * + * Idempotent per employee/leave-type via the accrual ledger: processing a + * balance records a "CARRY-YYYY" txn (source CARRY_FORWARD), and balances + * with an existing marker are skipped — a partially failed run can be + * re-run and only picks up the unprocessed combinations (the old guard + * refused the whole run if ANY next-year balance existed). */ @Transactional - public String yearEndProcess(String instituteId, Integer year) { + public String yearEndProcess(String instituteId, Integer year, CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); int nextYear = year + 1; + String carryPeriodKey = "CARRY-" + year; // Get all active employees List activeEmployees = employeeProfileRepository.findActiveEmployees( instituteId, Arrays.asList("ACTIVE", "PROBATION")); - // BUG 5 FIX: Guard against re-running year-end process - List employeeIds = activeEmployees.stream() - .map(EmployeeProfile::getId) - .collect(Collectors.toList()); - if (!employeeIds.isEmpty() && leaveBalanceRepository.existsByEmployeeIdsAndYear(employeeIds, nextYear)) { - throw new VacademyException("Year-end process already completed for year " + year); - } - int processedCount = 0; for (EmployeeProfile employee : activeEmployees) { @@ -182,35 +302,54 @@ public String yearEndProcess(String instituteId, Integer year) { for (LeaveBalance balance : balances) { LeaveType leaveType = balance.getLeaveType(); - BigDecimal closingBalance = balance.getClosingBalance(); - if (closingBalance.compareTo(BigDecimal.ZERO) <= 0) { - processedCount++; + // Re-run guard: already processed for this employee/type + if (leaveAccrualTxnRepository.existsByEmployeeIdAndLeaveTypeIdAndPeriodKey( + employee.getId(), leaveType.getId(), carryPeriodKey)) { continue; } + BigDecimal closingBalance = balance.getClosingBalance(); BigDecimal carryForwardAmount = BigDecimal.ZERO; BigDecimal encashedAmount = BigDecimal.ZERO; - // Handle carry forward - if (Boolean.TRUE.equals(leaveType.getIsCarryForward())) { - carryForwardAmount = closingBalance; - // Cap at max carry forward if specified - if (leaveType.getMaxCarryForward() != null && leaveType.getMaxCarryForward() > 0) { - BigDecimal maxCF = new BigDecimal(leaveType.getMaxCarryForward()); - if (carryForwardAmount.compareTo(maxCF) > 0) { - BigDecimal excess = carryForwardAmount.subtract(maxCF); - carryForwardAmount = maxCF; - - // If encashable, mark the excess as encashed - if (Boolean.TRUE.equals(leaveType.getIsEncashable())) { - encashedAmount = excess; + if (closingBalance.compareTo(BigDecimal.ZERO) > 0) { + // Handle carry forward + if (Boolean.TRUE.equals(leaveType.getIsCarryForward())) { + carryForwardAmount = closingBalance; + // Cap at max carry forward if specified + if (leaveType.getMaxCarryForward() != null && leaveType.getMaxCarryForward() > 0) { + BigDecimal maxCF = new BigDecimal(leaveType.getMaxCarryForward()); + if (carryForwardAmount.compareTo(maxCF) > 0) { + BigDecimal excess = carryForwardAmount.subtract(maxCF); + carryForwardAmount = maxCF; + + // If encashable, mark the excess as encashed + if (Boolean.TRUE.equals(leaveType.getIsEncashable())) { + encashedAmount = excess; + } } } + } else if (Boolean.TRUE.equals(leaveType.getIsEncashable())) { + // No carry forward, but encashable: encash entire closing balance + encashedAmount = closingBalance; } - } else if (Boolean.TRUE.equals(leaveType.getIsEncashable())) { - // No carry forward, but encashable: encash entire closing balance - encashedAmount = closingBalance; + } + + // Record the marker txn FIRST — its unique constraint is the + // guard against a concurrent run double-processing this combo. + LeaveAccrualTxn txn = new LeaveAccrualTxn(); + txn.setEmployeeId(employee.getId()); + txn.setLeaveTypeId(leaveType.getId()); + txn.setYear(year); + txn.setPeriodKey(carryPeriodKey); + txn.setAmount(carryForwardAmount); + txn.setSource("CARRY_FORWARD"); + try { + leaveAccrualTxnRepository.saveAndFlush(txn); + } catch (DataIntegrityViolationException e) { + // A concurrent run already processed this combination — skip + continue; } // Update encashed amount on closing year's balance diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeavePolicyService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeavePolicyService.java index f948873e6a..53b01d0d91 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeavePolicyService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeavePolicyService.java @@ -4,11 +4,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_leave.dto.LeavePolicyDTO; import vacademy.io.admin_core_service.features.hr_leave.entity.LeavePolicy; import vacademy.io.admin_core_service.features.hr_leave.entity.LeaveType; import vacademy.io.admin_core_service.features.hr_leave.repository.LeavePolicyRepository; import vacademy.io.admin_core_service.features.hr_leave.repository.LeaveTypeRepository; +import vacademy.io.common.auth.model.CustomUserDetails; import vacademy.io.common.exceptions.VacademyException; import java.util.List; @@ -23,8 +25,12 @@ public class LeavePolicyService { @Autowired private LeaveTypeRepository leaveTypeRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + @Transactional - public String createLeavePolicy(LeavePolicyDTO dto, String instituteId) { + public String createLeavePolicy(LeavePolicyDTO dto, String instituteId, CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); if (!StringUtils.hasText(dto.getLeaveTypeId())) { throw new VacademyException("Leave type ID is required"); } @@ -37,6 +43,7 @@ public String createLeavePolicy(LeavePolicyDTO dto, String instituteId) { LeaveType leaveType = leaveTypeRepository.findById(dto.getLeaveTypeId()) .orElseThrow(() -> new VacademyException("Leave type not found")); + hrAccessGuard.requireInstituteMatch(leaveType.getInstituteId(), instituteId, "Leave type"); LeavePolicy policy = new LeavePolicy(); policy.setInstituteId(instituteId); @@ -56,13 +63,17 @@ public String createLeavePolicy(LeavePolicyDTO dto, String instituteId) { } @Transactional - public String updateLeavePolicy(String id, LeavePolicyDTO dto) { + public String updateLeavePolicy(String id, LeavePolicyDTO dto, String instituteId, CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + LeavePolicy policy = leavePolicyRepository.findById(id) .orElseThrow(() -> new VacademyException("Leave policy not found")); + hrAccessGuard.requireInstituteMatch(policy.getInstituteId(), instituteId, "Leave policy"); if (StringUtils.hasText(dto.getLeaveTypeId())) { LeaveType leaveType = leaveTypeRepository.findById(dto.getLeaveTypeId()) .orElseThrow(() -> new VacademyException("Leave type not found")); + hrAccessGuard.requireInstituteMatch(leaveType.getInstituteId(), instituteId, "Leave type"); policy.setLeaveType(leaveType); } if (dto.getAnnualQuota() != null) { @@ -98,7 +109,8 @@ public String updateLeavePolicy(String id, LeavePolicyDTO dto) { } @Transactional(readOnly = true) - public List getLeavePolicies(String instituteId) { + public List getLeavePolicies(String instituteId, CustomUserDetails user) { + hrAccessGuard.validateMember(user, instituteId); List policies = leavePolicyRepository.findByInstituteIdAndStatus(instituteId, "ACTIVE"); return policies.stream() .map(this::toDTO) diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeaveTypeService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeaveTypeService.java index 43ede9b68a..f7daa2d201 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeaveTypeService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_leave/service/LeaveTypeService.java @@ -4,9 +4,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_leave.dto.LeaveTypeDTO; import vacademy.io.admin_core_service.features.hr_leave.entity.LeaveType; import vacademy.io.admin_core_service.features.hr_leave.repository.LeaveTypeRepository; +import vacademy.io.common.auth.model.CustomUserDetails; import vacademy.io.common.exceptions.VacademyException; import java.util.List; @@ -18,8 +20,12 @@ public class LeaveTypeService { @Autowired private LeaveTypeRepository leaveTypeRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + @Transactional - public String createLeaveType(LeaveTypeDTO dto, String instituteId) { + public String createLeaveType(LeaveTypeDTO dto, String instituteId, CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); if (!StringUtils.hasText(dto.getName())) { throw new VacademyException("Leave type name is required"); } @@ -50,9 +56,12 @@ public String createLeaveType(LeaveTypeDTO dto, String instituteId) { } @Transactional - public String updateLeaveType(String id, LeaveTypeDTO dto) { + public String updateLeaveType(String id, LeaveTypeDTO dto, String instituteId, CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + LeaveType leaveType = leaveTypeRepository.findById(id) .orElseThrow(() -> new VacademyException("Leave type not found")); + hrAccessGuard.requireInstituteMatch(leaveType.getInstituteId(), instituteId, "Leave type"); if (StringUtils.hasText(dto.getName())) { leaveType.setName(dto.getName()); @@ -101,7 +110,8 @@ public String updateLeaveType(String id, LeaveTypeDTO dto) { } @Transactional(readOnly = true) - public List getLeaveTypes(String instituteId) { + public List getLeaveTypes(String instituteId, CustomUserDetails user) { + hrAccessGuard.validateMember(user, instituteId); List leaveTypes = leaveTypeRepository.findByInstituteIdOrderByNameAsc(instituteId); return leaveTypes.stream() .map(this::toDTO) diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/controller/LoanController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/controller/LoanController.java index 9386ffc862..849f553e90 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/controller/LoanController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/controller/LoanController.java @@ -3,7 +3,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; import vacademy.io.admin_core_service.features.hr_payroll.dto.CreateLoanDTO; import vacademy.io.admin_core_service.features.hr_payroll.dto.EmployeeLoanDTO; import vacademy.io.admin_core_service.features.hr_payroll.dto.LoanRepaymentDTO; @@ -20,14 +21,20 @@ public class LoanController { private LoanService loanService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; @PostMapping + @Auditable( + entityType = "HR_LOAN", + action = "CREATE", + entityIdExpr = "#result?.body", + descriptionExpr = "'created loan for employee ' + #dto?.employeeId") public ResponseEntity createLoan( @RequestBody CreateLoanDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + // Loans (principal, interest rate, tenure) are granted by HR — admin only + hrAccessGuard.requireHrAdmin(user, instituteId); String id = loanService.createLoan(dto, instituteId); return ResponseEntity.ok(id); } @@ -37,18 +44,24 @@ public ResponseEntity> getLoans( @RequestParam("employeeId") String employeeId, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + // Employee must belong to the validated institute; non-HR callers may only read their own loans + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); List loans = loanService.getLoans(employeeId); return ResponseEntity.ok(loans); } @PutMapping("/{id}/approve") + @Auditable( + entityType = "HR_LOAN", + action = "APPROVE", + entityIdExpr = "#id", + descriptionExpr = "'approved loan ' + #id") public ResponseEntity approveLoan( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = loanService.approveLoan(id, user.getUserId()); + hrAccessGuard.requireHrAdmin(user, instituteId); + String resultId = loanService.approveLoan(id, user.getUserId(), instituteId); return ResponseEntity.ok(resultId); } @@ -57,8 +70,8 @@ public ResponseEntity> getRepayments( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - List repayments = loanService.getRepayments(id); + hrAccessGuard.validateMember(user, instituteId); + List repayments = loanService.getRepayments(id, instituteId, user); return ResponseEntity.ok(repayments); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/controller/PayrollController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/controller/PayrollController.java index e6ef4f3d52..4a0348585a 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/controller/PayrollController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/controller/PayrollController.java @@ -3,15 +3,26 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; import vacademy.io.admin_core_service.features.hr_payroll.dto.*; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntryError; +import vacademy.io.admin_core_service.features.hr_payroll.repository.PayrollEntryErrorRepository; +import vacademy.io.admin_core_service.features.hr_payroll.service.FnFService; +import vacademy.io.admin_core_service.features.hr_payroll.service.PayrollAdjustmentService; import vacademy.io.admin_core_service.features.hr_payroll.service.PayrollCalculationService; import vacademy.io.admin_core_service.features.hr_payroll.service.PayrollEntryService; import vacademy.io.admin_core_service.features.hr_payroll.service.PayrollRunService; import vacademy.io.common.auth.model.CustomUserDetails; +import java.math.BigDecimal; import java.util.List; +import java.util.Map; +/** + * Payroll runs & entries. Access (plan.md section G): all payroll processing is + * HR_ADMIN/ADMIN; entry/run reads are HR staff (HR_MANAGER may view). + */ @RestController @RequestMapping("/admin-core-service/api/v1/hr/payroll") public class PayrollController { @@ -26,17 +37,27 @@ public class PayrollController { private PayrollEntryService payrollEntryService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private PayrollAdjustmentService payrollAdjustmentService; + + @Autowired + private FnFService fnFService; + + @Autowired + private PayrollEntryErrorRepository payrollEntryErrorRepository; + + @Autowired + private HrAccessGuard hrAccessGuard; // ======================== Payroll Runs ======================== @PostMapping("/runs") + @Auditable(entityType = "HR_PAYROLL_RUN", action = "CREATE", entityIdExpr = "#result?.body") public ResponseEntity createPayrollRun( @RequestBody CreatePayrollRunDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String id = payrollRunService.createPayrollRun(dto); + hrAccessGuard.requireHrAdmin(user, instituteId); + String id = payrollRunService.createPayrollRun(dto, instituteId); return ResponseEntity.ok(id); } @@ -45,7 +66,7 @@ public ResponseEntity> getPayrollRuns( @RequestParam("instituteId") String instituteId, @RequestParam(value = "year", required = false) Integer year, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrStaff(user, instituteId); List runs = payrollRunService.getPayrollRuns(instituteId, year); return ResponseEntity.ok(runs); } @@ -55,48 +76,64 @@ public ResponseEntity getPayrollRunById( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - PayrollRunDTO run = payrollRunService.getPayrollRunById(id); + hrAccessGuard.requireHrStaff(user, instituteId); + PayrollRunDTO run = payrollRunService.getPayrollRunById(id, instituteId); return ResponseEntity.ok(run); } @PostMapping("/runs/{id}/process") + @Auditable(entityType = "HR_PAYROLL_RUN", action = "PROCESS", entityIdExpr = "#id") public ResponseEntity processPayroll( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = payrollCalculationService.processPayroll(id, user.getUserId()); + hrAccessGuard.requireHrAdmin(user, instituteId); + String resultId = payrollCalculationService.processPayroll(id, instituteId, user.getUserId()); return ResponseEntity.ok(resultId); } @PutMapping("/runs/{id}/approve") + @Auditable(entityType = "HR_PAYROLL_RUN", action = "APPROVE", entityIdExpr = "#id") public ResponseEntity approvePayroll( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = payrollRunService.approvePayroll(id, user.getUserId()); + hrAccessGuard.requireHrAdmin(user, instituteId); + String resultId = payrollRunService.approvePayroll(id, instituteId, user.getUserId()); + return ResponseEntity.ok(resultId); + } + + /** PROCESSED/APPROVED -> DRAFT with full financial reversal, so a wrong run can be recalculated. */ + @PutMapping("/runs/{id}/reject") + @Auditable(entityType = "HR_PAYROLL_RUN", action = "REJECT", entityIdExpr = "#id") + public ResponseEntity rejectPayroll( + @PathVariable("id") String id, + @RequestParam("instituteId") String instituteId, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + String resultId = payrollRunService.rejectPayroll(id, instituteId); return ResponseEntity.ok(resultId); } @PutMapping("/runs/{id}/mark-paid") + @Auditable(entityType = "HR_PAYROLL_RUN", action = "MARK_PAID", entityIdExpr = "#id") public ResponseEntity markPaid( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = payrollRunService.markPaid(id); + hrAccessGuard.requireHrAdmin(user, instituteId); + String resultId = payrollRunService.markPaid(id, instituteId); return ResponseEntity.ok(resultId); } @DeleteMapping("/runs/{id}") + @Auditable(entityType = "HR_PAYROLL_RUN", action = "CANCEL", entityIdExpr = "#id") public ResponseEntity cancelPayroll( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = payrollRunService.cancelPayroll(id); + hrAccessGuard.requireHrAdmin(user, instituteId); + String resultId = payrollRunService.cancelPayroll(id, instituteId); return ResponseEntity.ok(resultId); } @@ -107,8 +144,8 @@ public ResponseEntity> getEntriesByRun( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - List entries = payrollEntryService.getEntriesByRun(id); + hrAccessGuard.requireHrStaff(user, instituteId); + List entries = payrollEntryService.getEntriesByRun(id, instituteId); return ResponseEntity.ok(entries); } @@ -117,29 +154,94 @@ public ResponseEntity getEntryById( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - PayrollEntryDTO entry = payrollEntryService.getEntryById(id); + hrAccessGuard.requireHrStaff(user, instituteId); + PayrollEntryDTO entry = payrollEntryService.getEntryById(id, instituteId); return ResponseEntity.ok(entry); } @PutMapping("/entries/{id}/hold") + @Auditable(entityType = "HR_PAYROLL_ENTRY", action = "HOLD", entityIdExpr = "#id", + descriptionExpr = "'hold reason: ' + #holdDTO?.holdReason") public ResponseEntity holdEntry( @PathVariable("id") String id, @RequestBody HoldReleaseDTO holdDTO, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = payrollEntryService.holdEntry(id, holdDTO); + hrAccessGuard.requireHrAdmin(user, instituteId); + String resultId = payrollEntryService.holdEntry(id, instituteId, holdDTO); return ResponseEntity.ok(resultId); } + /** Per-employee processing failures of a run — replaces the old silent swallowing. */ + @GetMapping("/runs/{id}/errors") + public ResponseEntity> getRunErrors( + @PathVariable("id") String id, + @RequestParam("instituteId") String instituteId, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + payrollRunService.getPayrollRunById(id, instituteId); // institute-scope check + return ResponseEntity.ok(payrollEntryErrorRepository.findByPayrollRunIdOrderByCreatedAtAsc(id)); + } + + // ======================== Variable pay (adjustments) ======================== + + @PostMapping("/adjustments") + @Auditable(entityType = "HR_PAYROLL_ADJUSTMENT", action = "CREATE", entityIdExpr = "#result?.body") + public ResponseEntity createAdjustment( + @RequestBody PayrollAdjustmentDTO dto, + @RequestParam("instituteId") String instituteId, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + return ResponseEntity.ok(payrollAdjustmentService.createAdjustment(dto, instituteId, user, "MANUAL")); + } + + @GetMapping("/adjustments") + public ResponseEntity> getAdjustments( + @RequestParam("instituteId") String instituteId, + @RequestParam("year") Integer year, + @RequestParam("month") Integer month, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + return ResponseEntity.ok(payrollAdjustmentService.getAdjustments(instituteId, year, month)); + } + + @DeleteMapping("/adjustments/{id}") + @Auditable(entityType = "HR_PAYROLL_ADJUSTMENT", action = "DELETE", entityIdExpr = "#id") + public ResponseEntity deleteAdjustment( + @PathVariable("id") String id, + @RequestParam("instituteId") String instituteId, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + payrollAdjustmentService.deleteAdjustment(id, instituteId); + return ResponseEntity.ok(id); + } + + // ======================== Full & final settlement ======================== + + /** + * Prepares an exiting employee's F&F: leave-encashment (and optional + * notice-recovery) adjustments scoped to a run_type=FNF run for the exit + * month. Create + process that run to pay out. + */ + @PostMapping("/fnf/prepare") + @Auditable(entityType = "HR_PAYROLL_FNF", action = "PREPARE", entityIdExpr = "#employeeId") + public ResponseEntity> prepareFnF( + @RequestParam("instituteId") String instituteId, + @RequestParam("employeeId") String employeeId, + @RequestParam(value = "noticeRecoveryAmount", required = false) BigDecimal noticeRecoveryAmount, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + return ResponseEntity.ok(fnFService.prepareFnF(employeeId, instituteId, noticeRecoveryAmount, user.getUserId())); + } + @PutMapping("/entries/{id}/release") + @Auditable(entityType = "HR_PAYROLL_ENTRY", action = "RELEASE", entityIdExpr = "#id") public ResponseEntity releaseEntry( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = payrollEntryService.releaseEntry(id); + hrAccessGuard.requireHrAdmin(user, instituteId); + String resultId = payrollEntryService.releaseEntry(id, instituteId); return ResponseEntity.ok(resultId); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/controller/ReimbursementController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/controller/ReimbursementController.java index c3f7ab6733..54f2ce9675 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/controller/ReimbursementController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/controller/ReimbursementController.java @@ -4,7 +4,8 @@ import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; import vacademy.io.admin_core_service.features.hr_payroll.dto.CreateReimbursementDTO; import vacademy.io.admin_core_service.features.hr_payroll.dto.ReimbursementActionDTO; import vacademy.io.admin_core_service.features.hr_payroll.dto.ReimbursementDTO; @@ -19,15 +20,16 @@ public class ReimbursementController { private ReimbursementService reimbursementService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; @PostMapping public ResponseEntity submitReimbursement( @RequestBody CreateReimbursementDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String id = reimbursementService.submitReimbursement(dto, instituteId); + // Guarded inside the service: employee must belong to the institute and + // non-HR callers may only submit for their own employee record + String id = reimbursementService.submitReimbursement(dto, instituteId, user); return ResponseEntity.ok(id); } @@ -39,20 +41,32 @@ public ResponseEntity> getReimbursements( @RequestParam(defaultValue = "0") int pageNo, @RequestParam(defaultValue = "10") int pageSize, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + if (employeeId != null) { + // Employee-scoped view: HR staff, or the employee themselves + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); + } else { + // Institute-wide view: HR staff only + hrAccessGuard.requireHrStaff(user, instituteId); + } Page page = reimbursementService.getReimbursements( instituteId, status, employeeId, pageNo, pageSize); return ResponseEntity.ok(page); } @PutMapping("/{id}/action") + @Auditable( + entityType = "HR_REIMBURSEMENT", + action = "ACTION", + actionExpr = "#actionDTO?.action", + entityIdExpr = "#id", + descriptionExpr = "#actionDTO?.action + ' reimbursement ' + #id") public ResponseEntity approveRejectReimbursement( @PathVariable("id") String id, @RequestBody ReimbursementActionDTO actionDTO, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = reimbursementService.approveRejectReimbursement(id, actionDTO, user.getUserId()); + hrAccessGuard.requireHrStaff(user, instituteId); + String resultId = reimbursementService.approveRejectReimbursement(id, actionDTO, user.getUserId(), instituteId); return ResponseEntity.ok(resultId); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/CreateLoanDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/CreateLoanDTO.java index 351964542f..532a9ac426 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/CreateLoanDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/CreateLoanDTO.java @@ -22,4 +22,6 @@ public class CreateLoanDTO { private BigDecimal interestRate; private Integer tenureMonths; private String notes; + /** Optional ISO-4217 code (e.g. INR, USD); defaults to INR. */ + private String currency; } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/CreatePayrollRunDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/CreatePayrollRunDTO.java index 92077b36de..198b13ec18 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/CreatePayrollRunDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/CreatePayrollRunDTO.java @@ -18,4 +18,6 @@ public class CreatePayrollRunDTO { private Integer month; private Integer year; private String notes; + /** REGULAR (default) | OFF_CYCLE | FNF | BONUS */ + private String runType; } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/CreateReimbursementDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/CreateReimbursementDTO.java index 82634c56e6..10b7e6aa10 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/CreateReimbursementDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/CreateReimbursementDTO.java @@ -23,4 +23,6 @@ public class CreateReimbursementDTO { private String description; private String receiptFileId; private LocalDate expenseDate; + /** Optional ISO-4217 code (e.g. INR, USD); defaults to INR. */ + private String currency; } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/EmployeeLoanDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/EmployeeLoanDTO.java index 1fb72b97d6..0c060144f9 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/EmployeeLoanDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/EmployeeLoanDTO.java @@ -31,4 +31,5 @@ public class EmployeeLoanDTO { private Integer startYear; private String status; private String notes; + private String currency; } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/PayrollAdjustmentDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/PayrollAdjustmentDTO.java new file mode 100644 index 0000000000..a9a847dca1 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/PayrollAdjustmentDTO.java @@ -0,0 +1,34 @@ +package vacademy.io.admin_core_service.features.hr_payroll.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class PayrollAdjustmentDTO { + + private String id; + private String employeeId; + private Integer month; + private Integer year; + /** EARNING | DEDUCTION */ + private String type; + private String code; + private String label; + private BigDecimal amount; + private String currency; + /** REGULAR | OFF_CYCLE | FNF | BONUS (defaults REGULAR) */ + private String runScope; + private String source; + private String notes; + private String payrollEntryId; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/PayrollEntryDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/PayrollEntryDTO.java index d6ff3a540c..77d71f8218 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/PayrollEntryDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/PayrollEntryDTO.java @@ -35,6 +35,7 @@ public class PayrollEntryDTO { private BigDecimal arrears; private BigDecimal reimbursements; private BigDecimal loanDeduction; + private String currency; private String status; private List components; } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/PayrollRunDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/PayrollRunDTO.java index 1472fcd741..fde791f7a4 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/PayrollRunDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/PayrollRunDTO.java @@ -24,11 +24,13 @@ public class PayrollRunDTO { private Integer year; private LocalDate runDate; private String status; + private String runType; private Integer totalEmployees; private BigDecimal totalGross; private BigDecimal totalDeductions; private BigDecimal totalNetPay; private BigDecimal totalEmployerCost; + private String currency; private String processedBy; private LocalDateTime processedAt; private String approvedBy; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/ReimbursementDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/ReimbursementDTO.java index 0924bdccb5..711729f6e9 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/ReimbursementDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/dto/ReimbursementDTO.java @@ -29,4 +29,5 @@ public class ReimbursementDTO { private String status; private String approvedBy; private String rejectionReason; + private String currency; } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/EmployeeLoan.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/EmployeeLoan.java index 2e72dd199c..88864e6d4c 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/EmployeeLoan.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/EmployeeLoan.java @@ -70,6 +70,13 @@ public class EmployeeLoan { @Column(name = "notes", columnDefinition = "TEXT") private String notes; + @Version + @Column(name = "version") + private Long version; + + @Column(name = "currency", length = 3) + private String currency; + @Column(name = "created_at", insertable = false, updatable = false) private LocalDateTime createdAt; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/PayrollAdjustment.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/PayrollAdjustment.java new file mode 100644 index 0000000000..603a438145 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/PayrollAdjustment.java @@ -0,0 +1,87 @@ +package vacademy.io.admin_core_service.features.hr_payroll.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.UuidGenerator; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Variable-pay input (V482): a per-employee, per-month ad-hoc earning or + * deduction — bonus, incentive, notice recovery, leave encashment, arrears. + * Consumed by payroll processing for the matching run scope and materialized + * as a PayrollEntryComponent under {@code code}; {@code payrollEntryId} links + * it once consumed (cleared again if the run is rejected/cancelled). + */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "hr_payroll_adjustment") +public class PayrollAdjustment { + + @Id + @UuidGenerator + @Column(name = "id") + private String id; + + @Column(name = "institute_id", nullable = false) + private String instituteId; + + @Column(name = "employee_id", nullable = false) + private String employeeId; + + @Column(name = "month", nullable = false) + private Integer month; + + @Column(name = "year", nullable = false) + private Integer year; + + /** EARNING | DEDUCTION */ + @Column(name = "type", nullable = false, length = 20) + private String type; + + /** Component code it materializes under (BONUS, LEAVE_ENCASHMENT, NOTICE_RECOVERY, ...). */ + @Column(name = "code", nullable = false, length = 30) + private String code; + + @Column(name = "label", nullable = false, length = 100) + private String label; + + @Column(name = "amount", nullable = false, precision = 15, scale = 2) + private BigDecimal amount; + + @Column(name = "currency", length = 3) + private String currency; + + /** Run type that consumes it: REGULAR | OFF_CYCLE | FNF | BONUS. */ + @Column(name = "run_scope", length = 30) + private String runScope; + + /** MANUAL | FNF | CRM_INCENTIVE | SYSTEM */ + @Column(name = "source", length = 30) + private String source; + + @Column(name = "notes", columnDefinition = "TEXT") + private String notes; + + @Column(name = "payroll_entry_id") + private String payrollEntryId; + + @Column(name = "created_by") + private String createdBy; + + @Column(name = "created_at", insertable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", insertable = false) + private LocalDateTime updatedAt; + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/PayrollEntry.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/PayrollEntry.java index 9d1813791d..82bc192751 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/PayrollEntry.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/PayrollEntry.java @@ -103,6 +103,13 @@ public class PayrollEntry { @OneToMany(mappedBy = "payrollEntry", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private List entryComponents; + @Version + @Column(name = "version") + private Long version; + + @Column(name = "currency", length = 3) + private String currency; + @Column(name = "created_at", insertable = false, updatable = false) private LocalDateTime createdAt; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/PayrollEntryError.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/PayrollEntryError.java new file mode 100644 index 0000000000..131f191fc9 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/PayrollEntryError.java @@ -0,0 +1,44 @@ +package vacademy.io.admin_core_service.features.hr_payroll.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.UuidGenerator; + +import java.time.LocalDateTime; + +/** + * One row per employee whose payroll entry could not be calculated in a run + * (V480). Replaces the former silent empty-catch: the run still completes for + * everyone else, but failures are visible and reportable instead of vanishing. + * Rows for a run are cleared when the run is reprocessed, rejected, or cancelled. + */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "hr_payroll_entry_error") +public class PayrollEntryError { + + @Id + @UuidGenerator + @Column(name = "id") + private String id; + + @Column(name = "payroll_run_id", nullable = false) + private String payrollRunId; + + @Column(name = "employee_id", nullable = false) + private String employeeId; + + /** CALCULATION | TAX | PERSISTENCE — where in the pipeline it failed. */ + @Column(name = "error_stage", length = 50) + private String errorStage; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + @Column(name = "created_at", insertable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/PayrollRun.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/PayrollRun.java index 205bb99302..2ff01d8c1a 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/PayrollRun.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/PayrollRun.java @@ -39,6 +39,14 @@ public class PayrollRun { @Column(name = "status", length = 20) private String status; + // REGULAR | OFF_CYCLE | FNF | BONUS (V480; only REGULAR runs are unique per month) + @Column(name = "run_type", length = 30) + private String runType; + + @Version + @Column(name = "version") + private Long version; + @Column(name = "total_employees") private Integer totalEmployees; @@ -72,6 +80,9 @@ public class PayrollRun { @Column(name = "notes", columnDefinition = "TEXT") private String notes; + @Column(name = "currency", length = 3) + private String currency; + @Column(name = "created_at", insertable = false, updatable = false) private LocalDateTime createdAt; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/Reimbursement.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/Reimbursement.java index effb823f6c..8c613a92e8 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/Reimbursement.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/entity/Reimbursement.java @@ -63,6 +63,9 @@ public class Reimbursement { @Column(name = "rejection_reason", columnDefinition = "TEXT") private String rejectionReason; + @Column(name = "currency", length = 3) + private String currency; + @Column(name = "created_at", insertable = false, updatable = false) private LocalDateTime createdAt; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/repository/PayrollAdjustmentRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/repository/PayrollAdjustmentRepository.java new file mode 100644 index 0000000000..9b2b488df5 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/repository/PayrollAdjustmentRepository.java @@ -0,0 +1,36 @@ +package vacademy.io.admin_core_service.features.hr_payroll.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollAdjustment; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface PayrollAdjustmentRepository extends JpaRepository { + + List findByInstituteIdAndYearAndMonthOrderByCreatedAtAsc( + String instituteId, Integer year, Integer month); + + List findByEmployeeIdAndYearAndMonthAndRunScopeAndPayrollEntryIdIsNull( + String employeeId, Integer year, Integer month, String runScope); + + /** Employees that an OFF_CYCLE/BONUS run should pay: those with unconsumed adjustments in scope. */ + @Query("SELECT DISTINCT a.employeeId FROM PayrollAdjustment a WHERE a.instituteId = :instituteId " + + "AND a.year = :year AND a.month = :month AND a.runScope = :runScope AND a.payrollEntryId IS NULL") + List findEmployeeIdsWithPendingAdjustments( + @Param("instituteId") String instituteId, @Param("year") Integer year, + @Param("month") Integer month, @Param("runScope") String runScope); + + Optional findByIdAndInstituteId(String id, String instituteId); + + List findByPayrollEntryId(String payrollEntryId); + + @Modifying + @Query("UPDATE PayrollAdjustment a SET a.payrollEntryId = NULL WHERE a.payrollEntryId IN :entryIds") + void unlinkByPayrollEntryIds(@Param("entryIds") List entryIds); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/repository/PayrollEntryErrorRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/repository/PayrollEntryErrorRepository.java new file mode 100644 index 0000000000..aadfe2a527 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/repository/PayrollEntryErrorRepository.java @@ -0,0 +1,17 @@ +package vacademy.io.admin_core_service.features.hr_payroll.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntryError; + +import java.util.List; + +@Repository +public interface PayrollEntryErrorRepository extends JpaRepository { + + List findByPayrollRunIdOrderByCreatedAtAsc(String payrollRunId); + + @Modifying + void deleteByPayrollRunId(String payrollRunId); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/repository/PayrollRunRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/repository/PayrollRunRepository.java index 27e302b3a1..45ae791f47 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/repository/PayrollRunRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/repository/PayrollRunRepository.java @@ -1,6 +1,10 @@ package vacademy.io.admin_core_service.features.hr_payroll.repository; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollRun; @@ -12,6 +16,21 @@ public interface PayrollRunRepository extends JpaRepository Optional findByInstituteIdAndMonthAndYear(String instituteId, Integer month, Integer year); + Optional findByIdAndInstituteId(String id, String instituteId); + + /** Duplicate check for creating a run: CANCELLED runs don't block the month (V480 partial unique). */ + boolean existsByInstituteIdAndMonthAndYearAndRunTypeAndStatusNot( + String instituteId, Integer month, Integer year, String runType, String status); + + /** Month-lock check: a REGULAR run past DRAFT locks that month's attendance/leave. */ + boolean existsByInstituteIdAndMonthAndYearAndRunTypeAndStatusIn( + String instituteId, Integer month, Integer year, String runType, List statuses); + + /** Row-locks the run so two concurrent /process calls serialize instead of double-calculating. */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM PayrollRun r WHERE r.id = :id AND r.instituteId = :instituteId") + Optional findByIdAndInstituteIdForUpdate(@Param("id") String id, @Param("instituteId") String instituteId); + List findByInstituteIdAndYearOrderByMonthDesc(String instituteId, Integer year); List findByInstituteIdOrderByYearDescMonthDesc(String instituteId); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/FnFService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/FnFService.java new file mode 100644 index 0000000000..0e9d37cefa --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/FnFService.java @@ -0,0 +1,163 @@ +package vacademy.io.admin_core_service.features.hr_payroll.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; +import vacademy.io.admin_core_service.features.hr_leave.entity.LeaveBalance; +import vacademy.io.admin_core_service.features.hr_leave.repository.LeaveBalanceRepository; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollAdjustment; +import vacademy.io.admin_core_service.features.hr_payroll.repository.PayrollAdjustmentRepository; +import vacademy.io.admin_core_service.features.hr_salary.entity.EmployeeSalaryStructure; +import vacademy.io.admin_core_service.features.hr_salary.repository.EmployeeSalaryStructureRepository; +import vacademy.io.common.exceptions.VacademyException; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Full-and-final settlement preparation (Phase C6). The final month's prorated + * salary already falls out of the payroll engine's employment-window logic; + * this service adds the F&F-specific pieces as FNF-scoped adjustments: + * leave encashment for encashable types (closing balance × gross/30), and an + * optional notice-recovery deduction supplied by the admin. The admin then + * creates a run with run_type FNF for the exit month and processes it — the + * run picks up exactly the employees whose last working date falls in it. + * + * Encashed days are marked on the leave balance at prepare time; if the F&F is + * abandoned, the admin reverses via the balance-adjust endpoint (documented + * limitation until a formal F&F entity exists). + */ +@Service +public class FnFService { + + @Autowired + private EmployeeProfileRepository employeeProfileRepository; + + @Autowired + private LeaveBalanceRepository leaveBalanceRepository; + + @Autowired + private EmployeeSalaryStructureRepository salaryStructureRepository; + + @Autowired + private PayrollAdjustmentRepository adjustmentRepository; + + @Autowired + private HrAccessGuard hrAccessGuard; + + /** + * Creates the F&F adjustments for one exiting employee. Returns a summary + * of what was created. Idempotence: refuses if an unconsumed FNF-scoped + * LEAVE_ENCASHMENT adjustment already exists for the exit month. + */ + @Transactional + public Map prepareFnF(String employeeId, String instituteId, + BigDecimal noticeRecoveryAmount, String userId) { + EmployeeProfile employee = employeeProfileRepository.findById(employeeId) + .orElseThrow(() -> new VacademyException("Employee not found")); + hrAccessGuard.requireInstituteMatch(employee.getInstituteId(), instituteId, "Employee"); + + if (employee.getLastWorkingDate() == null) { + throw new VacademyException("Employee has no last working date set — record the exit first"); + } + int month = employee.getLastWorkingDate().getMonthValue(); + int year = employee.getLastWorkingDate().getYear(); + + boolean alreadyPrepared = adjustmentRepository + .findByEmployeeIdAndYearAndMonthAndRunScopeAndPayrollEntryIdIsNull( + employee.getId(), year, month, "FNF") + .stream().anyMatch(a -> "LEAVE_ENCASHMENT".equals(a.getCode())); + if (alreadyPrepared) { + throw new VacademyException("F&F already prepared for this employee (unconsumed FNF adjustments exist)"); + } + + EmployeeSalaryStructure structure = salaryStructureRepository + .findFirstByEmployee_IdAndStatusOrderByEffectiveFromDesc(employee.getId(), "ACTIVE") + .orElseThrow(() -> new VacademyException("Employee has no active salary structure")); + BigDecimal grossMonthly = structure.getGrossMonthly() != null + ? structure.getGrossMonthly() + : structure.getCtcMonthly() != null ? structure.getCtcMonthly() : BigDecimal.ZERO; + BigDecimal perDay = grossMonthly.divide(new BigDecimal("30"), 2, RoundingMode.HALF_UP); + String currency = structure.getCurrency() != null ? structure.getCurrency() : "INR"; + + List> created = new ArrayList<>(); + + // Leave encashment: encashable types' positive closing balances for the exit year. + BigDecimal totalEncashDays = BigDecimal.ZERO; + BigDecimal totalEncashAmount = BigDecimal.ZERO; + for (LeaveBalance balance : leaveBalanceRepository.findByEmployee_IdAndYear(employee.getId(), year)) { + if (balance.getLeaveType() == null + || balance.getLeaveType().getIsEncashable() == null + || !balance.getLeaveType().getIsEncashable()) { + continue; + } + BigDecimal days = balance.getClosingBalance(); + if (days == null || days.signum() <= 0) continue; + + BigDecimal amount = perDay.multiply(days).setScale(2, RoundingMode.HALF_UP); + balance.setEncashed((balance.getEncashed() != null ? balance.getEncashed() : BigDecimal.ZERO).add(days)); + leaveBalanceRepository.save(balance); + + totalEncashDays = totalEncashDays.add(days); + totalEncashAmount = totalEncashAmount.add(amount); + } + if (totalEncashAmount.signum() > 0) { + created.add(createAdjustment(employee, instituteId, month, year, "EARNING", + "LEAVE_ENCASHMENT", "Leave Encashment (" + totalEncashDays.stripTrailingZeros().toPlainString() + + " days)", totalEncashAmount, currency, userId)); + } + + // Notice recovery, when the admin supplies one. + if (noticeRecoveryAmount != null && noticeRecoveryAmount.signum() > 0) { + created.add(createAdjustment(employee, instituteId, month, year, "DEDUCTION", + "NOTICE_RECOVERY", "Notice Period Recovery", + noticeRecoveryAmount.setScale(2, RoundingMode.HALF_UP), currency, userId)); + } + + Map summary = new LinkedHashMap<>(); + summary.put("employeeId", employee.getId()); + summary.put("exitMonth", month); + summary.put("exitYear", year); + summary.put("encashedDays", totalEncashDays); + summary.put("encashmentAmount", totalEncashAmount); + summary.put("adjustments", created); + summary.put("nextStep", "Create a payroll run with run_type FNF for " + month + "/" + year + + " and process it — it pays exactly the employees exiting that month."); + return summary; + } + + private Map createAdjustment(EmployeeProfile employee, String instituteId, + int month, int year, String type, String code, + String label, BigDecimal amount, String currency, + String userId) { + PayrollAdjustment adj = new PayrollAdjustment(); + adj.setInstituteId(instituteId); + adj.setEmployeeId(employee.getId()); + adj.setMonth(month); + adj.setYear(year); + adj.setType(type); + adj.setCode(code); + adj.setLabel(label); + adj.setAmount(amount); + adj.setCurrency(currency); + adj.setRunScope("FNF"); + adj.setSource("FNF"); + adj.setCreatedBy(userId); + adj = adjustmentRepository.save(adj); + + Map out = new LinkedHashMap<>(); + out.put("id", adj.getId()); + out.put("type", type); + out.put("code", code); + out.put("label", label); + out.put("amount", amount); + return out; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/HrMonthLockService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/HrMonthLockService.java new file mode 100644 index 0000000000..35eee78192 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/HrMonthLockService.java @@ -0,0 +1,45 @@ +package vacademy.io.admin_core_service.features.hr_payroll.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import vacademy.io.admin_core_service.features.hr_payroll.repository.PayrollRunRepository; +import vacademy.io.common.exceptions.VacademyException; + +import java.time.LocalDate; +import java.util.List; + +/** + * Payroll month-lock (Phase C4): once a REGULAR payroll run for a month has + * moved past DRAFT (PROCESSING/PROCESSED/APPROVED/PAID), that month's + * attendance and leave records are frozen — bulk marking, regularization + * approval, leave approval/cancellation touching the month must refuse, + * otherwise the books silently diverge from what was paid. Rejecting or + * cancelling the run unlocks the month again. + */ +@Service +public class HrMonthLockService { + + private static final List LOCKING_STATUSES = + List.of("PROCESSING", "PROCESSED", "APPROVED", "PAID"); + + @Autowired + private PayrollRunRepository payrollRunRepository; + + public boolean isMonthLocked(String instituteId, int month, int year) { + return payrollRunRepository.existsByInstituteIdAndMonthAndYearAndRunTypeAndStatusIn( + instituteId, month, year, "REGULAR", LOCKING_STATUSES); + } + + public boolean isDateLocked(String instituteId, LocalDate date) { + return isMonthLocked(instituteId, date.getMonthValue(), date.getYear()); + } + + /** Throws a clean error naming the action when the date's month is payroll-locked. */ + public void requireUnlocked(String instituteId, LocalDate date, String action) { + if (date != null && isDateLocked(instituteId, date)) { + throw new VacademyException("Cannot " + action + " for " + date.getMonthValue() + "/" + + date.getYear() + ": payroll for that month is already processed. " + + "Reject the payroll run first if a correction is needed."); + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/LoanService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/LoanService.java index b4ef42a068..59c4c99300 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/LoanService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/LoanService.java @@ -3,8 +3,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; +import vacademy.io.admin_core_service.features.hr_employee.service.HrNotificationService; import vacademy.io.admin_core_service.features.hr_payroll.dto.CreateLoanDTO; import vacademy.io.admin_core_service.features.hr_payroll.dto.EmployeeLoanDTO; import vacademy.io.admin_core_service.features.hr_payroll.dto.LoanRepaymentDTO; @@ -13,6 +15,9 @@ import vacademy.io.admin_core_service.features.hr_payroll.enums.LoanStatus; import vacademy.io.admin_core_service.features.hr_payroll.repository.EmployeeLoanRepository; import vacademy.io.admin_core_service.features.hr_payroll.repository.LoanRepaymentRepository; +import vacademy.io.admin_core_service.features.workflow.enums.WorkflowTriggerEvent; +import vacademy.io.admin_core_service.features.workflow.service.WorkflowTriggerService; +import vacademy.io.common.auth.model.CustomUserDetails; import vacademy.io.common.exceptions.VacademyException; import java.math.BigDecimal; @@ -20,9 +25,12 @@ import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; +@lombok.extern.slf4j.Slf4j @Service public class LoanService { @@ -35,10 +43,20 @@ public class LoanService { @Autowired private EmployeeProfileRepository employeeProfileRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + + @Autowired + private HrNotificationService hrNotificationService; + + @Autowired + private WorkflowTriggerService workflowTriggerService; + @Transactional public String createLoan(CreateLoanDTO dto, String instituteId) { EmployeeProfile employee = employeeProfileRepository.findById(dto.getEmployeeId()) .orElseThrow(() -> new VacademyException("Employee not found")); + hrAccessGuard.requireInstituteMatch(employee.getInstituteId(), instituteId, "Employee"); EmployeeLoan loan = new EmployeeLoan(); loan.setEmployee(employee); @@ -48,6 +66,8 @@ public String createLoan(CreateLoanDTO dto, String instituteId) { loan.setInterestRate(dto.getInterestRate() != null ? dto.getInterestRate() : BigDecimal.ZERO); loan.setTenureMonths(dto.getTenureMonths()); loan.setNotes(dto.getNotes()); + // Currency defaults to INR unless an explicit 3-letter code is supplied + loan.setCurrency(normalizeCurrency(dto.getCurrency())); loan.setStatus(LoanStatus.PENDING.name()); loan.setDisbursedAmount(BigDecimal.ZERO); loan.setBalanceAmount(BigDecimal.ZERO); @@ -62,6 +82,31 @@ public String createLoan(CreateLoanDTO dto, String instituteId) { loan.setStartYear(nextMonth.getYear()); loan = loanRepository.save(loan); + + // Phase F5: HR_LOAN_REQUESTED workflow trigger (emit-and-forget — a + // workflow failure must never break the loan creation itself) + try { + Map contextData = new HashMap<>(); + contextData.put("loanId", loan.getId()); + contextData.put("employeeId", employee.getId()); + contextData.put("employeeUserId", employee.getUserId()); + contextData.put("loanType", loan.getLoanType()); + contextData.put("principalAmount", loan.getPrincipalAmount() != null + ? loan.getPrincipalAmount().toPlainString() : null); + contextData.put("emiAmount", loan.getEmiAmount() != null + ? loan.getEmiAmount().toPlainString() : null); + contextData.put("tenureMonths", loan.getTenureMonths()); + contextData.put("currency", loan.getCurrency()); + contextData.put("status", loan.getStatus()); + workflowTriggerService.handleTriggerEvents( + WorkflowTriggerEvent.HR_LOAN_REQUESTED.name(), + loan.getId(), + instituteId, + contextData); + } catch (Exception e) { + log.warn("Failed to trigger HR_LOAN_REQUESTED workflow", e); + } + return loan.getId(); } @@ -72,9 +117,15 @@ public List getLoans(String employeeId) { } @Transactional - public String approveLoan(String id, String approverUserId) { + public String approveLoan(String id, String approverUserId, String instituteId) { EmployeeLoan loan = loanRepository.findById(id) .orElseThrow(() -> new VacademyException("Loan not found")); + hrAccessGuard.requireInstituteMatch(loan.getInstituteId(), instituteId, "Loan"); + + // The approver must not be the loan's own employee + if (approverUserId != null && approverUserId.equals(loan.getEmployee().getUserId())) { + throw new VacademyException("You cannot approve your own loan"); + } if (!LoanStatus.PENDING.name().equals(loan.getStatus())) { throw new VacademyException("Only PENDING loans can be approved. Current status: " + loan.getStatus()); @@ -94,14 +145,57 @@ public String approveLoan(String id, String approverUserId) { loan.setBalanceAmount(loan.getEmiAmount().multiply(new BigDecimal(loan.getTenureMonths()))); loanRepository.save(loan); + + // Best-effort employee email on approval (send failures never break the operation) + try { + String currency = loan.getCurrency() != null ? loan.getCurrency() : "INR"; + String subject = "Your loan was approved"; + String body = hrNotificationService.buildEmailBody(subject, + "Loan type", loan.getLoanType(), + "Amount", currency + " " + loan.getPrincipalAmount().toPlainString(), + "Monthly EMI", currency + " " + loan.getEmiAmount().toPlainString(), + "Tenure", loan.getTenureMonths() + " month(s)", + "Deductions start", loan.getStartMonth() + "/" + loan.getStartYear()); + hrNotificationService.emailEmployee(loan.getEmployee(), subject, body); + } catch (Exception e) { + // emailEmployee already swallows send failures; this guards lazy-load surprises + } + + // Phase F5: HR_LOAN_DECIDED workflow trigger (emit-and-forget — a + // workflow failure must never break the approval itself) + try { + Map contextData = new HashMap<>(); + contextData.put("loanId", loan.getId()); + contextData.put("employeeId", loan.getEmployee().getId()); + contextData.put("employeeUserId", loan.getEmployee().getUserId()); + contextData.put("loanType", loan.getLoanType()); + contextData.put("principalAmount", loan.getPrincipalAmount() != null + ? loan.getPrincipalAmount().toPlainString() : null); + contextData.put("emiAmount", loan.getEmiAmount() != null + ? loan.getEmiAmount().toPlainString() : null); + contextData.put("tenureMonths", loan.getTenureMonths()); + contextData.put("currency", loan.getCurrency()); + contextData.put("status", loan.getStatus()); + contextData.put("approvedBy", loan.getApprovedBy()); + workflowTriggerService.handleTriggerEvents( + WorkflowTriggerEvent.HR_LOAN_DECIDED.name(), + loan.getId(), + instituteId, + contextData); + } catch (Exception e) { + log.warn("Failed to trigger HR_LOAN_DECIDED workflow", e); + } + return loan.getId(); } @Transactional(readOnly = true) - public List getRepayments(String loanId) { - // Verify the loan exists - loanRepository.findById(loanId) + public List getRepayments(String loanId, String instituteId, CustomUserDetails user) { + EmployeeLoan loan = loanRepository.findById(loanId) .orElseThrow(() -> new VacademyException("Loan not found")); + hrAccessGuard.requireInstituteMatch(loan.getInstituteId(), instituteId, "Loan"); + // Only HR staff or the loan's own employee may read its repayments + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, loan.getEmployee().getId()); List repayments = repaymentRepository.findByLoanIdOrderByYearAscMonthAsc(loanId); @@ -149,6 +243,18 @@ private BigDecimal calculateEMI(BigDecimal principal, BigDecimal annualInterestR return numerator.divide(denominator, 2, RoundingMode.HALF_UP); } + /** Defaults to INR; validates the 3-letter ISO-4217 shape when provided. */ + private String normalizeCurrency(String currency) { + if (currency == null || currency.trim().isEmpty()) { + return "INR"; + } + String normalized = currency.trim().toUpperCase(); + if (!normalized.matches("[A-Z]{3}")) { + throw new VacademyException("Invalid currency code: " + currency + ". Expected a 3-letter code like INR or USD."); + } + return normalized; + } + private EmployeeLoanDTO toDTO(EmployeeLoan loan) { return EmployeeLoanDTO.builder() .id(loan.getId()) @@ -166,6 +272,7 @@ private EmployeeLoanDTO toDTO(EmployeeLoan loan) { .startYear(loan.getStartYear()) .status(loan.getStatus()) .notes(loan.getNotes()) + .currency(loan.getCurrency() != null ? loan.getCurrency() : "INR") .build(); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollAdjustmentService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollAdjustmentService.java new file mode 100644 index 0000000000..f5e8ee338c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollAdjustmentService.java @@ -0,0 +1,123 @@ +package vacademy.io.admin_core_service.features.hr_payroll.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_payroll.dto.PayrollAdjustmentDTO; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollAdjustment; +import vacademy.io.admin_core_service.features.hr_payroll.repository.PayrollAdjustmentRepository; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.exceptions.VacademyException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Variable-pay input API (Phase C7): HR admins register per-employee monthly + * earnings/deductions (bonus, incentive, recovery) which payroll consumes for + * the matching run scope. CRM incentives and F&F both create rows here. + */ +@Service +public class PayrollAdjustmentService { + + private static final Set TYPES = Set.of("EARNING", "DEDUCTION"); + private static final Set SCOPES = Set.of("REGULAR", "OFF_CYCLE", "FNF", "BONUS"); + + @Autowired + private PayrollAdjustmentRepository adjustmentRepository; + + @Autowired + private HrAccessGuard hrAccessGuard; + + @Transactional + public String createAdjustment(PayrollAdjustmentDTO dto, String instituteId, + CustomUserDetails user, String source) { + EmployeeProfile employee = hrAccessGuard.requireSelfOrHrStaff(user, instituteId, dto.getEmployeeId()); + if (!hrAccessGuard.isHrAdmin(user)) { + throw new VacademyException("Only HR admins can create payroll adjustments"); + } + if (dto.getMonth() == null || dto.getMonth() < 1 || dto.getMonth() > 12 + || dto.getYear() == null || dto.getYear() < 2000 || dto.getYear() > 2100) { + throw new VacademyException("Valid month and year are required"); + } + if (dto.getType() == null || !TYPES.contains(dto.getType().toUpperCase())) { + throw new VacademyException("Adjustment type must be EARNING or DEDUCTION"); + } + if (dto.getAmount() == null || dto.getAmount().compareTo(BigDecimal.ZERO) <= 0) { + throw new VacademyException("Adjustment amount must be positive"); + } + if (dto.getLabel() == null || dto.getLabel().isBlank()) { + throw new VacademyException("Adjustment label is required"); + } + + String scope = dto.getRunScope() == null || dto.getRunScope().isBlank() + ? "REGULAR" : dto.getRunScope().toUpperCase(); + if (!SCOPES.contains(scope)) { + throw new VacademyException("run_scope must be one of " + SCOPES); + } + + PayrollAdjustment adj = new PayrollAdjustment(); + adj.setInstituteId(instituteId); + adj.setEmployeeId(employee.getId()); + adj.setMonth(dto.getMonth()); + adj.setYear(dto.getYear()); + adj.setType(dto.getType().toUpperCase()); + adj.setCode(sanitizeCode(dto.getCode() != null && !dto.getCode().isBlank() + ? dto.getCode() : dto.getLabel())); + adj.setLabel(dto.getLabel().trim()); + adj.setAmount(dto.getAmount().setScale(2, java.math.RoundingMode.HALF_UP)); + adj.setCurrency(dto.getCurrency() != null && dto.getCurrency().matches("[A-Za-z]{3}") + ? dto.getCurrency().toUpperCase() : "INR"); + adj.setRunScope(scope); + adj.setSource(source != null ? source : "MANUAL"); + adj.setNotes(dto.getNotes()); + adj.setCreatedBy(user.getUserId()); + return adjustmentRepository.save(adj).getId(); + } + + @Transactional(readOnly = true) + public List getAdjustments(String instituteId, Integer year, Integer month) { + return adjustmentRepository.findByInstituteIdAndYearAndMonthOrderByCreatedAtAsc(instituteId, year, month) + .stream().map(this::toDTO).collect(Collectors.toList()); + } + + @Transactional + public void deleteAdjustment(String id, String instituteId) { + PayrollAdjustment adj = adjustmentRepository.findByIdAndInstituteId(id, instituteId) + .orElseThrow(() -> new VacademyException("Adjustment not found")); + if (adj.getPayrollEntryId() != null) { + throw new VacademyException("Adjustment already consumed by a payroll run; reject that run first"); + } + adjustmentRepository.delete(adj); + } + + /** Uppercase snake component code, max 30 chars (component.code column limit). */ + static String sanitizeCode(String raw) { + String code = raw.trim().toUpperCase().replaceAll("[^A-Z0-9]+", "_") + .replaceAll("^_+|_+$", ""); + if (code.isEmpty()) code = "ADJUSTMENT"; + return code.length() > 30 ? code.substring(0, 30) : code; + } + + private PayrollAdjustmentDTO toDTO(PayrollAdjustment a) { + return PayrollAdjustmentDTO.builder() + .id(a.getId()) + .employeeId(a.getEmployeeId()) + .month(a.getMonth()) + .year(a.getYear()) + .type(a.getType()) + .code(a.getCode()) + .label(a.getLabel()) + .amount(a.getAmount()) + .currency(a.getCurrency()) + .runScope(a.getRunScope()) + .source(a.getSource()) + .notes(a.getNotes()) + .payrollEntryId(a.getPayrollEntryId()) + .build(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollCalculationService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollCalculationService.java index 3b6f96a324..86ceec03ae 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollCalculationService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollCalculationService.java @@ -1,5 +1,7 @@ package vacademy.io.admin_core_service.features.hr_payroll.service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,16 +21,23 @@ import vacademy.io.admin_core_service.features.hr_payroll.repository.*; import vacademy.io.admin_core_service.features.hr_salary.entity.EmployeeSalaryComponent; import vacademy.io.admin_core_service.features.hr_salary.entity.EmployeeSalaryStructure; +import vacademy.io.admin_core_service.features.hr_salary.entity.SalaryComponent; import vacademy.io.admin_core_service.features.hr_salary.enums.ComponentType; import vacademy.io.admin_core_service.features.hr_salary.repository.EmployeeSalaryStructureRepository; +import vacademy.io.admin_core_service.features.hr_salary.repository.SalaryComponentRepository; import vacademy.io.admin_core_service.features.hr_tax.entity.TaxComputation; import vacademy.io.admin_core_service.features.hr_tax.entity.TaxConfiguration; import vacademy.io.admin_core_service.features.hr_tax.entity.TaxDeclaration; import vacademy.io.admin_core_service.features.hr_tax.repository.TaxComputationRepository; import vacademy.io.admin_core_service.features.hr_tax.repository.TaxConfigurationRepository; import vacademy.io.admin_core_service.features.hr_tax.repository.TaxDeclarationRepository; +import vacademy.io.admin_core_service.features.hr_tax.service.engine.StatutoryItem; +import vacademy.io.admin_core_service.features.hr_tax.service.engine.TaxInput; import vacademy.io.admin_core_service.features.hr_tax.service.engine.TaxRegimeEngine; +import vacademy.io.admin_core_service.features.hr_tax.service.engine.TaxResult; import vacademy.io.admin_core_service.features.hr_tax.service.engine.TaxRegimeFactory; +import vacademy.io.admin_core_service.features.workflow.enums.WorkflowTriggerEvent; +import vacademy.io.admin_core_service.features.workflow.service.WorkflowTriggerService; import vacademy.io.common.exceptions.VacademyException; import java.math.BigDecimal; @@ -43,21 +52,45 @@ @Service public class PayrollCalculationService { + private static final Logger log = LoggerFactory.getLogger(PayrollCalculationService.class); + + /** + * Last-resort currency for rows whose own source (salary structure, + * adjustment) carries none. The RUN's currency comes from + * {@link PayrollCurrencyResolver} so a Gulf institute is not stamped INR. + */ + private static final String DEFAULT_CURRENCY = PayrollCurrencyResolver.FALLBACK_CURRENCY; + + /** Structure component codes that mean a statutory scheme is template-managed (skip engine). */ + private static final Map> STATUTORY_ALIASES = Map.of( + "PF", Set.of("PF", "EPF", "PF_EMP", "PROVIDENT_FUND"), + "ESI", Set.of("ESI", "ESI_EMP"), + "PT", Set.of("PT", "PROF_TAX", "PROFESSIONAL_TAX")); + @Autowired private PayrollRunRepository payrollRunRepository; + @Autowired + private PayrollCurrencyResolver payrollCurrencyResolver; + @Autowired private PayrollEntryRepository payrollEntryRepository; @Autowired private PayrollEntryComponentRepository payrollEntryComponentRepository; + @Autowired + private PayrollEntryErrorRepository payrollEntryErrorRepository; + @Autowired private EmployeeProfileRepository employeeProfileRepository; @Autowired private EmployeeSalaryStructureRepository salaryStructureRepository; + @Autowired + private SalaryComponentRepository salaryComponentRepository; + @Autowired private AttendanceRecordRepository attendanceRecordRepository; @@ -91,107 +124,172 @@ public class PayrollCalculationService { @Autowired private TaxComputationRepository taxComputationRepository; + @Autowired + private PayrollAdjustmentRepository payrollAdjustmentRepository; + + @Autowired + private WorkflowTriggerService workflowTriggerService; + + /** + * Everything one employee's calculation produced, held in memory until the + * calculation SUCCEEDED — nothing (loan balances included) is mutated + * before persistence, so a failed employee leaves no partial state behind. + */ + private static class EntryBundle { + PayrollEntry entry; + List components = new ArrayList<>(); + List repayments = new ArrayList<>(); + Map loanNewBalances = new LinkedHashMap<>(); + List reimbursements = new ArrayList<>(); + List adjustments = new ArrayList<>(); + TaxComputation taxComputation; + } + + /** + * Processes a payroll run. The run is row-locked for the duration so two + * concurrent calls serialize; a failure rolls the whole transaction back, + * leaving the run in DRAFT exactly as before the call. Per-employee + * failures do NOT fail the run — they are recorded as + * {@link PayrollEntryError} rows and surfaced in the response message. + */ @Transactional - public String processPayroll(String payrollRunId, String processedByUserId) { - // 1. Get PayrollRun and validate status - PayrollRun run = payrollRunRepository.findById(payrollRunId) + public String processPayroll(String payrollRunId, String instituteId, String processedByUserId) { + PayrollRun run = payrollRunRepository.findByIdAndInstituteIdForUpdate(payrollRunId, instituteId) .orElseThrow(() -> new VacademyException("Payroll run not found")); if (!PayrollStatus.DRAFT.name().equals(run.getStatus())) { throw new VacademyException("Payroll run must be in DRAFT status to process. Current status: " + run.getStatus()); } - // 2. Set status to PROCESSING run.setStatus(PayrollStatus.PROCESSING.name()); run.setProcessedBy(processedByUserId); payrollRunRepository.save(run); - try { - // BUG 3 FIX: Delete existing entries for this payroll run before creating new ones. - // This prevents duplicate entries if re-processing after a previous failure. - cleanupExistingEntries(payrollRunId); + // Re-processing after reject/failure: reverse loan/reimbursement side + // effects and delete prior entries, components, tax computations, errors. + reverseAndDeleteEntries(payrollRunId); - // 3. Get all ACTIVE employees for the institute - List activeStatuses = Arrays.asList("ACTIVE", "PROBATION"); - List employees = employeeProfileRepository.findActiveEmployees( - run.getInstituteId(), activeStatuses); + String runType = run.getRunType() != null ? run.getRunType() : "REGULAR"; + List employees = selectEmployeesForRun(run, runType); - if (employees.isEmpty()) { - throw new VacademyException("No active employees found for the institute"); - } + if (employees.isEmpty()) { + String reason = switch (runType) { + case "FNF" -> " (no employees exiting in this month)"; + case "OFF_CYCLE", "BONUS" -> " (no pending adjustments for this month)"; + default -> ""; + }; + throw new VacademyException("No employees in scope for this " + runType + " run" + reason); + } - // Date range for the payroll month - YearMonth yearMonth = YearMonth.of(run.getYear(), run.getMonth()); - LocalDate monthStart = yearMonth.atDay(1); - LocalDate monthEnd = yearMonth.atEndOfMonth(); - - BigDecimal totalGross = BigDecimal.ZERO; - BigDecimal totalDeductions = BigDecimal.ZERO; - BigDecimal totalNetPay = BigDecimal.ZERO; - BigDecimal totalEmployerCost = BigDecimal.ZERO; - int processedCount = 0; - - // 4. For each employee, calculate payroll - for (EmployeeProfile employee : employees) { - try { - PayrollEntry entry = calculateEmployeePayroll(run, employee, monthStart, monthEnd, yearMonth); - if (entry != null) { - totalGross = totalGross.add(entry.getGrossSalary()); - totalDeductions = totalDeductions.add( - entry.getTotalDeductions() != null ? entry.getTotalDeductions() : BigDecimal.ZERO); - totalNetPay = totalNetPay.add(entry.getNetPay()); - totalEmployerCost = totalEmployerCost.add( - entry.getTotalEmployerContributions() != null - ? entry.getGrossSalary().add(entry.getTotalEmployerContributions()) - : entry.getGrossSalary()); - processedCount++; - } - } catch (Exception e) { - // Log error for this employee but continue processing others - // In production, consider storing these errors in a separate table + YearMonth yearMonth = YearMonth.of(run.getYear(), run.getMonth()); + LocalDate monthStart = yearMonth.atDay(1); + LocalDate monthEnd = yearMonth.atEndOfMonth(); + + // Hoisted per-run context (was fetched per-employee before). + TaxContext taxContext = resolveTaxContext(run); + Set weekdayHolidayDates = resolveWeekdayHolidays(run.getInstituteId(), monthStart, monthEnd); + Map componentCache = new HashMap<>(); + + BigDecimal totalGross = BigDecimal.ZERO; + BigDecimal totalDeductions = BigDecimal.ZERO; + BigDecimal totalNetPay = BigDecimal.ZERO; + BigDecimal totalEmployerCost = BigDecimal.ZERO; + int processedCount = 0; + List errors = new ArrayList<>(); + + for (EmployeeProfile employee : employees) { + try { + EntryBundle bundle = calculateEmployeePayroll(run, employee, monthStart, monthEnd, + yearMonth, weekdayHolidayDates, taxContext, componentCache, runType); + if (bundle == null) { + continue; // no salary structure / not employed this month — deliberately skipped } + persistBundle(bundle); + PayrollEntry entry = bundle.entry; + totalGross = totalGross.add(entry.getGrossSalary()); + totalDeductions = totalDeductions.add(nvl(entry.getTotalDeductions())); + totalNetPay = totalNetPay.add(entry.getNetPay()); + totalEmployerCost = totalEmployerCost.add( + entry.getGrossSalary().add(nvl(entry.getTotalEmployerContributions()))); + processedCount++; + } catch (Exception e) { + log.error("Payroll {}: entry calculation failed for employee {} ({})", + payrollRunId, employee.getId(), employee.getEmployeeCode(), e); + PayrollEntryError error = new PayrollEntryError(); + error.setPayrollRunId(payrollRunId); + error.setEmployeeId(employee.getId()); + error.setErrorStage("CALCULATION"); + error.setErrorMessage(e.getMessage()); + errors.add(error); } + } - // 5. Update PayrollRun totals - run.setTotalEmployees(processedCount); - run.setTotalGross(totalGross); - run.setTotalDeductions(totalDeductions); - run.setTotalNetPay(totalNetPay); - run.setTotalEmployerCost(totalEmployerCost); - run.setStatus(PayrollStatus.PROCESSED.name()); - run.setProcessedAt(LocalDateTime.now()); - payrollRunRepository.save(run); + if (!errors.isEmpty()) { + payrollEntryErrorRepository.saveAll(errors); + } - return run.getId(); + run.setTotalEmployees(processedCount); + run.setTotalGross(totalGross); + run.setTotalDeductions(totalDeductions); + run.setTotalNetPay(totalNetPay); + run.setTotalEmployerCost(totalEmployerCost); + run.setStatus(PayrollStatus.PROCESSED.name()); + run.setProcessedAt(LocalDateTime.now()); + if (run.getCurrency() == null) { + run.setCurrency(payrollCurrencyResolver.resolve(run.getInstituteId())); + } + payrollRunRepository.save(run); + // Phase F5: HR_PAYROLL_PROCESSED workflow trigger (emit-and-forget — a + // workflow failure must never break the payroll processing itself) + try { + Map contextData = new HashMap<>(); + contextData.put("runId", run.getId()); + contextData.put("month", run.getMonth()); + contextData.put("year", run.getYear()); + contextData.put("runType", runType); + contextData.put("totalEmployees", processedCount); + contextData.put("totalNetPay", totalNetPay.toPlainString()); + contextData.put("errorCount", errors.size()); + workflowTriggerService.handleTriggerEvents( + WorkflowTriggerEvent.HR_PAYROLL_PROCESSED.name(), + run.getId(), + run.getInstituteId(), + contextData); } catch (Exception e) { - // If processing fails, revert status to DRAFT - run.setStatus(PayrollStatus.DRAFT.name()); - run.setProcessedBy(null); - payrollRunRepository.save(run); - throw new VacademyException("Payroll processing failed: " + e.getMessage()); + log.warn("Failed to trigger HR_PAYROLL_PROCESSED workflow", e); } + + if (!errors.isEmpty()) { + return run.getId() + " (processed " + processedCount + " employees, " + + errors.size() + " failed — see run errors)"; + } + return run.getId(); } /** - * BUG 3 FIX: Cleanup existing payroll entries and their related records - * for a given payroll run. This ensures re-processing doesn't create duplicates. + * Reverses all financial side effects of a run's entries and deletes them: + * loan balances restored (CLOSED loans reopened), reimbursements unlinked, + * loan repayments / entry components / tax computations / error rows + * deleted. Used before reprocessing, on reject (PROCESSED -> DRAFT), and on + * cancel — so a cancelled run no longer eats EMIs and reimbursements. */ - private void cleanupExistingEntries(String payrollRunId) { + @Transactional + public void reverseAndDeleteEntries(String payrollRunId) { List existingEntries = payrollEntryRepository .findByPayrollRunIdOrderByEmployeeEmployeeCodeAsc(payrollRunId); + PayrollRun run = payrollRunRepository.findById(payrollRunId).orElse(null); + for (PayrollEntry entry : existingEntries) { String entryId = entry.getId(); - // Unlink reimbursements (set payrollEntry to null so they become available again) List linkedReimbursements = reimbursementRepository.findByPayrollEntryId(entryId); for (Reimbursement reimb : linkedReimbursements) { reimb.setPayrollEntry(null); reimbursementRepository.save(reimb); } - // Reverse loan repayments: restore loan balances and statuses List linkedRepayments = loanRepaymentRepository.findByPayrollEntryId(entryId); for (LoanRepayment repayment : linkedRepayments) { EmployeeLoan loan = repayment.getLoan(); @@ -205,51 +303,121 @@ private void cleanupExistingEntries(String payrollRunId) { } } - // Delete loan repayments for this entry loanRepaymentRepository.deleteByPayrollEntryId(entryId); - - // Delete entry components payrollEntryComponentRepository.deleteByPayrollEntryId(entryId); + + if (run != null && entry.getEmployee() != null) { + taxComputationRepository.deleteByEmployee_IdAndMonthAndYear( + entry.getEmployee().getId(), run.getMonth(), run.getYear()); + } } - // Delete the entries themselves if (!existingEntries.isEmpty()) { + List entryIds = existingEntries.stream().map(PayrollEntry::getId).collect(Collectors.toList()); + payrollAdjustmentRepository.unlinkByPayrollEntryIds(entryIds); payrollEntryRepository.deleteAll(existingEntries); } + payrollEntryErrorRepository.deleteByPayrollRunId(payrollRunId); } - private PayrollEntry calculateEmployeePayroll(PayrollRun run, EmployeeProfile employee, - LocalDate monthStart, LocalDate monthEnd, - YearMonth yearMonth) { - // a. Get active salary structure with components - EmployeeSalaryStructure salaryStructure = salaryStructureRepository.findFirstByEmployee_IdAndStatusOrderByEffectiveFromDesc(employee.getId(), "ACTIVE") - .orElse(null); + // ------------------------------------------------------------------ + // Per-run context + // ------------------------------------------------------------------ - if (salaryStructure == null) { - // Skip employees without a salary structure - return null; + private static class TaxContext { + TaxConfiguration config; + TaxRegimeEngine engine; + String financialYear; + int fyStartMonth; + } + + /** + * Resolves tax config + engine once per run. If a tax configuration exists + * but no engine supports its country, the run FAILS loudly — silently + * paying every employee with zero withholding is never acceptable. + */ + private TaxContext resolveTaxContext(PayrollRun run) { + List configs = taxConfigurationRepository + .findAllByInstituteIdAndStatus(run.getInstituteId(), "ACTIVE"); + if (configs.isEmpty()) { + configs = taxConfigurationRepository.findAllByInstituteId(run.getInstituteId()); + } + if (configs.isEmpty()) { + return null; // institute has not configured tax — TDS simply not computed } - // b. Calculate attendance for the month - int totalCalendarDays = yearMonth.lengthOfMonth(); + TaxContext ctx = new TaxContext(); + ctx.config = configs.get(0); + try { + ctx.engine = taxRegimeFactory.getEngine(ctx.config.getCountryCode()); + } catch (Exception e) { + throw new VacademyException("Tax is configured for country " + ctx.config.getCountryCode() + + " but no tax engine supports it: " + e.getMessage()); + } + ctx.fyStartMonth = ctx.config.getFinancialYearStartMonth() != null + ? ctx.config.getFinancialYearStartMonth() : 4; + ctx.financialYear = getFinancialYear(run.getMonth(), run.getYear(), ctx.fyStartMonth); + return ctx; + } - // Count weekends (Saturdays and Sundays) - int weekends = 0; - for (LocalDate date = monthStart; !date.isAfter(monthEnd); date = date.plusDays(1)) { - DayOfWeek dayOfWeek = date.getDayOfWeek(); - if (dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY) { - weekends++; + /** V144 requires component_id NOT NULL — system rows (TDS/PF/ESI/PT/adjustments) need real components. */ + private SalaryComponent getOrCreateSystemComponent(Map cache, String instituteId, + String code, String name, String type) { + return cache.computeIfAbsent(code, c -> + salaryComponentRepository.findByInstituteIdAndCode(instituteId, c) + .orElseGet(() -> { + SalaryComponent comp = new SalaryComponent(); + comp.setInstituteId(instituteId); + comp.setName(name); + comp.setCode(c); + comp.setType(type); + comp.setCategory("STATUTORY"); + comp.setIsTaxable(false); + comp.setIsStatutory(true); + comp.setIsActive(true); + comp.setDisplayOrder(100); + comp.setDescription("System component: " + name); + return salaryComponentRepository.save(comp); + })); + } + + /** + * Who a run pays. REGULAR: everyone employed (incl. notice period). FNF: + * exactly the employees whose last working date falls in the month, any + * status. OFF_CYCLE/BONUS: exactly the employees holding unconsumed + * adjustments scoped to that run type for the month. + */ + private List selectEmployeesForRun(PayrollRun run, String runType) { + YearMonth ym = YearMonth.of(run.getYear(), run.getMonth()); + switch (runType) { + case "FNF": { + List statuses = Arrays.asList( + "ACTIVE", "PROBATION", "NOTICE_PERIOD", "RELIEVED", "TERMINATED"); + return employeeProfileRepository.findActiveEmployees(run.getInstituteId(), statuses).stream() + .filter(e -> e.getLastWorkingDate() != null + && !e.getLastWorkingDate().isBefore(ym.atDay(1)) + && !e.getLastWorkingDate().isAfter(ym.atEndOfMonth())) + .collect(Collectors.toList()); + } + case "OFF_CYCLE": + case "BONUS": { + List employeeIds = payrollAdjustmentRepository.findEmployeeIdsWithPendingAdjustments( + run.getInstituteId(), run.getYear(), run.getMonth(), runType); + return employeeIds.isEmpty() ? List.of() + : employeeProfileRepository.findAllById(employeeIds).stream() + .filter(e -> run.getInstituteId().equals(e.getInstituteId())) + .collect(Collectors.toList()); } + default: + return employeeProfileRepository.findActiveEmployees( + run.getInstituteId(), Arrays.asList("ACTIVE", "PROBATION", "NOTICE_PERIOD")); } + } - // BUG 1 FIX: Fetch holiday list and only count holidays that fall on weekdays. - // Previously, countMandatoryHolidays counted ALL holidays including those on weekends, - // leading to double-subtraction since weekends were already excluded. + private Set resolveWeekdayHolidays(String instituteId, LocalDate monthStart, LocalDate monthEnd) { List mandatoryHolidays = holidayRepository.findByInstituteIdAndDateRange( - run.getInstituteId(), monthStart, monthEnd); - - // Filter: only mandatory holidays that fall on weekdays - Set weekdayHolidayDates = mandatoryHolidays.stream() + instituteId, monthStart, monthEnd); + return mandatoryHolidays.stream() .filter(h -> h.getIsOptional() == null || !h.getIsOptional()) .filter(h -> { DayOfWeek dow = h.getDate().getDayOfWeek(); @@ -257,29 +425,68 @@ private PayrollEntry calculateEmployeePayroll(PayrollRun run, EmployeeProfile em }) .map(Holiday::getDate) .collect(Collectors.toSet()); + } - int daysHoliday = weekdayHolidayDates.size(); + // ------------------------------------------------------------------ + // Per-employee calculation (no persistence, no side effects) + // ------------------------------------------------------------------ + + private EntryBundle calculateEmployeePayroll(PayrollRun run, EmployeeProfile employee, + LocalDate monthStart, LocalDate monthEnd, + YearMonth yearMonth, + Set weekdayHolidayDates, + TaxContext taxContext, + Map componentCache, + String runType) { + // OFF_CYCLE/BONUS runs pay exactly the pending adjustments — no base salary. + if ("OFF_CYCLE".equals(runType) || "BONUS".equals(runType)) { + return calculateAdjustmentsOnlyEntry(run, employee, runType, taxContext, componentCache); + } - // Total working days = calendar days - weekends - weekday holidays - int totalWorkingDays = totalCalendarDays - weekends - daysHoliday; - if (totalWorkingDays <= 0) { - totalWorkingDays = 1; // Prevent division by zero + // Employment window: joiners/leavers are paid only for their employed + // slice of the month; employees fully outside the month are skipped. + LocalDate empStart = employee.getJoinDate() != null ? employee.getJoinDate() : monthStart; + LocalDate empEnd = employee.getLastWorkingDate() != null ? employee.getLastWorkingDate() : monthEnd; + if (empStart.isAfter(monthEnd) || empEnd.isBefore(monthStart)) { + return null; } + LocalDate windowStart = empStart.isAfter(monthStart) ? empStart : monthStart; + LocalDate windowEnd = empEnd.isBefore(monthEnd) ? empEnd : monthEnd; + + // a. Salary structure effective for THIS period (was: latest ACTIVE regardless of dates). + List structures = salaryStructureRepository + .findByEmployeeIdOrderByEffectiveFromDesc(employee.getId()); + EmployeeSalaryStructure salaryStructure = selectStructureFor(structures, monthStart, monthEnd); + if (salaryStructure == null) { + return null; // skip employees without an applicable salary structure + } + + // b. Working-day math + int totalCalendarDays = yearMonth.lengthOfMonth(); + int weekends = 0; + for (LocalDate date = monthStart; !date.isAfter(monthEnd); date = date.plusDays(1)) { + DayOfWeek dow = date.getDayOfWeek(); + if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) weekends++; + } + int daysHoliday = weekdayHolidayDates.size(); + int totalWorkingDays = totalCalendarDays - weekends - daysHoliday; + if (totalWorkingDays <= 0) totalWorkingDays = 1; + + long workingDaysInWindow = windowStart.datesUntil(windowEnd.plusDays(1)) + .filter(d -> isWorkingDay(d, weekdayHolidayDates)) + .count(); - // Count present days from attendance records long presentCount = attendanceRecordRepository.countByEmployeeAndDateRangeAndStatus( employee.getId(), monthStart, monthEnd, "PRESENT"); long halfDayCount = attendanceRecordRepository.countByEmployeeAndDateRangeAndStatus( employee.getId(), monthStart, monthEnd, "HALF_DAY"); - // BUG 4 FIX: If no attendance records exist at all for the month, assume full attendance. - // This handles new joiners or employees where attendance tracking hasn't started yet. List allAttendanceRecords = attendanceRecordRepository .findByEmployeeIdAndAttendanceDateBetweenOrderByAttendanceDateAsc( employee.getId(), monthStart, monthEnd); boolean hasAttendanceRecords = !allAttendanceRecords.isEmpty(); - // Count approved leaves in the month + // Approved leaves, clipped to both the month and the employment window List approvedLeaves = leaveApplicationRepository.findApprovedLeavesInRange( employee.getId(), monthStart, monthEnd); @@ -288,121 +495,94 @@ private PayrollEntry calculateEmployeePayroll(PayrollRun run, EmployeeProfile em BigDecimal totalLeaveDays = BigDecimal.ZERO; for (LeaveApplication leave : approvedLeaves) { - // Calculate overlapping days between leave period and payroll month - LocalDate leaveStart = leave.getFromDate().isBefore(monthStart) ? monthStart : leave.getFromDate(); - LocalDate leaveEnd = leave.getToDate().isAfter(monthEnd) ? monthEnd : leave.getToDate(); + LocalDate leaveStart = maxDate(leave.getFromDate(), windowStart); + LocalDate leaveEnd = minDate(leave.getToDate(), windowEnd); + if (leaveStart.isAfter(leaveEnd)) continue; - // BUG 2 FIX: Only count leave days that fall on weekdays (Mon-Fri) and are not holidays. - // Previously, all calendar days between leaveStart and leaveEnd were counted, - // which inflated leave counts by including weekends. - long leaveDaysInMonth; + boolean isPaid = leave.getLeaveType() != null + && leave.getLeaveType().getIsPaid() != null + && leave.getLeaveType().getIsPaid(); + BigDecimal days; if (leave.getIsHalfDay() != null && leave.getIsHalfDay()) { - // Half day leave: count as 0.5 only if the day is a working day - DayOfWeek dow = leaveStart.getDayOfWeek(); - boolean isWorkingDay = dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY - && !weekdayHolidayDates.contains(leaveStart); - if (isWorkingDay) { - BigDecimal halfDay = new BigDecimal("0.5"); - totalLeaveDays = totalLeaveDays.add(halfDay); - - boolean isPaid = leave.getLeaveType() != null - && leave.getLeaveType().getIsPaid() != null - && leave.getLeaveType().getIsPaid(); - if (isPaid) { - paidLeaveDays = paidLeaveDays.add(halfDay); - } else { - unpaidLeaveDays = unpaidLeaveDays.add(halfDay); - } - } + days = isWorkingDay(leaveStart, weekdayHolidayDates) ? new BigDecimal("0.5") : BigDecimal.ZERO; } else { - // Full day leave: count only weekdays that are not holidays - final LocalDate finalLeaveEnd = leaveEnd; - leaveDaysInMonth = leaveStart.datesUntil(finalLeaveEnd.plusDays(1)) - .filter(d -> { - DayOfWeek dow = d.getDayOfWeek(); - return dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY - && !weekdayHolidayDates.contains(d); - }) + long full = leaveStart.datesUntil(leaveEnd.plusDays(1)) + .filter(d -> isWorkingDay(d, weekdayHolidayDates)) .count(); - BigDecimal daysInMonth = new BigDecimal(leaveDaysInMonth); - - totalLeaveDays = totalLeaveDays.add(daysInMonth); - - boolean isPaid = leave.getLeaveType() != null - && leave.getLeaveType().getIsPaid() != null - && leave.getLeaveType().getIsPaid(); - - if (isPaid) { - paidLeaveDays = paidLeaveDays.add(daysInMonth); - } else { - unpaidLeaveDays = unpaidLeaveDays.add(daysInMonth); - } + days = new BigDecimal(full); } + if (days.signum() <= 0) continue; + + totalLeaveDays = totalLeaveDays.add(days); + if (isPaid) paidLeaveDays = paidLeaveDays.add(days); + else unpaidLeaveDays = unpaidLeaveDays.add(days); } + BigDecimal windowWorkingDaysBD = new BigDecimal(workingDaysInWindow); BigDecimal daysPresent; BigDecimal daysAbsent; if (hasAttendanceRecords) { daysPresent = new BigDecimal(presentCount) .add(new BigDecimal(halfDayCount).multiply(new BigDecimal("0.5"))); - daysAbsent = new BigDecimal(totalWorkingDays) - .subtract(daysPresent) - .subtract(totalLeaveDays); + daysAbsent = windowWorkingDaysBD.subtract(daysPresent).subtract(totalLeaveDays); } else { - // BUG 4 FIX: No attendance records — assume full attendance - daysPresent = new BigDecimal(totalWorkingDays).subtract(totalLeaveDays); + // No attendance records at all — assume full attendance within the employment window. + daysPresent = windowWorkingDaysBD.subtract(totalLeaveDays); daysAbsent = BigDecimal.ZERO; } + if (daysPresent.signum() < 0) daysPresent = BigDecimal.ZERO; + if (daysAbsent.signum() < 0) daysAbsent = BigDecimal.ZERO; - // Ensure daysAbsent is not negative - if (daysAbsent.compareTo(BigDecimal.ZERO) < 0) { - daysAbsent = BigDecimal.ZERO; - } - - // Effective paid days = present days + paid leave days BigDecimal effectivePaidDays = daysPresent.add(paidLeaveDays); - // c. Pro-rate salary based on attendance + // c. Pro-rate against the FULL month's working days — the employment + // window shrinks the numerator, so mid-month joiners/leavers prorate. BigDecimal grossMonthly = salaryStructure.getGrossMonthly(); if (grossMonthly == null) { grossMonthly = salaryStructure.getCtcMonthly() != null ? salaryStructure.getCtcMonthly() : BigDecimal.ZERO; } - BigDecimal proRateFactor; BigDecimal totalWorkingDaysBD = new BigDecimal(totalWorkingDays); - - if (effectivePaidDays.compareTo(totalWorkingDaysBD) >= 0) { - proRateFactor = BigDecimal.ONE; - } else { - proRateFactor = effectivePaidDays.divide(totalWorkingDaysBD, 6, RoundingMode.HALF_UP); - } + BigDecimal proRateFactor = effectivePaidDays.compareTo(totalWorkingDaysBD) >= 0 + ? BigDecimal.ONE + : effectivePaidDays.divide(totalWorkingDaysBD, 6, RoundingMode.HALF_UP); BigDecimal grossForMonth = grossMonthly.multiply(proRateFactor).setScale(2, RoundingMode.HALF_UP); - // d. Calculate each component proportionally + // d. Components proportionally (+ discover BASIC and HRA for the tax engine) + EntryBundle bundle = new EntryBundle(); BigDecimal totalEarnings = BigDecimal.ZERO; BigDecimal totalDeductionsAmount = BigDecimal.ZERO; BigDecimal totalEmployerContributions = BigDecimal.ZERO; - - List entryComponents = new ArrayList<>(); + BigDecimal basicMonthlyFull = BigDecimal.ZERO; + BigDecimal hraAnnual = BigDecimal.ZERO; + Set structureComponentCodes = new HashSet<>(); if (salaryStructure.getComponents() != null) { for (EmployeeSalaryComponent salComp : salaryStructure.getComponents()) { + SalaryComponent def = salComp.getComponent(); + String code = def.getCode() != null ? def.getCode().toUpperCase() : ""; + structureComponentCodes.add(code); + if ("BASIC".equals(code)) basicMonthlyFull = nvl(salComp.getMonthlyAmount()); + if ("HRA".equals(code)) { + hraAnnual = salComp.getAnnualAmount() != null + ? salComp.getAnnualAmount() + : nvl(salComp.getMonthlyAmount()).multiply(new BigDecimal("12")); + } + BigDecimal componentAmount = salComp.getMonthlyAmount() .multiply(proRateFactor) .setScale(2, RoundingMode.HALF_UP); PayrollEntryComponent entryComp = new PayrollEntryComponent(); - entryComp.setComponent(salComp.getComponent()); - entryComp.setComponentType(salComp.getComponent().getType()); + entryComp.setComponent(def); + entryComp.setComponentType(def.getType()); entryComp.setAmount(componentAmount); + bundle.components.add(entryComp); - entryComponents.add(entryComp); - - // Categorize by component type - String compType = salComp.getComponent().getType(); + String compType = def.getType(); if (ComponentType.EARNING.name().equals(compType)) { totalEarnings = totalEarnings.add(componentAmount); } else if (ComponentType.DEDUCTION.name().equals(compType)) { @@ -412,96 +592,126 @@ private PayrollEntry calculateEmployeePayroll(PayrollRun run, EmployeeProfile em } } } + if (basicMonthlyFull.signum() == 0) { + // No BASIC component defined — fall back to the common 50%-of-gross heuristic. + basicMonthlyFull = grossMonthly.multiply(new BigDecimal("0.5")); + } + BigDecimal basicForMonth = basicMonthlyFull.multiply(proRateFactor).setScale(2, RoundingMode.HALF_UP); + + // Variable-pay adjustments (V482) scoped to this run type — bonuses, + // incentives, recoveries, F&F encashment. Materialized as components + // and taxed via the true-up (earnings raise this month's taxable gross). + BigDecimal adjEarnings = BigDecimal.ZERO; + BigDecimal adjDeductions = BigDecimal.ZERO; + List adjustments = payrollAdjustmentRepository + .findByEmployeeIdAndYearAndMonthAndRunScopeAndPayrollEntryIdIsNull( + employee.getId(), run.getYear(), run.getMonth(), runType); + for (PayrollAdjustment adj : adjustments) { + boolean earning = "EARNING".equals(adj.getType()); + PayrollEntryComponent comp = new PayrollEntryComponent(); + comp.setComponent(getOrCreateSystemComponent(componentCache, run.getInstituteId(), + adj.getCode(), adj.getLabel(), + earning ? ComponentType.EARNING.name() : ComponentType.DEDUCTION.name())); + comp.setComponentType(earning ? ComponentType.EARNING.name() : ComponentType.DEDUCTION.name()); + comp.setAmount(adj.getAmount()); + bundle.components.add(comp); + if (earning) adjEarnings = adjEarnings.add(adj.getAmount()); + else adjDeductions = adjDeductions.add(adj.getAmount()); + } + bundle.adjustments.addAll(adjustments); + BigDecimal taxableGrossForMonth = grossForMonth.add(adjEarnings); - // --- Tax Engine Integration: compute TDS (income tax) --- + // --- Tax + statutory via the per-run engine. A failure here fails THIS + // employee (recorded as an error row) — never silently paid untaxed. BigDecimal tdsAmount = BigDecimal.ZERO; - Optional taxConfigOpt = taxConfigurationRepository.findByInstituteId(run.getInstituteId()); - if (taxConfigOpt.isPresent()) { - TaxConfiguration taxConfig = taxConfigOpt.get(); - try { - TaxRegimeEngine taxEngine = taxRegimeFactory.getEngine(taxConfig.getCountryCode()); - - // Build declarations map from employee's tax declaration for this financial year - Map declarations = new HashMap<>(); - String financialYear = getFinancialYear(run.getMonth(), run.getYear()); - Optional declOpt = taxDeclarationRepository.findByEmployee_IdAndFinancialYear( - employee.getId(), financialYear); - if (declOpt.isPresent() && declOpt.get().getDeclarations() != null) { - declarations = declOpt.get().getDeclarations(); - } - - // Project annual taxable income from this month's gross - BigDecimal projectedAnnualIncome = grossForMonth.multiply(new BigDecimal("12")); - - // Calculate monthly TDS using the tax engine - Map taxRules = taxConfig.getTaxRules() != null ? taxConfig.getTaxRules() : new HashMap<>(); - tdsAmount = taxEngine.calculateMonthlyTax(projectedAnnualIncome, declarations, taxRules); - - // Add TDS as a deduction component - if (tdsAmount.compareTo(BigDecimal.ZERO) > 0) { - totalDeductionsAmount = totalDeductionsAmount.add(tdsAmount); + if (taxContext != null) { + TaxInput taxInput = buildTaxInput(run, employee, taxContext, structures, + taxableGrossForMonth, grossMonthly, basicForMonth, basicMonthlyFull, hraAnnual); + + TaxResult taxResult = taxContext.engine.calculateMonthlyTax(taxInput); + tdsAmount = nvl(taxResult.getMonthlyTax()); + + if (tdsAmount.signum() > 0) { + totalDeductionsAmount = totalDeductionsAmount.add(tdsAmount); + PayrollEntryComponent tdsComponent = new PayrollEntryComponent(); + tdsComponent.setComponent(getOrCreateSystemComponent(componentCache, run.getInstituteId(), + "TDS", "Income Tax (TDS)", ComponentType.DEDUCTION.name())); + tdsComponent.setComponentType(ComponentType.DEDUCTION.name()); + tdsComponent.setAmount(tdsAmount); + bundle.components.add(tdsComponent); + } - // Create TDS payroll entry component (no linked SalaryComponent -- system-generated) - PayrollEntryComponent tdsComponent = new PayrollEntryComponent(); - tdsComponent.setComponentType("DEDUCTION"); - tdsComponent.setAmount(tdsAmount); - entryComponents.add(tdsComponent); + // Statutory (PF/ESI/PT) — engine amounts, unless the salary template + // already carries the scheme as a component (no double deduction). + for (StatutoryItem item : taxContext.engine.calculateStatutory(taxInput)) { + Set aliases = STATUTORY_ALIASES.getOrDefault(item.getCode(), Set.of(item.getCode())); + if (aliases.stream().anyMatch(structureComponentCodes::contains)) { + continue; } - - // Save tax computation record for audit trail - BigDecimal total80C = BigDecimal.ZERO; - if (declOpt.isPresent() && declOpt.get().getDeclarations() != null) { - Map rawDecl = declOpt.get().getDeclarations(); - for (String key : new String[]{"section_80c", "80c", "ppf", "elss", "life_insurance", - "nsc", "tuition_fees", "fixed_deposit_5yr", "sukanya_samriddhi", "employee_pf_contribution"}) { - Object val = rawDecl.get(key); - if (val instanceof Number) { - total80C = total80C.add(new BigDecimal(val.toString())); - } - } + BigDecimal employeeAmt = nvl(item.getEmployeeMonthly()); + if (employeeAmt.signum() > 0) { + PayrollEntryComponent comp = new PayrollEntryComponent(); + comp.setComponent(getOrCreateSystemComponent(componentCache, run.getInstituteId(), + item.getCode(), item.getName(), ComponentType.DEDUCTION.name())); + comp.setComponentType(ComponentType.DEDUCTION.name()); + comp.setAmount(employeeAmt); + bundle.components.add(comp); + totalDeductionsAmount = totalDeductionsAmount.add(employeeAmt); + } + BigDecimal employerAmt = nvl(item.getEmployerMonthly()); + if (employerAmt.signum() > 0) { + PayrollEntryComponent comp = new PayrollEntryComponent(); + comp.setComponent(getOrCreateSystemComponent(componentCache, run.getInstituteId(), + item.getCode() + "_ER", item.getName() + " (Employer)", + ComponentType.EMPLOYER_CONTRIBUTION.name())); + comp.setComponentType(ComponentType.EMPLOYER_CONTRIBUTION.name()); + comp.setAmount(employerAmt); + bundle.components.add(comp); + totalEmployerContributions = totalEmployerContributions.add(employerAmt); } - - TaxComputation computation = new TaxComputation(); - computation.setEmployee(employee); - computation.setFinancialYear(financialYear); - computation.setMonth(run.getMonth()); - computation.setYear(run.getYear()); - computation.setProjectedAnnualIncome(projectedAnnualIncome); - computation.setProjectedAnnualTax(tdsAmount.multiply(new BigDecimal("12"))); - computation.setProjectedMonthlyTax(tdsAmount); - computation.setActualIncomeTillDate(grossForMonth); - computation.setActualTaxDeducted(tdsAmount); - computation.setTotalDeductions80c(total80C); - taxComputationRepository.save(computation); - - } catch (Exception e) { - // Log but don't fail payroll if tax engine is not configured for this country. - // Tax will need manual handling in such cases. } + + // Cumulative audit row (upsert under the V480 unique). + BigDecimal ytdIncomeAfter = nvl(taxInput.getYtdTaxableIncome()).add(taxableGrossForMonth); + BigDecimal ytdTaxAfter = nvl(taxInput.getYtdTaxDeducted()).add(tdsAmount); + TaxComputation computation = taxComputationRepository + .findByEmployee_IdAndFinancialYearAndMonthAndYear( + employee.getId(), taxContext.financialYear, run.getMonth(), run.getYear()) + .orElseGet(TaxComputation::new); + computation.setEmployee(employee); + computation.setFinancialYear(taxContext.financialYear); + computation.setMonth(run.getMonth()); + computation.setYear(run.getYear()); + computation.setProjectedAnnualIncome(taxResult.getProjectedAnnualGross()); + computation.setProjectedAnnualTax(taxResult.getProjectedAnnualTax()); + computation.setProjectedMonthlyTax(tdsAmount); + computation.setActualIncomeTillDate(ytdIncomeAfter); + computation.setActualTaxDeducted(ytdTaxAfter); + computation.setTotalExemptions(taxResult.getTotalExemptions()); + computation.setTotalDeductions80c(extract80c(taxResult)); + computation.setComputationDetails(taxResult.getBreakdown()); + bundle.taxComputation = computation; } - // e. Get approved unpaid reimbursements, add to earnings + // e. Approved unpaid reimbursements List unpaidReimbursements = reimbursementRepository.findApprovedUnpaid(employee.getId()); BigDecimal reimbursementTotal = BigDecimal.ZERO; for (Reimbursement reimb : unpaidReimbursements) { reimbursementTotal = reimbursementTotal.add(reimb.getAmount()); } + bundle.reimbursements.addAll(unpaidReimbursements); - // f. Get active loans, deduct EMI + // f. Loan EMIs — planned only; balances are mutated at persist time. List activeLoans = employeeLoanRepository.findActiveLoans(employee.getId()); BigDecimal loanDeduction = BigDecimal.ZERO; - List repayments = new ArrayList<>(); for (EmployeeLoan loan : activeLoans) { BigDecimal emi = loan.getEmiAmount(); BigDecimal balance = loan.getBalanceAmount() != null ? loan.getBalanceAmount() : BigDecimal.ZERO; - - if (balance.compareTo(BigDecimal.ZERO) > 0) { - // Deduct the lesser of EMI or remaining balance + if (balance.signum() > 0) { BigDecimal deductAmount = emi.min(balance); loanDeduction = loanDeduction.add(deductAmount); - // Create repayment record LoanRepayment repayment = new LoanRepayment(); repayment.setLoan(loan); repayment.setAmount(deductAmount); @@ -509,35 +719,20 @@ private PayrollEntry calculateEmployeePayroll(PayrollRun run, EmployeeProfile em repayment.setMonth(run.getMonth()); repayment.setYear(run.getYear()); repayment.setBalanceAfter(balance.subtract(deductAmount)); - repayments.add(repayment); - - // Update loan balance - loan.setBalanceAmount(balance.subtract(deductAmount)); - if (loan.getBalanceAmount().compareTo(BigDecimal.ZERO) <= 0) { - loan.setStatus("CLOSED"); - loan.setBalanceAmount(BigDecimal.ZERO); - } - employeeLoanRepository.save(loan); + bundle.repayments.add(repayment); + bundle.loanNewBalances.put(loan, balance.subtract(deductAmount)); } } - // BUG 5 FIX: Calculate overtime pay from overtime hours. - // Overtime hours are tracked in attendance records but previously had no financial impact. - // Overtime rate = (grossMonthly / totalWorkingDays / 8) * 1.5 * overtimeHours + // Overtime BigDecimal overtimeHours = BigDecimal.ZERO; - List attendanceRecords = hasAttendanceRecords ? allAttendanceRecords - : attendanceRecordRepository.findByEmployeeIdAndAttendanceDateBetweenOrderByAttendanceDateAsc( - employee.getId(), monthStart, monthEnd); - for (AttendanceRecord record : attendanceRecords) { + for (AttendanceRecord record : allAttendanceRecords) { if (record.getOvertimeHours() != null) { overtimeHours = overtimeHours.add(record.getOvertimeHours()); } } - BigDecimal overtimePay = BigDecimal.ZERO; - if (overtimeHours.compareTo(BigDecimal.ZERO) > 0) { - // Hourly rate = grossMonthly / totalWorkingDays / 8 - // Overtime pay = hourlyRate * 1.5 * overtimeHours + if (overtimeHours.signum() > 0) { BigDecimal hourlyRate = grossMonthly .divide(totalWorkingDaysBD, 6, RoundingMode.HALF_UP) .divide(new BigDecimal("8"), 6, RoundingMode.HALF_UP); @@ -547,20 +742,18 @@ private PayrollEntry calculateEmployeePayroll(PayrollRun run, EmployeeProfile em .setScale(2, RoundingMode.HALF_UP); } - // g. Calculate net pay (now includes overtime pay) - BigDecimal otherEarnings = overtimePay; + // g. Net pay + BigDecimal otherEarnings = overtimePay.add(adjEarnings); + BigDecimal otherDeductions = adjDeductions; BigDecimal netPay = totalEarnings .add(reimbursementTotal) .add(otherEarnings) .subtract(totalDeductionsAmount) + .subtract(otherDeductions) .subtract(loanDeduction); + if (netPay.signum() < 0) netPay = BigDecimal.ZERO; - // Ensure net pay is not negative - if (netPay.compareTo(BigDecimal.ZERO) < 0) { - netPay = BigDecimal.ZERO; - } - - // h. Create PayrollEntry + // h. Entry PayrollEntry entry = new PayrollEntry(); entry.setPayrollRun(run); entry.setEmployee(employee); @@ -580,49 +773,310 @@ private PayrollEntry calculateEmployeePayroll(PayrollRun run, EmployeeProfile em entry.setReimbursements(reimbursementTotal); entry.setLoanDeduction(loanDeduction); entry.setOtherEarnings(otherEarnings); - entry.setOtherDeductions(BigDecimal.ZERO); + entry.setOtherDeductions(otherDeductions); + entry.setStatus(PayrollEntryStatus.CALCULATED.name()); + entry.setCurrency(salaryStructure.getCurrency() != null ? salaryStructure.getCurrency() : DEFAULT_CURRENCY); + + EmployeeBankDetail primaryBank = bankDetailRepository.findByEmployeeIdAndIsPrimaryTrue(employee.getId()) + .orElse(null); + if (primaryBank != null) { + entry.setBankAccount(primaryBank); + } + + bundle.entry = entry; + return bundle; + } + + /** + * OFF_CYCLE/BONUS entry: pays exactly the pending adjustments — no base + * salary, attendance, loans, reimbursements, or statutory. Income tax + * still applies to the earnings via the YTD true-up, so a bonus run is + * withheld correctly and recorded in the FY's cumulative computation. + */ + private EntryBundle calculateAdjustmentsOnlyEntry(PayrollRun run, EmployeeProfile employee, + String runType, TaxContext taxContext, + Map componentCache) { + List adjustments = payrollAdjustmentRepository + .findByEmployeeIdAndYearAndMonthAndRunScopeAndPayrollEntryIdIsNull( + employee.getId(), run.getYear(), run.getMonth(), runType); + if (adjustments.isEmpty()) { + return null; + } + + EntryBundle bundle = new EntryBundle(); + BigDecimal adjEarnings = BigDecimal.ZERO; + BigDecimal adjDeductions = BigDecimal.ZERO; + for (PayrollAdjustment adj : adjustments) { + boolean earning = "EARNING".equals(adj.getType()); + PayrollEntryComponent comp = new PayrollEntryComponent(); + comp.setComponent(getOrCreateSystemComponent(componentCache, run.getInstituteId(), + adj.getCode(), adj.getLabel(), + earning ? ComponentType.EARNING.name() : ComponentType.DEDUCTION.name())); + comp.setComponentType(earning ? ComponentType.EARNING.name() : ComponentType.DEDUCTION.name()); + comp.setAmount(adj.getAmount()); + bundle.components.add(comp); + if (earning) adjEarnings = adjEarnings.add(adj.getAmount()); + else adjDeductions = adjDeductions.add(adj.getAmount()); + } + bundle.adjustments.addAll(adjustments); + + BigDecimal tdsAmount = BigDecimal.ZERO; + if (taxContext != null && adjEarnings.signum() > 0) { + TaxInput taxInput = buildTaxInput(run, employee, taxContext, List.of(), + adjEarnings, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO); + TaxResult taxResult = taxContext.engine.calculateMonthlyTax(taxInput); + tdsAmount = nvl(taxResult.getMonthlyTax()); + if (tdsAmount.signum() > 0) { + PayrollEntryComponent tdsComponent = new PayrollEntryComponent(); + tdsComponent.setComponent(getOrCreateSystemComponent(componentCache, run.getInstituteId(), + "TDS", "Income Tax (TDS)", ComponentType.DEDUCTION.name())); + tdsComponent.setComponentType(ComponentType.DEDUCTION.name()); + tdsComponent.setAmount(tdsAmount); + bundle.components.add(tdsComponent); + } + TaxComputation computation = taxComputationRepository + .findByEmployee_IdAndFinancialYearAndMonthAndYear( + employee.getId(), taxContext.financialYear, run.getMonth(), run.getYear()) + .orElseGet(TaxComputation::new); + computation.setEmployee(employee); + computation.setFinancialYear(taxContext.financialYear); + computation.setMonth(run.getMonth()); + computation.setYear(run.getYear()); + computation.setProjectedAnnualIncome(taxResult.getProjectedAnnualGross()); + computation.setProjectedAnnualTax(taxResult.getProjectedAnnualTax()); + computation.setProjectedMonthlyTax(tdsAmount); + computation.setActualIncomeTillDate(nvl(taxInput.getYtdTaxableIncome()).add(adjEarnings)); + computation.setActualTaxDeducted(nvl(taxInput.getYtdTaxDeducted()).add(tdsAmount)); + computation.setTotalExemptions(taxResult.getTotalExemptions()); + computation.setComputationDetails(taxResult.getBreakdown()); + bundle.taxComputation = computation; + } + + BigDecimal netPay = adjEarnings.subtract(adjDeductions).subtract(tdsAmount); + if (netPay.signum() < 0) netPay = BigDecimal.ZERO; + + PayrollEntry entry = new PayrollEntry(); + entry.setPayrollRun(run); + entry.setEmployee(employee); + entry.setGrossSalary(adjEarnings); + entry.setTotalEarnings(BigDecimal.ZERO); + entry.setTotalDeductions(tdsAmount); + entry.setTotalEmployerContributions(BigDecimal.ZERO); + entry.setNetPay(netPay); + entry.setTotalWorkingDays(0); + entry.setDaysPresent(BigDecimal.ZERO); + entry.setDaysAbsent(BigDecimal.ZERO); + entry.setDaysOnLeave(BigDecimal.ZERO); + entry.setDaysHoliday(0); + entry.setOvertimeHours(BigDecimal.ZERO); + entry.setArrears(BigDecimal.ZERO); + entry.setReimbursements(BigDecimal.ZERO); + entry.setLoanDeduction(BigDecimal.ZERO); + entry.setOtherEarnings(adjEarnings); + entry.setOtherDeductions(adjDeductions); entry.setStatus(PayrollEntryStatus.CALCULATED.name()); + entry.setCurrency(adjustments.get(0).getCurrency() != null + ? adjustments.get(0).getCurrency() : DEFAULT_CURRENCY); - // i. Set primary bank account EmployeeBankDetail primaryBank = bankDetailRepository.findByEmployeeIdAndIsPrimaryTrue(employee.getId()) .orElse(null); if (primaryBank != null) { entry.setBankAccount(primaryBank); } - entry = payrollEntryRepository.save(entry); + bundle.entry = entry; + return bundle; + } + + // ------------------------------------------------------------------ + // Tax input assembly + // ------------------------------------------------------------------ + + private TaxInput buildTaxInput(PayrollRun run, EmployeeProfile employee, TaxContext ctx, + List structures, + BigDecimal grossForMonth, BigDecimal grossMonthlyFull, + BigDecimal basicForMonth, BigDecimal basicMonthlyFull, + BigDecimal hraAnnual) { + // Regime + declarations, gated by verification policy: VERIFIED always + // counts; SUBMITTED counts only until the proof cutoff (Jan-Mar of the + // FY require VERIFIED — the standard Indian payroll control). + String regime = null; + Map declarations = Map.of(); + Optional declOpt = taxDeclarationRepository.findByEmployee_IdAndFinancialYear( + employee.getId(), ctx.financialYear); + if (declOpt.isPresent()) { + TaxDeclaration decl = declOpt.get(); + regime = decl.getRegime(); + boolean verified = "VERIFIED".equals(decl.getStatus()) || "LOCKED".equals(decl.getStatus()); + boolean beforeCutoff = run.getMonth() >= 4; // Apr-Dec + if (decl.getDeclarations() != null && (verified || beforeCutoff)) { + declarations = decl.getDeclarations(); + } + } + + // YTD from the cumulative audit trail: the latest FY row before this month. + BigDecimal ytdIncome = BigDecimal.ZERO; + BigDecimal ytdTax = BigDecimal.ZERO; + List fyRows = taxComputationRepository + .findByEmployee_IdAndFinancialYearOrderByMonthAsc(employee.getId(), ctx.financialYear); + TaxComputation latestPrior = null; + int currentPos = fyMonthPosition(run.getMonth(), ctx.fyStartMonth); + for (TaxComputation row : fyRows) { + int rowPos = fyMonthPosition(row.getMonth(), ctx.fyStartMonth); + if (rowPos < currentPos && (latestPrior == null + || rowPos > fyMonthPosition(latestPrior.getMonth(), ctx.fyStartMonth))) { + latestPrior = row; + } + } + if (latestPrior != null) { + ytdIncome = nvl(latestPrior.getActualIncomeTillDate()); + ytdTax = nvl(latestPrior.getActualTaxDeducted()); + } + + int monthsRemainingAfterCurrent = 12 - fyMonthPosition(run.getMonth(), ctx.fyStartMonth); + + // ESI stickiness: gross at the start of the current Apr-Sep / Oct-Mar period. + BigDecimal esiGrossAtPeriodStart = null; + LocalDate periodStart = esiPeriodStart(run.getMonth(), run.getYear()); + EmployeeSalaryStructure periodStructure = selectStructureFor(structures, periodStart, periodStart); + if (periodStructure != null) { + esiGrossAtPeriodStart = periodStructure.getGrossMonthly() != null + ? periodStructure.getGrossMonthly() : periodStructure.getCtcMonthly(); + } + + return TaxInput.builder() + .financialYear(ctx.financialYear) + .month(run.getMonth()) + .year(run.getYear()) + .monthsRemainingAfterCurrent(monthsRemainingAfterCurrent) + .grossForMonth(grossForMonth) + .grossMonthlyFull(grossMonthlyFull) + .basicForMonth(basicForMonth) + .basicMonthlyFull(basicMonthlyFull) + .hraReceivedAnnual(hraAnnual.signum() > 0 ? hraAnnual : null) + .ytdTaxableIncome(ytdIncome) + .ytdTaxDeducted(ytdTax) + .regime(regime) + .declarations(declarations) + .taxRules(ctx.config.getTaxRules() != null ? ctx.config.getTaxRules() : Map.of()) + .statutorySettings(ctx.config.getStatutorySettings() != null + ? ctx.config.getStatutorySettings() : Map.of()) + .stateCode(ctx.config.getStateCode()) + .esiGrossAtPeriodStart(esiGrossAtPeriodStart) + .nationality(employee.getNationality()) + .serviceYears(serviceYearsAsOf(employee, run)) + .build(); + } + + /** Persists one employee's bundle; only now are loan balances actually mutated. */ + private void persistBundle(EntryBundle bundle) { + PayrollEntry entry = payrollEntryRepository.save(bundle.entry); - // Save entry components - for (PayrollEntryComponent entryComp : entryComponents) { + for (PayrollEntryComponent entryComp : bundle.components) { entryComp.setPayrollEntry(entry); payrollEntryComponentRepository.save(entryComp); } - // Save loan repayments - for (LoanRepayment repayment : repayments) { + for (LoanRepayment repayment : bundle.repayments) { repayment.setPayrollEntry(entry); loanRepaymentRepository.save(repayment); } - // Mark reimbursements as processed by linking to this payroll entry - for (Reimbursement reimb : unpaidReimbursements) { + for (Map.Entry loanUpdate : bundle.loanNewBalances.entrySet()) { + EmployeeLoan loan = loanUpdate.getKey(); + BigDecimal newBalance = loanUpdate.getValue(); + loan.setBalanceAmount(newBalance); + if (newBalance.signum() <= 0) { + loan.setStatus("CLOSED"); + loan.setBalanceAmount(BigDecimal.ZERO); + } + employeeLoanRepository.save(loan); + } + + for (Reimbursement reimb : bundle.reimbursements) { reimb.setPayrollEntry(entry); reimbursementRepository.save(reimb); } - return entry; + for (PayrollAdjustment adj : bundle.adjustments) { + adj.setPayrollEntryId(entry.getId()); + payrollAdjustmentRepository.save(adj); + } + + if (bundle.taxComputation != null) { + taxComputationRepository.save(bundle.taxComputation); + } + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /** Latest structure whose effective window overlaps [from, to]; ACTIVE or SUPERSEDED. */ + private EmployeeSalaryStructure selectStructureFor(List structures, + LocalDate from, LocalDate to) { + if (structures == null) return null; + return structures.stream() + .filter(s -> "ACTIVE".equals(s.getStatus()) || "SUPERSEDED".equals(s.getStatus())) + .filter(s -> s.getEffectiveFrom() == null || !s.getEffectiveFrom().isAfter(to)) + .filter(s -> s.getEffectiveTo() == null || !s.getEffectiveTo().isBefore(from)) + .findFirst() // list is ordered effectiveFrom DESC + .orElse(null); + } + + private static boolean isWorkingDay(LocalDate d, Set weekdayHolidayDates) { + DayOfWeek dow = d.getDayOfWeek(); + return dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY && !weekdayHolidayDates.contains(d); + } + + /** Fractional completed years of service as of the payroll month's end (for Gulf EOSB bands). */ + private static BigDecimal serviceYearsAsOf(EmployeeProfile employee, PayrollRun run) { + if (employee.getJoinDate() == null) return BigDecimal.ZERO; + LocalDate asOf = YearMonth.of(run.getYear(), run.getMonth()).atEndOfMonth(); + long days = java.time.temporal.ChronoUnit.DAYS.between(employee.getJoinDate(), asOf); + if (days <= 0) return BigDecimal.ZERO; + return new BigDecimal(days).divide(new BigDecimal("365.25"), 2, RoundingMode.HALF_UP); + } + + /** 1-based position of a calendar month within the financial year. */ + private static int fyMonthPosition(int month, int fyStartMonth) { + return ((month - fyStartMonth + 12) % 12) + 1; + } + + /** ESI contribution periods: Apr-Sep and Oct-Mar. */ + private static LocalDate esiPeriodStart(int month, int year) { + if (month >= 4 && month <= 9) return LocalDate.of(year, 4, 1); + if (month >= 10) return LocalDate.of(year, 10, 1); + return LocalDate.of(year - 1, 10, 1); + } + + @SuppressWarnings("unchecked") + private static BigDecimal extract80c(TaxResult result) { + Object v = result.getBreakdown() != null ? result.getBreakdown().get("deduction80c") : null; + return v instanceof BigDecimal b ? b : BigDecimal.ZERO; + } + + private static LocalDate maxDate(LocalDate a, LocalDate b) { + return a.isAfter(b) ? a : b; + } + + private static LocalDate minDate(LocalDate a, LocalDate b) { + return a.isBefore(b) ? a : b; + } + + private static BigDecimal nvl(BigDecimal v) { + return v != null ? v : BigDecimal.ZERO; } /** - * Determine the Indian financial year string for a given month and year. - * Indian financial year runs April to March: e.g. month=6, year=2025 -> "2025-26"; - * month=2, year=2026 -> "2025-26". + * Financial-year label honoring the configured start month: April start + * (India) -> "2025-26"; January start -> "2026". */ - private String getFinancialYear(int month, int year) { - if (month >= 4) { - return year + "-" + ((year + 1) % 100); - } else { - return (year - 1) + "-" + (year % 100); + private String getFinancialYear(int month, int year, int fyStartMonth) { + if (fyStartMonth <= 1) { + return String.valueOf(year); } + int fyStartYear = month >= fyStartMonth ? year : year - 1; + return fyStartYear + "-" + ((fyStartYear + 1) % 100); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollCurrencyResolver.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollCurrencyResolver.java new file mode 100644 index 0000000000..41c17c8610 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollCurrencyResolver.java @@ -0,0 +1,46 @@ +package vacademy.io.admin_core_service.features.hr_payroll.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import vacademy.io.admin_core_service.features.hr_tax.entity.TaxConfiguration; +import vacademy.io.admin_core_service.features.hr_tax.repository.TaxConfigurationRepository; +import vacademy.io.admin_core_service.features.hr_tax.service.engine.TaxRegimeFactory; + +/** + * The currency an institute pays salaries in, derived from its configured tax + * country. Payroll runs, entries and payslips all stamp a currency, and they + * must agree — so they resolve it here rather than each defaulting on their own. + * + *

An institute that has not configured tax yet still has to be able to create + * a payroll run (hr_payroll_run.currency is NOT NULL), so an unset or unknown + * country falls back to {@link #FALLBACK_CURRENCY} instead of failing. + */ +@Component +public class PayrollCurrencyResolver { + + public static final String FALLBACK_CURRENCY = "INR"; + + @Autowired + private TaxConfigurationRepository taxConfigurationRepository; + + /** ISO-4217 code for the institute's payroll, never null. */ + public String resolve(String instituteId) { + if (instituteId == null || instituteId.isBlank()) { + return FALLBACK_CURRENCY; + } + return taxConfigurationRepository.findByInstituteId(instituteId) + .map(TaxConfiguration::getCountryCode) + .map(PayrollCurrencyResolver::currencyForCountry) + .orElse(FALLBACK_CURRENCY); + } + + /** Country → currency for the geographies payroll supports today. */ + public static String currencyForCountry(String countryCode) { + return switch (TaxRegimeFactory.normalize(countryCode)) { + case "IND" -> "INR"; + case "ARE" -> "AED"; + case "SAU" -> "SAR"; + default -> FALLBACK_CURRENCY; + }; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollEntryService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollEntryService.java index 15ec2b748c..d5ca386b3a 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollEntryService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollEntryService.java @@ -8,12 +8,15 @@ import vacademy.io.admin_core_service.features.hr_payroll.dto.PayrollEntryDTO; import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntry; import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntryComponent; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollRun; import vacademy.io.admin_core_service.features.hr_payroll.enums.PayrollEntryStatus; +import vacademy.io.admin_core_service.features.hr_payroll.enums.PayrollStatus; import vacademy.io.admin_core_service.features.hr_payroll.repository.PayrollEntryComponentRepository; import vacademy.io.admin_core_service.features.hr_payroll.repository.PayrollEntryRepository; import vacademy.io.common.exceptions.VacademyException; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; @Service @@ -25,23 +28,26 @@ public class PayrollEntryService { @Autowired private PayrollEntryComponentRepository payrollEntryComponentRepository; + @Autowired + private PayrollRunService payrollRunService; + @Transactional(readOnly = true) - public List getEntriesByRun(String payrollRunId) { + public List getEntriesByRun(String payrollRunId, String instituteId) { + // Scoped load throws if the run isn't in the validated institute + payrollRunService.loadScoped(payrollRunId, instituteId); List entries = payrollEntryRepository.findByPayrollRunIdOrderByEmployeeEmployeeCodeAsc(payrollRunId); return entries.stream().map(this::toDTO).collect(Collectors.toList()); } @Transactional(readOnly = true) - public PayrollEntryDTO getEntryById(String id) { - PayrollEntry entry = payrollEntryRepository.findById(id) - .orElseThrow(() -> new VacademyException("Payroll entry not found")); - return toDTO(entry); + public PayrollEntryDTO getEntryById(String id, String instituteId) { + return toDTO(loadScoped(id, instituteId)); } @Transactional - public String holdEntry(String id, HoldReleaseDTO holdDTO) { - PayrollEntry entry = payrollEntryRepository.findById(id) - .orElseThrow(() -> new VacademyException("Payroll entry not found")); + public String holdEntry(String id, String instituteId, HoldReleaseDTO holdDTO) { + PayrollEntry entry = loadScoped(id, instituteId); + requireMutableRun(entry); if (!PayrollEntryStatus.CALCULATED.name().equals(entry.getStatus())) { throw new VacademyException("Only CALCULATED entries can be held. Current status: " + entry.getStatus()); @@ -51,13 +57,14 @@ public String holdEntry(String id, HoldReleaseDTO holdDTO) { entry.setHoldReason(holdDTO.getHoldReason()); payrollEntryRepository.save(entry); + payrollRunService.recomputeTotals(entry.getPayrollRun()); return entry.getId(); } @Transactional - public String releaseEntry(String id) { - PayrollEntry entry = payrollEntryRepository.findById(id) - .orElseThrow(() -> new VacademyException("Payroll entry not found")); + public String releaseEntry(String id, String instituteId) { + PayrollEntry entry = loadScoped(id, instituteId); + requireMutableRun(entry); if (!PayrollEntryStatus.HELD.name().equals(entry.getStatus())) { throw new VacademyException("Only HELD entries can be released. Current status: " + entry.getStatus()); @@ -67,17 +74,36 @@ public String releaseEntry(String id) { entry.setHoldReason(null); payrollEntryRepository.save(entry); + payrollRunService.recomputeTotals(entry.getPayrollRun()); return entry.getId(); } + private PayrollEntry loadScoped(String id, String instituteId) { + PayrollEntry entry = payrollEntryRepository.findById(id) + .orElseThrow(() -> new VacademyException("Payroll entry not found")); + PayrollRun run = entry.getPayrollRun(); + if (run == null || !Objects.equals(run.getInstituteId(), instituteId)) { + throw new VacademyException("Payroll entry not found"); + } + return entry; + } + + /** Hold/release must not rewrite history on a PAID or CANCELLED run. */ + private void requireMutableRun(PayrollEntry entry) { + String runStatus = entry.getPayrollRun().getStatus(); + if (PayrollStatus.PAID.name().equals(runStatus) || PayrollStatus.CANCELLED.name().equals(runStatus)) { + throw new VacademyException("Cannot modify entries of a " + runStatus + " payroll run"); + } + } + private PayrollEntryDTO toDTO(PayrollEntry entry) { List components = payrollEntryComponentRepository.findByPayrollEntryId(entry.getId()); List componentDTOs = components.stream() .map(c -> PayrollEntryComponentDTO.builder() - .componentId(c.getComponent().getId()) - .componentName(c.getComponent().getName()) - .componentCode(c.getComponent().getCode()) + .componentId(c.getComponent() != null ? c.getComponent().getId() : null) + .componentName(c.getComponent() != null ? c.getComponent().getName() : "System") + .componentCode(c.getComponent() != null ? c.getComponent().getCode() : null) .componentType(c.getComponentType()) .amount(c.getAmount()) .build()) @@ -102,6 +128,7 @@ private PayrollEntryDTO toDTO(PayrollEntry entry) { .arrears(entry.getArrears()) .reimbursements(entry.getReimbursements()) .loanDeduction(entry.getLoanDeduction()) + .currency(entry.getCurrency() != null ? entry.getCurrency() : "INR") .status(entry.getStatus()) .components(componentDTOs) .build(); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollRunService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollRunService.java index 9e21432237..2866c48741 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollRunService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollRunService.java @@ -1,49 +1,105 @@ package vacademy.io.admin_core_service.features.hr_payroll.service; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import vacademy.io.admin_core_service.features.hr_payroll.dto.CreatePayrollRunDTO; import vacademy.io.admin_core_service.features.hr_payroll.dto.PayrollRunDTO; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntry; import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollRun; +import vacademy.io.admin_core_service.features.hr_payroll.enums.PayrollEntryStatus; import vacademy.io.admin_core_service.features.hr_payroll.enums.PayrollStatus; +import vacademy.io.admin_core_service.features.hr_payroll.repository.PayrollEntryRepository; import vacademy.io.admin_core_service.features.hr_payroll.repository.PayrollRunRepository; +import vacademy.io.admin_core_service.features.workflow.enums.WorkflowTriggerEvent; +import vacademy.io.admin_core_service.features.workflow.service.WorkflowTriggerService; import vacademy.io.common.exceptions.VacademyException; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; +@lombok.extern.slf4j.Slf4j @Service public class PayrollRunService { + private static final String RUN_TYPE_REGULAR = "REGULAR"; + @Autowired private PayrollRunRepository payrollRunRepository; + @Autowired + private PayrollEntryRepository payrollEntryRepository; + + @Autowired + private PayrollCalculationService payrollCalculationService; + + @Autowired + private vacademy.io.admin_core_service.features.erp_finance.service.JournalService journalService; + + @Autowired + private WorkflowTriggerService workflowTriggerService; + + @Autowired + private PayrollCurrencyResolver payrollCurrencyResolver; + + /** + * Creates a run for the VALIDATED institute — the caller-supplied + * instituteId inside the DTO is deliberately ignored (body-vs-param + * cross-tenant spoof fix). CANCELLED runs no longer block the month. + */ @Transactional - public String createPayrollRun(CreatePayrollRunDTO dto) { - // Check if a payroll run already exists for this month/year - payrollRunRepository.findByInstituteIdAndMonthAndYear(dto.getInstituteId(), dto.getMonth(), dto.getYear()) - .ifPresent(existing -> { - throw new VacademyException("Payroll run already exists for " + dto.getMonth() + "/" + dto.getYear()); - }); + public String createPayrollRun(CreatePayrollRunDTO dto, String instituteId) { + if (dto.getMonth() == null || dto.getMonth() < 1 || dto.getMonth() > 12) { + throw new VacademyException("Month must be between 1 and 12"); + } + if (dto.getYear() == null || dto.getYear() < 2000 || dto.getYear() > 2100) { + throw new VacademyException("Invalid year"); + } + + String runType = dto.getRunType() == null || dto.getRunType().isBlank() + ? RUN_TYPE_REGULAR : dto.getRunType().toUpperCase(); + if (!List.of("REGULAR", "OFF_CYCLE", "FNF", "BONUS").contains(runType)) { + throw new VacademyException("run_type must be REGULAR, OFF_CYCLE, FNF or BONUS"); + } + + // Only REGULAR runs are one-per-month; off-cycle/FNF/bonus runs coexist. + if (RUN_TYPE_REGULAR.equals(runType)) { + boolean exists = payrollRunRepository.existsByInstituteIdAndMonthAndYearAndRunTypeAndStatusNot( + instituteId, dto.getMonth(), dto.getYear(), RUN_TYPE_REGULAR, PayrollStatus.CANCELLED.name()); + if (exists) { + throw new VacademyException("Payroll run already exists for " + dto.getMonth() + "/" + dto.getYear()); + } + } PayrollRun run = new PayrollRun(); - run.setInstituteId(dto.getInstituteId()); + run.setInstituteId(instituteId); run.setMonth(dto.getMonth()); run.setYear(dto.getYear()); run.setRunDate(LocalDate.now()); run.setStatus(PayrollStatus.DRAFT.name()); + run.setRunType(runType); run.setTotalEmployees(0); run.setTotalGross(BigDecimal.ZERO); run.setTotalDeductions(BigDecimal.ZERO); run.setTotalNetPay(BigDecimal.ZERO); run.setTotalEmployerCost(BigDecimal.ZERO); run.setNotes(dto.getNotes()); + // NOT NULL in hr_payroll_run — stamped at creation, not at processing, + // or the very first insert for an institute fails. + run.setCurrency(payrollCurrencyResolver.resolve(instituteId)); - run = payrollRunRepository.save(run); + try { + run = payrollRunRepository.save(run); + } catch (DataIntegrityViolationException e) { + // V480 partial unique index: concurrent create for the same month + throw new VacademyException("Payroll run already exists for " + dto.getMonth() + "/" + dto.getYear()); + } return run.getId(); } @@ -59,60 +115,196 @@ public List getPayrollRuns(String instituteId, Integer year) { } @Transactional(readOnly = true) - public PayrollRunDTO getPayrollRunById(String id) { - PayrollRun run = payrollRunRepository.findById(id) - .orElseThrow(() -> new VacademyException("Payroll run not found")); - return toDTO(run); + public PayrollRunDTO getPayrollRunById(String id, String instituteId) { + return toDTO(loadScoped(id, instituteId)); } @Transactional - public String approvePayroll(String id, String approverUserId) { - PayrollRun run = payrollRunRepository.findById(id) - .orElseThrow(() -> new VacademyException("Payroll run not found")); + public String approvePayroll(String id, String instituteId, String approverUserId) { + PayrollRun run = loadScoped(id, instituteId); if (!PayrollStatus.PROCESSED.name().equals(run.getStatus())) { throw new VacademyException("Payroll run must be in PROCESSED status to approve. Current status: " + run.getStatus()); } + recomputeTotals(run); run.setStatus(PayrollStatus.APPROVED.name()); run.setApprovedBy(approverUserId); run.setApprovedAt(LocalDateTime.now()); payrollRunRepository.save(run); + // Phase F4: approval posts the run's accounting journal (idempotent per run). + journalService.postPayrollJournal(run, approverUserId); + + // Phase F5: HR_PAYROLL_APPROVED workflow trigger (emit-and-forget — a + // workflow failure must never break the approval itself) + try { + Map contextData = new HashMap<>(); + contextData.put("runId", run.getId()); + contextData.put("month", run.getMonth()); + contextData.put("year", run.getYear()); + contextData.put("runType", run.getRunType() != null ? run.getRunType() : "REGULAR"); + contextData.put("totalEmployees", run.getTotalEmployees()); + contextData.put("totalNetPay", run.getTotalNetPay() != null + ? run.getTotalNetPay().toPlainString() : null); + contextData.put("approvedBy", approverUserId); + workflowTriggerService.handleTriggerEvents( + WorkflowTriggerEvent.HR_PAYROLL_APPROVED.name(), + run.getId(), + instituteId, + contextData); + } catch (Exception e) { + log.warn("Failed to trigger HR_PAYROLL_APPROVED workflow", e); + } + return run.getId(); } + /** + * PROCESSED -> DRAFT: reverses every financial side effect (loan EMIs, + * reimbursement links, tax computations) and deletes the entries so the + * run can be corrected and reprocessed. The path the review found missing — + * a wrong run was previously unfixable. + */ @Transactional - public String markPaid(String id) { - PayrollRun run = payrollRunRepository.findById(id) - .orElseThrow(() -> new VacademyException("Payroll run not found")); + public String rejectPayroll(String id, String instituteId) { + PayrollRun run = loadScoped(id, instituteId); + + if (!PayrollStatus.PROCESSED.name().equals(run.getStatus()) + && !PayrollStatus.APPROVED.name().equals(run.getStatus())) { + throw new VacademyException("Only PROCESSED or APPROVED payroll runs can be rejected. Current status: " + run.getStatus()); + } + + // Phase F4: an APPROVED run already posted its journal — mirror-reverse it. + journalService.reversePayrollJournal(run, null); + + payrollCalculationService.reverseAndDeleteEntries(run.getId()); + + run.setStatus(PayrollStatus.DRAFT.name()); + run.setProcessedBy(null); + run.setProcessedAt(null); + run.setApprovedBy(null); + run.setApprovedAt(null); + run.setTotalEmployees(0); + run.setTotalGross(BigDecimal.ZERO); + run.setTotalDeductions(BigDecimal.ZERO); + run.setTotalNetPay(BigDecimal.ZERO); + run.setTotalEmployerCost(BigDecimal.ZERO); + payrollRunRepository.save(run); + + return run.getId(); + } + + @Transactional + public String markPaid(String id, String instituteId) { + PayrollRun run = loadScoped(id, instituteId); if (!PayrollStatus.APPROVED.name().equals(run.getStatus())) { throw new VacademyException("Payroll run must be APPROVED before marking as PAID. Current status: " + run.getStatus()); } + // Entry-level PAID (was never set anywhere); HELD entries stay held and + // are excluded from the paid totals. + List entries = payrollEntryRepository.findByPayrollRunIdOrderByEmployeeEmployeeCodeAsc(id); + for (PayrollEntry entry : entries) { + if (PayrollEntryStatus.CALCULATED.name().equals(entry.getStatus())) { + entry.setStatus(PayrollEntryStatus.PAID.name()); + payrollEntryRepository.save(entry); + } + } + + recomputeTotals(run); run.setStatus(PayrollStatus.PAID.name()); run.setPaidAt(LocalDateTime.now()); payrollRunRepository.save(run); + // Phase F5: HR_PAYROLL_PAID workflow trigger (emit-and-forget — a + // workflow failure must never break the mark-paid operation itself) + try { + Map contextData = new HashMap<>(); + contextData.put("runId", run.getId()); + contextData.put("month", run.getMonth()); + contextData.put("year", run.getYear()); + contextData.put("runType", run.getRunType() != null ? run.getRunType() : "REGULAR"); + contextData.put("totalEmployees", run.getTotalEmployees()); + contextData.put("totalNetPay", run.getTotalNetPay() != null + ? run.getTotalNetPay().toPlainString() : null); + workflowTriggerService.handleTriggerEvents( + WorkflowTriggerEvent.HR_PAYROLL_PAID.name(), + run.getId(), + instituteId, + contextData); + } catch (Exception e) { + log.warn("Failed to trigger HR_PAYROLL_PAID workflow", e); + } + return run.getId(); } + /** + * Cancels a run AND reverses its financial side effects (previously the + * entries kept their loan deductions and consumed reimbursements forever). + * The V480 partial unique index lets a new run be created for the month. + */ @Transactional - public String cancelPayroll(String id) { - PayrollRun run = payrollRunRepository.findById(id) - .orElseThrow(() -> new VacademyException("Payroll run not found")); + public String cancelPayroll(String id, String instituteId) { + PayrollRun run = loadScoped(id, instituteId); if (PayrollStatus.PAID.name().equals(run.getStatus())) { throw new VacademyException("Cannot cancel a PAID payroll run"); } + // Phase F4: an APPROVED run already posted its journal — mirror-reverse it. + journalService.reversePayrollJournal(run, null); + + payrollCalculationService.reverseAndDeleteEntries(run.getId()); + run.setStatus(PayrollStatus.CANCELLED.name()); payrollRunRepository.save(run); return run.getId(); } + /** Run totals derived from live entries, excluding HELD (fixes totals ≠ bank total after a hold). */ + void recomputeTotals(PayrollRun run) { + List entries = payrollEntryRepository + .findByPayrollRunIdOrderByEmployeeEmployeeCodeAsc(run.getId()); + + BigDecimal totalGross = BigDecimal.ZERO; + BigDecimal totalDeductions = BigDecimal.ZERO; + BigDecimal totalNetPay = BigDecimal.ZERO; + BigDecimal totalEmployerCost = BigDecimal.ZERO; + int count = 0; + + for (PayrollEntry entry : entries) { + if (PayrollEntryStatus.HELD.name().equals(entry.getStatus())) { + continue; + } + totalGross = totalGross.add(nvl(entry.getGrossSalary())); + totalDeductions = totalDeductions.add(nvl(entry.getTotalDeductions())); + totalNetPay = totalNetPay.add(nvl(entry.getNetPay())); + totalEmployerCost = totalEmployerCost.add( + nvl(entry.getGrossSalary()).add(nvl(entry.getTotalEmployerContributions()))); + count++; + } + + run.setTotalEmployees(count); + run.setTotalGross(totalGross); + run.setTotalDeductions(totalDeductions); + run.setTotalNetPay(totalNetPay); + run.setTotalEmployerCost(totalEmployerCost); + payrollRunRepository.save(run); + } + + PayrollRun loadScoped(String id, String instituteId) { + return payrollRunRepository.findByIdAndInstituteId(id, instituteId) + .orElseThrow(() -> new VacademyException("Payroll run not found")); + } + + private BigDecimal nvl(BigDecimal v) { + return v != null ? v : BigDecimal.ZERO; + } + private PayrollRunDTO toDTO(PayrollRun run) { return PayrollRunDTO.builder() .id(run.getId()) @@ -121,11 +313,13 @@ private PayrollRunDTO toDTO(PayrollRun run) { .year(run.getYear()) .runDate(run.getRunDate()) .status(run.getStatus()) + .runType(run.getRunType() != null ? run.getRunType() : "REGULAR") .totalEmployees(run.getTotalEmployees()) .totalGross(run.getTotalGross()) .totalDeductions(run.getTotalDeductions()) .totalNetPay(run.getTotalNetPay()) .totalEmployerCost(run.getTotalEmployerCost()) + .currency(run.getCurrency() != null ? run.getCurrency() : "INR") .processedBy(run.getProcessedBy()) .processedAt(run.getProcessedAt()) .approvedBy(run.getApprovedBy()) diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/ReimbursementService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/ReimbursementService.java index ac4ff30473..9c6c109b3d 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/ReimbursementService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payroll/service/ReimbursementService.java @@ -6,17 +6,24 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; -import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; +import vacademy.io.admin_core_service.features.hr_employee.service.HrNotificationService; import vacademy.io.admin_core_service.features.hr_payroll.dto.CreateReimbursementDTO; import vacademy.io.admin_core_service.features.hr_payroll.dto.ReimbursementActionDTO; import vacademy.io.admin_core_service.features.hr_payroll.dto.ReimbursementDTO; import vacademy.io.admin_core_service.features.hr_payroll.entity.Reimbursement; import vacademy.io.admin_core_service.features.hr_payroll.repository.ReimbursementRepository; +import vacademy.io.admin_core_service.features.workflow.enums.WorkflowTriggerEvent; +import vacademy.io.admin_core_service.features.workflow.service.WorkflowTriggerService; +import vacademy.io.common.auth.model.CustomUserDetails; import vacademy.io.common.exceptions.VacademyException; import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +@lombok.extern.slf4j.Slf4j @Service public class ReimbursementService { @@ -24,12 +31,19 @@ public class ReimbursementService { private ReimbursementRepository reimbursementRepository; @Autowired - private EmployeeProfileRepository employeeProfileRepository; + private HrAccessGuard hrAccessGuard; + + @Autowired + private HrNotificationService hrNotificationService; + + @Autowired + private WorkflowTriggerService workflowTriggerService; @Transactional - public String submitReimbursement(CreateReimbursementDTO dto, String instituteId) { - EmployeeProfile employee = employeeProfileRepository.findById(dto.getEmployeeId()) - .orElseThrow(() -> new VacademyException("Employee not found")); + public String submitReimbursement(CreateReimbursementDTO dto, String instituteId, CustomUserDetails user) { + // Resolves the employee, verifies it belongs to the validated institute, and + // lets non-HR callers submit only for their OWN employee record + EmployeeProfile employee = hrAccessGuard.requireSelfOrHrStaff(user, instituteId, dto.getEmployeeId()); Reimbursement reimbursement = new Reimbursement(); reimbursement.setEmployee(employee); @@ -39,9 +53,35 @@ public String submitReimbursement(CreateReimbursementDTO dto, String instituteId reimbursement.setDescription(dto.getDescription()); reimbursement.setReceiptFileId(dto.getReceiptFileId()); reimbursement.setExpenseDate(dto.getExpenseDate()); + // Currency defaults to INR unless an explicit 3-letter code is supplied + reimbursement.setCurrency(normalizeCurrency(dto.getCurrency())); reimbursement.setStatus("PENDING"); reimbursement = reimbursementRepository.save(reimbursement); + + // Phase F5: HR_REIMBURSEMENT_REQUESTED workflow trigger (emit-and-forget — + // a workflow failure must never break the submission itself) + try { + Map contextData = new HashMap<>(); + contextData.put("reimbursementId", reimbursement.getId()); + contextData.put("employeeId", employee.getId()); + contextData.put("employeeUserId", employee.getUserId()); + contextData.put("type", reimbursement.getType()); + contextData.put("amount", reimbursement.getAmount() != null + ? reimbursement.getAmount().toPlainString() : null); + contextData.put("currency", reimbursement.getCurrency()); + contextData.put("expenseDate", reimbursement.getExpenseDate() != null + ? reimbursement.getExpenseDate().toString() : null); + contextData.put("status", reimbursement.getStatus()); + workflowTriggerService.handleTriggerEvents( + WorkflowTriggerEvent.HR_REIMBURSEMENT_REQUESTED.name(), + reimbursement.getId(), + instituteId, + contextData); + } catch (Exception e) { + log.warn("Failed to trigger HR_REIMBURSEMENT_REQUESTED workflow", e); + } + return reimbursement.getId(); } @@ -54,9 +94,15 @@ public Page getReimbursements(String instituteId, String statu } @Transactional - public String approveRejectReimbursement(String id, ReimbursementActionDTO actionDTO, String approverUserId) { + public String approveRejectReimbursement(String id, ReimbursementActionDTO actionDTO, String approverUserId, String instituteId) { Reimbursement reimbursement = reimbursementRepository.findById(id) .orElseThrow(() -> new VacademyException("Reimbursement not found")); + hrAccessGuard.requireInstituteMatch(reimbursement.getInstituteId(), instituteId, "Reimbursement"); + + // A user must not approve/reject their own reimbursement + if (approverUserId != null && approverUserId.equals(reimbursement.getEmployee().getUserId())) { + throw new VacademyException("You cannot action your own reimbursement"); + } if (!"PENDING".equals(reimbursement.getStatus())) { throw new VacademyException("Only PENDING reimbursements can be actioned. Current status: " + reimbursement.getStatus()); @@ -75,9 +121,67 @@ public String approveRejectReimbursement(String id, ReimbursementActionDTO actio } reimbursementRepository.save(reimbursement); + + // Best-effort employee email on the decision (send failures never break the operation) + try { + boolean approved = "APPROVED".equals(reimbursement.getStatus()); + String currency = reimbursement.getCurrency() != null ? reimbursement.getCurrency() : "INR"; + String subject = approved + ? "Your reimbursement was approved" + : "Your reimbursement was rejected"; + String body = hrNotificationService.buildEmailBody(subject, + "Type", reimbursement.getType(), + "Amount", reimbursement.getAmount() != null + ? currency + " " + reimbursement.getAmount().toPlainString() : null, + "Expense date", reimbursement.getExpenseDate() != null + ? reimbursement.getExpenseDate().toString() : null, + "Status", reimbursement.getStatus(), + "Reason", approved ? null : reimbursement.getRejectionReason()); + hrNotificationService.emailEmployee(reimbursement.getEmployee(), subject, body); + } catch (Exception e) { + // emailEmployee already swallows send failures; this guards lazy-load surprises + } + + // Phase F5: HR_REIMBURSEMENT_DECIDED workflow trigger (emit-and-forget — + // a workflow failure must never break the decision itself) + try { + Map contextData = new HashMap<>(); + contextData.put("reimbursementId", reimbursement.getId()); + contextData.put("employeeId", reimbursement.getEmployee().getId()); + contextData.put("employeeUserId", reimbursement.getEmployee().getUserId()); + contextData.put("type", reimbursement.getType()); + contextData.put("amount", reimbursement.getAmount() != null + ? reimbursement.getAmount().toPlainString() : null); + contextData.put("currency", reimbursement.getCurrency()); + contextData.put("expenseDate", reimbursement.getExpenseDate() != null + ? reimbursement.getExpenseDate().toString() : null); + contextData.put("status", reimbursement.getStatus()); + contextData.put("approvedBy", reimbursement.getApprovedBy()); + contextData.put("rejectionReason", reimbursement.getRejectionReason()); + workflowTriggerService.handleTriggerEvents( + WorkflowTriggerEvent.HR_REIMBURSEMENT_DECIDED.name(), + reimbursement.getId(), + instituteId, + contextData); + } catch (Exception e) { + log.warn("Failed to trigger HR_REIMBURSEMENT_DECIDED workflow", e); + } + return reimbursement.getId(); } + /** Defaults to INR; validates the 3-letter ISO-4217 shape when provided. */ + private String normalizeCurrency(String currency) { + if (currency == null || currency.trim().isEmpty()) { + return "INR"; + } + String normalized = currency.trim().toUpperCase(); + if (!normalized.matches("[A-Z]{3}")) { + throw new VacademyException("Invalid currency code: " + currency + ". Expected a 3-letter code like INR or USD."); + } + return normalized; + } + private ReimbursementDTO toDTO(Reimbursement r) { return ReimbursementDTO.builder() .id(r.getId()) @@ -92,6 +196,7 @@ private ReimbursementDTO toDTO(Reimbursement r) { .status(r.getStatus()) .approvedBy(r.getApprovedBy()) .rejectionReason(r.getRejectionReason()) + .currency(r.getCurrency() != null ? r.getCurrency() : "INR") .build(); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/controller/PayslipController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/controller/PayslipController.java index ad44290825..be825b7e89 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/controller/PayslipController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/controller/PayslipController.java @@ -1,11 +1,17 @@ package vacademy.io.admin_core_service.features.hr_payslip.controller; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; +import vacademy.io.admin_core_service.features.hr_payslip.dto.FileDownloadDTO; import vacademy.io.admin_core_service.features.hr_payslip.dto.GeneratePayslipDTO; import vacademy.io.admin_core_service.features.hr_payslip.dto.PayslipDTO; +import vacademy.io.admin_core_service.features.hr_payslip.dto.PayslipEmailResultDTO; import vacademy.io.admin_core_service.features.hr_payslip.service.PayslipService; import vacademy.io.common.auth.model.CustomUserDetails; @@ -19,15 +25,20 @@ public class PayslipController { private PayslipService payslipService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; @PostMapping("/generate") + @Auditable( + entityType = "HR_PAYSLIP", + action = "GENERATE", + entityIdExpr = "#dto?.payrollRunId", + descriptionExpr = "'generated payslips for payroll run ' + #dto?.payrollRunId") public ResponseEntity generatePayslips( @RequestBody GeneratePayslipDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String result = payslipService.generatePayslips(dto.getPayrollRunId()); + hrAccessGuard.requireHrAdmin(user, instituteId); + String result = payslipService.generatePayslips(dto.getPayrollRunId(), instituteId); return ResponseEntity.ok(result); } @@ -37,7 +48,8 @@ public ResponseEntity> getPayslips( @RequestParam(value = "year", required = false) Integer year, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + // Employee must belong to the validated institute; non-HR callers may only read their own payslips + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); List payslips = payslipService.getPayslips(employeeId, year); return ResponseEntity.ok(payslips); } @@ -47,8 +59,37 @@ public ResponseEntity getPayslipById( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - PayslipDTO payslip = payslipService.getPayslipById(id); + hrAccessGuard.validateMember(user, instituteId); + PayslipDTO payslip = payslipService.getPayslipById(id, instituteId, user); return ResponseEntity.ok(payslip); } + + @GetMapping("/{id}/download") + public ResponseEntity downloadPayslip( + @PathVariable("id") String id, + @RequestParam("instituteId") String instituteId, + @RequestAttribute("user") CustomUserDetails user) { + // Same self-or-staff rule as getPayslipById (enforced inside the service) + hrAccessGuard.validateMember(user, instituteId); + FileDownloadDTO file = payslipService.downloadPayslipPdf(id, instituteId, user); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + headers.setContentDispositionFormData("attachment", file.getFileName()); + return new ResponseEntity<>(file.getBytes(), headers, HttpStatus.OK); + } + + @PostMapping("/email") + @Auditable( + entityType = "HR_PAYSLIP", + action = "EMAIL", + entityIdExpr = "#dto?.payrollRunId", + descriptionExpr = "'emailed payslips for payroll run ' + #dto?.payrollRunId") + public ResponseEntity emailPayslips( + @RequestBody GeneratePayslipDTO dto, + @RequestParam("instituteId") String instituteId, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + PayslipEmailResultDTO result = payslipService.emailPayslips(dto.getPayrollRunId(), instituteId); + return ResponseEntity.ok(result); + } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/controller/ReportsController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/controller/ReportsController.java index 5dff080534..c03905f45f 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/controller/ReportsController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/controller/ReportsController.java @@ -6,14 +6,16 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; import vacademy.io.admin_core_service.features.hr_payslip.dto.BankExportDTO; import vacademy.io.admin_core_service.features.hr_payslip.dto.BankExportRequestDTO; +import vacademy.io.admin_core_service.features.hr_payslip.dto.BankExportResultDTO; +import vacademy.io.admin_core_service.features.hr_payslip.dto.FileDownloadDTO; import vacademy.io.admin_core_service.features.hr_payslip.service.BankExportService; import vacademy.io.admin_core_service.features.hr_payslip.service.HrReportService; import vacademy.io.common.auth.model.CustomUserDetails; -import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; @@ -28,19 +30,41 @@ public class ReportsController { private HrReportService hrReportService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; @PostMapping("/bank-export") - public ResponseEntity generateBankExport( + @Auditable( + entityType = "HR_BANK_EXPORT", + action = "GENERATE", + entityIdExpr = "#requestDTO?.payrollRunId", + descriptionExpr = "'generated bank export for payroll run ' + #requestDTO?.payrollRunId") + public ResponseEntity generateBankExport( @RequestBody BankExportRequestDTO requestDTO, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String csvContent = bankExportService.generateBankExport(requestDTO, user.getUserId()); + // Plaintext bank account numbers + net pay for all staff — HR admin ONLY + hrAccessGuard.requireHrAdmin(user, instituteId); + BankExportResultDTO result = bankExportService.generateBankExport(requestDTO, user.getUserId(), instituteId); + return ResponseEntity.ok(result); + } + + @GetMapping("/bank-export/{id}/download") + @Auditable( + entityType = "HR_BANK_EXPORT", + action = "DOWNLOAD", + entityIdExpr = "#id", + descriptionExpr = "'downloaded bank export ' + #id") + public ResponseEntity downloadBankExport( + @PathVariable("id") String id, + @RequestParam("instituteId") String instituteId, + @RequestAttribute("user") CustomUserDetails user) { + // Plaintext bank account numbers + net pay for all staff — HR admin ONLY + hrAccessGuard.requireHrAdmin(user, instituteId); + FileDownloadDTO file = bankExportService.downloadBankExport(id, instituteId); HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.parseMediaType("text/csv")); - headers.setContentDispositionFormData("attachment", "bank_export.csv"); - return new ResponseEntity<>(csvContent.getBytes(StandardCharsets.UTF_8), headers, HttpStatus.OK); + headers.setContentType(MediaType.parseMediaType(file.getContentType())); + headers.setContentDispositionFormData("attachment", file.getFileName()); + return new ResponseEntity<>(file.getBytes(), headers, HttpStatus.OK); } @GetMapping("/bank-export") @@ -48,8 +72,8 @@ public ResponseEntity> getBankExports( @RequestParam("payrollRunId") String payrollRunId, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - List exports = bankExportService.getBankExports(payrollRunId); + hrAccessGuard.requireHrStaff(user, instituteId); + List exports = bankExportService.getBankExports(payrollRunId, instituteId); return ResponseEntity.ok(exports); } @@ -59,7 +83,7 @@ public ResponseEntity> getPayrollSummary( @RequestParam("month") Integer month, @RequestParam("year") Integer year, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrStaff(user, instituteId); Map summary = hrReportService.getPayrollSummary(instituteId, month, year); return ResponseEntity.ok(summary); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/BankExportDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/BankExportDTO.java index d3a06102da..6a9628f0e0 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/BankExportDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/BankExportDTO.java @@ -27,4 +27,5 @@ public class BankExportDTO { private BigDecimal totalAmount; private String generatedBy; private LocalDateTime generatedAt; + private String currency; } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/BankExportRequestDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/BankExportRequestDTO.java index 306fa84d3a..35d776e0c3 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/BankExportRequestDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/BankExportRequestDTO.java @@ -15,5 +15,5 @@ public class BankExportRequestDTO { private String payrollRunId; - private String format; // CSV or XLSX + private String format; // CSV, XLSX, or bank text templates: HDFC, ICICI, SBI } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/BankExportResultDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/BankExportResultDTO.java new file mode 100644 index 0000000000..1757789629 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/BankExportResultDTO.java @@ -0,0 +1,37 @@ +package vacademy.io.admin_core_service.features.hr_payslip.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Response for POST /reports/bank-export: the persisted export log (with real + * file id) plus the entries excluded from the file for missing bank details. + * The file itself is served by GET /reports/bank-export/{id}/download. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class BankExportResultDTO { + + private BankExportDTO export; + private List skipped; + private Integer skippedCount; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public static class SkippedEntryDTO { + private String employeeCode; + private String reason; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/FileDownloadDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/FileDownloadDTO.java new file mode 100644 index 0000000000..fbf1a2662d --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/FileDownloadDTO.java @@ -0,0 +1,21 @@ +package vacademy.io.admin_core_service.features.hr_payslip.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Internal service→controller carrier for streamed file downloads + * (payslip PDFs, bank-export files). Never serialized to JSON. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class FileDownloadDTO { + + private String fileName; + private String contentType; + private byte[] bytes; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/PayslipDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/PayslipDTO.java index ca4f76d567..7d48e9774d 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/PayslipDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/PayslipDTO.java @@ -28,4 +28,5 @@ public class PayslipDTO { private LocalDateTime generatedAt; private LocalDateTime emailedAt; private String emailStatus; + private String currency; } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/PayslipEmailResultDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/PayslipEmailResultDTO.java new file mode 100644 index 0000000000..5388d78d7c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/dto/PayslipEmailResultDTO.java @@ -0,0 +1,39 @@ +package vacademy.io.admin_core_service.features.hr_payslip.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Response for POST /payslips/email: per-employee outcome of the payslip + * email fan-out for one payroll run. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class PayslipEmailResultDTO { + + private Integer total; + private Integer sent; + private Integer failed; + private List outcomes; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public static class EmailOutcomeDTO { + private String payslipId; + private String employeeCode; + private String status; // SENT / FAILED + private String reason; // only for FAILED + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/entity/BankExportLog.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/entity/BankExportLog.java index 82b88e61ff..7b04d5134a 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/entity/BankExportLog.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/entity/BankExportLog.java @@ -52,6 +52,9 @@ public class BankExportLog { @Column(name = "generated_at") private LocalDateTime generatedAt; + @Column(name = "currency", length = 3) + private String currency; + @Column(name = "created_at", insertable = false, updatable = false) private LocalDateTime createdAt; } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/entity/Payslip.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/entity/Payslip.java index f94f74c61e..48114a29cb 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/entity/Payslip.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/entity/Payslip.java @@ -56,6 +56,9 @@ public class Payslip { @Column(name = "email_status", length = 20) private String emailStatus; + @Column(name = "currency", length = 3) + private String currency; + @Column(name = "created_at", insertable = false, updatable = false) private LocalDateTime createdAt; } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/repository/PayslipRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/repository/PayslipRepository.java index 149b32536b..57af706e7d 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/repository/PayslipRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/repository/PayslipRepository.java @@ -16,5 +16,7 @@ public interface PayslipRepository extends JpaRepository { Optional findByPayrollEntryId(String payrollEntryId); + List findByPayrollEntryPayrollRunIdOrderByEmployeeEmployeeCodeAsc(String payrollRunId); + List findByInstituteIdAndMonthAndYear(String instituteId, Integer month, Integer year); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/service/BankExportService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/service/BankExportService.java index 6e504165c5..45862f4682 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/service/BankExportService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/service/BankExportService.java @@ -1,8 +1,17 @@ package vacademy.io.admin_core_service.features.hr_payslip.service; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeBankDetail; import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntry; import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollRun; @@ -11,18 +20,37 @@ import vacademy.io.admin_core_service.features.hr_payroll.repository.PayrollRunRepository; import vacademy.io.admin_core_service.features.hr_payslip.dto.BankExportDTO; import vacademy.io.admin_core_service.features.hr_payslip.dto.BankExportRequestDTO; +import vacademy.io.admin_core_service.features.hr_payslip.dto.BankExportResultDTO; +import vacademy.io.admin_core_service.features.hr_payslip.dto.FileDownloadDTO; import vacademy.io.admin_core_service.features.hr_payslip.entity.BankExportLog; import vacademy.io.admin_core_service.features.hr_payslip.repository.BankExportLogRepository; +import vacademy.io.common.auth.entity.User; +import vacademy.io.common.auth.repository.UserRepository; import vacademy.io.common.exceptions.VacademyException; +import vacademy.io.common.media.dto.FileDetailsDTO; +import java.io.ByteArrayOutputStream; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; +import java.time.Month; +import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.stream.Collectors; +@Slf4j @Service public class BankExportService { + private static final String[] SPREADSHEET_HEADERS = { + "Sr.No", "Employee Code", "Employee Name", "Account No", "IFSC", + "Bank Name", "Net Pay", "Currency", "Email" + }; + @Autowired private BankExportLogRepository bankExportLogRepository; @@ -32,18 +60,46 @@ public class BankExportService { @Autowired private PayrollEntryRepository payrollEntryRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + + @Autowired + private HrFileStorageService fileStorageService; + + @Autowired + private UserRepository userRepository; + + /** One included line of the export file. */ + private static class ExportRow { + String employeeCode; + String name; + String accountNo; + String ifsc; + String bankName; + BigDecimal netPay; + String currency; + String email; + } + @Transactional - public String generateBankExport(BankExportRequestDTO requestDTO, String userId) { + public BankExportResultDTO generateBankExport(BankExportRequestDTO requestDTO, String userId, String instituteId) { PayrollRun run = payrollRunRepository.findById(requestDTO.getPayrollRunId()) .orElseThrow(() -> new VacademyException("Payroll run not found")); + hrAccessGuard.requireInstituteMatch(run.getInstituteId(), instituteId, "Payroll run"); - // BUG 4 FIX: Validate payroll run status before generating bank export + // Validate payroll run status before generating bank export String runStatus = run.getStatus(); if (!"APPROVED".equals(runStatus) && !"PAID".equals(runStatus)) { throw new VacademyException("Bank export can only be generated for APPROVED or PAID payroll runs. Current status: " + runStatus); } - // Get all CALCULATED (not HELD) payroll entries for the run + String format = requestDTO.getFormat() != null ? requestDTO.getFormat().toUpperCase() : "CSV"; + if (!List.of("CSV", "XLSX", "HDFC", "ICICI", "SBI").contains(format)) { + throw new VacademyException("Unsupported bank export format: " + format + + ". Supported: CSV, XLSX, HDFC, ICICI, SBI"); + } + + // Get all CALCULATED/PAID (not HELD) payroll entries for the run List entries = payrollEntryRepository .findByPayrollRunIdOrderByEmployeeEmployeeCodeAsc(requestDTO.getPayrollRunId()); @@ -56,72 +112,293 @@ public String generateBankExport(BankExportRequestDTO requestDTO, String userId) throw new VacademyException("No eligible payroll entries found for bank export"); } - // Build CSV content - StringBuilder csvContent = new StringBuilder(); - csvContent.append("Sr.No,Employee Code,Employee Name,Account No,IFSC,Bank Name,Net Pay,Email\n"); + Map userMap = buildUserMap(eligibleEntries); + // Split into included rows vs skipped (missing/blank bank details). + // Entries with no usable account number or IFSC are EXCLUDED from the file + // — a payment file with blank account columns would be rejected (or worse, + // silently mis-processed) by the bank portal. + List rows = new ArrayList<>(); + List skipped = new ArrayList<>(); BigDecimal totalAmount = BigDecimal.ZERO; - int recordCount = 0; - for (int i = 0; i < eligibleEntries.size(); i++) { - PayrollEntry entry = eligibleEntries.get(i); + for (PayrollEntry entry : eligibleEntries) { + String employeeCode = entry.getEmployee().getEmployeeCode() != null + ? entry.getEmployee().getEmployeeCode() : entry.getEmployee().getId(); EmployeeBankDetail bank = entry.getBankAccount(); - String accountNo = ""; - String ifsc = ""; - String bankName = ""; - - if (bank != null) { - accountNo = bank.getAccountNumber() != null ? bank.getAccountNumber() : ""; - ifsc = bank.getIfscCode() != null ? bank.getIfscCode() : ""; - bankName = bank.getBankName() != null ? bank.getBankName() : ""; + String skipReason = null; + if (bank == null) { + skipReason = "No bank account on file"; + } else if (!StringUtils.hasText(bank.getAccountNumber())) { + skipReason = "Missing bank account number"; + } else if (!StringUtils.hasText(bank.getIfscCode())) { + skipReason = "Missing IFSC code"; + } + if (skipReason != null) { + skipped.add(BankExportResultDTO.SkippedEntryDTO.builder() + .employeeCode(employeeCode) + .reason(skipReason) + .build()); + continue; } - String employeeCode = entry.getEmployee().getEmployeeCode() != null - ? entry.getEmployee().getEmployeeCode() : ""; - - csvContent.append(i + 1).append(",") - .append(escapeCSV(employeeCode)).append(",") - .append(escapeCSV(employeeCode)).append(",") // Employee Name column — using code as placeholder - .append(escapeCSV(accountNo)).append(",") - .append(escapeCSV(ifsc)).append(",") - .append(escapeCSV(bankName)).append(",") - .append(entry.getNetPay()).append(",") - .append("") // Email placeholder - .append("\n"); + User user = userMap.get(entry.getEmployee().getUserId()); + + ExportRow row = new ExportRow(); + row.employeeCode = employeeCode; + // Account holder name from the bank detail wins (that's what the bank + // matches against); fall back to the platform user's full name. + row.name = StringUtils.hasText(bank.getAccountHolderName()) + ? bank.getAccountHolderName() + : (user != null && StringUtils.hasText(user.getFullName()) ? user.getFullName() : employeeCode); + row.accountNo = bank.getAccountNumber(); + row.ifsc = bank.getIfscCode(); + row.bankName = bank.getBankName() != null ? bank.getBankName() : ""; + row.netPay = entry.getNetPay() != null ? entry.getNetPay() : BigDecimal.ZERO; + row.currency = entry.getCurrency() != null ? entry.getCurrency() : "INR"; + row.email = user != null && user.getEmail() != null ? user.getEmail() : ""; + rows.add(row); - totalAmount = totalAmount.add(entry.getNetPay()); - recordCount++; + totalAmount = totalAmount.add(row.netPay); } - // Create BankExportLog record - String format = requestDTO.getFormat() != null ? requestDTO.getFormat().toUpperCase() : "CSV"; - String fileName = "bank_export_" + run.getMonth() + "_" + run.getYear() + "." + format.toLowerCase(); - - BankExportLog log = new BankExportLog(); - log.setPayrollRun(run); - log.setInstituteId(run.getInstituteId()); - log.setFileName(fileName); - log.setFormat(format); - log.setTotalRecords(recordCount); - log.setTotalAmount(totalAmount); - log.setGeneratedBy(userId); - log.setGeneratedAt(LocalDateTime.now()); - // fileId would be set after uploading to S3 -- for now null - log.setFileId(null); - - log = bankExportLogRepository.save(log); - - // BUG 3 FIX: Return CSV content so the controller can serve it as a downloadable file - return csvContent.toString(); + if (rows.isEmpty()) { + throw new VacademyException("All eligible entries are missing bank details — nothing to export. Skipped: " + + skipped.size()); + } + + String narration = "SAL " + monthAbbrev(run.getMonth()) + " " + run.getYear(); + byte[] fileBytes = switch (format) { + case "XLSX" -> buildXlsx(rows); + case "HDFC", "ICICI", "SBI" -> buildBankTextFile(format, rows, narration) + .getBytes(StandardCharsets.UTF_8); + default -> buildCsv(rows).getBytes(StandardCharsets.UTF_8); + }; + + String fileName = "bank_export_" + format.toLowerCase() + "_" + run.getMonth() + "_" + run.getYear() + + "." + fileExtension(format); + + // Persist the file to media_service so the export is re-downloadable + FileDetailsDTO fileDetails = fileStorageService.uploadBytes(fileName, contentTypeFor(format), fileBytes); + + BankExportLog exportLog = new BankExportLog(); + exportLog.setPayrollRun(run); + exportLog.setInstituteId(run.getInstituteId()); + exportLog.setFileName(fileName); + exportLog.setFormat(format); + exportLog.setTotalRecords(rows.size()); + exportLog.setTotalAmount(totalAmount); + exportLog.setGeneratedBy(userId); + exportLog.setGeneratedAt(LocalDateTime.now()); + exportLog.setCurrency(run.getCurrency() != null ? run.getCurrency() : "INR"); + exportLog.setFileId(fileDetails.getId()); + + exportLog = bankExportLogRepository.save(exportLog); + + if (!skipped.isEmpty()) { + // hr_bank_export_log has no excluded-count column; total_records holds the + // INCLUDED count and the exclusions are logged + returned to the caller. + log.warn("[BANK-EXPORT] Export {} for run {}: {} entries included, {} excluded for missing bank details: {}", + exportLog.getId(), run.getId(), rows.size(), skipped.size(), + skipped.stream().map(s -> s.getEmployeeCode() + " (" + s.getReason() + ")") + .collect(Collectors.joining(", "))); + } + + return BankExportResultDTO.builder() + .export(toDTO(exportLog)) + .skipped(skipped) + .skippedCount(skipped.size()) + .build(); } @Transactional(readOnly = true) - public List getBankExports(String payrollRunId) { + public List getBankExports(String payrollRunId, String instituteId) { + PayrollRun run = payrollRunRepository.findById(payrollRunId) + .orElseThrow(() -> new VacademyException("Payroll run not found")); + hrAccessGuard.requireInstituteMatch(run.getInstituteId(), instituteId, "Payroll run"); List logs = bankExportLogRepository.findByPayrollRunIdOrderByCreatedAtDesc(payrollRunId); return logs.stream().map(this::toDTO).collect(Collectors.toList()); } + /** Streams a previously generated export file back from media_service. HR admin only (enforced at controller). */ + @Transactional(readOnly = true) + public FileDownloadDTO downloadBankExport(String id, String instituteId) { + BankExportLog exportLog = bankExportLogRepository.findById(id) + .orElseThrow(() -> new VacademyException("Bank export not found")); + hrAccessGuard.requireInstituteMatch(exportLog.getInstituteId(), instituteId, "Bank export"); + + byte[] bytes = fileStorageService.downloadBytes(exportLog.getFileId()); + if (bytes == null || bytes.length == 0) { + throw new VacademyException("Bank export file is no longer available — regenerate the export"); + } + String format = exportLog.getFormat() != null ? exportLog.getFormat() : "CSV"; + String fileName = exportLog.getFileName() != null ? exportLog.getFileName() + : "bank_export." + fileExtension(format); + return FileDownloadDTO.builder() + .fileName(fileName) + .contentType(contentTypeFor(format)) + .bytes(bytes) + .build(); + } + + // ----------------------------------------------------------------------- + // File builders + // ----------------------------------------------------------------------- + + private String buildCsv(List rows) { + StringBuilder csv = new StringBuilder(); + csv.append(String.join(",", SPREADSHEET_HEADERS)).append("\n"); + int srNo = 1; + for (ExportRow row : rows) { + csv.append(srNo++).append(",") + .append(escapeCSV(row.employeeCode)).append(",") + .append(escapeCSV(row.name)).append(",") + .append(escapeCSV(row.accountNo)).append(",") + .append(escapeCSV(row.ifsc)).append(",") + .append(escapeCSV(row.bankName)).append(",") + .append(row.netPay.setScale(2, java.math.RoundingMode.HALF_UP).toPlainString()).append(",") + .append(escapeCSV(row.currency)).append(",") + .append(escapeCSV(row.email)) + .append("\n"); + } + return csv.toString(); + } + + /** Real XLSX workbook (Apache POI): bold header row, autosized columns, same columns as CSV. */ + private byte[] buildXlsx(List rows) { + try (XSSFWorkbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("Bank Export"); + + CellStyle headerStyle = wb.createCellStyle(); + Font headerFont = wb.createFont(); + headerFont.setBold(true); + headerStyle.setFont(headerFont); + + Row header = sheet.createRow(0); + for (int c = 0; c < SPREADSHEET_HEADERS.length; c++) { + Cell cell = header.createCell(c); + cell.setCellValue(SPREADSHEET_HEADERS[c]); + cell.setCellStyle(headerStyle); + } + + int rowIdx = 1; + for (ExportRow row : rows) { + Row r = sheet.createRow(rowIdx); + r.createCell(0).setCellValue(rowIdx); + r.createCell(1).setCellValue(row.employeeCode); + r.createCell(2).setCellValue(row.name); + // Account numbers as text — numeric cells would lose leading zeros + r.createCell(3).setCellValue(row.accountNo); + r.createCell(4).setCellValue(row.ifsc); + r.createCell(5).setCellValue(row.bankName); + r.createCell(6).setCellValue(row.netPay.doubleValue()); + r.createCell(7).setCellValue(row.currency); + r.createCell(8).setCellValue(row.email); + rowIdx++; + } + + for (int c = 0; c < SPREADSHEET_HEADERS.length; c++) { + sheet.autoSizeColumn(c); + } + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + wb.write(out); + return out.toByteArray(); + } catch (Exception e) { + log.error("[BANK-EXPORT] Failed to build XLSX workbook", e); + throw new VacademyException("Failed to build XLSX export: " + e.getMessage()); + } + } + + /** + * v1 bank payment-file templates — simple fixed-column text layouts. + * + * IMPORTANT: these are v1 placeholders using the commonly documented column + * orders. Each bank's corporate portal (HDFC ENet, ICICI CIB, SBI CMP) has an + * exact upload spec (column order, date columns, debit-account column, header/ + * trailer records) that MUST be verified against the institute's bank portal + * before live salary uploads. Payment mode is fixed to NEFT; narration is + * "SAL ". + */ + private String buildBankTextFile(String format, List rows, String narration) { + StringBuilder sb = new StringBuilder(); + for (ExportRow row : rows) { + String amount = row.netPay.setScale(2, java.math.RoundingMode.HALF_UP).toPlainString(); + switch (format) { + // HDFC (ENet-style): Mode, Beneficiary Name, Account, IFSC, Amount, Narration + case "HDFC" -> sb.append("NEFT").append(",") + .append(sanitizeBankField(row.name)).append(",") + .append(sanitizeBankField(row.accountNo)).append(",") + .append(sanitizeBankField(row.ifsc)).append(",") + .append(amount).append(",") + .append(sanitizeBankField(narration)).append("\r\n"); + // ICICI (CIB-style): Mode, Account, Beneficiary Name, IFSC, Amount, Narration + case "ICICI" -> sb.append("NEFT").append(",") + .append(sanitizeBankField(row.accountNo)).append(",") + .append(sanitizeBankField(row.name)).append(",") + .append(sanitizeBankField(row.ifsc)).append(",") + .append(amount).append(",") + .append(sanitizeBankField(narration)).append("\r\n"); + // SBI (CMP-style): Beneficiary Name, Account, IFSC, Amount, Mode, Narration + case "SBI" -> sb.append(sanitizeBankField(row.name)).append(",") + .append(sanitizeBankField(row.accountNo)).append(",") + .append(sanitizeBankField(row.ifsc)).append(",") + .append(amount).append(",") + .append("NEFT").append(",") + .append(sanitizeBankField(narration)).append("\r\n"); + default -> throw new VacademyException("Unsupported bank text format: " + format); + } + } + return sb.toString(); + } + + /** Bank upload fields are comma-delimited with no quoting — strip delimiters/newlines. */ + private String sanitizeBankField(String value) { + if (value == null) return ""; + return value.replace(",", " ").replace("\n", " ").replace("\r", " ").trim(); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private Map buildUserMap(List entries) { + List userIds = entries.stream() + .map(e -> e.getEmployee().getUserId()) + .filter(StringUtils::hasText) + .distinct() + .collect(Collectors.toList()); + if (userIds.isEmpty()) { + return new HashMap<>(); + } + return userRepository.findByIdIn(userIds).stream() + .collect(Collectors.toMap(User::getId, u -> u, (a, b) -> a)); + } + + private static String monthAbbrev(Integer month) { + if (month == null || month < 1 || month > 12) { + return String.valueOf(month); + } + return Month.of(month).getDisplayName(TextStyle.SHORT, Locale.ENGLISH).toUpperCase(Locale.ENGLISH); + } + + private static String fileExtension(String format) { + return switch (format) { + case "XLSX" -> "xlsx"; + case "HDFC", "ICICI", "SBI" -> "txt"; + default -> "csv"; + }; + } + + private static String contentTypeFor(String format) { + return switch (format) { + case "XLSX" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + case "HDFC", "ICICI", "SBI" -> "text/plain"; + default -> "text/csv"; + }; + } + private String escapeCSV(String value) { if (value == null) return ""; if (value.contains(",") || value.contains("\"") || value.contains("\n")) { @@ -142,6 +419,7 @@ private BankExportDTO toDTO(BankExportLog log) { .totalAmount(log.getTotalAmount()) .generatedBy(log.getGeneratedBy()) .generatedAt(log.getGeneratedAt()) + .currency(log.getCurrency() != null ? log.getCurrency() : "INR") .build(); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/service/HrFileStorageService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/service/HrFileStorageService.java new file mode 100644 index 0000000000..41640eb635 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/service/HrFileStorageService.java @@ -0,0 +1,118 @@ +package vacademy.io.admin_core_service.features.hr_payslip.service; + +import com.itextpdf.styledxmlparser.jsoup.Jsoup; +import com.itextpdf.styledxmlparser.jsoup.nodes.Document; +import com.itextpdf.styledxmlparser.jsoup.nodes.Entities; +import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.features.media_service.service.MediaService; +import vacademy.io.common.exceptions.VacademyException; +import vacademy.io.common.media.dto.FileDetailsDTO; +import vacademy.io.common.media.dto.InMemoryMultipartFile; + +import java.io.ByteArrayOutputStream; + +/** + * HR payslip / bank-export file plumbing: HTML → PDF rendering (same + * openhtmltopdf pattern as {@code InvoiceService} / {@code StudentReportPdfService}), + * byte upload to media_service, and byte download back by file id. + */ +@Slf4j +@Service +public class HrFileStorageService { + + @Autowired + private MediaService mediaService; + + /** + * Converts an HTML string to PDF bytes using openhtmltopdf (PdfRendererBuilder). + * Mirrors {@code StudentReportPdfService#generatePdfFromHtml} minus the SVG/image + * handling (payslips contain neither). + */ + public byte[] htmlToPdf(String htmlContent) { + try { + boolean isCompleteHtml = htmlContent.trim().toLowerCase().startsWith("" + htmlContent + ""; + + String xhtml = escapeBareAmpersands(sanitizeToXhtml(htmlWithCss)); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + PdfRendererBuilder builder = new PdfRendererBuilder(); + builder.useFastMode(); + builder.withHtmlContent(xhtml, "file:///"); + builder.useDefaultPageSize(210f, 297f, PdfRendererBuilder.PageSizeUnits.MM); + builder.toStream(outputStream); + builder.run(); + + return outputStream.toByteArray(); + } catch (Exception e) { + log.error("[HR-FILE] Error generating PDF from HTML", e); + throw new VacademyException("Failed to generate PDF: " + e.getMessage()); + } + } + + /** Uploads raw bytes to media_service and returns the file details (id + url). */ + public FileDetailsDTO uploadBytes(String fileName, String contentType, byte[] bytes) { + try { + InMemoryMultipartFile file = new InMemoryMultipartFile(fileName, fileName, contentType, bytes); + FileDetailsDTO details = mediaService.uploadFileV2(file); + if (details == null || !StringUtils.hasText(details.getId())) { + throw new VacademyException("media_service returned no file id for " + fileName); + } + return details; + } catch (VacademyException e) { + throw e; + } catch (Exception e) { + log.error("[HR-FILE] Failed to upload {} to media_service: {}", fileName, e.getMessage()); + throw new VacademyException("Failed to store file " + fileName + ": " + e.getMessage()); + } + } + + /** + * Downloads a stored file's bytes back from media_service by file id. + * Returns {@code null} when the id cannot be resolved (expired / missing) so + * callers can decide whether to re-render. + */ + public byte[] downloadBytes(String fileId) { + if (!StringUtils.hasText(fileId)) { + return null; + } + try { + String url = mediaService.getFilePublicUrlById(fileId); + if (!StringUtils.hasText(url)) return null; + java.net.URL u = new java.net.URL(url); + java.net.HttpURLConnection conn = (java.net.HttpURLConnection) u.openConnection(); + conn.setConnectTimeout(8000); + conn.setReadTimeout(30000); + if (conn.getResponseCode() == 200) { + try (java.io.InputStream is = conn.getInputStream(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + byte[] buf = new byte[8192]; + int n; + while ((n = is.read(buf)) != -1) baos.write(buf, 0, n); + return baos.toByteArray(); + } + } + } catch (Exception e) { + log.warn("[HR-FILE] Could not download bytes for fileId={}: {}", fileId, e.getMessage()); + } + return null; + } + + private String sanitizeToXhtml(String html) { + Document doc = Jsoup.parse(html); + doc.outputSettings().syntax(Document.OutputSettings.Syntax.xml); + doc.outputSettings().escapeMode(Entities.EscapeMode.xhtml); + return doc.html(); + } + + private static String escapeBareAmpersands(String xhtml) { + if (xhtml == null) return null; + return xhtml.replaceAll("&(?![A-Za-z][A-Za-z0-9]*;|#[0-9]+;|#x[0-9A-Fa-f]+;)", "&"); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/service/PayslipService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/service/PayslipService.java index 65bce62136..6dd43c49a7 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/service/PayslipService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_payslip/service/PayslipService.java @@ -1,8 +1,11 @@ package vacademy.io.admin_core_service.features.hr_payslip.service; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntry; import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollEntryComponent; import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollRun; @@ -10,20 +13,40 @@ import vacademy.io.admin_core_service.features.hr_payroll.enums.PayrollStatus; import vacademy.io.admin_core_service.features.hr_payroll.repository.PayrollEntryRepository; import vacademy.io.admin_core_service.features.hr_payroll.repository.PayrollRunRepository; +import vacademy.io.admin_core_service.features.hr_payslip.dto.FileDownloadDTO; import vacademy.io.admin_core_service.features.hr_payslip.dto.PayslipDTO; +import vacademy.io.admin_core_service.features.hr_payslip.dto.PayslipEmailResultDTO; import vacademy.io.admin_core_service.features.hr_payslip.entity.Payslip; import vacademy.io.admin_core_service.features.hr_payslip.repository.PayslipRepository; +import vacademy.io.admin_core_service.features.notification_service.service.NotificationService; +import vacademy.io.common.auth.entity.User; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.auth.repository.UserRepository; import vacademy.io.common.exceptions.VacademyException; +import vacademy.io.common.media.dto.FileDetailsDTO; +import vacademy.io.common.notification.dto.AttachmentNotificationDTO; +import vacademy.io.common.notification.dto.AttachmentUsersDTO; import java.time.LocalDateTime; +import java.time.Month; +import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Optional; -import java.util.UUID; import java.util.stream.Collectors; +@Slf4j @Service public class PayslipService { + private static final String EMAIL_STATUS_NOT_SENT = "NOT_SENT"; + private static final String EMAIL_STATUS_SENT = "SENT"; + private static final String EMAIL_STATUS_FAILED = "FAILED"; + @Autowired private PayslipRepository payslipRepository; @@ -33,14 +56,29 @@ public class PayslipService { @Autowired private PayrollEntryRepository payrollEntryRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + + @Autowired + private HrFileStorageService fileStorageService; + + @Autowired + private NotificationService notificationService; + + @Autowired + private UserRepository userRepository; + @Transactional - public String generatePayslips(String payrollRunId) { + public String generatePayslips(String payrollRunId, String instituteId) { PayrollRun run = payrollRunRepository.findById(payrollRunId) .orElseThrow(() -> new VacademyException("Payroll run not found")); + hrAccessGuard.requireInstituteMatch(run.getInstituteId(), instituteId, "Payroll run"); - // Payroll must be at least PROCESSED to generate payslips + // Payroll must be at least PROCESSED (and not cancelled) to generate payslips String status = run.getStatus(); - if (PayrollStatus.DRAFT.name().equals(status) || PayrollStatus.PROCESSING.name().equals(status)) { + if (PayrollStatus.DRAFT.name().equals(status) + || PayrollStatus.PROCESSING.name().equals(status) + || PayrollStatus.CANCELLED.name().equals(status)) { throw new VacademyException("Payroll run must be PROCESSED or later to generate payslips. Current status: " + status); } @@ -51,22 +89,29 @@ public String generatePayslips(String payrollRunId) { throw new VacademyException("No payroll entries found for this run"); } + Map userMap = buildUserMap(entries); + int generated = 0; + int regenerated = 0; for (PayrollEntry entry : entries) { - // BUG 1 FIX: Skip entries with HELD status + // Skip entries with HELD status if (PayrollEntryStatus.HELD.name().equals(entry.getStatus())) { continue; } - // Skip if payslip already exists for this entry Optional existingPayslip = payslipRepository.findByPayrollEntryId(entry.getId()); if (existingPayslip.isPresent()) { + // Skip payslips that already have a real media file; regenerate legacy + // rows that stored raw HTML in file_url (pre-PDF pipeline). + Payslip existing = existingPayslip.get(); + if (!hasRealMediaFile(existing)) { + renderAndStorePdf(existing, entry, run, resolveUserName(userMap, entry)); + payslipRepository.save(existing); + regenerated++; + } continue; } - // BUG 2 FIX: Generate payslip HTML content - String payslipHtml = buildPayslipHtml(entry, run); - Payslip payslip = new Payslip(); payslip.setPayrollEntry(entry); payslip.setEmployee(entry.getEmployee()); @@ -74,15 +119,19 @@ public String generatePayslips(String payrollRunId) { payslip.setMonth(run.getMonth()); payslip.setYear(run.getYear()); payslip.setGeneratedAt(LocalDateTime.now()); - payslip.setFileId(UUID.randomUUID().toString()); - payslip.setFileUrl(payslipHtml); - payslip.setEmailStatus("NOT_SENT"); + payslip.setCurrency(entry.getCurrency() != null ? entry.getCurrency() : "INR"); + payslip.setEmailStatus(EMAIL_STATUS_NOT_SENT); + renderAndStorePdf(payslip, entry, run, resolveUserName(userMap, entry)); payslipRepository.save(payslip); generated++; } - return "Generated " + generated + " payslips for payroll run " + payrollRunId; + String result = "Generated " + generated + " payslips for payroll run " + payrollRunId; + if (regenerated > 0) { + result += " (regenerated " + regenerated + " legacy payslips as PDF)"; + } + return result; } @Transactional(readOnly = true) @@ -98,16 +147,216 @@ public List getPayslips(String employeeId, Integer year) { } @Transactional(readOnly = true) - public PayslipDTO getPayslipById(String id) { + public PayslipDTO getPayslipById(String id, String instituteId, CustomUserDetails user) { Payslip payslip = payslipRepository.findById(id) .orElseThrow(() -> new VacademyException("Payslip not found")); + hrAccessGuard.requireInstituteMatch(payslip.getInstituteId(), instituteId, "Payslip"); + // Only HR staff or the payslip's own employee may read it + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, payslip.getEmployee().getId()); return toDTO(payslip); } - private String buildPayslipHtml(PayrollEntry entry, PayrollRun run) { + /** + * Streams the payslip PDF. Same self-or-staff rule as {@link #getPayslipById}. + * Legacy rows (raw HTML in file_url) and expired media files are re-rendered + * and persisted on the fly. + */ + @Transactional + public FileDownloadDTO downloadPayslipPdf(String id, String instituteId, CustomUserDetails user) { + Payslip payslip = payslipRepository.findById(id) + .orElseThrow(() -> new VacademyException("Payslip not found")); + hrAccessGuard.requireInstituteMatch(payslip.getInstituteId(), instituteId, "Payslip"); + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, payslip.getEmployee().getId()); + + byte[] bytes = getOrRenderPdf(payslip); + String employeeCode = payslip.getEmployee().getEmployeeCode() != null + ? payslip.getEmployee().getEmployeeCode() : "employee"; + String fileName = "payslip_" + employeeCode + "_" + payslip.getMonth() + "_" + payslip.getYear() + ".pdf"; + return FileDownloadDTO.builder() + .fileName(fileName) + .contentType("application/pdf") + .bytes(bytes) + .build(); + } + + /** + * Emails each employee of a payroll run their payslip PDF as an attachment. + * Processes resiliently: one employee's failure never stops the rest; each + * payslip's email_status is updated to SENT/FAILED individually. + */ + @Transactional + public PayslipEmailResultDTO emailPayslips(String payrollRunId, String instituteId) { + PayrollRun run = payrollRunRepository.findById(payrollRunId) + .orElseThrow(() -> new VacademyException("Payroll run not found")); + hrAccessGuard.requireInstituteMatch(run.getInstituteId(), instituteId, "Payroll run"); + + List payslips = payslipRepository.findByPayrollEntryPayrollRunIdOrderByEmployeeEmployeeCodeAsc(payrollRunId); + if (payslips.isEmpty()) { + throw new VacademyException("No payslips generated for this payroll run yet"); + } + + List userIds = payslips.stream() + .map(p -> p.getEmployee().getUserId()) + .filter(StringUtils::hasText) + .distinct() + .collect(Collectors.toList()); + Map userMap = userIds.isEmpty() ? Map.of() + : userRepository.findByIdIn(userIds).stream() + .collect(Collectors.toMap(User::getId, u -> u, (a, b) -> a)); + + String periodLabel = monthName(run.getMonth()) + " " + run.getYear(); + String subject = "Payslip " + periodLabel; + + int sent = 0; + int failed = 0; + List outcomes = new ArrayList<>(); + + for (Payslip payslip : payslips) { + String employeeCode = payslip.getEmployee().getEmployeeCode() != null + ? payslip.getEmployee().getEmployeeCode() : payslip.getEmployee().getId(); + String failureReason = null; + try { + User recipient = userMap.get(payslip.getEmployee().getUserId()); + String email = recipient != null ? recipient.getEmail() : null; + if (!StringUtils.hasText(email)) { + failureReason = "No email on record for employee"; + } else { + byte[] pdfBytes = getOrRenderPdf(payslip); + String recipientName = recipient.getFullName() != null ? recipient.getFullName() : employeeCode; + var response = notificationService.sendAttachmentEmailViaUnified( + List.of(buildPayslipEmail(payslip, subject, periodLabel, recipientName, email, + recipient.getId(), pdfBytes, employeeCode)), + instituteId); + if (response != null && response.getFailed() > 0) { + failureReason = "Notification service reported delivery failure"; + } + } + } catch (Exception e) { + failureReason = e.getMessage(); + log.error("[PAYSLIP-EMAIL] Failed to email payslip {} (employee {}): {}", + payslip.getId(), employeeCode, e.getMessage()); + } + + if (failureReason == null) { + payslip.setEmailStatus(EMAIL_STATUS_SENT); + payslip.setEmailedAt(LocalDateTime.now()); + sent++; + } else { + payslip.setEmailStatus(EMAIL_STATUS_FAILED); + log.warn("[PAYSLIP-EMAIL] Payslip {} (employee {}) marked FAILED: {}", + payslip.getId(), employeeCode, failureReason); + failed++; + } + payslipRepository.save(payslip); + + outcomes.add(PayslipEmailResultDTO.EmailOutcomeDTO.builder() + .payslipId(payslip.getId()) + .employeeCode(employeeCode) + .status(failureReason == null ? EMAIL_STATUS_SENT : EMAIL_STATUS_FAILED) + .reason(failureReason) + .build()); + } + + return PayslipEmailResultDTO.builder() + .total(payslips.size()) + .sent(sent) + .failed(failed) + .outcomes(outcomes) + .build(); + } + + // ----------------------------------------------------------------------- + // PDF pipeline + // ----------------------------------------------------------------------- + + /** True when file_id points at a real media_service file (not the legacy fake-UUID + raw-HTML rows). */ + private boolean hasRealMediaFile(Payslip payslip) { + if (!StringUtils.hasText(payslip.getFileId())) { + return false; + } + // Legacy rows stored the full payslip HTML in file_url with a random UUID as file_id. + String fileUrl = payslip.getFileUrl(); + return fileUrl == null || !fileUrl.trim().startsWith("<"); + } + + /** Renders the payslip PDF, uploads it to media_service and sets the real fileId/fileUrl. Returns the bytes. */ + private byte[] renderAndStorePdf(Payslip payslip, PayrollEntry entry, PayrollRun run, String employeeName) { + String html = buildPayslipHtml(entry, run, employeeName); + byte[] pdfBytes = fileStorageService.htmlToPdf(html); + String employeeCode = entry.getEmployee().getEmployeeCode() != null + ? entry.getEmployee().getEmployeeCode() : "employee"; + String fileName = "payslip_" + employeeCode + "_" + run.getMonth() + "_" + run.getYear() + ".pdf"; + FileDetailsDTO details = fileStorageService.uploadBytes(fileName, "application/pdf", pdfBytes); + payslip.setFileId(details.getId()); + payslip.setFileUrl(details.getUrl()); + payslip.setGeneratedAt(LocalDateTime.now()); + return pdfBytes; + } + + /** + * Returns the payslip's PDF bytes: from media_service when the stored file + * resolves, otherwise re-rendered (and persisted) from the payroll entry. + */ + private byte[] getOrRenderPdf(Payslip payslip) { + if (hasRealMediaFile(payslip)) { + byte[] cached = fileStorageService.downloadBytes(payslip.getFileId()); + if (cached != null && cached.length > 0) { + return cached; + } + log.warn("[PAYSLIP] Stored file_id={} for payslip {} could not be resolved; re-rendering", + payslip.getFileId(), payslip.getId()); + } + + PayrollEntry entry = payslip.getPayrollEntry(); + PayrollRun run = entry.getPayrollRun(); + Map userMap = buildUserMap(List.of(entry)); + byte[] pdfBytes = renderAndStorePdf(payslip, entry, run, resolveUserName(userMap, entry)); + payslipRepository.save(payslip); + return pdfBytes; + } + + private AttachmentNotificationDTO buildPayslipEmail(Payslip payslip, String subject, String periodLabel, + String recipientName, String email, String userId, + byte[] pdfBytes, String employeeCode) { + String attachmentName = "payslip_" + employeeCode + "_" + payslip.getMonth() + "_" + payslip.getYear() + ".pdf"; + + AttachmentUsersDTO.AttachmentDTO attachmentDTO = new AttachmentUsersDTO.AttachmentDTO(); + attachmentDTO.setAttachmentName(attachmentName); + attachmentDTO.setAttachment(Base64.getEncoder().encodeToString(pdfBytes)); + + AttachmentUsersDTO toUser = new AttachmentUsersDTO(); + toUser.setChannelId(email); + toUser.setUserId(userId); + toUser.setPlaceholders(Map.of("email", email)); + toUser.setAttachments(List.of(attachmentDTO)); + + String body = "" + + "

Dear " + escHtml(recipientName) + ",

" + + "

Please find attached your payslip for " + escHtml(periodLabel) + ".

" + + "

This is an auto-generated email. Please contact HR for any discrepancies.

" + + "

Regards,
HR Department

" + + ""; + + return AttachmentNotificationDTO.builder() + .body(body) + .subject(subject) + .notificationType("EMAIL") + .source("HR_PAYSLIP") + .sourceId(payslip.getId()) + .users(List.of(toUser)) + .emailType("UTILITY_EMAIL") + .build(); + } + + // ----------------------------------------------------------------------- + // HTML template + // ----------------------------------------------------------------------- + + private String buildPayslipHtml(PayrollEntry entry, PayrollRun run, String employeeName) { String employeeCode = entry.getEmployee().getEmployeeCode() != null ? entry.getEmployee().getEmployeeCode() : "N/A"; - String monthYear = run.getMonth() + "/" + run.getYear(); + String monthYear = monthName(run.getMonth()) + " " + run.getYear(); + String currency = entry.getCurrency() != null ? entry.getCurrency() : "INR"; StringBuilder html = new StringBuilder(); html.append(""); - html.append("

Payslip - ").append(monthYear).append("

"); - html.append("

Employee Code: ").append(employeeCode).append("

"); - html.append("

Period: ").append(monthYear).append("

"); + html.append("

Payslip - ").append(escHtml(monthYear)).append("

"); + if (StringUtils.hasText(employeeName)) { + html.append("

Employee: ").append(escHtml(employeeName)).append("

"); + } + html.append("

Employee Code: ").append(escHtml(employeeCode)).append("

"); + html.append("

Period: ").append(escHtml(monthYear)).append("

"); + html.append("

Currency: ").append(escHtml(currency)).append("

"); html.append("
"); - // Earnings and deductions table - html.append(""); + // Earnings and deductions table (amounts are in the entry's currency) + html.append("
ComponentTypeAmount
"); List components = entry.getEntryComponents(); if (components != null) { @@ -132,8 +386,8 @@ private String buildPayslipHtml(PayrollEntry entry, PayrollRun run) { String compName = comp.getComponent() != null && comp.getComponent().getName() != null ? comp.getComponent().getName() : "Unknown"; String compType = comp.getComponentType() != null ? comp.getComponentType() : ""; - html.append("") - .append("") + html.append("") + .append("") .append(""); } } @@ -156,6 +410,43 @@ private String buildPayslipHtml(PayrollEntry entry, PayrollRun run) { return html.toString(); } + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private Map buildUserMap(List entries) { + List userIds = entries.stream() + .map(e -> e.getEmployee().getUserId()) + .filter(StringUtils::hasText) + .distinct() + .collect(Collectors.toList()); + if (userIds.isEmpty()) { + return new HashMap<>(); + } + return userRepository.findByIdIn(userIds).stream() + .collect(Collectors.toMap(User::getId, u -> u, (a, b) -> a)); + } + + private String resolveUserName(Map userMap, PayrollEntry entry) { + User user = userMap.get(entry.getEmployee().getUserId()); + if (user == null) { + return null; + } + return user.getFullName() != null ? user.getFullName() : user.getUsername(); + } + + private static String monthName(Integer month) { + if (month == null || month < 1 || month > 12) { + return String.valueOf(month); + } + return Month.of(month).getDisplayName(TextStyle.FULL, Locale.ENGLISH); + } + + private static String escHtml(String s) { + if (s == null) return ""; + return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } + private PayslipDTO toDTO(Payslip p) { return PayslipDTO.builder() .id(p.getId()) @@ -170,6 +461,7 @@ private PayslipDTO toDTO(Payslip p) { .generatedAt(p.getGeneratedAt()) .emailedAt(p.getEmailedAt()) .emailStatus(p.getEmailStatus()) + .currency(p.getCurrency() != null ? p.getCurrency() : "INR") .build(); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/controller/SalaryComponentController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/controller/SalaryComponentController.java index 31db4ffaef..b8fcf0b6d7 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/controller/SalaryComponentController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/controller/SalaryComponentController.java @@ -3,7 +3,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; import vacademy.io.admin_core_service.features.hr_salary.dto.SalaryComponentDTO; import vacademy.io.admin_core_service.features.hr_salary.service.SalaryComponentService; import vacademy.io.common.auth.model.CustomUserDetails; @@ -18,14 +19,19 @@ public class SalaryComponentController { private SalaryComponentService salaryComponentService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; @PostMapping + @Auditable( + entityType = "HR_SALARY_COMPONENT", + action = "CREATE", + entityIdExpr = "#result?.body", + descriptionExpr = "'created salary component ' + #dto?.name") public ResponseEntity createComponent( @RequestBody SalaryComponentDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrAdmin(user, instituteId); String id = salaryComponentService.createComponent(dto, instituteId); return ResponseEntity.ok(id); } @@ -34,19 +40,24 @@ public ResponseEntity createComponent( public ResponseEntity> getComponents( @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrStaff(user, instituteId); List components = salaryComponentService.getComponents(instituteId); return ResponseEntity.ok(components); } @PutMapping("/{id}") + @Auditable( + entityType = "HR_SALARY_COMPONENT", + action = "UPDATE", + entityIdExpr = "#id", + descriptionExpr = "'updated salary component ' + (#dto?.name ?: #id)") public ResponseEntity updateComponent( @PathVariable("id") String id, @RequestBody SalaryComponentDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String updatedId = salaryComponentService.updateComponent(id, dto); + hrAccessGuard.requireHrAdmin(user, instituteId); + String updatedId = salaryComponentService.updateComponent(id, dto, instituteId); return ResponseEntity.ok(updatedId); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/controller/SalaryStructureController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/controller/SalaryStructureController.java index 83fd8395ec..a884a9c1e5 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/controller/SalaryStructureController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/controller/SalaryStructureController.java @@ -3,7 +3,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; import vacademy.io.admin_core_service.features.hr_salary.dto.AssignSalaryDTO; import vacademy.io.admin_core_service.features.hr_salary.dto.EmployeeSalaryStructureDTO; import vacademy.io.admin_core_service.features.hr_salary.dto.SalaryRevisionDTO; @@ -20,14 +21,19 @@ public class SalaryStructureController { private SalaryStructureService salaryStructureService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; @PostMapping + @Auditable( + entityType = "HR_SALARY_STRUCTURE", + action = "ASSIGN", + entityIdExpr = "#result?.body", + descriptionExpr = "'assigned salary structure to employee ' + #dto?.employeeId") public ResponseEntity assignSalary( @RequestBody AssignSalaryDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrAdmin(user, instituteId); String structureId = salaryStructureService.assignSalary(dto, instituteId, user.getUserId()); return ResponseEntity.ok(structureId); } @@ -37,8 +43,9 @@ public ResponseEntity getStructure( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - EmployeeSalaryStructureDTO structure = salaryStructureService.getStructure(id); + // Self-or-HR-staff check happens inside the service: the owning employee + // is only known after the structure is loaded by id. + EmployeeSalaryStructureDTO structure = salaryStructureService.getStructure(id, instituteId, user); return ResponseEntity.ok(structure); } @@ -47,7 +54,7 @@ public ResponseEntity> getEmployeeSalaryHistory @RequestParam("employeeId") String employeeId, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); List history = salaryStructureService.getEmployeeSalaryHistory(employeeId); return ResponseEntity.ok(history); } @@ -57,7 +64,7 @@ public ResponseEntity> getRevisionHistory( @RequestParam("employeeId") String employeeId, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); List revisions = salaryStructureService.getRevisionHistory(employeeId); return ResponseEntity.ok(revisions); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/controller/SalaryTemplateController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/controller/SalaryTemplateController.java index c35f83ea07..264d10fab9 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/controller/SalaryTemplateController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/controller/SalaryTemplateController.java @@ -3,7 +3,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; import vacademy.io.admin_core_service.features.hr_salary.dto.SalaryTemplateDTO; import vacademy.io.admin_core_service.features.hr_salary.service.SalaryTemplateService; import vacademy.io.common.auth.model.CustomUserDetails; @@ -18,15 +19,20 @@ public class SalaryTemplateController { private SalaryTemplateService salaryTemplateService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; @PostMapping + @Auditable( + entityType = "HR_SALARY_TEMPLATE", + action = "CREATE", + entityIdExpr = "#result?.body", + descriptionExpr = "'created salary template ' + #dto?.name") public ResponseEntity createTemplate( @RequestBody SalaryTemplateDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String id = salaryTemplateService.createTemplate(dto); + hrAccessGuard.requireHrAdmin(user, instituteId); + String id = salaryTemplateService.createTemplate(dto, instituteId); return ResponseEntity.ok(id); } @@ -34,7 +40,7 @@ public ResponseEntity createTemplate( public ResponseEntity> getTemplates( @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrStaff(user, instituteId); List templates = salaryTemplateService.getTemplates(instituteId); return ResponseEntity.ok(templates); } @@ -44,19 +50,24 @@ public ResponseEntity getTemplateById( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - SalaryTemplateDTO template = salaryTemplateService.getTemplateById(id); + hrAccessGuard.requireHrStaff(user, instituteId); + SalaryTemplateDTO template = salaryTemplateService.getTemplateById(id, instituteId); return ResponseEntity.ok(template); } @PutMapping("/{id}") + @Auditable( + entityType = "HR_SALARY_TEMPLATE", + action = "UPDATE", + entityIdExpr = "#id", + descriptionExpr = "'updated salary template ' + (#dto?.name ?: #id)") public ResponseEntity updateTemplate( @PathVariable("id") String id, @RequestBody SalaryTemplateDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String updatedId = salaryTemplateService.updateTemplate(id, dto); + hrAccessGuard.requireHrAdmin(user, instituteId); + String updatedId = salaryTemplateService.updateTemplate(id, dto, instituteId); return ResponseEntity.ok(updatedId); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/dto/AssignSalaryDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/dto/AssignSalaryDTO.java index 712d439950..76f8f77425 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/dto/AssignSalaryDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/dto/AssignSalaryDTO.java @@ -23,5 +23,7 @@ public class AssignSalaryDTO { private BigDecimal ctcAnnual; private LocalDate effectiveFrom; private String revisionReason; + /** Optional ISO-4217 code (e.g. INR, USD); defaults to INR. */ + private String currency; private List componentOverrides; } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/dto/EmployeeSalaryStructureDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/dto/EmployeeSalaryStructureDTO.java index a5ecb5f372..abda53ec56 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/dto/EmployeeSalaryStructureDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/dto/EmployeeSalaryStructureDTO.java @@ -29,6 +29,7 @@ public class EmployeeSalaryStructureDTO { private BigDecimal ctcMonthly; private BigDecimal grossMonthly; private BigDecimal netMonthly; + private String currency; private String status; private String revisionReason; private List components; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/entity/EmployeeSalaryStructure.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/entity/EmployeeSalaryStructure.java index c9ad9b9e40..116caec5b9 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/entity/EmployeeSalaryStructure.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/entity/EmployeeSalaryStructure.java @@ -67,6 +67,13 @@ public class EmployeeSalaryStructure { @OneToMany(mappedBy = "salaryStructure", fetch = FetchType.LAZY) private List components; + @Version + @Column(name = "version") + private Long version; + + @Column(name = "currency", length = 3) + private String currency; + @Column(name = "created_at", insertable = false, updatable = false) private LocalDateTime createdAt; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/entity/SalaryComponent.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/entity/SalaryComponent.java index 5156665216..dd01ce10ea 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/entity/SalaryComponent.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/entity/SalaryComponent.java @@ -52,6 +52,10 @@ public class SalaryComponent { @Column(name = "description", columnDefinition = "TEXT") private String description; + /** GL account this component posts to (V484); null -> type-based default. */ + @Column(name = "gl_account_code", length = 50) + private String glAccountCode; + @Column(name = "created_at", insertable = false, updatable = false) private LocalDateTime createdAt; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryComponentService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryComponentService.java index c28c010c4b..1fb1921b1b 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryComponentService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryComponentService.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_salary.dto.SalaryComponentDTO; import vacademy.io.admin_core_service.features.hr_salary.entity.SalaryComponent; import vacademy.io.admin_core_service.features.hr_salary.enums.ComponentCategory; @@ -20,6 +21,9 @@ public class SalaryComponentService { @Autowired private SalaryComponentRepository salaryComponentRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + @Transactional public String createComponent(SalaryComponentDTO dto, String instituteId) { if (!StringUtils.hasText(dto.getName())) { @@ -66,9 +70,10 @@ public String createComponent(SalaryComponentDTO dto, String instituteId) { } @Transactional - public String updateComponent(String id, SalaryComponentDTO dto) { + public String updateComponent(String id, SalaryComponentDTO dto, String instituteId) { SalaryComponent component = salaryComponentRepository.findById(id) .orElseThrow(() -> new VacademyException("Salary component not found")); + hrAccessGuard.requireInstituteMatch(component.getInstituteId(), instituteId, "Salary component"); if (StringUtils.hasText(dto.getName())) { component.setName(dto.getName()); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryStructureService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryStructureService.java index c7e09aeb10..5c42eca2ae 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryStructureService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryStructureService.java @@ -1,16 +1,22 @@ package vacademy.io.admin_core_service.features.hr_salary.service; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.SimpleEvaluationContext; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; import vacademy.io.admin_core_service.features.hr_salary.dto.*; import vacademy.io.admin_core_service.features.hr_salary.entity.*; import vacademy.io.admin_core_service.features.hr_salary.enums.CalculationType; +import vacademy.io.admin_core_service.features.hr_salary.enums.ComponentCategory; import vacademy.io.admin_core_service.features.hr_salary.enums.ComponentType; import vacademy.io.admin_core_service.features.hr_salary.repository.*; +import vacademy.io.common.auth.model.CustomUserDetails; import vacademy.io.common.exceptions.VacademyException; import java.math.BigDecimal; @@ -22,6 +28,13 @@ @Service public class SalaryStructureService { + private static final String DEFAULT_CURRENCY = "INR"; + private static final String SPECIAL_ALLOWANCE_CODE = "SPECIAL_ALLOWANCE"; + /** Annual rounding tolerance (1 rupee) for the CTC tie-out. */ + private static final BigDecimal CTC_TOLERANCE = BigDecimal.ONE; + + private static final SpelExpressionParser SPEL_PARSER = new SpelExpressionParser(); + @Autowired private EmployeeProfileRepository employeeProfileRepository; @@ -31,6 +44,9 @@ public class SalaryStructureService { @Autowired private EmployeeSalaryComponentRepository salaryComponentRepository; + @Autowired + private SalaryComponentRepository masterComponentRepository; + @Autowired private SalaryTemplateRepository salaryTemplateRepository; @@ -40,10 +56,13 @@ public class SalaryStructureService { @Autowired private SalaryRevisionRepository salaryRevisionRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + /** * Assigns a salary structure to an employee based on a template and CTC. * Handles component resolution order: Basic first, then percentage-of-basic, - * then gross-dependent components, then deductions. + * then gross-dependent components, then formulas — followed by a CTC tie-out. */ @Transactional public String assignSalary(AssignSalaryDTO dto, String instituteId, String approverUserId) { @@ -61,11 +80,14 @@ public String assignSalary(AssignSalaryDTO dto, String instituteId, String appro throw new VacademyException("Effective from date is required"); } - // 1. Find EmployeeProfile + // 1. Find EmployeeProfile — must belong to the validated institute (cross-tenant IDOR fix) EmployeeProfile employee = employeeProfileRepository.findById(dto.getEmployeeId()) .orElseThrow(() -> new VacademyException("Employee not found with id: " + dto.getEmployeeId())); + hrAccessGuard.requireInstituteMatch(employee.getInstituteId(), instituteId, "Employee"); - // 2. Find and supersede any active structure + // 2. Find and supersede any active structure (effective-dated revision). + // The old structure ends the day before the new one starts, so payroll's + // effective-date selection has real, non-overlapping windows to pick from. BigDecimal oldCtc = null; EmployeeSalaryStructure oldStructure = null; @@ -75,14 +97,22 @@ public String assignSalary(AssignSalaryDTO dto, String instituteId, String appro if (activeStructureOpt.isPresent()) { oldStructure = activeStructureOpt.get(); oldCtc = oldStructure.getCtcAnnual(); + + if (!oldStructure.getEffectiveFrom().isBefore(dto.getEffectiveFrom())) { + throw new VacademyException( + "New salary structure must start after the current structure's effective date (" + + oldStructure.getEffectiveFrom() + "). Backdating over an existing structure is not supported."); + } + oldStructure.setStatus("SUPERSEDED"); oldStructure.setEffectiveTo(dto.getEffectiveFrom().minusDays(1)); salaryStructureRepository.save(oldStructure); } - // 3. Load template + // 3. Load template — must belong to the validated institute (cross-tenant IDOR fix) SalaryTemplate template = salaryTemplateRepository.findById(dto.getTemplateId()) .orElseThrow(() -> new VacademyException("Salary template not found with id: " + dto.getTemplateId())); + hrAccessGuard.requireInstituteMatch(template.getInstituteId(), instituteId, "Salary template"); // 4. Create new EmployeeSalaryStructure BigDecimal ctcMonthly = dto.getCtcAnnual().divide(BigDecimal.valueOf(12), 2, RoundingMode.HALF_UP); @@ -94,6 +124,7 @@ public String assignSalary(AssignSalaryDTO dto, String instituteId, String appro newStructure.setCtcAnnual(dto.getCtcAnnual()); newStructure.setCtcMonthly(ctcMonthly); newStructure.setStatus("ACTIVE"); + newStructure.setCurrency(normalizeCurrency(dto.getCurrency())); newStructure.setRevisionReason(dto.getRevisionReason()); newStructure.setApprovedBy(approverUserId); newStructure.setApprovedAt(LocalDateTime.now()); @@ -118,9 +149,9 @@ public String assignSalary(AssignSalaryDTO dto, String instituteId, String appro } } - // 7. Calculate component amounts in dependency order + // 7. Calculate component amounts in dependency order (includes CTC tie-out) List calculatedComponents = calculateComponentAmounts( - templateComponents, ctcMonthly, overrideMap, newStructure); + templateComponents, dto.getCtcAnnual(), ctcMonthly, overrideMap, newStructure, instituteId); // 8. Save all employee salary components salaryComponentRepository.saveAll(calculatedComponents); @@ -171,17 +202,26 @@ public String assignSalary(AssignSalaryDTO dto, String instituteId, String appro /** * Calculates salary component amounts respecting dependency order: - * Phase 1: FIXED_AMOUNT and PERCENTAGE_OF_CTC (these have no dependencies) - * Phase 2: PERCENTAGE_OF_BASIC (depends on Basic being resolved in Phase 1) - * Phase 3: PERCENTAGE_OF_GROSS (depends on all EARNING components being resolved) + * Phase 1: FIXED_AMOUNT (no dependencies) + * Phase 2: PERCENTAGE_OF_CTC (depends only on CTC) + * Phase 3: PERCENTAGE_OF_BASIC (depends on Basic being resolved in Phase 1/2) + * Phase 4: PERCENTAGE_OF_GROSS (depends on the phase 1-3 earnings base; see below) + * Phase 5: FORMULA (SpEL, may reference any already-resolved component) + * + * After all phases a CTC tie-out runs: EARNING + EMPLOYER_CONTRIBUTION annual + * amounts must equal the CTC. A shortfall is absorbed by a system-managed + * "Special Allowance" balancing component; an overshoot (beyond a 1-rupee + * rounding tolerance) is a template configuration error and throws. * * Overrides bypass calculation and use the provided monthly amount directly. */ private List calculateComponentAmounts( List templateComponents, + BigDecimal ctcAnnual, BigDecimal ctcMonthly, Map overrideMap, - EmployeeSalaryStructure structure) { + EmployeeSalaryStructure structure, + String instituteId) { // Separate components by calculation type for ordered processing List fixedComponents = new ArrayList<>(); @@ -272,7 +312,12 @@ private List calculateComponentAmounts( } // PHASE 4: Process PERCENTAGE_OF_GROSS components - // Gross = sum of all EARNING components resolved so far + // GROSS SEMANTICS: "gross" is defined as the sum of all EARNING components + // resolved in phases 1-3 (FIXED_AMOUNT, PERCENTAGE_OF_CTC, PERCENTAGE_OF_BASIC), + // i.e. all non-GROSS-based earnings. The base is computed ONCE here and every + // PERCENTAGE_OF_GROSS component resolves against that same base. GROSS-based + // components therefore do NOT compound on each other — this is deliberate and + // makes the result deterministic regardless of template display order. BigDecimal earningsTotal = resultMap.values().stream() .filter(c -> ComponentType.EARNING.name().equals(c.getComponent().getType())) .map(EmployeeSalaryComponent::getMonthlyAmount) @@ -297,7 +342,13 @@ private List calculateComponentAmounts( structure, tc, monthlyAmount, isOverridden)); } - // PHASE 5: Process FORMULA components (placeholder -- treat as zero unless overridden) + // PHASE 5: Process FORMULA components via SpEL, in template display order. + // The formula result is the MONTHLY amount. Available variables: + // #CTC (annual CTC), #CTC_MONTHLY, #BASIC (monthly basic, 0 if absent), + // #GROSS (monthly gross of phases 1-3), and # = monthly + // amount of every already-resolved component (uppercased, non-alphanumeric + // characters replaced with '_'). Formulas resolve in display order, so a + // formula may also reference earlier FORMULA components by code. for (SalaryTemplateComponent tc : formulaComponents) { String componentId = tc.getComponent().getId(); BigDecimal monthlyAmount; @@ -307,17 +358,155 @@ private List calculateComponentAmounts( monthlyAmount = overrideMap.get(componentId); isOverridden = true; } else { - // Formula evaluation is not implemented; default to fixed_value or zero - monthlyAmount = tc.getFixedValue() != null ? tc.getFixedValue() : BigDecimal.ZERO; + monthlyAmount = evaluateFormula(tc, ctcAnnual, ctcMonthly, basicMonthly, earningsTotal, resultMap); + monthlyAmount = clampValue(monthlyAmount, tc.getMinValue(), tc.getMaxValue()); } resultMap.put(componentId, buildEmployeeSalaryComponent( structure, tc, monthlyAmount, isOverridden)); } + // CTC TIE-OUT: EARNING + EMPLOYER_CONTRIBUTION annual amounts must sum to the + // CTC. Without this, a template of e.g. 40% + 20% silently pays 60% of CTC. + applyCtcTieOut(resultMap, ctcAnnual, structure, instituteId); + return new ArrayList<>(resultMap.values()); } + /** + * Evaluates a FORMULA component with SpEL. Uses SimpleEvaluationContext (data + * binding only — no reflection, type references or bean access) for safety. + * The result is interpreted as the MONTHLY amount. + */ + private BigDecimal evaluateFormula( + SalaryTemplateComponent tc, + BigDecimal ctcAnnual, + BigDecimal ctcMonthly, + BigDecimal basicMonthly, + BigDecimal grossMonthly, + Map resultMap) { + + String formula = tc.getFormula(); + String componentName = tc.getComponent().getName(); + + if (!StringUtils.hasText(formula)) { + throw new VacademyException( + "Component '" + componentName + "' uses FORMULA calculation but has no formula defined"); + } + + SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); + context.setVariable("CTC", ctcAnnual); + context.setVariable("CTC_MONTHLY", ctcMonthly); + context.setVariable("BASIC", basicMonthly != null ? basicMonthly : BigDecimal.ZERO); + context.setVariable("GROSS", grossMonthly); + + // Every already-resolved component is exposed by its sanitized code + for (EmployeeSalaryComponent resolved : resultMap.values()) { + String code = resolved.getComponent().getCode(); + if (StringUtils.hasText(code)) { + context.setVariable(sanitizeVariableName(code), resolved.getMonthlyAmount()); + } + } + + try { + Expression expression = SPEL_PARSER.parseExpression(formula); + BigDecimal value = expression.getValue(context, BigDecimal.class); + if (value == null) { + throw new VacademyException( + "Formula for component '" + componentName + "' evaluated to null: " + formula); + } + return value.setScale(2, RoundingMode.HALF_UP); + } catch (VacademyException e) { + throw e; + } catch (Exception e) { + throw new VacademyException( + "Invalid formula for component '" + componentName + "': " + formula + + " (" + e.getMessage() + ")"); + } + } + + /** Uppercases a component code and replaces non-alphanumeric characters with '_'. */ + private String sanitizeVariableName(String code) { + return code.toUpperCase().replaceAll("[^A-Z0-9]", "_"); + } + + /** + * Ensures EARNING + EMPLOYER_CONTRIBUTION annual amounts tie out to the CTC. + * A shortfall of at least 1 rupee annually is absorbed by a system-managed + * "Special Allowance" balancing component (adjusted in place if the template + * already contains one); an overshoot beyond the 1-rupee rounding tolerance + * throws — such a template is misconfigured. + */ + private void applyCtcTieOut( + Map resultMap, + BigDecimal ctcAnnual, + EmployeeSalaryStructure structure, + String instituteId) { + + BigDecimal totalAnnual = resultMap.values().stream() + .filter(c -> ComponentType.EARNING.name().equals(c.getComponent().getType()) + || ComponentType.EMPLOYER_CONTRIBUTION.name().equals(c.getComponent().getType())) + .map(EmployeeSalaryComponent::getAnnualAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal residual = ctcAnnual.subtract(totalAnnual); + + if (residual.compareTo(CTC_TOLERANCE.negate()) < 0) { + throw new VacademyException( + "Salary template components exceed CTC by " + residual.abs().setScale(2, RoundingMode.HALF_UP) + + " annually (components total " + totalAnnual.setScale(2, RoundingMode.HALF_UP) + + " against CTC " + ctcAnnual.setScale(2, RoundingMode.HALF_UP) + + "). Fix the template so components do not exceed CTC."); + } + + if (residual.compareTo(CTC_TOLERANCE) < 0) { + // Within rounding tolerance — nothing to balance + return; + } + + // Adjust an existing Special Allowance from the template, if present + for (EmployeeSalaryComponent existing : resultMap.values()) { + if (SPECIAL_ALLOWANCE_CODE.equalsIgnoreCase(existing.getComponent().getCode())) { + BigDecimal newAnnual = existing.getAnnualAmount().add(residual); + existing.setAnnualAmount(newAnnual); + existing.setMonthlyAmount(newAnnual.divide(BigDecimal.valueOf(12), 2, RoundingMode.HALF_UP)); + return; + } + } + + // Otherwise add a balancing Special Allowance component + SalaryComponent specialAllowance = getOrCreateSpecialAllowanceComponent(instituteId); + + EmployeeSalaryComponent balancing = new EmployeeSalaryComponent(); + balancing.setSalaryStructure(structure); + balancing.setComponent(specialAllowance); + balancing.setAnnualAmount(residual); + balancing.setMonthlyAmount(residual.divide(BigDecimal.valueOf(12), 2, RoundingMode.HALF_UP)); + balancing.setCalculationType(CalculationType.FIXED_AMOUNT.name()); + balancing.setIsOverridden(false); + + resultMap.put(specialAllowance.getId(), balancing); + } + + /** Get-or-create the institute's system-managed Special Allowance salary component. */ + private SalaryComponent getOrCreateSpecialAllowanceComponent(String instituteId) { + return masterComponentRepository.findByInstituteIdAndCode(instituteId, SPECIAL_ALLOWANCE_CODE) + .orElseGet(() -> { + SalaryComponent component = new SalaryComponent(); + component.setInstituteId(instituteId); + component.setName("Special Allowance"); + component.setCode(SPECIAL_ALLOWANCE_CODE); + component.setType(ComponentType.EARNING.name()); + component.setCategory(ComponentCategory.FIXED.name()); + component.setIsTaxable(true); + component.setIsStatutory(false); + component.setIsActive(true); + component.setDescription( + "System-managed balancing component: absorbs the CTC residual left after all template components are resolved."); + return masterComponentRepository.save(component); + }); + } + /** * Resolves the Basic salary amount from already-calculated components. * Searches by component code "BASIC" (case-insensitive). @@ -348,6 +537,21 @@ private BigDecimal resolveBasicAmount( return BigDecimal.ZERO; } + /** + * Normalizes an optional ISO-4217 currency code: defaults to INR, trims, + * uppercases and sanity-checks the 3-letter shape. + */ + private String normalizeCurrency(String currency) { + if (!StringUtils.hasText(currency)) { + return DEFAULT_CURRENCY; + } + String normalized = currency.trim().toUpperCase(); + if (!normalized.matches("[A-Z]{3}")) { + throw new VacademyException("Invalid currency code: " + currency + ". Expected a 3-letter code like INR or USD."); + } + return normalized; + } + /** * Clamps a value between min and max bounds (if specified). */ @@ -383,12 +587,17 @@ private EmployeeSalaryComponent buildEmployeeSalaryComponent( /** * Gets a salary structure by ID with all its components. + * The owning employee is only known after the load, so the self-or-HR-staff + * check (institute membership + employee-in-institute + caller is HR staff + * or IS that employee) happens here rather than in the controller. */ @Transactional(readOnly = true) - public EmployeeSalaryStructureDTO getStructure(String structureId) { + public EmployeeSalaryStructureDTO getStructure(String structureId, String instituteId, CustomUserDetails user) { EmployeeSalaryStructure structure = salaryStructureRepository.findById(structureId) .orElseThrow(() -> new VacademyException("Salary structure not found")); + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, structure.getEmployee().getId()); + return toStructureDTO(structure); } @@ -429,6 +638,7 @@ private EmployeeSalaryStructureDTO toStructureDTO(EmployeeSalaryStructure struct .ctcMonthly(structure.getCtcMonthly()) .grossMonthly(structure.getGrossMonthly()) .netMonthly(structure.getNetMonthly()) + .currency(structure.getCurrency() != null ? structure.getCurrency() : DEFAULT_CURRENCY) .status(structure.getStatus()) .revisionReason(structure.getRevisionReason()) .build(); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryTemplateService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryTemplateService.java index d7e7456249..bebbda9252 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryTemplateService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryTemplateService.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_salary.dto.SalaryTemplateComponentDTO; import vacademy.io.admin_core_service.features.hr_salary.dto.SalaryTemplateDTO; import vacademy.io.admin_core_service.features.hr_salary.entity.SalaryComponent; @@ -31,17 +32,18 @@ public class SalaryTemplateService { @Autowired private SalaryComponentRepository salaryComponentRepository; + @Autowired + private HrAccessGuard hrAccessGuard; + @Transactional - public String createTemplate(SalaryTemplateDTO dto) { + public String createTemplate(SalaryTemplateDTO dto, String instituteId) { if (!StringUtils.hasText(dto.getName())) { throw new VacademyException("Template name is required"); } - if (!StringUtils.hasText(dto.getInstituteId())) { - throw new VacademyException("Institute ID is required"); - } SalaryTemplate template = new SalaryTemplate(); - template.setInstituteId(dto.getInstituteId()); + // Always the validated query param — never the DTO's instituteId (cross-tenant write hole). + template.setInstituteId(instituteId); template.setName(dto.getName()); template.setDescription(dto.getDescription()); template.setIsDefault(dto.getIsDefault() != null ? dto.getIsDefault() : false); @@ -51,7 +53,7 @@ public String createTemplate(SalaryTemplateDTO dto) { // Save template components if (dto.getComponents() != null && !dto.getComponents().isEmpty()) { - List templateComponents = buildTemplateComponents(dto.getComponents(), template); + List templateComponents = buildTemplateComponents(dto.getComponents(), template, instituteId); salaryTemplateComponentRepository.saveAll(templateComponents); template.setComponents(templateComponents); } @@ -60,9 +62,10 @@ public String createTemplate(SalaryTemplateDTO dto) { } @Transactional - public String updateTemplate(String id, SalaryTemplateDTO dto) { + public String updateTemplate(String id, SalaryTemplateDTO dto, String instituteId) { SalaryTemplate template = salaryTemplateRepository.findById(id) .orElseThrow(() -> new VacademyException("Salary template not found")); + hrAccessGuard.requireInstituteMatch(template.getInstituteId(), instituteId, "Salary template"); if (StringUtils.hasText(dto.getName())) { template.setName(dto.getName()); @@ -83,7 +86,7 @@ public String updateTemplate(String id, SalaryTemplateDTO dto) { if (dto.getComponents() != null) { salaryTemplateComponentRepository.deleteByTemplateId(id); if (!dto.getComponents().isEmpty()) { - List templateComponents = buildTemplateComponents(dto.getComponents(), template); + List templateComponents = buildTemplateComponents(dto.getComponents(), template, instituteId); salaryTemplateComponentRepository.saveAll(templateComponents); } } @@ -102,9 +105,10 @@ public List getTemplates(String instituteId) { } @Transactional(readOnly = true) - public SalaryTemplateDTO getTemplateById(String id) { + public SalaryTemplateDTO getTemplateById(String id, String instituteId) { SalaryTemplate template = salaryTemplateRepository.findById(id) .orElseThrow(() -> new VacademyException("Salary template not found")); + hrAccessGuard.requireInstituteMatch(template.getInstituteId(), instituteId, "Salary template"); SalaryTemplateDTO dto = toDTO(template); @@ -119,7 +123,7 @@ public SalaryTemplateDTO getTemplateById(String id) { } private List buildTemplateComponents( - List componentDTOs, SalaryTemplate template) { + List componentDTOs, SalaryTemplate template, String instituteId) { List templateComponents = new ArrayList<>(); @@ -131,6 +135,7 @@ private List buildTemplateComponents( SalaryComponent salaryComponent = salaryComponentRepository.findById(compDTO.getComponentId()) .orElseThrow(() -> new VacademyException( "Salary component not found with id: " + compDTO.getComponentId())); + hrAccessGuard.requireInstituteMatch(salaryComponent.getInstituteId(), instituteId, "Salary component"); String calcType = StringUtils.hasText(compDTO.getCalculationType()) ? compDTO.getCalculationType() : "FIXED_AMOUNT"; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/controller/TaxController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/controller/TaxController.java index b593ed1530..d22132002b 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/controller/TaxController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/controller/TaxController.java @@ -3,7 +3,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; import vacademy.io.admin_core_service.features.hr_tax.dto.TaxComputationDTO; import vacademy.io.admin_core_service.features.hr_tax.dto.TaxConfigurationDTO; import vacademy.io.admin_core_service.features.hr_tax.dto.TaxDeclarationDTO; @@ -28,17 +30,22 @@ public class TaxController { private TaxComputationService taxComputationService; @Autowired - private InstituteAccessValidator instituteAccessValidator; + private HrAccessGuard hrAccessGuard; // ======================== Tax Configuration ======================== @PostMapping("/config") + @Auditable( + entityType = "HR_TAX_CONFIG", + action = "UPDATE", + entityIdExpr = "#result?.body", + descriptionExpr = "'saved tax configuration for institute ' + #instituteId") public ResponseEntity saveConfig( @RequestBody TaxConfigurationDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String id = taxConfigurationService.saveConfig(dto); + hrAccessGuard.requireHrAdmin(user, instituteId); + String id = taxConfigurationService.saveConfig(dto, instituteId); return ResponseEntity.ok(id); } @@ -46,7 +53,7 @@ public ResponseEntity saveConfig( public ResponseEntity getConfig( @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireHrStaff(user, instituteId); TaxConfigurationDTO config = taxConfigurationService.getConfig(instituteId); return ResponseEntity.ok(config); } @@ -58,8 +65,8 @@ public ResponseEntity submitDeclaration( @RequestBody TaxDeclarationDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String id = taxDeclarationService.submitDeclaration(dto); + EmployeeProfile employee = hrAccessGuard.requireSelfOrHrStaff(user, instituteId, dto.getEmployeeId()); + String id = taxDeclarationService.submitDeclaration(dto, employee); return ResponseEntity.ok(id); } @@ -69,7 +76,7 @@ public ResponseEntity getDeclaration( @RequestParam("fy") String financialYear, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); TaxDeclarationDTO declaration = taxDeclarationService.getDeclaration(employeeId, financialYear); return ResponseEntity.ok(declaration); } @@ -80,18 +87,24 @@ public ResponseEntity updateDeclaration( @RequestBody TaxDeclarationDTO dto, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = taxDeclarationService.updateDeclaration(id, dto); + // Self-or-HR-staff check happens inside the service: the owning employee + // is only known after the declaration is loaded by id. + String resultId = taxDeclarationService.updateDeclaration(id, dto, instituteId, user); return ResponseEntity.ok(resultId); } @PutMapping("/declarations/{id}/verify") + @Auditable( + entityType = "HR_TAX_DECLARATION", + action = "VERIFY", + entityIdExpr = "#id", + descriptionExpr = "'verified tax declaration ' + #id") public ResponseEntity verifyDeclaration( @PathVariable("id") String id, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); - String resultId = taxDeclarationService.verifyDeclaration(id, user.getUserId()); + hrAccessGuard.requireHrAdmin(user, instituteId); + String resultId = taxDeclarationService.verifyDeclaration(id, instituteId, user.getUserId()); return ResponseEntity.ok(resultId); } @@ -103,7 +116,7 @@ public ResponseEntity> getComputation( @RequestParam("fy") String financialYear, @RequestParam("instituteId") String instituteId, @RequestAttribute("user") CustomUserDetails user) { - instituteAccessValidator.validateUserAccess(user, instituteId); + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); List computations = taxComputationService.getComputation(employeeId, financialYear); return ResponseEntity.ok(computations); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/repository/TaxComputationRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/repository/TaxComputationRepository.java index c09f8a89be..ab6268e028 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/repository/TaxComputationRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/repository/TaxComputationRepository.java @@ -4,10 +4,20 @@ import org.springframework.stereotype.Repository; import vacademy.io.admin_core_service.features.hr_tax.entity.TaxComputation; +import org.springframework.data.jpa.repository.Modifying; + import java.util.List; +import java.util.Optional; @Repository public interface TaxComputationRepository extends JpaRepository { List findByEmployee_IdAndFinancialYearOrderByMonthAsc(String employeeId, String financialYear); + + /** One row per employee per period (V480 unique) — payroll upserts instead of appending. */ + Optional findByEmployee_IdAndFinancialYearAndMonthAndYear( + String employeeId, String financialYear, Integer month, Integer year); + + @Modifying + void deleteByEmployee_IdAndMonthAndYear(String employeeId, Integer month, Integer year); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/repository/TaxConfigurationRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/repository/TaxConfigurationRepository.java index 298f4979e4..afb15750e8 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/repository/TaxConfigurationRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/repository/TaxConfigurationRepository.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Repository; import vacademy.io.admin_core_service.features.hr_tax.entity.TaxConfiguration; +import java.util.List; import java.util.Optional; @Repository @@ -11,7 +12,14 @@ public interface TaxConfigurationRepository extends JpaRepository findByInstituteIdAndCountryCode(String instituteId, String countryCode); + // NOTE: UNIQUE is (institute_id, country_code) — an institute may hold one config per + // country, so the single-row finders below throw NonUniqueResult once a second country + // is configured. Use the List forms for any lookup not keyed by country. Optional findByInstituteIdAndStatus(String instituteId, String status); Optional findByInstituteId(String instituteId); + + List findAllByInstituteIdAndStatus(String instituteId, String status); + + List findAllByInstituteId(String instituteId); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/TaxConfigurationService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/TaxConfigurationService.java index b464aeb3d0..2e7a20fd4f 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/TaxConfigurationService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/TaxConfigurationService.java @@ -8,6 +8,7 @@ import vacademy.io.admin_core_service.features.hr_tax.repository.TaxConfigurationRepository; import vacademy.io.common.exceptions.VacademyException; +import java.util.List; import java.util.Optional; @Service @@ -17,17 +18,19 @@ public class TaxConfigurationService { private TaxConfigurationRepository taxConfigurationRepository; @Transactional - public String saveConfig(TaxConfigurationDTO dto) { - // Upsert by instituteId + countryCode + public String saveConfig(TaxConfigurationDTO dto, String instituteId) { + // Upsert by validated instituteId + countryCode — never the DTO's + // instituteId (cross-tenant write hole: a user could overwrite another + // institute's tax config by putting its id in the body). Optional existingOpt = taxConfigurationRepository - .findByInstituteIdAndCountryCode(dto.getInstituteId(), dto.getCountryCode()); + .findByInstituteIdAndCountryCode(instituteId, dto.getCountryCode()); TaxConfiguration config; if (existingOpt.isPresent()) { config = existingOpt.get(); } else { config = new TaxConfiguration(); - config.setInstituteId(dto.getInstituteId()); + config.setInstituteId(instituteId); config.setCountryCode(dto.getCountryCode()); } @@ -44,10 +47,16 @@ public String saveConfig(TaxConfigurationDTO dto) { @Transactional(readOnly = true) public TaxConfigurationDTO getConfig(String instituteId) { - TaxConfiguration config = taxConfigurationRepository.findByInstituteIdAndStatus(instituteId, "ACTIVE") - .orElseThrow(() -> new VacademyException("Tax configuration not found for institute")); + // List form: (institute_id, country_code) is the unique key, so the + // single-row finder throws NonUniqueResult once a second country is + // configured. No countryCode at hand here — take the first active config. + List configs = taxConfigurationRepository + .findAllByInstituteIdAndStatus(instituteId, "ACTIVE"); + if (configs.isEmpty()) { + throw new VacademyException("Tax configuration not found for institute"); + } - return toDTO(config); + return toDTO(configs.get(0)); } private TaxConfigurationDTO toDTO(TaxConfiguration config) { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/TaxDeclarationService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/TaxDeclarationService.java index 01a5254607..b39d971756 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/TaxDeclarationService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/TaxDeclarationService.java @@ -3,12 +3,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; -import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; import vacademy.io.admin_core_service.features.hr_tax.dto.TaxDeclarationDTO; import vacademy.io.admin_core_service.features.hr_tax.entity.TaxDeclaration; import vacademy.io.admin_core_service.features.hr_tax.enums.DeclarationStatus; import vacademy.io.admin_core_service.features.hr_tax.repository.TaxDeclarationRepository; +import vacademy.io.common.auth.model.CustomUserDetails; import vacademy.io.common.exceptions.VacademyException; import java.time.LocalDateTime; @@ -21,16 +22,18 @@ public class TaxDeclarationService { private TaxDeclarationRepository taxDeclarationRepository; @Autowired - private EmployeeProfileRepository employeeProfileRepository; + private HrAccessGuard hrAccessGuard; + /** + * The employee is the one already resolved and institute/self-checked by + * {@link HrAccessGuard#requireSelfOrHrStaff} in the controller — never + * re-fetched here from an unchecked dto id. + */ @Transactional - public String submitDeclaration(TaxDeclarationDTO dto) { - EmployeeProfile employee = employeeProfileRepository.findById(dto.getEmployeeId()) - .orElseThrow(() -> new VacademyException("Employee not found")); - + public String submitDeclaration(TaxDeclarationDTO dto, EmployeeProfile employee) { // Check if a declaration already exists for this employee and FY Optional existingOpt = taxDeclarationRepository - .findByEmployee_IdAndFinancialYear(dto.getEmployeeId(), dto.getFinancialYear()); + .findByEmployee_IdAndFinancialYear(employee.getId(), dto.getFinancialYear()); if (existingOpt.isPresent()) { throw new VacademyException("Tax declaration already exists for this employee and financial year. Use update instead."); @@ -50,10 +53,14 @@ public String submitDeclaration(TaxDeclarationDTO dto) { } @Transactional - public String updateDeclaration(String id, TaxDeclarationDTO dto) { + public String updateDeclaration(String id, TaxDeclarationDTO dto, String instituteId, CustomUserDetails user) { TaxDeclaration declaration = taxDeclarationRepository.findById(id) .orElseThrow(() -> new VacademyException("Tax declaration not found")); + // Owning employee is only known after the load: verify it belongs to the + // validated institute and the caller is HR staff or IS that employee. + hrAccessGuard.requireSelfOrHrStaff(user, instituteId, declaration.getEmployee().getId()); + // Only allow updates if status is DRAFT or SUBMITTED String status = declaration.getStatus(); if (DeclarationStatus.VERIFIED.name().equals(status) || DeclarationStatus.LOCKED.name().equals(status)) { @@ -84,9 +91,11 @@ public TaxDeclarationDTO getDeclaration(String employeeId, String financialYear) } @Transactional - public String verifyDeclaration(String id, String verifierUserId) { + public String verifyDeclaration(String id, String instituteId, String verifierUserId) { TaxDeclaration declaration = taxDeclarationRepository.findById(id) .orElseThrow(() -> new VacademyException("Tax declaration not found")); + hrAccessGuard.requireInstituteMatch( + declaration.getEmployee().getInstituteId(), instituteId, "Tax declaration"); if (!DeclarationStatus.SUBMITTED.name().equals(declaration.getStatus())) { throw new VacademyException("Only SUBMITTED declarations can be verified. Current status: " + declaration.getStatus()); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/IndiaTaxRegimeEngine.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/IndiaTaxRegimeEngine.java index 16561e2a2d..2d754cb6e5 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/IndiaTaxRegimeEngine.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/IndiaTaxRegimeEngine.java @@ -4,240 +4,457 @@ import java.math.BigDecimal; import java.math.RoundingMode; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +/** + * India tax engine — FY 2025-26 (AY 2026-27) rules by default, overridable per + * financial year through the institute's tax_rules JSONB (see resolveRules): + * a top-level key equal to the FY string ("2025-26") overrides for that year, + * else a "defaults" key, else the built-in constants below. Rate changes are + * therefore DATA, not redeploys. + * + * Implements: + * - New regime (default): 0-4L/4-8L/8-12L/12-16L/16-20L/20-24L/24L+ at + * 0/5/10/15/20/25/30%, standard deduction 75k, §87A full rebate up to 12L + * taxable with marginal relief, 80CCD(2) employer NPS (14% of basic cap). + * - Old regime: 2.5L/5L/10L slabs at 0/5/20/30%, SD 50k, §87A up to 5L (cap + * 12,500), HRA exemption COMPUTED (min of received, rent-10% basic, 50/40% + * of basic), 80C (1.5L, employee-PF auto-counted), 80D (25k/50k senior + + * parents), 80CCD(1B) 50k, 80E, 80TTA. + * - Surcharge with marginal relief (new regime capped at 25%), 4% cess. + * - Monthly TDS as YTD true-up: (annual liability − already withheld) spread + * over remaining months. + * - Statutory: EPF 12%/12% on min(basic, 15k) with EPS 8.33% split; ESI + * 0.75%/3.25% under the 21k gross ceiling with contribution-period + * stickiness; Professional Tax by state slab (built-in defaults for the + * common states, overridable via tax_rules.professional_tax). + */ @Component public class IndiaTaxRegimeEngine implements TaxRegimeEngine { - // Indian New Regime Tax Slabs (FY 2024-25 onwards) - private static final BigDecimal SLAB_1_LIMIT = new BigDecimal("300000"); // 0-3L: 0% - private static final BigDecimal SLAB_2_LIMIT = new BigDecimal("700000"); // 3-7L: 5% - private static final BigDecimal SLAB_3_LIMIT = new BigDecimal("1000000"); // 7-10L: 10% - private static final BigDecimal SLAB_4_LIMIT = new BigDecimal("1200000"); // 10-12L: 15% - private static final BigDecimal SLAB_5_LIMIT = new BigDecimal("1500000"); // 12-15L: 20% - // 15L+: 30% + public static final String REGIME_NEW = "NEW"; + public static final String REGIME_OLD = "OLD"; + + // ---- FY 2025-26 built-in defaults (all overridable via tax_rules) ---- + private static final BigDecimal[][] NEW_SLABS = { + {bd(400000), bd(0)}, {bd(800000), bd(0.05)}, {bd(1200000), bd(0.10)}, + {bd(1600000), bd(0.15)}, {bd(2000000), bd(0.20)}, {bd(2400000), bd(0.25)}, + {null, bd(0.30)}}; + private static final BigDecimal NEW_STANDARD_DEDUCTION = bd(75000); + private static final BigDecimal NEW_REBATE_87A_THRESHOLD = bd(1200000); + + private static final BigDecimal[][] OLD_SLABS = { + {bd(250000), bd(0)}, {bd(500000), bd(0.05)}, {bd(1000000), bd(0.20)}, {null, bd(0.30)}}; + private static final BigDecimal OLD_STANDARD_DEDUCTION = bd(50000); + private static final BigDecimal OLD_REBATE_87A_THRESHOLD = bd(500000); + private static final BigDecimal OLD_REBATE_87A_CAP = bd(12500); + + // surcharge tiers: [income-threshold, rate] + private static final BigDecimal[][] SURCHARGE_TIERS = { + {bd(5000000), bd(0.10)}, {bd(10000000), bd(0.15)}, + {bd(20000000), bd(0.25)}, {bd(50000000), bd(0.37)}}; + private static final BigDecimal NEW_REGIME_SURCHARGE_CAP = bd(0.25); + private static final BigDecimal CESS_RATE = bd(0.04); + + private static final BigDecimal CAP_80C = bd(150000); + private static final BigDecimal CAP_80D_BASE = bd(25000); + private static final BigDecimal CAP_80D_SENIOR = bd(50000); + private static final BigDecimal CAP_80CCD1B = bd(50000); + private static final BigDecimal CAP_80TTA = bd(10000); + private static final BigDecimal CAP_80CCD2_PCT_OF_BASIC = bd(0.14); + + private static final BigDecimal PF_WAGE_CEILING = bd(15000); + private static final BigDecimal PF_RATE = bd(0.12); + private static final BigDecimal EPS_RATE = bd(0.0833); + private static final BigDecimal ESI_GROSS_CEILING = bd(21000); + private static final BigDecimal ESI_EMPLOYEE_RATE = bd(0.0075); + private static final BigDecimal ESI_EMPLOYER_RATE = bd(0.0325); + + private static final String[] KEYS_80C = {"section_80c", "80c", "ppf", "elss", "life_insurance", + "nsc", "tuition_fees", "fixed_deposit_5yr", "sukanya_samriddhi", "home_loan_principal"}; - private static final BigDecimal STANDARD_DEDUCTION = new BigDecimal("75000"); - - // EPF statutory limits - private static final BigDecimal EPF_RATE = new BigDecimal("0.12"); // 12% - private static final BigDecimal EPF_MAX_BASE = new BigDecimal("15000"); // Max basic for EPF - - // ESI limits - private static final BigDecimal ESI_EMPLOYEE_RATE = new BigDecimal("0.0075"); // 0.75% - private static final BigDecimal ESI_EMPLOYER_RATE = new BigDecimal("0.0325"); // 3.25% - private static final BigDecimal ESI_GROSS_LIMIT = new BigDecimal("21000"); // ESI applicable if gross <= 21000 + @Override + public String getCountryCode() { + return "IND"; + } - // Professional Tax (typical monthly amount) - private static final BigDecimal PROFESSIONAL_TAX = new BigDecimal("200"); + // ================================================================== + // Income tax + // ================================================================== - private static final BigDecimal TWELVE = new BigDecimal("12"); + @Override + public TaxResult calculateMonthlyTax(TaxInput in) { + Map rules = resolveRules(in.getTaxRules(), in.getFinancialYear()); + Map breakdown = new LinkedHashMap<>(); + + String regime = REGIME_OLD.equalsIgnoreCase(in.getRegime()) ? REGIME_OLD : REGIME_NEW; + breakdown.put("regime", regime); + + // Projection: actuals to date + this month + full months for the rest of the FY. + BigDecimal ytdIncome = nvl(in.getYtdTaxableIncome()); + BigDecimal projectedAnnualGross = ytdIncome + .add(nvl(in.getGrossForMonth())) + .add(nvl(in.getGrossMonthlyFull()).multiply(bd(in.getMonthsRemainingAfterCurrent()))); + breakdown.put("projectedAnnualGross", projectedAnnualGross); + + BigDecimal annualBasic = nvl(in.getBasicMonthlyFull()).multiply(bd(12)); + Map decl = in.getDeclarations() != null ? in.getDeclarations() : Map.of(); + + BigDecimal taxable; + BigDecimal totalExemptions; + if (REGIME_NEW.equals(regime)) { + BigDecimal sd = readAmount(rules, "new_standard_deduction", NEW_STANDARD_DEDUCTION); + // Only 80CCD(2) (employer NPS) survives the new regime, capped at 14% of basic. + BigDecimal nps80ccd2 = min(declAmount(decl, "section_80ccd2", "employer_nps"), + annualBasic.multiply(CAP_80CCD2_PCT_OF_BASIC)); + totalExemptions = sd.add(nps80ccd2); + breakdown.put("standardDeduction", sd); + if (nps80ccd2.signum() > 0) breakdown.put("deduction80ccd2", nps80ccd2); + taxable = projectedAnnualGross.subtract(totalExemptions); + } else { + BigDecimal sd = readAmount(rules, "old_standard_deduction", OLD_STANDARD_DEDUCTION); + + BigDecimal hraExemption = computeHraExemption(in, decl, annualBasic); + breakdown.put("hraExemption", hraExemption); + + // 80C aggregate — employee's own PF contribution is counted automatically. + BigDecimal annualEmployeePf = pfEmployeeMonthly(nvl(in.getBasicMonthlyFull())).multiply(bd(12)); + BigDecimal total80c = annualEmployeePf.add(declAmount(decl, "employee_pf_contribution")); + for (String key : KEYS_80C) total80c = total80c.add(declAmount(decl, key)); + total80c = min(total80c, readAmount(rules, "cap_80c", CAP_80C)); + breakdown.put("deduction80c", total80c); + + BigDecimal cap80dSelf = truthy(decl.get("is_senior_citizen")) ? CAP_80D_SENIOR : CAP_80D_BASE; + BigDecimal cap80dParents = truthy(decl.get("parents_senior")) ? CAP_80D_SENIOR : CAP_80D_BASE; + BigDecimal total80d = min(declAmount(decl, "section_80d", "80d_self"), cap80dSelf) + .add(min(declAmount(decl, "80d_parents"), cap80dParents)); + breakdown.put("deduction80d", total80d); + + BigDecimal d80ccd1b = min(declAmount(decl, "section_80ccd1b", "nps_self"), CAP_80CCD1B); + BigDecimal d80e = declAmount(decl, "section_80e", "education_loan_interest"); + BigDecimal d80tta = min(declAmount(decl, "section_80tta", "savings_interest"), CAP_80TTA); + + totalExemptions = sd.add(hraExemption).add(total80c).add(total80d) + .add(d80ccd1b).add(d80e).add(d80tta); + breakdown.put("standardDeduction", sd); + taxable = projectedAnnualGross.subtract(totalExemptions); + } - // Section 80C maximum deduction limit - private static final BigDecimal SECTION_80C_LIMIT = new BigDecimal("150000"); + if (taxable.signum() < 0) taxable = BigDecimal.ZERO; + breakdown.put("taxableIncome", taxable); + + BigDecimal annualTax = annualTaxOn(taxable, regime, rules, breakdown); + + // YTD true-up: remaining liability spread over remaining months (incl. current). + BigDecimal alreadyDeducted = nvl(in.getYtdTaxDeducted()); + int monthsLeft = in.getMonthsRemainingAfterCurrent() + 1; + BigDecimal remaining = annualTax.subtract(alreadyDeducted); + BigDecimal monthlyTax = remaining.signum() <= 0 + ? BigDecimal.ZERO + : remaining.divide(bd(monthsLeft), 0, RoundingMode.HALF_UP); + breakdown.put("ytdTaxDeducted", alreadyDeducted); + breakdown.put("monthsRemaining", monthsLeft); + + return TaxResult.builder() + .monthlyTax(monthlyTax) + .projectedAnnualGross(projectedAnnualGross) + .projectedAnnualTaxable(taxable) + .projectedAnnualTax(annualTax) + .totalExemptions(totalExemptions) + .breakdown(breakdown) + .build(); + } - // Keys in the declarations map that qualify under Section 80C - private static final String[] SECTION_80C_KEYS = { - "section_80c", "80c", "ppf", "elss", "life_insurance", "nsc", - "tuition_fees", "fixed_deposit_5yr", "sukanya_samriddhi", - "employee_pf_contribution" - }; + /** Slab tax → §87A rebate (with new-regime marginal relief) → surcharge (with marginal relief) → cess. */ + private BigDecimal annualTaxOn(BigDecimal taxable, String regime, Map rules, + Map breakdown) { + BigDecimal[][] slabs = readSlabs(rules, + REGIME_NEW.equals(regime) ? "new_slabs" : "old_slabs", + REGIME_NEW.equals(regime) ? NEW_SLABS : OLD_SLABS); + + BigDecimal slabTax = slabTax(taxable, slabs); + breakdown.put("slabTax", slabTax); + + // §87A rebate + BigDecimal taxAfterRebate = slabTax; + if (REGIME_NEW.equals(regime)) { + BigDecimal threshold = readAmount(rules, "new_rebate_threshold", NEW_REBATE_87A_THRESHOLD); + if (taxable.compareTo(threshold) <= 0) { + taxAfterRebate = BigDecimal.ZERO; + } else { + // Marginal relief just above the rebate threshold: pay no more than the excess income. + BigDecimal excess = taxable.subtract(threshold); + if (slabTax.compareTo(excess) > 0) taxAfterRebate = excess; + } + } else { + BigDecimal threshold = readAmount(rules, "old_rebate_threshold", OLD_REBATE_87A_THRESHOLD); + if (taxable.compareTo(threshold) <= 0) { + taxAfterRebate = slabTax.subtract(min(slabTax, OLD_REBATE_87A_CAP)); + } + } + breakdown.put("taxAfterRebate", taxAfterRebate); + + // Surcharge with marginal relief + BigDecimal surchargeRate = BigDecimal.ZERO; + BigDecimal tierThreshold = null; + for (BigDecimal[] tier : SURCHARGE_TIERS) { + if (taxable.compareTo(tier[0]) > 0) { + surchargeRate = tier[1]; + tierThreshold = tier[0]; + } + } + if (REGIME_NEW.equals(regime) && surchargeRate.compareTo(NEW_REGIME_SURCHARGE_CAP) > 0) { + surchargeRate = NEW_REGIME_SURCHARGE_CAP; + } + BigDecimal surcharge = BigDecimal.ZERO; + if (surchargeRate.signum() > 0 && tierThreshold != null) { + surcharge = taxAfterRebate.multiply(surchargeRate); + // Marginal relief: (tax+surcharge) may not exceed tax-at-threshold + income-above-threshold. + BigDecimal taxAtThreshold = slabTax(tierThreshold, slabs); + BigDecimal maxPayable = taxAtThreshold.add(taxable.subtract(tierThreshold)); + if (taxAfterRebate.add(surcharge).compareTo(maxPayable) > 0) { + surcharge = max(BigDecimal.ZERO, maxPayable.subtract(taxAfterRebate)); + } + breakdown.put("surcharge", surcharge); + } - // Other declaration keys for deductions beyond 80C - private static final String KEY_80D = "section_80d"; // Medical insurance - private static final String KEY_80E = "section_80e"; // Education loan interest - private static final String KEY_80CCD_1B = "section_80ccd_1b"; // NPS additional - private static final String KEY_HRA = "hra_exemption"; // HRA exemption + BigDecimal cess = taxAfterRebate.add(surcharge).multiply(CESS_RATE); + breakdown.put("cess", scale2(cess)); - private static final BigDecimal SECTION_80D_LIMIT = new BigDecimal("50000"); - private static final BigDecimal SECTION_80CCD_1B_LIMIT = new BigDecimal("50000"); + return scale2(taxAfterRebate.add(surcharge).add(cess)); + } - @Override - public BigDecimal calculateMonthlyTax(BigDecimal annualTaxableIncome, - Map declarations, - Map taxRules) { - if (annualTaxableIncome == null || annualTaxableIncome.compareTo(BigDecimal.ZERO) <= 0) { - return BigDecimal.ZERO; + private BigDecimal slabTax(BigDecimal taxable, BigDecimal[][] slabs) { + BigDecimal tax = BigDecimal.ZERO; + BigDecimal lower = BigDecimal.ZERO; + for (BigDecimal[] slab : slabs) { + BigDecimal upper = slab[0]; // null = no ceiling + BigDecimal rate = slab[1]; + if (upper == null || taxable.compareTo(upper) < 0) { + tax = tax.add(taxable.subtract(lower).multiply(rate)); + return scale2(max(tax, BigDecimal.ZERO)); + } + tax = tax.add(upper.subtract(lower).multiply(rate)); + lower = upper; } + return scale2(tax); + } - // Apply standard deduction - BigDecimal taxableIncome = annualTaxableIncome.subtract(STANDARD_DEDUCTION); - if (taxableIncome.compareTo(BigDecimal.ZERO) <= 0) { - return BigDecimal.ZERO; - } + /** + * Statutory HRA exemption = min(HRA received, rent − 10% of basic, + * 50%/40% of basic by metro) — computed from rent declared, never taken + * as a self-declared exemption amount. + */ + private BigDecimal computeHraExemption(TaxInput in, Map decl, BigDecimal annualBasic) { + BigDecimal rentPaid = declAmount(decl, "hra_rent_paid", "rent_paid"); + if (rentPaid.signum() <= 0) return BigDecimal.ZERO; - // Apply declaration-based deductions (Section 80C, 80D, 80E, 80CCD(1B), HRA) - if (declarations != null && !declarations.isEmpty()) { - // Section 80C: aggregate qualifying items, capped at 1.5L - BigDecimal total80C = BigDecimal.ZERO; - for (String key : SECTION_80C_KEYS) { - Object val = declarations.get(key); - if (val instanceof Number) { - total80C = total80C.add(new BigDecimal(val.toString())); - } - } - total80C = total80C.min(SECTION_80C_LIMIT); - taxableIncome = taxableIncome.subtract(total80C); + BigDecimal hraReceived = nvl(in.getHraReceivedAnnual()); + if (hraReceived.signum() <= 0) hraReceived = declAmount(decl, "hra_received"); + if (hraReceived.signum() <= 0) return BigDecimal.ZERO; - // Section 80D: Medical insurance premium, capped at 50K - taxableIncome = applyDeclarationDeduction(taxableIncome, declarations, KEY_80D, SECTION_80D_LIMIT); + BigDecimal rentMinus10PctBasic = rentPaid.subtract(annualBasic.multiply(bd(0.10))); + BigDecimal basicPct = annualBasic.multiply(truthy(decl.get("is_metro_city")) ? bd(0.50) : bd(0.40)); - // Section 80CCD(1B): NPS additional contribution, capped at 50K - taxableIncome = applyDeclarationDeduction(taxableIncome, declarations, KEY_80CCD_1B, SECTION_80CCD_1B_LIMIT); + BigDecimal exemption = min(min(hraReceived, rentMinus10PctBasic), basicPct); + return max(exemption, BigDecimal.ZERO); + } - // Section 80E: Education loan interest (no upper limit) - taxableIncome = applyDeclarationDeduction(taxableIncome, declarations, KEY_80E, null); + // ================================================================== + // Statutory: EPF / ESI / PT + // ================================================================== - // HRA exemption - taxableIncome = applyDeclarationDeduction(taxableIncome, declarations, KEY_HRA, null); + @Override + public List calculateStatutory(TaxInput in) { + List items = new ArrayList<>(); + Map settings = in.getStatutorySettings() != null ? in.getStatutorySettings() : Map.of(); + + // --- EPF on earned (prorated) basic, ceiling 15k; employer split EPS 8.33 / EPF 3.67. + if (!falsy(settings.get("pf_enabled"))) { + BigDecimal basicForMonth = nvl(in.getBasicForMonth()); + if (basicForMonth.signum() > 0) { + BigDecimal wageBase = min(basicForMonth, PF_WAGE_CEILING); + BigDecimal employee = wageBase.multiply(PF_RATE).setScale(0, RoundingMode.HALF_UP); + BigDecimal eps = wageBase.multiply(EPS_RATE).setScale(0, RoundingMode.HALF_UP); + BigDecimal employer = wageBase.multiply(PF_RATE).setScale(0, RoundingMode.HALF_UP); + Map detail = new LinkedHashMap<>(); + detail.put("wageBase", wageBase); + detail.put("eps", eps); + detail.put("epfEmployer", employer.subtract(eps)); + items.add(StatutoryItem.builder() + .code("PF").name("Provident Fund") + .employeeMonthly(employee).employerMonthly(employer) + .detail(detail).build()); + } } - if (taxableIncome.compareTo(BigDecimal.ZERO) <= 0) { - return BigDecimal.ZERO; + // --- ESI under the 21k ceiling, sticky within the Apr-Sep / Oct-Mar contribution period. + if (!falsy(settings.get("esi_enabled"))) { + BigDecimal eligibilityGross = in.getEsiGrossAtPeriodStart() != null + ? in.getEsiGrossAtPeriodStart() : nvl(in.getGrossMonthlyFull()); + if (eligibilityGross.signum() > 0 && eligibilityGross.compareTo(ESI_GROSS_CEILING) <= 0) { + BigDecimal payBase = nvl(in.getGrossForMonth()); + // ESI amounts round UP to the next rupee by statute. + BigDecimal employee = payBase.multiply(ESI_EMPLOYEE_RATE).setScale(0, RoundingMode.CEILING); + BigDecimal employer = payBase.multiply(ESI_EMPLOYER_RATE).setScale(0, RoundingMode.CEILING); + items.add(StatutoryItem.builder() + .code("ESI").name("Employee State Insurance") + .employeeMonthly(employee).employerMonthly(employer) + .detail(Map.of("eligibilityGross", eligibilityGross)).build()); + } } - // Calculate annual tax using New Regime slabs - BigDecimal annualTax = calculateSlabTax(taxableIncome); - - // Add 4% health and education cess - BigDecimal cess = annualTax.multiply(new BigDecimal("0.04")).setScale(2, RoundingMode.HALF_UP); - annualTax = annualTax.add(cess); - - // Divide by 12 for monthly tax - BigDecimal monthlyTax = annualTax.divide(TWELVE, 2, RoundingMode.HALF_UP); - - return monthlyTax; - } - - /** - * Apply a single declaration-based deduction to taxable income. - * If limit is null, no cap is applied. - */ - private BigDecimal applyDeclarationDeduction(BigDecimal taxableIncome, - Map declarations, - String key, BigDecimal limit) { - Object val = declarations.get(key); - if (val instanceof Number) { - BigDecimal amount = new BigDecimal(val.toString()); - if (limit != null) { - amount = amount.min(limit); + // --- Professional Tax by state slab (employee-only). + if (!falsy(settings.get("pt_enabled"))) { + BigDecimal pt = professionalTax(in); + if (pt.signum() > 0) { + items.add(StatutoryItem.builder() + .code("PT").name("Professional Tax") + .employeeMonthly(pt).employerMonthly(BigDecimal.ZERO) + .detail(Map.of("stateCode", in.getStateCode() == null ? "" : in.getStateCode())) + .build()); } - taxableIncome = taxableIncome.subtract(amount); } - return taxableIncome; + + return items; } /** - * Calculate tax based on Indian New Regime slabs: - * 0-3L: 0%, 3-7L: 5%, 7-10L: 10%, 10-12L: 15%, 12-15L: 20%, 15L+: 30% + * Monthly PT from tax_rules.professional_tax.{STATE} = [{"upTo": n|null, + * "amount": a, "februaryAmount": b?}, ...], else built-in defaults for the + * common PT states; states without PT (DL, UP, HR, RJ, ...) yield zero. */ - private BigDecimal calculateSlabTax(BigDecimal taxableIncome) { - BigDecimal tax = BigDecimal.ZERO; - BigDecimal remaining = taxableIncome; - - // Slab 1: 0-3L at 0% - if (remaining.compareTo(SLAB_1_LIMIT) <= 0) { - return tax; + @SuppressWarnings("unchecked") + private BigDecimal professionalTax(TaxInput in) { + String state = in.getStateCode() == null ? "" : in.getStateCode().toUpperCase(); + BigDecimal gross = nvl(in.getGrossMonthlyFull()); + boolean february = in.getMonth() == 2; + + Object ptRules = resolveRules(in.getTaxRules(), in.getFinancialYear()).get("professional_tax"); + if (ptRules instanceof Map ptMap && ptMap.get(state) instanceof List slabs) { + for (Object slabObj : slabs) { + if (slabObj instanceof Map slab) { + Object upTo = slab.get("upTo"); + if (upTo == null || gross.compareTo(toBd(upTo)) <= 0) { + Object amount = february && slab.get("februaryAmount") != null + ? slab.get("februaryAmount") : slab.get("amount"); + return toBd(amount); + } + } + } + return BigDecimal.ZERO; } - remaining = remaining.subtract(SLAB_1_LIMIT); - // Slab 2: 3L-7L at 5% - BigDecimal slab2Width = SLAB_2_LIMIT.subtract(SLAB_1_LIMIT); // 4L - if (remaining.compareTo(slab2Width) <= 0) { - tax = tax.add(remaining.multiply(new BigDecimal("0.05"))); - return tax.setScale(2, RoundingMode.HALF_UP); - } - tax = tax.add(slab2Width.multiply(new BigDecimal("0.05"))); - remaining = remaining.subtract(slab2Width); - - // Slab 3: 7L-10L at 10% - BigDecimal slab3Width = SLAB_3_LIMIT.subtract(SLAB_2_LIMIT); // 3L - if (remaining.compareTo(slab3Width) <= 0) { - tax = tax.add(remaining.multiply(new BigDecimal("0.10"))); - return tax.setScale(2, RoundingMode.HALF_UP); - } - tax = tax.add(slab3Width.multiply(new BigDecimal("0.10"))); - remaining = remaining.subtract(slab3Width); - - // Slab 4: 10L-12L at 15% - BigDecimal slab4Width = SLAB_4_LIMIT.subtract(SLAB_3_LIMIT); // 2L - if (remaining.compareTo(slab4Width) <= 0) { - tax = tax.add(remaining.multiply(new BigDecimal("0.15"))); - return tax.setScale(2, RoundingMode.HALF_UP); - } - tax = tax.add(slab4Width.multiply(new BigDecimal("0.15"))); - remaining = remaining.subtract(slab4Width); - - // Slab 5: 12L-15L at 20% - BigDecimal slab5Width = SLAB_5_LIMIT.subtract(SLAB_4_LIMIT); // 3L - if (remaining.compareTo(slab5Width) <= 0) { - tax = tax.add(remaining.multiply(new BigDecimal("0.20"))); - return tax.setScale(2, RoundingMode.HALF_UP); - } - tax = tax.add(slab5Width.multiply(new BigDecimal("0.20"))); - remaining = remaining.subtract(slab5Width); + return switch (state) { + case "MH" -> gross.compareTo(bd(10000)) > 0 ? (february ? bd(300) : bd(200)) + : gross.compareTo(bd(7500)) > 0 ? bd(175) : BigDecimal.ZERO; + case "KA" -> gross.compareTo(bd(25000)) >= 0 ? bd(200) : BigDecimal.ZERO; + case "WB" -> gross.compareTo(bd(40000)) > 0 ? bd(200) + : gross.compareTo(bd(25000)) > 0 ? bd(150) + : gross.compareTo(bd(15000)) > 0 ? bd(130) + : gross.compareTo(bd(10000)) > 0 ? bd(110) : BigDecimal.ZERO; + case "TN" -> gross.compareTo(bd(12500)) > 0 ? bd(208) + : gross.compareTo(bd(10000)) > 0 ? bd(171) + : gross.compareTo(bd(7500)) > 0 ? bd(115) + : gross.compareTo(bd(5000)) > 0 ? bd(52) + : gross.compareTo(bd(3500)) > 0 ? bd(22) : BigDecimal.ZERO; + case "TS", "AP" -> gross.compareTo(bd(20000)) > 0 ? bd(200) + : gross.compareTo(bd(15000)) > 0 ? bd(150) : BigDecimal.ZERO; + case "GJ" -> gross.compareTo(bd(12000)) > 0 ? bd(200) : BigDecimal.ZERO; + case "MP" -> gross.compareTo(bd(18750)) > 0 ? (february ? bd(212) : bd(208)) : BigDecimal.ZERO; + default -> BigDecimal.ZERO; + }; + } - // Slab 6: Above 15L at 30% - tax = tax.add(remaining.multiply(new BigDecimal("0.30"))); + /** Employee EPF share for a given (un-prorated) monthly basic — used for auto-80C. */ + private BigDecimal pfEmployeeMonthly(BigDecimal basicMonthly) { + if (basicMonthly.signum() <= 0) return BigDecimal.ZERO; + return min(basicMonthly, PF_WAGE_CEILING).multiply(PF_RATE).setScale(0, RoundingMode.HALF_UP); + } - return tax.setScale(2, RoundingMode.HALF_UP); + // ================================================================== + // Rules resolution + helpers + // ================================================================== + + /** Per-FY override object, else "defaults", else the map itself. */ + @SuppressWarnings("unchecked") + private Map resolveRules(Map taxRules, String financialYear) { + if (taxRules == null) return Map.of(); + Object fy = taxRules.get(financialYear); + if (fy instanceof Map) return (Map) fy; + Object defaults = taxRules.get("defaults"); + if (defaults instanceof Map) return (Map) defaults; + return taxRules; } - @Override - public Map getStatutoryDeductions(BigDecimal grossMonthly, - Map statutorySettings) { - Map deductions = new HashMap<>(); + private BigDecimal readAmount(Map rules, String key, BigDecimal fallback) { + Object v = rules.get(key); + return v instanceof Number ? toBd(v) : fallback; + } - if (grossMonthly == null || grossMonthly.compareTo(BigDecimal.ZERO) <= 0) { - return deductions; + /** Slabs as [[upperLimitOrNull, rate], ...] from rules key, else the built-in table. */ + private BigDecimal[][] readSlabs(Map rules, String key, BigDecimal[][] fallback) { + Object v = rules.get(key); + if (!(v instanceof List list) || list.isEmpty()) return fallback; + try { + BigDecimal[][] out = new BigDecimal[list.size()][2]; + for (int i = 0; i < list.size(); i++) { + List pair = (List) list.get(i); + out[i][0] = pair.get(0) == null ? null : toBd(pair.get(0)); + out[i][1] = toBd(pair.get(1)); + } + return out; + } catch (Exception e) { + return fallback; // malformed override — built-ins are safer than a crash } + } - // EPF Employee contribution: 12% of basic (capped at basic of 15000) - // For simplicity, assume basic is ~40-50% of gross; use min(basic, 15000) - BigDecimal basicForEpf = grossMonthly.multiply(new BigDecimal("0.5")).setScale(2, RoundingMode.HALF_UP); - BigDecimal epfBase = basicForEpf.min(EPF_MAX_BASE); - BigDecimal epfEmployee = epfBase.multiply(EPF_RATE).setScale(2, RoundingMode.HALF_UP); - deductions.put("EPF_EMPLOYEE", epfEmployee); - - // ESI Employee contribution: 0.75% if gross <= 21000 - if (grossMonthly.compareTo(ESI_GROSS_LIMIT) <= 0) { - BigDecimal esiEmployee = grossMonthly.multiply(ESI_EMPLOYEE_RATE).setScale(2, RoundingMode.HALF_UP); - deductions.put("ESI_EMPLOYEE", esiEmployee); + private BigDecimal declAmount(Map decl, String... keys) { + for (String key : keys) { + Object v = decl.get(key); + if (v instanceof Number || (v instanceof String s && !s.isBlank())) { + try { + BigDecimal amount = toBd(v); + if (amount.signum() > 0) return amount; + } catch (NumberFormatException ignored) { + } + } } + return BigDecimal.ZERO; + } - // Professional Tax: 200/month (typical for most Indian states) - deductions.put("PROFESSIONAL_TAX", PROFESSIONAL_TAX); + private static boolean truthy(Object v) { + return v != null && ("true".equalsIgnoreCase(v.toString()) || "1".equals(v.toString())); + } - return deductions; + private static boolean falsy(Object v) { + return v != null && ("false".equalsIgnoreCase(v.toString()) || "0".equals(v.toString())); } - @Override - public Map getEmployerContributions(BigDecimal grossMonthly, - Map contributionSettings) { - Map contributions = new HashMap<>(); + private static BigDecimal bd(double v) { + return BigDecimal.valueOf(v); + } - if (grossMonthly == null || grossMonthly.compareTo(BigDecimal.ZERO) <= 0) { - return contributions; - } + private static BigDecimal toBd(Object v) { + return new BigDecimal(v.toString()); + } - // EPF Employer contribution: 12% of basic (capped at basic of 15000) - BigDecimal basicForEpf = grossMonthly.multiply(new BigDecimal("0.5")).setScale(2, RoundingMode.HALF_UP); - BigDecimal epfBase = basicForEpf.min(EPF_MAX_BASE); - BigDecimal epfEmployer = epfBase.multiply(EPF_RATE).setScale(2, RoundingMode.HALF_UP); - contributions.put("EPF_EMPLOYER", epfEmployer); + private static BigDecimal nvl(BigDecimal v) { + return v != null ? v : BigDecimal.ZERO; + } - // ESI Employer contribution: 3.25% if gross <= 21000 - if (grossMonthly.compareTo(ESI_GROSS_LIMIT) <= 0) { - BigDecimal esiEmployer = grossMonthly.multiply(ESI_EMPLOYER_RATE).setScale(2, RoundingMode.HALF_UP); - contributions.put("ESI_EMPLOYER", esiEmployer); - } + private static BigDecimal min(BigDecimal a, BigDecimal b) { + return a.compareTo(b) <= 0 ? a : b; + } - return contributions; + private static BigDecimal max(BigDecimal a, BigDecimal b) { + return a.compareTo(b) >= 0 ? a : b; } - @Override - public String getCountryCode() { - return "IND"; + private static BigDecimal scale2(BigDecimal v) { + return v.setScale(2, RoundingMode.HALF_UP); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/SaudiTaxRegimeEngine.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/SaudiTaxRegimeEngine.java new file mode 100644 index 0000000000..d9013ac46c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/SaudiTaxRegimeEngine.java @@ -0,0 +1,122 @@ +package vacademy.io.admin_core_service.features.hr_tax.service.engine; + +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Saudi Arabia engine (country code SAU / alias KSA). No personal income tax + * on salary; the statutory obligations are: + * + * - GOSI: Saudi nationals — employee 9.75% (annuities 9% + SANED 0.75%), + * employer 11.75% (annuities 9% + SANED 0.75% + occupational hazard 2%); + * expats — employer-only 2% occupational hazard. v1 contribution base is + * monthly BASIC (statutorily basic + housing), clamped to SAR 1,500–45,000. + * - EOSB accrual as an employer cost (Labor Law art. 84): half a month's + * basic per year for the first 5 years, a full month per year after — + * emitted monthly (annual/12). + * + * Overrides via statutory_settings: gosi_enabled, eosb_enabled. + */ +@Component +public class SaudiTaxRegimeEngine implements TaxRegimeEngine { + + private static final BigDecimal GOSI_SAUDI_EMPLOYEE_RATE = new BigDecimal("0.0975"); + private static final BigDecimal GOSI_SAUDI_EMPLOYER_RATE = new BigDecimal("0.1175"); + private static final BigDecimal GOSI_EXPAT_EMPLOYER_RATE = new BigDecimal("0.02"); + private static final BigDecimal GOSI_MIN_BASE = new BigDecimal("1500"); + private static final BigDecimal GOSI_MAX_BASE = new BigDecimal("45000"); + + private static final BigDecimal FIVE_YEARS = new BigDecimal("5"); + + @Override + public String getCountryCode() { + return "SAU"; + } + + @Override + public TaxResult calculateMonthlyTax(TaxInput input) { + Map breakdown = new LinkedHashMap<>(); + breakdown.put("note", "Saudi Arabia levies no personal income tax on salary"); + BigDecimal projected = nvl(input.getYtdTaxableIncome()) + .add(nvl(input.getGrossForMonth())) + .add(nvl(input.getGrossMonthlyFull()) + .multiply(new BigDecimal(input.getMonthsRemainingAfterCurrent()))); + return TaxResult.builder() + .monthlyTax(BigDecimal.ZERO) + .projectedAnnualGross(projected) + .projectedAnnualTaxable(BigDecimal.ZERO) + .projectedAnnualTax(BigDecimal.ZERO) + .totalExemptions(BigDecimal.ZERO) + .breakdown(breakdown) + .build(); + } + + @Override + public List calculateStatutory(TaxInput input) { + List items = new ArrayList<>(); + Map settings = input.getStatutorySettings() != null + ? input.getStatutorySettings() : Map.of(); + + BigDecimal basicFull = nvl(input.getBasicMonthlyFull()); + boolean saudi = isSaudiNational(input.getNationality()); + + // GOSI + if (!isFalse(settings.get("gosi_enabled")) && basicFull.signum() > 0) { + BigDecimal base = clamp(nvl(input.getBasicForMonth()), GOSI_MIN_BASE, GOSI_MAX_BASE); + BigDecimal employee = saudi + ? base.multiply(GOSI_SAUDI_EMPLOYEE_RATE).setScale(2, RoundingMode.HALF_UP) + : BigDecimal.ZERO; + BigDecimal employer = base.multiply(saudi ? GOSI_SAUDI_EMPLOYER_RATE : GOSI_EXPAT_EMPLOYER_RATE) + .setScale(2, RoundingMode.HALF_UP); + Map detail = new LinkedHashMap<>(); + detail.put("contributionBase", base); + detail.put("national", saudi); + items.add(StatutoryItem.builder() + .code("GOSI").name("GOSI") + .employeeMonthly(employee).employerMonthly(employer) + .detail(detail).build()); + } + + // EOSB accrual + if (!isFalse(settings.get("eosb_enabled")) && basicFull.signum() > 0) { + BigDecimal serviceYears = nvl(input.getServiceYears()); + BigDecimal monthsPerYear = serviceYears.compareTo(FIVE_YEARS) < 0 + ? new BigDecimal("0.5") : BigDecimal.ONE; + BigDecimal monthlyAccrual = basicFull.multiply(monthsPerYear) + .divide(new BigDecimal("12"), 2, RoundingMode.HALF_UP); + Map detail = new LinkedHashMap<>(); + detail.put("serviceYears", serviceYears); + detail.put("monthsPerYear", monthsPerYear); + items.add(StatutoryItem.builder() + .code("EOSB").name("End of Service Benefit (accrual)") + .employeeMonthly(BigDecimal.ZERO).employerMonthly(monthlyAccrual) + .detail(detail).build()); + } + + return items; + } + + private static boolean isSaudiNational(String nationality) { + return nationality != null && nationality.toLowerCase().contains("saudi"); + } + + private static boolean isFalse(Object v) { + return v != null && ("false".equalsIgnoreCase(v.toString()) || "0".equals(v.toString())); + } + + private static BigDecimal clamp(BigDecimal v, BigDecimal min, BigDecimal max) { + if (v.compareTo(min) < 0) return min; + if (v.compareTo(max) > 0) return max; + return v; + } + + private static BigDecimal nvl(BigDecimal v) { + return v != null ? v : BigDecimal.ZERO; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/StatutoryItem.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/StatutoryItem.java new file mode 100644 index 0000000000..3537dba95c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/StatutoryItem.java @@ -0,0 +1,25 @@ +package vacademy.io.admin_core_service.features.hr_tax.service.engine; + +import lombok.Builder; +import lombok.Getter; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * One statutory scheme's monthly amounts for one employee: the employee-side + * deduction and the employer-side contribution (either may be zero). `code` + * doubles as the system SalaryComponent code payroll materializes it under. + */ +@Getter +@Builder +public class StatutoryItem { + + /** PF | ESI | PT (component codes PF_EMP/PF_ER etc. derive from this). */ + private final String code; + private final String name; + private final BigDecimal employeeMonthly; + private final BigDecimal employerMonthly; + /** Scheme detail for filings (e.g. PF: eps/epf split, wage base). */ + private final Map detail; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/TaxInput.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/TaxInput.java new file mode 100644 index 0000000000..9f0d8ca16d --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/TaxInput.java @@ -0,0 +1,80 @@ +package vacademy.io.admin_core_service.features.hr_tax.service.engine; + +import lombok.Builder; +import lombok.Getter; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * Everything a country engine needs to compute one payroll month's withholding + * and statutory contributions for one employee. Built by PayrollCalculationService. + * + * Money semantics: "ForMonth" values are attendance/joining-prorated actuals for + * the payroll month; "MonthlyFull" values are the un-prorated structure amounts + * used for forward projection. YTD values cover the financial year up to but + * EXCLUDING the current month. + */ +@Getter +@Builder +public class TaxInput { + + private final String financialYear; // e.g. "2025-26" + private final int month; // payroll month 1-12 + private final int year; // payroll calendar year + + /** Payroll months of this FY remaining AFTER this one (0 for the last FY month). */ + private final int monthsRemainingAfterCurrent; + + private final BigDecimal grossForMonth; + private final BigDecimal grossMonthlyFull; + private final BigDecimal basicForMonth; + private final BigDecimal basicMonthlyFull; + + /** Annual HRA component per the salary structure (un-prorated), null if none. */ + private final BigDecimal hraReceivedAnnual; + + /** Taxable income already paid this FY (excluding this month). */ + private final BigDecimal ytdTaxableIncome; + /** Income tax already withheld this FY (excluding this month). */ + private final BigDecimal ytdTaxDeducted; + + /** OLD | NEW (defaulted by the engine when null). */ + private final String regime; + + /** + * Declaration items ALREADY FILTERED by the caller's verification policy — + * the engine trusts these amounts but still applies statutory caps and + * computes exemptions (HRA etc.) itself; it never honors a self-declared + * exemption amount directly. + */ + private final Map declarations; + + /** Institute tax rules JSONB (may hold per-FY overrides); never null (empty ok). */ + private final Map taxRules; + /** Institute statutory settings JSONB; never null (empty ok). */ + private final Map statutorySettings; + + /** State for professional tax etc., may be null. */ + private final String stateCode; + + /** + * Gross at the start of the current ESI contribution period (Apr/Oct), for + * the stickiness rule; null when unknown (engine falls back to grossMonthlyFull). + */ + private final BigDecimal esiGrossAtPeriodStart; + + /** + * Employee nationality (free text from the profile), for nationality-gated + * schemes: GPSSA applies to UAE/GCC nationals, GOSI splits Saudi nationals + * vs expats. Null-safe — engines treat null as expat/non-national. + */ + private final String nationality; + + /** + * Completed years of service as of the payroll month (fractional), for + * tenure-banded accruals: UAE EOSB 21 vs 30 days/year at the 5-year mark, + * Saudi EOSB half- vs full-month. Null treated as 0. + */ + private final BigDecimal serviceYears; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/TaxRegimeEngine.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/TaxRegimeEngine.java index 7806c24c30..e199217424 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/TaxRegimeEngine.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/TaxRegimeEngine.java @@ -1,46 +1,30 @@ package vacademy.io.admin_core_service.features.hr_tax.service.engine; -import java.math.BigDecimal; -import java.util.Map; +import java.util.List; +/** + * Country tax engine (Strategy). Implementations must be pure functions of + * {@link TaxInput} — no repository access — so they stay unit-testable against + * hand-computed statutory scenarios. + */ public interface TaxRegimeEngine { - /** - * Calculate monthly tax based on annual taxable income, employee declarations, and tax rules. - * - * @param annualTaxableIncome the projected annual taxable income - * @param declarations the employee's tax declarations (80C, HRA, etc.) - * @param taxRules the institute's tax rules configuration - * @return monthly tax amount to be deducted - */ - BigDecimal calculateMonthlyTax(BigDecimal annualTaxableIncome, - Map declarations, - Map taxRules); - - /** - * Calculate statutory deductions for the employee (EPF employee share, ESI, Professional Tax, etc.) - * - * @param grossMonthly the employee's gross monthly salary - * @param statutorySettings the institute's statutory settings - * @return map of deduction name to amount - */ - Map getStatutoryDeductions(BigDecimal grossMonthly, - Map statutorySettings); + /** ISO-3166 alpha-3 country code this engine handles (e.g. "IND"). */ + String getCountryCode(); /** - * Calculate employer contributions (EPF employer share, ESI employer share, etc.) - * - * @param grossMonthly the employee's gross monthly salary - * @param contributionSettings the institute's employer contribution settings - * @return map of contribution name to amount + * Income-tax withholding for the month, computed as a YTD true-up: project + * the full year, compute annual liability, subtract tax already withheld, + * spread the remainder over the months left (current included). */ - Map getEmployerContributions(BigDecimal grossMonthly, - Map contributionSettings); + TaxResult calculateMonthlyTax(TaxInput input); /** - * Return the country code this engine handles. - * - * @return the country code (e.g. "IND", "USA") + * Statutory schemes for the month (India: EPF, ESI, PT), each with the + * employee deduction and employer contribution. Payroll materializes these + * as system salary components — UNLESS the employee's salary structure + * already carries a component with the same code (template-managed + * statutory wins; no double deduction). */ - String getCountryCode(); + List calculateStatutory(TaxInput input); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/TaxRegimeFactory.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/TaxRegimeFactory.java index 71ed85aa17..9310befc08 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/TaxRegimeFactory.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/TaxRegimeFactory.java @@ -29,10 +29,25 @@ public TaxRegimeFactory(List engines) { * @throws VacademyException if no engine is found for the country code */ public TaxRegimeEngine getEngine(String countryCode) { - TaxRegimeEngine engine = engineMap.get(countryCode); + TaxRegimeEngine engine = engineMap.get(normalize(countryCode)); if (engine == null) { throw new VacademyException("No tax engine found for country code: " + countryCode); } return engine; } + + /** + * Institutes configure common aliases; engines register ISO alpha-3. Public + * so anything else keying off a configured country code (payroll currency, + * statutory exports) resolves aliases the same way this factory does. + */ + public static String normalize(String countryCode) { + if (countryCode == null) return ""; + return switch (countryCode.trim().toUpperCase()) { + case "IN", "INDIA" -> "IND"; + case "UAE", "AE" -> "ARE"; + case "KSA", "SA", "SAUDI" -> "SAU"; + default -> countryCode.trim().toUpperCase(); + }; + } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/TaxResult.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/TaxResult.java new file mode 100644 index 0000000000..c3a173ef39 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/TaxResult.java @@ -0,0 +1,25 @@ +package vacademy.io.admin_core_service.features.hr_tax.service.engine; + +import lombok.Builder; +import lombok.Getter; + +import java.math.BigDecimal; +import java.util.Map; + +/** One month's income-tax outcome, with the full breakdown for audit/Form 16. */ +@Getter +@Builder +public class TaxResult { + + /** Withholding for THIS month after YTD true-up (never negative). */ + private final BigDecimal monthlyTax; + + private final BigDecimal projectedAnnualGross; + private final BigDecimal projectedAnnualTaxable; + /** Full-year tax liability (slabs + surcharge + cess, after rebate). */ + private final BigDecimal projectedAnnualTax; + private final BigDecimal totalExemptions; // SD + HRA + chapter VI-A actually allowed + + /** Explainable computation: slab math, rebate, surcharge, exemption items. */ + private final Map breakdown; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/UaeTaxRegimeEngine.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/UaeTaxRegimeEngine.java new file mode 100644 index 0000000000..33d624d155 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/UaeTaxRegimeEngine.java @@ -0,0 +1,124 @@ +package vacademy.io.admin_core_service.features.hr_tax.service.engine; + +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * UAE engine (country code ARE / alias UAE). No personal income tax; the + * statutory obligations are: + * + * - GPSSA pension for UAE nationals (private sector): employee 5%, employer + * 12.5% of the contribution salary. v1 uses monthly BASIC as the + * contribution base (statutorily basic + housing allowance), clamped to the + * AED 1,000–50,000 band. GCC nationals technically contribute at their home + * scheme's rates — not modeled in v1 (treated as expats), noted here. + * - End-of-service benefit (EOSB) accrual as an employer cost: 21 days of + * basic per year for the first 5 years of service, 30 days/year after + * (Federal Decree-Law 33/2021 art. 51), daily basic = monthly basic / 30. + * Emitted monthly (annual/12) so payroll carries the true employer cost and + * the provision report can aggregate it. + * + * Overrides via statutory_settings: gpssa_enabled, eosb_enabled ("false" + * disables). Currency is implicit (institute currency, expected AED). + */ +@Component +public class UaeTaxRegimeEngine implements TaxRegimeEngine { + + private static final BigDecimal GPSSA_EMPLOYEE_RATE = new BigDecimal("0.05"); + private static final BigDecimal GPSSA_EMPLOYER_RATE = new BigDecimal("0.125"); + private static final BigDecimal GPSSA_MIN_BASE = new BigDecimal("1000"); + private static final BigDecimal GPSSA_MAX_BASE = new BigDecimal("50000"); + + private static final BigDecimal EOSB_DAYS_FIRST_BAND = new BigDecimal("21"); + private static final BigDecimal EOSB_DAYS_SECOND_BAND = new BigDecimal("30"); + private static final BigDecimal FIVE_YEARS = new BigDecimal("5"); + + @Override + public String getCountryCode() { + return "ARE"; + } + + @Override + public TaxResult calculateMonthlyTax(TaxInput input) { + Map breakdown = new LinkedHashMap<>(); + breakdown.put("note", "UAE levies no personal income tax on salary"); + BigDecimal projected = nvl(input.getYtdTaxableIncome()) + .add(nvl(input.getGrossForMonth())) + .add(nvl(input.getGrossMonthlyFull()) + .multiply(new BigDecimal(input.getMonthsRemainingAfterCurrent()))); + return TaxResult.builder() + .monthlyTax(BigDecimal.ZERO) + .projectedAnnualGross(projected) + .projectedAnnualTaxable(BigDecimal.ZERO) + .projectedAnnualTax(BigDecimal.ZERO) + .totalExemptions(BigDecimal.ZERO) + .breakdown(breakdown) + .build(); + } + + @Override + public List calculateStatutory(TaxInput input) { + List items = new ArrayList<>(); + Map settings = input.getStatutorySettings() != null + ? input.getStatutorySettings() : Map.of(); + + BigDecimal basicFull = nvl(input.getBasicMonthlyFull()); + + // GPSSA — UAE nationals only. + if (!isFalse(settings.get("gpssa_enabled")) && isUaeNational(input.getNationality()) + && basicFull.signum() > 0) { + BigDecimal base = clamp(nvl(input.getBasicForMonth()), GPSSA_MIN_BASE, GPSSA_MAX_BASE); + BigDecimal employee = base.multiply(GPSSA_EMPLOYEE_RATE).setScale(2, RoundingMode.HALF_UP); + BigDecimal employer = base.multiply(GPSSA_EMPLOYER_RATE).setScale(2, RoundingMode.HALF_UP); + items.add(StatutoryItem.builder() + .code("GPSSA").name("GPSSA Pension") + .employeeMonthly(employee).employerMonthly(employer) + .detail(Map.of("contributionBase", base)).build()); + } + + // EOSB accrual — employer cost for every employee. + if (!isFalse(settings.get("eosb_enabled")) && basicFull.signum() > 0) { + BigDecimal serviceYears = nvl(input.getServiceYears()); + BigDecimal daysPerYear = serviceYears.compareTo(FIVE_YEARS) < 0 + ? EOSB_DAYS_FIRST_BAND : EOSB_DAYS_SECOND_BAND; + BigDecimal dailyBasic = basicFull.divide(new BigDecimal("30"), 6, RoundingMode.HALF_UP); + BigDecimal monthlyAccrual = dailyBasic.multiply(daysPerYear) + .divide(new BigDecimal("12"), 2, RoundingMode.HALF_UP); + Map detail = new LinkedHashMap<>(); + detail.put("serviceYears", serviceYears); + detail.put("daysPerYear", daysPerYear); + items.add(StatutoryItem.builder() + .code("EOSB").name("End of Service Benefit (accrual)") + .employeeMonthly(BigDecimal.ZERO).employerMonthly(monthlyAccrual) + .detail(detail).build()); + } + + return items; + } + + private static boolean isUaeNational(String nationality) { + if (nationality == null) return false; + String n = nationality.toLowerCase(); + return n.contains("emirat") || n.contains("uae") || n.contains("united arab"); + } + + private static boolean isFalse(Object v) { + return v != null && ("false".equalsIgnoreCase(v.toString()) || "0".equals(v.toString())); + } + + private static BigDecimal clamp(BigDecimal v, BigDecimal min, BigDecimal max) { + if (v.compareTo(min) < 0) return min; + if (v.compareTo(max) > 0) return max; + return v; + } + + private static BigDecimal nvl(BigDecimal v) { + return v != null ? v : BigDecimal.ZERO; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/controller/HrTeachingController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/controller/HrTeachingController.java new file mode 100644 index 0000000000..9ce9887e4a --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/controller/HrTeachingController.java @@ -0,0 +1,99 @@ +package vacademy.io.admin_core_service.features.hr_teaching.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.admin_activity_logs.annotation.Auditable; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_teaching.dto.TeachingAttendanceSyncResultDTO; +import vacademy.io.admin_core_service.features.hr_teaching.dto.TeachingPayResultDTO; +import vacademy.io.admin_core_service.features.hr_teaching.dto.TeachingSummaryResponseDTO; +import vacademy.io.admin_core_service.features.hr_teaching.service.TeachingActivityService; +import vacademy.io.admin_core_service.features.hr_teaching.service.TeachingAttendanceSyncService; +import vacademy.io.admin_core_service.features.hr_teaching.service.TeachingPayService; +import vacademy.io.common.auth.model.CustomUserDetails; + +/** + * Phase F2 "LMS teaching → pay": bridges live-session hosting activity into HR + * attendance and variable pay. A teacher is identified as + * {@code live_session.created_by_user_id} matched to + * {@code hr_employee_profile.user_id} within the institute. + * + *

Access matrix: reads (summary, pay preview) need HR staff; the summary + * with an explicit {@code employeeId} additionally allows that employee to + * read their own numbers. Mutations (attendance sync, pay materialize) need + * HR admin and are audited. + */ +@RestController +@RequestMapping("/admin-core-service/api/v1/hr/teaching") +public class HrTeachingController { + + @Autowired + private HrAccessGuard hrAccessGuard; + + @Autowired + private TeachingActivityService teachingActivityService; + + @Autowired + private TeachingAttendanceSyncService teachingAttendanceSyncService; + + @Autowired + private TeachingPayService teachingPayService; + + @GetMapping("/summary") + public ResponseEntity getSummary( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") Integer month, + @RequestParam("year") Integer year, + @RequestParam(value = "employeeId", required = false) String employeeId, + @RequestAttribute("user") CustomUserDetails user) { + TeachingActivityService.validateMonthYear(month, year); + String onlyTeacherUserId = null; + if (employeeId != null && !employeeId.isBlank()) { + // Self-or-staff: an employee may read their OWN teaching summary + EmployeeProfile employee = hrAccessGuard.requireSelfOrHrStaff(user, instituteId, employeeId); + onlyTeacherUserId = employee.getUserId(); + } else { + hrAccessGuard.requireHrStaff(user, instituteId); + } + return ResponseEntity.ok( + teachingActivityService.buildSummary(instituteId, month, year, onlyTeacherUserId)); + } + + @PostMapping("/attendance-sync") + @Auditable(entityType = "HR_TEACHING", action = "ATTENDANCE_SYNC", + descriptionExpr = "'Teaching attendance sync ' + #month + '/' + #year") + public ResponseEntity syncAttendance( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") Integer month, + @RequestParam("year") Integer year, + @RequestParam(value = "requireLog", required = false, defaultValue = "true") boolean requireLog, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + return ResponseEntity.ok( + teachingAttendanceSyncService.sync(instituteId, month, year, requireLog)); + } + + @PostMapping("/pay/preview") + public ResponseEntity previewPay( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") Integer month, + @RequestParam("year") Integer year, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrStaff(user, instituteId); + return ResponseEntity.ok(teachingPayService.preview(instituteId, month, year)); + } + + @PostMapping("/pay/materialize") + @Auditable(entityType = "HR_TEACHING", action = "PAY_MATERIALIZE", + descriptionExpr = "'Teaching pay materialize ' + #month + '/' + #year") + public ResponseEntity materializePay( + @RequestParam("instituteId") String instituteId, + @RequestParam("month") Integer month, + @RequestParam("year") Integer year, + @RequestAttribute("user") CustomUserDetails user) { + hrAccessGuard.requireHrAdmin(user, instituteId); + return ResponseEntity.ok(teachingPayService.materialize(instituteId, month, year, user)); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingAttendanceSyncResultDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingAttendanceSyncResultDTO.java new file mode 100644 index 0000000000..cd2d89dcc9 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingAttendanceSyncResultDTO.java @@ -0,0 +1,36 @@ +package vacademy.io.admin_core_service.features.hr_teaching.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** Result counts of a teaching → hr_attendance sync run. */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class TeachingAttendanceSyncResultDTO { + + private String instituteId; + private Integer month; + private Integer year; + private boolean requireLog; + + /** New PRESENT rows inserted. */ + private int created; + /** Existing non-PRESENT/non-ON_LEAVE rows upgraded to PRESENT. */ + private int updated; + /** Rows left untouched (already PRESENT or ON_LEAVE, or concurrent insert race). */ + private int skipped; + /** Distinct (employee, date) pairs considered. */ + private int datesConsidered; + + /** Teacher userIds that created sessions but have no HR employee profile (not synced). */ + private List teachersWithoutProfile; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingDayDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingDayDTO.java new file mode 100644 index 0000000000..f3a7a66c33 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingDayDTO.java @@ -0,0 +1,26 @@ +package vacademy.io.admin_core_service.features.hr_teaching.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +/** Per-date breakdown of a teacher's month. */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class TeachingDayDTO { + + private LocalDate date; + private int sessionsScheduled; + private int sessionsWithAttendance; + private long taughtMinutes; + private List occurrences; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingEmployeeSummaryDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingEmployeeSummaryDTO.java new file mode 100644 index 0000000000..3896b8059b --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingEmployeeSummaryDTO.java @@ -0,0 +1,37 @@ +package vacademy.io.admin_core_service.features.hr_teaching.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * One teaching person's month. A "teacher" is any user who created live + * sessions with occurrences in the month; when no hr_employee_profile matches + * that userId in the institute, {@code noEmployeeProfile} is true and + * {@code employeeId}/{@code employeeCode} are null. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class TeachingEmployeeSummaryDTO { + + /** hr_employee_profile.id — null when the teacher has no HR profile. */ + private String employeeId; + private String userId; + private String employeeName; + private String employeeCode; + private boolean noEmployeeProfile; + + private int sessionsScheduled; + private int sessionsWithAttendance; + private long totalTaughtMinutes; + + private List days; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingOccurrenceDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingOccurrenceDTO.java new file mode 100644 index 0000000000..9878fa4568 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingOccurrenceDTO.java @@ -0,0 +1,32 @@ +package vacademy.io.admin_core_service.features.hr_teaching.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalTime; + +/** One session occurrence a teacher hosted (or was scheduled to host). */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class TeachingOccurrenceDTO { + + private String scheduleId; + private String sessionId; + private String sessionTitle; + private String subject; + private LocalTime startTime; + private LocalTime lastEntryTime; + /** True when the teacher has an ATTENDANCE_RECORDED log for this occurrence. */ + private boolean attendanceRecorded; + /** Minutes actually taught (0 when no attendance log exists). */ + private long taughtMinutes; + /** Scheduled span lastEntryTime - startTime in minutes (0 when times are missing). */ + private long scheduledMinutes; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingPayLineDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingPayLineDTO.java new file mode 100644 index 0000000000..62bb526d8f --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingPayLineDTO.java @@ -0,0 +1,45 @@ +package vacademy.io.admin_core_service.features.hr_teaching.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * One teacher's computed pay line. Statuses: + * ELIGIBLE (preview: would be materialized), CREATED (adjustment written), + * SKIPPED_EXISTING (TEACHING_PAY adjustment already present for the month), + * UNRATED (no valid rate key on the employee profile custom fields), + * ZERO_QUANTITY (rated but nothing billable), NO_EMPLOYEE_PROFILE. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class TeachingPayLineDTO { + + private String employeeId; + private String userId; + private String employeeName; + private String employeeCode; + + /** PER_SESSION | PER_HOUR — null when unrated. */ + private String basis; + private BigDecimal rate; + + private int sessionsWithAttendance; + private long taughtMinutes; + /** taughtMinutes / 60 rounded to 2 decimals (payable hours for PER_HOUR basis). */ + private BigDecimal taughtHours; + + private BigDecimal amount; + private String status; + /** hr_payroll_adjustment.id once materialized. */ + private String adjustmentId; + private String note; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingPayResultDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingPayResultDTO.java new file mode 100644 index 0000000000..a8534f512d --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingPayResultDTO.java @@ -0,0 +1,33 @@ +package vacademy.io.admin_core_service.features.hr_teaching.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class TeachingPayResultDTO { + + private String instituteId; + private Integer month; + private Integer year; + /** True for /pay/preview, false for /pay/materialize. */ + private boolean preview; + + private int eligibleCount; + private int createdCount; + private int skippedExistingCount; + private int unratedCount; + private BigDecimal totalAmount; + + private List lines; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingSummaryResponseDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingSummaryResponseDTO.java new file mode 100644 index 0000000000..ffabf7e4f6 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/dto/TeachingSummaryResponseDTO.java @@ -0,0 +1,23 @@ +package vacademy.io.admin_core_service.features.hr_teaching.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class TeachingSummaryResponseDTO { + + private String instituteId; + private Integer month; + private Integer year; + private List teachers; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/HrTeachingAdjustmentRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/HrTeachingAdjustmentRepository.java new file mode 100644 index 0000000000..4bec3ef9ad --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/HrTeachingAdjustmentRepository.java @@ -0,0 +1,21 @@ +package vacademy.io.admin_core_service.features.hr_teaching.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollAdjustment; + +import java.util.Collection; +import java.util.List; + +/** + * hr_teaching's own idempotency check on hr_payroll_adjustment: an employee + * who already has a TEACHING_PAY adjustment for a month (consumed by a payroll + * run or not — {@code payrollEntryId} state is deliberately ignored) must not + * receive a second one from a re-run of materialize. + */ +@Repository +public interface HrTeachingAdjustmentRepository extends JpaRepository { + + List findByInstituteIdAndYearAndMonthAndCodeAndEmployeeIdIn( + String instituteId, Integer year, Integer month, String code, Collection employeeIds); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/HrTeachingAttendanceRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/HrTeachingAttendanceRepository.java new file mode 100644 index 0000000000..905731cc6a --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/HrTeachingAttendanceRepository.java @@ -0,0 +1,19 @@ +package vacademy.io.admin_core_service.features.hr_teaching.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceRecord; + +import java.time.LocalDate; +import java.util.Optional; + +/** + * hr_teaching's own access to hr_attendance_record for the teaching → + * attendance sync. The table has UNIQUE(employee_id, attendance_date), so the + * sync must load-then-update instead of blindly inserting. + */ +@Repository +public interface HrTeachingAttendanceRepository extends JpaRepository { + + Optional findByEmployeeIdAndAttendanceDate(String employeeId, LocalDate attendanceDate); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/HrTeachingEmployeeRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/HrTeachingEmployeeRepository.java new file mode 100644 index 0000000000..591a8ab22d --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/HrTeachingEmployeeRepository.java @@ -0,0 +1,20 @@ +package vacademy.io.admin_core_service.features.hr_teaching.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; + +import java.util.Collection; +import java.util.List; + +/** + * hr_teaching's own batch lookup on hr_employee_profile: teachers found in the + * live-session data are matched to HR employees by + * {@code EmployeeProfile.userId == LiveSession.createdByUserId}, always scoped + * to the validated institute. + */ +@Repository +public interface HrTeachingEmployeeRepository extends JpaRepository { + + List findByInstituteIdAndUserIdIn(String instituteId, Collection userIds); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/HrTeachingScheduleRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/HrTeachingScheduleRepository.java new file mode 100644 index 0000000000..329bdc67ff --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/HrTeachingScheduleRepository.java @@ -0,0 +1,67 @@ +package vacademy.io.admin_core_service.features.hr_teaching.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.live_session.entity.LiveSession; + +import java.time.LocalDate; +import java.util.List; + +/** + * hr_teaching's OWN read-only query surface over the live-session schema + * (Phase F2 "LMS teaching → pay"). Deliberately not added to the live_session + * repositories — this feature owns its query shape and can evolve it without + * touching that module. + */ +// NOTE: keep SQL "--" comments OUT of the native @Query text block below. Spring +// Data's SpEL QuotationMap scans the whole string for apostrophes before binding +// params, so a lone "'" in a SQL comment crashes boot. Explanations live in Java +// "//" comments like this one. +@Repository +public interface HrTeachingScheduleRepository extends JpaRepository { + + // One row per schedule occurrence of the month whose parent session was + // created by an institute user (created_by_user_id = the host/teacher). + // The LATERAL join picks the teacher's OWN latest ATTENDANCE_RECORDED log + // for that occurrence, mirroring the shape of + // LiveSessionParticipantRepository.findAttendanceForUser. + @Query(value = """ + SELECT + ls.created_by_user_id AS teacherUserId, + ls.id AS sessionId, + ls.title AS sessionTitle, + ls.subject AS subject, + ss.id AS scheduleId, + ss.meeting_date AS meetingDate, + ss.start_time AS startTime, + ss.last_entry_time AS lastEntryTime, + lsl.status AS attendanceStatus, + lsl.provider_total_duration_minutes AS durationMinutes, + lsl.provider_total_duration_seconds AS durationSeconds + FROM session_schedules ss + JOIN live_session ls ON ls.id = ss.session_id + LEFT JOIN LATERAL ( + SELECT status, provider_total_duration_minutes, provider_total_duration_seconds + FROM live_session_logs + WHERE session_id = ls.id + AND schedule_id = ss.id + AND user_source_type = 'USER' + AND user_source_id = ls.created_by_user_id + AND log_type = 'ATTENDANCE_RECORDED' + ORDER BY created_at DESC + LIMIT 1 + ) lsl ON TRUE + WHERE ls.institute_id = :instituteId + AND ls.created_by_user_id IS NOT NULL + AND ss.meeting_date BETWEEN :fromDate AND :toDate + AND ls.status <> 'DELETED' + AND ss.status <> 'DELETED' + ORDER BY ls.created_by_user_id, ss.meeting_date, ss.start_time + """, nativeQuery = true) + List findTeachingSchedules( + @Param("instituteId") String instituteId, + @Param("fromDate") LocalDate fromDate, + @Param("toDate") LocalDate toDate); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/TeachingScheduleProjection.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/TeachingScheduleProjection.java new file mode 100644 index 0000000000..e076afc5fd --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/repository/TeachingScheduleProjection.java @@ -0,0 +1,42 @@ +package vacademy.io.admin_core_service.features.hr_teaching.repository; + +import java.sql.Date; +import java.sql.Time; + +/** + * One row per (teacher, session occurrence) in a month: the schedule itself + * plus the teacher's own latest ATTENDANCE_RECORDED log for that occurrence + * (null columns when the teacher has no log). The teacher is the session's + * {@code live_session.created_by_user_id} — the only host identity the + * live-session model carries. + */ +public interface TeachingScheduleProjection { + + String getTeacherUserId(); + + String getSessionId(); + + String getSessionTitle(); + + String getSubject(); + + String getScheduleId(); + + /** DATE column — convert via {@code toLocalDate()}. */ + Date getMeetingDate(); + + /** TIME column — convert via {@code toLocalTime()}; may be null. */ + Time getStartTime(); + + /** TIME column — convert via {@code toLocalTime()}; may be null. */ + Time getLastEntryTime(); + + /** Status of the teacher's attendance log; null when no log exists. */ + String getAttendanceStatus(); + + /** Provider minutes (whole minutes, e.g. Zoom); may be null. */ + Integer getDurationMinutes(); + + /** Provider seconds (BBB only, V471); may be null. */ + Integer getDurationSeconds(); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/service/TeachingActivityService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/service/TeachingActivityService.java new file mode 100644 index 0000000000..3776ed7e86 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/service/TeachingActivityService.java @@ -0,0 +1,258 @@ +package vacademy.io.admin_core_service.features.hr_teaching.service; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_teaching.dto.TeachingDayDTO; +import vacademy.io.admin_core_service.features.hr_teaching.dto.TeachingEmployeeSummaryDTO; +import vacademy.io.admin_core_service.features.hr_teaching.dto.TeachingOccurrenceDTO; +import vacademy.io.admin_core_service.features.hr_teaching.dto.TeachingSummaryResponseDTO; +import vacademy.io.admin_core_service.features.hr_teaching.repository.HrTeachingEmployeeRepository; +import vacademy.io.admin_core_service.features.hr_teaching.repository.HrTeachingScheduleRepository; +import vacademy.io.admin_core_service.features.hr_teaching.repository.TeachingScheduleProjection; +import vacademy.io.common.auth.entity.User; +import vacademy.io.common.auth.repository.UserRepository; +import vacademy.io.common.exceptions.VacademyException; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Phase F2 "LMS teaching → pay" — the shared read model. A teaching occurrence + * is a session_schedules row of the month whose parent live_session was created + * by a user of the institute (created_by_user_id is the only host identity the + * live-session model has). Actual participation is the teacher's own + * ATTENDANCE_RECORDED row in live_session_logs for that schedule. + * + *

Taught-minutes rule per occurrence WITH an attendance log: + * providerTotalDurationSeconds/60 when present (BBB, exact), else + * providerTotalDurationMinutes (Zoom, whole minutes), else the scheduled span + * lastEntryTime - startTime. Occurrences without a log contribute 0 taught + * minutes — they only count toward "sessions scheduled". + */ +@Service +public class TeachingActivityService { + + @Autowired + private HrTeachingScheduleRepository scheduleRepository; + + @Autowired + private HrTeachingEmployeeRepository employeeRepository; + + @Autowired + private UserRepository userRepository; + + /** In-memory occurrence, converted once from the native projection. */ + @Getter + @Builder + public static class Occurrence { + private final String teacherUserId; + private final String sessionId; + private final String sessionTitle; + private final String subject; + private final String scheduleId; + private final LocalDate date; + private final LocalTime startTime; + private final LocalTime lastEntryTime; + private final boolean attendanceRecorded; + private final long taughtSeconds; + private final long scheduledMinutes; + } + + /** A month of teaching activity: teacher userId → occurrences, plus profile matches. */ + @Getter + @Builder + public static class MonthActivity { + private final Map> byTeacherUserId; + /** teacher userId → EmployeeProfile, only for teachers that HAVE a profile. */ + private final Map profileByUserId; + /** teacher userId → display name (buildUserNameMap pattern). */ + private final Map nameByUserId; + } + + public static void validateMonthYear(Integer month, Integer year) { + if (month == null || month < 1 || month > 12 + || year == null || year < 2000 || year > 2100) { + throw new VacademyException("Valid month (1-12) and year are required"); + } + } + + @Transactional(readOnly = true) + public MonthActivity loadMonthActivity(String instituteId, int month, int year) { + YearMonth yearMonth = YearMonth.of(year, month); + List rows = scheduleRepository.findTeachingSchedules( + instituteId, yearMonth.atDay(1), yearMonth.atEndOfMonth()); + + Map> byTeacher = new LinkedHashMap<>(); + for (TeachingScheduleProjection row : rows) { + Occurrence occurrence = toOccurrence(row); + if (occurrence == null) { + continue; + } + byTeacher.computeIfAbsent(occurrence.getTeacherUserId(), k -> new ArrayList<>()) + .add(occurrence); + } + + List teacherUserIds = new ArrayList<>(byTeacher.keySet()); + Map profileByUserId = teacherUserIds.isEmpty() + ? Map.of() + : employeeRepository.findByInstituteIdAndUserIdIn(instituteId, teacherUserIds).stream() + .collect(Collectors.toMap(EmployeeProfile::getUserId, Function.identity(), (a, b) -> a)); + + return MonthActivity.builder() + .byTeacherUserId(byTeacher) + .profileByUserId(profileByUserId) + .nameByUserId(buildUserNameMap(teacherUserIds)) + .build(); + } + + @Transactional(readOnly = true) + public TeachingSummaryResponseDTO buildSummary(String instituteId, int month, int year, + String onlyTeacherUserId) { + MonthActivity activity = loadMonthActivity(instituteId, month, year); + + List teachers = new ArrayList<>(); + for (Map.Entry> entry : activity.getByTeacherUserId().entrySet()) { + String userId = entry.getKey(); + if (onlyTeacherUserId != null && !onlyTeacherUserId.equals(userId)) { + continue; + } + teachers.add(toEmployeeSummary(userId, entry.getValue(), + activity.getProfileByUserId().get(userId), + activity.getNameByUserId().getOrDefault(userId, "Unknown"))); + } + teachers.sort(Comparator.comparing(t -> t.getEmployeeName() == null ? "" : t.getEmployeeName(), + String.CASE_INSENSITIVE_ORDER)); + + return TeachingSummaryResponseDTO.builder() + .instituteId(instituteId) + .month(month) + .year(year) + .teachers(teachers) + .build(); + } + + public static int countSessionsWithAttendance(List occurrences) { + return (int) occurrences.stream().filter(Occurrence::isAttendanceRecorded).count(); + } + + public static long totalTaughtSeconds(List occurrences) { + return occurrences.stream().mapToLong(Occurrence::getTaughtSeconds).sum(); + } + + public static long secondsToRoundedMinutes(long seconds) { + return Math.round(seconds / 60.0); + } + + private TeachingEmployeeSummaryDTO toEmployeeSummary(String userId, List occurrences, + EmployeeProfile profile, String name) { + Map> byDate = occurrences.stream() + .collect(Collectors.groupingBy(Occurrence::getDate, TreeMap::new, Collectors.toList())); + + List days = new ArrayList<>(); + for (Map.Entry> dayEntry : byDate.entrySet()) { + List dayOccurrences = dayEntry.getValue(); + days.add(TeachingDayDTO.builder() + .date(dayEntry.getKey()) + .sessionsScheduled(dayOccurrences.size()) + .sessionsWithAttendance(countSessionsWithAttendance(dayOccurrences)) + .taughtMinutes(secondsToRoundedMinutes(totalTaughtSeconds(dayOccurrences))) + .occurrences(dayOccurrences.stream().map(this::toOccurrenceDTO) + .collect(Collectors.toList())) + .build()); + } + + return TeachingEmployeeSummaryDTO.builder() + .employeeId(profile != null ? profile.getId() : null) + .userId(userId) + .employeeName(name) + .employeeCode(profile != null ? profile.getEmployeeCode() : null) + .noEmployeeProfile(profile == null) + .sessionsScheduled(occurrences.size()) + .sessionsWithAttendance(countSessionsWithAttendance(occurrences)) + .totalTaughtMinutes(secondsToRoundedMinutes(totalTaughtSeconds(occurrences))) + .days(days) + .build(); + } + + private TeachingOccurrenceDTO toOccurrenceDTO(Occurrence o) { + return TeachingOccurrenceDTO.builder() + .scheduleId(o.getScheduleId()) + .sessionId(o.getSessionId()) + .sessionTitle(o.getSessionTitle()) + .subject(o.getSubject()) + .startTime(o.getStartTime()) + .lastEntryTime(o.getLastEntryTime()) + .attendanceRecorded(o.isAttendanceRecorded()) + .taughtMinutes(secondsToRoundedMinutes(o.getTaughtSeconds())) + .scheduledMinutes(o.getScheduledMinutes()) + .build(); + } + + private Occurrence toOccurrence(TeachingScheduleProjection row) { + if (row.getMeetingDate() == null || row.getTeacherUserId() == null) { + return null; + } + LocalDate date = row.getMeetingDate().toLocalDate(); + LocalTime startTime = row.getStartTime() != null ? row.getStartTime().toLocalTime() : null; + LocalTime lastEntryTime = row.getLastEntryTime() != null ? row.getLastEntryTime().toLocalTime() : null; + + long scheduledMinutes = 0; + if (startTime != null && lastEntryTime != null && lastEntryTime.isAfter(startTime)) { + scheduledMinutes = Duration.between(startTime, lastEntryTime).toMinutes(); + } + + boolean attendanceRecorded = row.getAttendanceStatus() != null; + long taughtSeconds = 0; + if (attendanceRecorded) { + if (row.getDurationSeconds() != null && row.getDurationSeconds() > 0) { + taughtSeconds = row.getDurationSeconds(); + } else if (row.getDurationMinutes() != null && row.getDurationMinutes() > 0) { + taughtSeconds = row.getDurationMinutes() * 60L; + } else { + taughtSeconds = scheduledMinutes * 60L; + } + } + + return Occurrence.builder() + .teacherUserId(row.getTeacherUserId()) + .sessionId(row.getSessionId()) + .sessionTitle(row.getSessionTitle()) + .subject(row.getSubject()) + .scheduleId(row.getScheduleId()) + .date(date) + .startTime(startTime) + .lastEntryTime(lastEntryTime) + .attendanceRecorded(attendanceRecorded) + .taughtSeconds(taughtSeconds) + .scheduledMinutes(scheduledMinutes) + .build(); + } + + /** Same pattern as hr_attendance AttendanceService.buildUserNameMap. */ + private Map buildUserNameMap(List userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + List users = userRepository.findByIdIn(userIds); + return users.stream() + .collect(Collectors.toMap( + User::getId, + u -> u.getFullName() != null ? u.getFullName() : u.getUsername(), + (a, b) -> a + )); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/service/TeachingAttendanceSyncService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/service/TeachingAttendanceSyncService.java new file mode 100644 index 0000000000..1352c8be1c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/service/TeachingAttendanceSyncService.java @@ -0,0 +1,130 @@ +package vacademy.io.admin_core_service.features.hr_teaching.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.hr_attendance.entity.AttendanceRecord; +import vacademy.io.admin_core_service.features.hr_attendance.enums.AttendanceSource; +import vacademy.io.admin_core_service.features.hr_attendance.enums.AttendanceStatus; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_payroll.service.HrMonthLockService; +import vacademy.io.admin_core_service.features.hr_teaching.dto.TeachingAttendanceSyncResultDTO; +import vacademy.io.admin_core_service.features.hr_teaching.repository.HrTeachingAttendanceRepository; +import vacademy.io.admin_core_service.features.hr_teaching.service.TeachingActivityService.MonthActivity; +import vacademy.io.admin_core_service.features.hr_teaching.service.TeachingActivityService.Occurrence; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * Teaching → HR attendance sync (Phase F2): every date a teaching employee + * actually taught (has an ATTENDANCE_RECORDED log; {@code requireLog=false} + * relaxes this to "had a scheduled session") becomes / upgrades an + * hr_attendance_record PRESENT row. + * + *

Rules: the table is UNIQUE(employee_id, attendance_date) so existing rows + * are updated, never re-inserted; an existing PRESENT or ON_LEAVE row is never + * downgraded or overwritten (ON_LEAVE means HR explicitly recorded leave — + * teaching data must not silently contradict it); a payroll-locked month + * refuses outright. + */ +@Service +public class TeachingAttendanceSyncService { + + @Autowired + private TeachingActivityService teachingActivityService; + + @Autowired + private HrTeachingAttendanceRepository attendanceRepository; + + @Autowired + private HrMonthLockService hrMonthLockService; + + @Transactional + public TeachingAttendanceSyncResultDTO sync(String instituteId, int month, int year, boolean requireLog) { + TeachingActivityService.validateMonthYear(month, year); + hrMonthLockService.requireUnlocked(instituteId, YearMonth.of(year, month).atDay(1), + "sync teaching attendance"); + + MonthActivity activity = teachingActivityService.loadMonthActivity(instituteId, month, year); + + int created = 0; + int updated = 0; + int skipped = 0; + int datesConsidered = 0; + List teachersWithoutProfile = new ArrayList<>(); + + for (Map.Entry> entry : activity.getByTeacherUserId().entrySet()) { + String userId = entry.getKey(); + EmployeeProfile employee = activity.getProfileByUserId().get(userId); + if (employee == null) { + teachersWithoutProfile.add(userId); + continue; + } + + Map> byDate = entry.getValue().stream() + .filter(o -> !requireLog || o.isAttendanceRecorded()) + .collect(Collectors.groupingBy(Occurrence::getDate, TreeMap::new, Collectors.toList())); + + for (Map.Entry> dayEntry : byDate.entrySet()) { + datesConsidered++; + int sessionCount = dayEntry.getValue().size(); + String remark = "Taught: " + sessionCount + " session(s)"; + + Optional existingOpt = + attendanceRepository.findByEmployeeIdAndAttendanceDate(employee.getId(), dayEntry.getKey()); + + if (existingOpt.isEmpty()) { + AttendanceRecord record = new AttendanceRecord(); + record.setEmployee(employee); + record.setInstituteId(instituteId); + record.setAttendanceDate(dayEntry.getKey()); + record.setStatus(AttendanceStatus.PRESENT.name()); + record.setSource(AttendanceSource.ADMIN.name()); + record.setRemarks(remark); + try { + attendanceRepository.save(record); + created++; + } catch (DataIntegrityViolationException e) { + // Raced with a concurrent write for the same (employee, date) + skipped++; + } + continue; + } + + AttendanceRecord existing = existingOpt.get(); + if (AttendanceStatus.PRESENT.name().equals(existing.getStatus()) + || AttendanceStatus.ON_LEAVE.name().equals(existing.getStatus())) { + skipped++; + continue; + } + existing.setStatus(AttendanceStatus.PRESENT.name()); + existing.setSource(AttendanceSource.ADMIN.name()); + existing.setRemarks(existing.getRemarks() == null || existing.getRemarks().isBlank() + ? remark + : existing.getRemarks() + " | " + remark); + attendanceRepository.save(existing); + updated++; + } + } + + return TeachingAttendanceSyncResultDTO.builder() + .instituteId(instituteId) + .month(month) + .year(year) + .requireLog(requireLog) + .created(created) + .updated(updated) + .skipped(skipped) + .datesConsidered(datesConsidered) + .teachersWithoutProfile(teachersWithoutProfile) + .build(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/service/TeachingPayService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/service/TeachingPayService.java new file mode 100644 index 0000000000..9d879f5e30 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/hr_teaching/service/TeachingPayService.java @@ -0,0 +1,247 @@ +package vacademy.io.admin_core_service.features.hr_teaching.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_payroll.dto.PayrollAdjustmentDTO; +import vacademy.io.admin_core_service.features.hr_payroll.entity.PayrollAdjustment; +import vacademy.io.admin_core_service.features.hr_payroll.service.HrMonthLockService; +import vacademy.io.admin_core_service.features.hr_payroll.service.PayrollAdjustmentService; +import vacademy.io.admin_core_service.features.hr_teaching.dto.TeachingPayLineDTO; +import vacademy.io.admin_core_service.features.hr_teaching.dto.TeachingPayResultDTO; +import vacademy.io.admin_core_service.features.hr_teaching.repository.HrTeachingAdjustmentRepository; +import vacademy.io.admin_core_service.features.hr_teaching.service.TeachingActivityService.MonthActivity; +import vacademy.io.admin_core_service.features.hr_teaching.service.TeachingActivityService.Occurrence; +import vacademy.io.common.auth.model.CustomUserDetails; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Month; +import java.time.YearMonth; +import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Teaching pay computation + materialization (Phase F2 "LMS teaching → pay"). + * + *

Rate configuration, v1: until a proper rate table exists, per-teacher + * rates live on the HR profile as {@code EmployeeProfile.customFields} keys — + * {@code teaching_rate_per_session} (paid per occurrence that has the + * teacher's ATTENDANCE_RECORDED log) and {@code teaching_rate_per_hour} + * (paid per taught hour, taught minutes as computed by + * {@link TeachingActivityService}). When both keys are present the per-session + * rate wins. Values may be numbers or numeric strings; anything else (or a + * non-positive value) makes the employee UNRATED and skipped. + * + *

Materialize writes one REGULAR-scope EARNING adjustment (code + * TEACHING_PAY, source SYSTEM) per rated employee via + * {@link PayrollAdjustmentService}, which the next REGULAR payroll run + * consumes. Idempotent: an employee already holding a TEACHING_PAY adjustment + * for that month — consumed by a run or not — is skipped. + */ +@Service +public class TeachingPayService { + + public static final String CODE_TEACHING_PAY = "TEACHING_PAY"; + public static final String RATE_KEY_PER_SESSION = "teaching_rate_per_session"; + public static final String RATE_KEY_PER_HOUR = "teaching_rate_per_hour"; + + public static final String STATUS_ELIGIBLE = "ELIGIBLE"; + public static final String STATUS_CREATED = "CREATED"; + public static final String STATUS_SKIPPED_EXISTING = "SKIPPED_EXISTING"; + public static final String STATUS_UNRATED = "UNRATED"; + public static final String STATUS_ZERO_QUANTITY = "ZERO_QUANTITY"; + public static final String STATUS_NO_EMPLOYEE_PROFILE = "NO_EMPLOYEE_PROFILE"; + + @Autowired + private TeachingActivityService teachingActivityService; + + @Autowired + private HrTeachingAdjustmentRepository adjustmentRepository; + + @Autowired + private PayrollAdjustmentService payrollAdjustmentService; + + @Autowired + private HrMonthLockService hrMonthLockService; + + @Transactional(readOnly = true) + public TeachingPayResultDTO preview(String instituteId, int month, int year) { + TeachingActivityService.validateMonthYear(month, year); + return buildResult(instituteId, month, year, computeLines(instituteId, month, year), true); + } + + @Transactional + public TeachingPayResultDTO materialize(String instituteId, int month, int year, CustomUserDetails user) { + TeachingActivityService.validateMonthYear(month, year); + // Judgment call: an adjustment created after the REGULAR run has already + // processed the month would sit unconsumed forever, so materialize obeys + // the same month lock as attendance mutations. + hrMonthLockService.requireUnlocked(instituteId, YearMonth.of(year, month).atDay(1), + "materialize teaching pay"); + + List lines = computeLines(instituteId, month, year); + String label = "Teaching Pay " + monthLabel(month, year); + + for (TeachingPayLineDTO line : lines) { + if (!STATUS_ELIGIBLE.equals(line.getStatus())) { + continue; + } + PayrollAdjustmentDTO dto = PayrollAdjustmentDTO.builder() + .employeeId(line.getEmployeeId()) + .month(month) + .year(year) + .type("EARNING") + .code(CODE_TEACHING_PAY) + .label(label) + .amount(line.getAmount()) + .runScope("REGULAR") + .notes(line.getNote()) + .build(); + String adjustmentId = payrollAdjustmentService.createAdjustment(dto, instituteId, user, "SYSTEM"); + line.setAdjustmentId(adjustmentId); + line.setStatus(STATUS_CREATED); + } + + return buildResult(instituteId, month, year, lines, false); + } + + private List computeLines(String instituteId, int month, int year) { + MonthActivity activity = teachingActivityService.loadMonthActivity(instituteId, month, year); + + List employeeIds = activity.getProfileByUserId().values().stream() + .map(EmployeeProfile::getId) + .collect(Collectors.toList()); + Set alreadyMaterialized = employeeIds.isEmpty() ? Set.of() + : adjustmentRepository + .findByInstituteIdAndYearAndMonthAndCodeAndEmployeeIdIn( + instituteId, year, month, CODE_TEACHING_PAY, employeeIds) + .stream().map(PayrollAdjustment::getEmployeeId) + .collect(Collectors.toCollection(HashSet::new)); + + List lines = new ArrayList<>(); + for (Map.Entry> entry : activity.getByTeacherUserId().entrySet()) { + String userId = entry.getKey(); + lines.add(computeLine(userId, entry.getValue(), + activity.getProfileByUserId().get(userId), + activity.getNameByUserId().getOrDefault(userId, "Unknown"), + alreadyMaterialized)); + } + lines.sort(Comparator.comparing(l -> l.getEmployeeName() == null ? "" : l.getEmployeeName(), + String.CASE_INSENSITIVE_ORDER)); + return lines; + } + + private TeachingPayLineDTO computeLine(String userId, List occurrences, + EmployeeProfile profile, String name, + Set alreadyMaterialized) { + int sessionsWithAttendance = TeachingActivityService.countSessionsWithAttendance(occurrences); + long taughtSeconds = TeachingActivityService.totalTaughtSeconds(occurrences); + long taughtMinutes = TeachingActivityService.secondsToRoundedMinutes(taughtSeconds); + BigDecimal taughtHours = BigDecimal.valueOf(taughtSeconds) + .divide(BigDecimal.valueOf(3600), 2, RoundingMode.HALF_UP); + + TeachingPayLineDTO.TeachingPayLineDTOBuilder line = TeachingPayLineDTO.builder() + .userId(userId) + .employeeName(name) + .sessionsWithAttendance(sessionsWithAttendance) + .taughtMinutes(taughtMinutes) + .taughtHours(taughtHours); + + if (profile == null) { + return line.status(STATUS_NO_EMPLOYEE_PROFILE) + .note("No HR employee profile matches this session creator") + .build(); + } + line.employeeId(profile.getId()).employeeCode(profile.getEmployeeCode()); + + BigDecimal perSession = readRate(profile, RATE_KEY_PER_SESSION); + BigDecimal perHour = readRate(profile, RATE_KEY_PER_HOUR); + + if (perSession == null && perHour == null) { + return line.status(STATUS_UNRATED) + .note("Set " + RATE_KEY_PER_SESSION + " or " + RATE_KEY_PER_HOUR + + " in the employee profile custom fields") + .build(); + } + + BigDecimal amount; + String note; + if (perSession != null) { + line.basis("PER_SESSION").rate(perSession); + amount = perSession.multiply(BigDecimal.valueOf(sessionsWithAttendance)) + .setScale(2, RoundingMode.HALF_UP); + note = "Auto (teaching): " + sessionsWithAttendance + " session(s) x " + + perSession.toPlainString() + "/session"; + } else { + line.basis("PER_HOUR").rate(perHour); + amount = perHour.multiply(taughtHours).setScale(2, RoundingMode.HALF_UP); + note = "Auto (teaching): " + taughtHours.toPlainString() + " h x " + + perHour.toPlainString() + "/hour"; + } + line.amount(amount).note(note); + + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + return line.status(STATUS_ZERO_QUANTITY) + .note("Rated but no attended sessions or taught time this month") + .build(); + } + if (alreadyMaterialized.contains(profile.getId())) { + return line.status(STATUS_SKIPPED_EXISTING) + .note("A TEACHING_PAY adjustment already exists for this month") + .build(); + } + return line.status(STATUS_ELIGIBLE).build(); + } + + /** Numeric or numeric-string custom-field value, positive; anything else is null. */ + static BigDecimal readRate(EmployeeProfile profile, String key) { + Map customFields = profile.getCustomFields(); + if (customFields == null) { + return null; + } + Object raw = customFields.get(key); + if (raw == null) { + return null; + } + try { + BigDecimal rate = new BigDecimal(raw.toString().trim()); + return rate.compareTo(BigDecimal.ZERO) > 0 ? rate : null; + } catch (NumberFormatException e) { + return null; + } + } + + private TeachingPayResultDTO buildResult(String instituteId, int month, int year, + List lines, boolean preview) { + BigDecimal totalAmount = lines.stream() + .filter(l -> STATUS_ELIGIBLE.equals(l.getStatus()) || STATUS_CREATED.equals(l.getStatus())) + .map(TeachingPayLineDTO::getAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + return TeachingPayResultDTO.builder() + .instituteId(instituteId) + .month(month) + .year(year) + .preview(preview) + .eligibleCount((int) lines.stream().filter(l -> STATUS_ELIGIBLE.equals(l.getStatus())).count()) + .createdCount((int) lines.stream().filter(l -> STATUS_CREATED.equals(l.getStatus())).count()) + .skippedExistingCount((int) lines.stream() + .filter(l -> STATUS_SKIPPED_EXISTING.equals(l.getStatus())).count()) + .unratedCount((int) lines.stream().filter(l -> STATUS_UNRATED.equals(l.getStatus())).count()) + .totalAmount(totalAmount.setScale(2, RoundingMode.HALF_UP)) + .lines(lines) + .build(); + } + + private static String monthLabel(int month, int year) { + return Month.of(month).getDisplayName(TextStyle.FULL, Locale.ENGLISH) + " " + year; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/institute/repository/InstituteRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/institute/repository/InstituteRepository.java index 85130a57ad..7cfd4be8ad 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/institute/repository/InstituteRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/institute/repository/InstituteRepository.java @@ -129,6 +129,15 @@ OR LOWER(i.email) LIKE LOWER(CONCAT('%', :search, '%'))) """, nativeQuery = true) Long countAllInstitutes(@Param("search") String search); + /** + * Bulk id -> display name, for screens that list institutes they do not otherwise + * need to load. Deliberately a two-column projection: {@code institutes} carries + * {@code setting_json}, which is large, and fetching whole entities to read a name + * pulls every institute's settings blob across with it. + */ + @Query(value = "SELECT i.id, i.name FROM institutes i WHERE i.id IN (:ids)", nativeQuery = true) + List findIdAndNameByIds(@Param("ids") java.util.Collection ids); + @Query(value = "SELECT COUNT(*) FROM institutes", nativeQuery = true) Long countTotalInstitutes(); @@ -315,4 +324,19 @@ OR LOWER(i.email) LIKE LOWER(CONCAT('%', :search, '%'))) AND (:leadTag IS NULL OR :leadTag = '' OR i.lead_tag = :leadTag) """, nativeQuery = true) Long countAllInstitutesFiltered(@Param("search") String search, @Param("leadTag") String leadTag); + + /** + * Every custom live-class hostname currently configured, deduplicated. + * + * Consumed on each BBB pool-server start to rebuild that server's nginx + * server_name list and its certificate SAN set, so an institute added here + * becomes reachable on the next restore with no manual step. + */ + @Query(value = """ + SELECT DISTINCT i.live_session_base_url FROM institutes i + WHERE i.live_session_base_url IS NOT NULL + AND TRIM(i.live_session_base_url) <> '' + ORDER BY 1 + """, nativeQuery = true) + List findDistinctLiveSessionBaseUrls(); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/institute_learner/dto/batch_enrollment/BatchEnrolledLearnerDto.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/institute_learner/dto/batch_enrollment/BatchEnrolledLearnerDto.java new file mode 100644 index 0000000000..cc6881f71c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/institute_learner/dto/batch_enrollment/BatchEnrolledLearnerDto.java @@ -0,0 +1,33 @@ +package vacademy.io.admin_core_service.features.institute_learner.dto.batch_enrollment; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +/** + * The minimum a caller needs to say "this learner is enrolled in this batch". + * + *

Kept lean on purpose. The consumer is assessment_service's "not attempted yet" + * list, which fetches the WHOLE enrolled set (see + * {@code StudentSessionInstituteGroupMappingRepository#findEnrolledLearnersByPackageSessions}), + * so every field here is paid for once per learner over the wire. The contact fields + * exist because that list is exported to CSV so an admin can chase the learners who + * never sat the test — chasing needs an email or a phone number. Adding anything beyond + * that is not free: check the caller actually renders it first. + */ +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public interface BatchEnrolledLearnerDto { + String getUserId(); + + String getFullName(); + + String getPackageSessionId(); + + // Contact details, for the "not attempted" CSV export. All three are populated in + // practice (16/16 on a sampled live batch), but treat them as optional anyway — + // a learner added by CSV import can be missing any of them. + String getEmail(); + + String getMobileNumber(); + + String getUsername(); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/institute_learner/dto/batch_enrollment/EnrolledLearnersRequest.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/institute_learner/dto/batch_enrollment/EnrolledLearnersRequest.java new file mode 100644 index 0000000000..2877fab44a --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/institute_learner/dto/batch_enrollment/EnrolledLearnersRequest.java @@ -0,0 +1,31 @@ +package vacademy.io.admin_core_service.features.institute_learner.dto.batch_enrollment; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Request for the internal "who is enrolled in these batches" lookup. + * + *

Note what is NOT here: any kind of exclude-these-users list. Callers that need a + * difference (e.g. "enrolled but has not attempted") take the full set and subtract + * locally. That is deliberate — an exclusion list has to reach SQL as an opaque array, + * which the planner cannot estimate, and a generic plan then re-evaluates it per row. + * Measured on prod: the same page went from 22ms to 434-880ms once Postgres switched to + * a generic plan, intermittently. Without that predicate the query is plan-stable + * (22ms custom / 28ms generic on the largest batch in prod). + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class EnrolledLearnersRequest { + private String instituteId; + private List packageSessionIds; + /** Mapping statuses to count as enrolled. Defaults to ACTIVE when null/empty. */ + private List statuses; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/institute_learner/repository/StudentSessionInstituteGroupMappingRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/institute_learner/repository/StudentSessionInstituteGroupMappingRepository.java index c540150a19..4eadc6a7fc 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/institute_learner/repository/StudentSessionInstituteGroupMappingRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/institute_learner/repository/StudentSessionInstituteGroupMappingRepository.java @@ -13,6 +13,50 @@ public interface StudentSessionInstituteGroupMappingRepository extends JpaRepository { + /** + * Every learner enrolled in any of {@code psIds}, deduped to one row per learner. + * + *

Used by assessment_service to build the "enrolled but has not attempted" list. + * Two properties matter and must be preserved: + * + *

    + *
  • No exclusion parameter. The caller subtracts the already-attempted + * learners itself. Pushing an exclude-list in as an array made the predicate + * unestimable, and once Postgres picked a generic plan it re-evaluated the array + * per row — measured on prod, the same page went from 22ms to 434-880ms, + * intermittently. As written the plan is stable: 22ms custom / 28ms generic on + * the largest batch in prod (4051 learners), 1-2ms on a typical one.
  • + *
  • Index-only on the mapping side. The WHERE columns match + * {@code idx_student_batch_lookup (package_session_id, institute_id, user_id, + * status) WHERE status = 'ACTIVE'}, so the mapping is read from the index with no + * heap access. Adding a column from ssigm outside that index costs heap fetches + * per row. (The student columns come from the joined row, not this index.)
  • + *
+ * + *

DISTINCT ON is needed because a learner can sit in more than one of the requested + * batches; the ORDER BY inside picks the lowest package_session_id so the batch shown + * is at least deterministic. + */ + @Query(value = """ + SELECT DISTINCT ON (ssigm.user_id) + ssigm.user_id AS userId, + s.full_name AS fullName, + ssigm.package_session_id AS packageSessionId, + s.email AS email, + s.mobile_number AS mobileNumber, + s.username AS username + FROM student_session_institute_group_mapping ssigm + JOIN student s ON s.user_id = ssigm.user_id + WHERE ssigm.package_session_id IN (:psIds) + AND ssigm.institute_id = :instituteId + AND ssigm.status IN (:statuses) + ORDER BY ssigm.user_id, ssigm.package_session_id + """, nativeQuery = true) + List + findEnrolledLearnersByPackageSessions(@Param("psIds") List psIds, + @Param("instituteId") String instituteId, + @Param("statuses") List statuses); + @Query(value = """ SELECT ssigm.id AS mapping_id, -- Index 0 diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/learner/controller/InternalLearnerDetailController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/learner/controller/InternalLearnerDetailController.java index f297e9edda..7f801cbd15 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/learner/controller/InternalLearnerDetailController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/learner/controller/InternalLearnerDetailController.java @@ -7,8 +7,13 @@ import vacademy.io.admin_core_service.features.learner.service.LearnerCredentialSyncService; import vacademy.io.admin_core_service.features.learner.service.LearnerLmsUserSyncService; import vacademy.io.admin_core_service.features.learner.service.LearnerService; +import vacademy.io.admin_core_service.features.institute_learner.dto.batch_enrollment.BatchEnrolledLearnerDto; +import vacademy.io.admin_core_service.features.institute_learner.dto.batch_enrollment.EnrolledLearnersRequest; +import vacademy.io.admin_core_service.features.institute_learner.repository.StudentSessionInstituteGroupMappingRepository; import vacademy.io.common.auth.dto.UserDTO; +import java.util.List; + @RestController @RequestMapping("/admin-core-service/internal/learner/v1") public class InternalLearnerDetailController { @@ -22,6 +27,19 @@ public class InternalLearnerDetailController { @Autowired private LearnerCredentialSyncService learnerCredentialSyncService; + @Autowired + private StudentSessionInstituteGroupMappingRepository studentSessionRepository; + + /** Mapping statuses that count as enrolled when the caller does not say. */ + private static final List DEFAULT_ENROLLED_STATUSES = List.of("ACTIVE"); + + /** + * Refuses absurd batch sets rather than streaming an unbounded result. The largest + * single batch in prod holds ~4k learners, so this is roughly 5x headroom; a request + * past it is a bug or an abuse, not a real assessment. + */ + private static final int MAX_PACKAGE_SESSIONS = 200; + /** * Called by auth_service AFTER it commits a new {@code users.username}. * Updates admin_core's own {@code student.username} copies and forwards the @@ -39,6 +57,38 @@ public ResponseEntity updateLearnerDetail(@RequestBody UserDTO userDTO){ return ResponseEntity.ok(learnerService.updateLearnerDetail(userDTO)); } + /** + * Learners enrolled in the given batches, one row per learner. + * + *

Exists for assessment_service's "enrolled but has not attempted" list: batch + * learners get no row in the assessment database until they actually start a test, so + * that set cannot be derived there at all. The caller fetches the enrolled set and + * subtracts the learners who already have an attempt. + * + *

There is deliberately no exclude-users parameter and no pagination. Both were + * tried; both push an unestimable predicate into the query, and once Postgres chose a + * generic plan the array got re-evaluated per row (22ms -> 434-880ms on prod data, + * intermittently). Returning the whole enrolled set keeps the plan stable, and the + * caller caches it, so this runs once per batch-set per cache window rather than once + * per page view. + */ + @PostMapping("/enrolled-by-package-sessions") + public ResponseEntity> getEnrolledLearners( + @RequestBody EnrolledLearnersRequest request) { + if (request == null || request.getInstituteId() == null || request.getInstituteId().isBlank() + || request.getPackageSessionIds() == null || request.getPackageSessionIds().isEmpty()) { + return ResponseEntity.ok(List.of()); + } + if (request.getPackageSessionIds().size() > MAX_PACKAGE_SESSIONS) { + return ResponseEntity.badRequest().build(); + } + List statuses = (request.getStatuses() == null || request.getStatuses().isEmpty()) + ? DEFAULT_ENROLLED_STATUSES + : request.getStatuses(); + return ResponseEntity.ok(studentSessionRepository.findEnrolledLearnersByPackageSessions( + request.getPackageSessionIds(), request.getInstituteId(), statuses)); + } + /** * Mirrors a learner's newly-changed portal password to any WordPress LMS their * courses are connected to. Called by auth_service after a password update. diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/learner/service/LearnerEnrollRequestService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/learner/service/LearnerEnrollRequestService.java index e681437190..10403afe6c 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/learner/service/LearnerEnrollRequestService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/learner/service/LearnerEnrollRequestService.java @@ -12,6 +12,7 @@ import vacademy.io.admin_core_service.features.enroll_invite.entity.EnrollInvite; import vacademy.io.admin_core_service.features.enroll_invite.enums.EnrollInviteTag; import vacademy.io.admin_core_service.features.enroll_invite.service.EnrollInviteService; +import vacademy.io.admin_core_service.features.enroll_invite.service.InviteFormAdminNotificationService; import vacademy.io.admin_core_service.features.enroll_invite.service.SubOrgService; import vacademy.io.admin_core_service.features.faculty.dto.AddUserAccessDTO; import vacademy.io.admin_core_service.features.faculty.service.FacultyService; @@ -109,6 +110,9 @@ public class LearnerEnrollRequestService { @Autowired private SubOrgService subOrgService; + @Autowired + private InviteFormAdminNotificationService inviteFormAdminNotificationService; + @Autowired private PackageSessionRepository packageSessionRepository; @@ -448,6 +452,17 @@ public LearnerEnrollResponseDTO recordLearnerRequest(LearnerEnrollRequestDTO lea userPlan, extraData); + // Invite-form team notification for FREE invites only. The learner FE skips the + // /open/v1/enrollment/form-submit step when the invite is FREE, so this is the + // only place their submission is observed; every other payment type is notified + // from EnrollmentFormService, which keeps it exactly-once either way. + if (PaymentOptionType.FREE.name().equals(paymentOption.getType())) { + inviteFormAdminNotificationService.notifyAdminsOnFormFill( + enrollInvite, + learnerEnrollRequestDTO.getUser(), + enrollDTO.getCustomFieldValues()); + } + // B2B: Post-processing for SUB_ORG invite enrollment // Creates ROOT_ADMIN mappings, StudentSubOrg entry, and faculty mappings if (EnrollInviteTag.SUB_ORG.name().equals(enrollInvite.getTag()) diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/AttendanceReportDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/AttendanceReportDTO.java index ab1f0afcf9..0f440b6aa4 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/AttendanceReportDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/AttendanceReportDTO.java @@ -19,5 +19,8 @@ public interface AttendanceReportDTO { String getStatusType(); String getEngagementData(); Integer getProviderTotalDurationMinutes(); + + /** Exact seconds when the provider reports them (BBB); NULL for Zoom. */ + Integer getProviderTotalDurationSeconds(); String getFeedbackDetails(); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/AttendanceReportDTOImpl.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/AttendanceReportDTOImpl.java index 3667714722..d23859101d 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/AttendanceReportDTOImpl.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/AttendanceReportDTOImpl.java @@ -30,5 +30,7 @@ public class AttendanceReportDTOImpl implements AttendanceReportDTO { private String statusType; private String engagementData; private Integer providerTotalDurationMinutes; + /** Exact seconds when the provider reports them (BBB); NULL for Zoom. */ + private Integer providerTotalDurationSeconds; private String feedbackDetails; } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/AttendanceReportProjection.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/AttendanceReportProjection.java index 19ee5ecc55..907acc6f43 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/AttendanceReportProjection.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/AttendanceReportProjection.java @@ -27,5 +27,8 @@ public interface AttendanceReportProjection { String getFeedbackDetails(); String getPackageSessionId(); Integer getProviderTotalDurationMinutes(); + + /** Exact seconds when the provider reports them (BBB); NULL for Zoom. */ + Integer getProviderTotalDurationSeconds(); String getEngagementData(); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/GuestAttendanceDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/GuestAttendanceDTO.java index 7b057af2e6..6a9ba6e10b 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/GuestAttendanceDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/dto/GuestAttendanceDTO.java @@ -16,5 +16,8 @@ public interface GuestAttendanceDTO { String getStatusType(); String getEngagementData(); Integer getProviderTotalDurationMinutes(); + + /** Exact seconds when the provider reports them (BBB); NULL for Zoom. */ + Integer getProviderTotalDurationSeconds(); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/entity/LiveSessionLogs.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/entity/LiveSessionLogs.java index 50f027adb9..b0cff81b92 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/entity/LiveSessionLogs.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/entity/LiveSessionLogs.java @@ -78,6 +78,14 @@ public class LiveSessionLogs { @Column(name = "provider_total_duration_minutes") private Integer providerTotalDurationMinutes; + /** + * Exact seconds in the meeting, when the provider reports that precisely + * (BBB does; Zoom gives whole minutes, leaving this NULL). The minutes + * column above is a floor of this and is kept for existing consumers. + */ + @Column(name = "provider_total_duration_seconds") + private Integer providerTotalDurationSeconds; + /** * Audit of the attendance-criteria evaluation for this row: verdict, reason, * attendedMinutes, scheduledMinutes, requiredMinutes, thresholdPercent, diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/controller/BbbCustomDomainController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/controller/BbbCustomDomainController.java new file mode 100644 index 0000000000..2295305f73 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/controller/BbbCustomDomainController.java @@ -0,0 +1,126 @@ +package vacademy.io.admin_core_service.features.live_session.provider.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import vacademy.io.admin_core_service.features.institute.repository.InstituteRepository; +import vacademy.io.admin_core_service.features.live_session.provider.manager.BbbMeetingManager; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Serves the list of per-institute custom live-class hostnames to the BBB pool + * start workflow, which uses it to rebuild each server's nginx {@code + * server_name} list, its certificate SAN set, and the alias DNS records. + * + *

Auth. Gated by the shared cluster secret in the + * {@code X-Internal-Service-Token} header, following the same pattern as + * assessment_service's copy-check callbacks. The path is in the security + * config's permitAll list purely so no JWT is demanded; this controller is the + * thing that actually authorises the call, so the token check must not be + * removed. + * + *

Why not {@code /internal/...}. {@code InternalAuthFilter} triggers on + * {@code request.getRequestURI().contains("internal")} — a substring match, not + * a path prefix — and demands {@code clientName} + {@code Signature} HMAC + * headers. Putting the word "internal" anywhere in this path would silently 401 + * every call from GitHub Actions, so the path deliberately avoids it. + */ +@RestController +@RequestMapping("/admin-core-service/bbb/custom-domains") +@RequiredArgsConstructor +@Slf4j +public class BbbCustomDomainController { + + private final InstituteRepository instituteRepository; + + /** + * The shared cluster secret. + * + * Falls back to {@code ai.service.internal.token} because admin_core's deploy + * workflow historically injected the very same GitHub secret under the name + * AI_SERVICE_INTERNAL_TOKEN and never set INTERNAL_SERVICE_TOKEN at all — so + * without this fallback the property resolves to empty and every call 401s, + * including one carrying the correct token. Same two-level pattern + * assessment_service's copy-check callbacks use. + */ + @Value("${internal.service.token:${ai.service.internal.token:}}") + private String expectedToken; + + @jakarta.annotation.PostConstruct + void logTokenConfig() { + // Length only, never the value — makes a misconfigured deploy obvious at + // startup instead of surfacing as an unexplained 401 hours later. + if (expectedToken == null || expectedToken.isEmpty()) { + log.error("[BBB] custom-domains token is EMPTY at startup — every call will 401. " + + "Set INTERNAL_SERVICE_TOKEN on admin-core-service."); + } else { + log.info("[BBB] custom-domains token loaded (length={})", expectedToken.length()); + } + } + + /** + * GET /admin-core-service/bbb/custom-domains + * + * Returns {@code {"domains": ["meet.zoeedtech.com", ...], "count": n}}. + * + * Values are re-normalised on the way out rather than trusted from the + * database: this list is interpolated into an nginx {@code server_name} + * directive and a certbot command line on the pool server, so a malformed + * row must be dropped here rather than become a config-injection vector. + */ + @GetMapping + public ResponseEntity> listCustomDomains( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token) { + + if (!verify(token)) { + return ResponseEntity.status(401).body(Map.of("error", "invalid token")); + } + + List raw = instituteRepository.findDistinctLiveSessionBaseUrls(); + Set clean = new LinkedHashSet<>(); + List rejected = new ArrayList<>(); + for (String candidate : raw) { + String host = BbbMeetingManager.normalizeLiveSessionHost(candidate); + if (host != null) { + clean.add(host); + } else if (candidate != null && !candidate.isBlank()) { + rejected.add(candidate); + } + } + if (!rejected.isEmpty()) { + log.warn("[BBB] Ignoring {} malformed live_session_base_url value(s): {}", + rejected.size(), rejected); + } + + List domains = new ArrayList<>(clean); + log.info("[BBB] Serving {} custom live-class domain(s) to the pool workflow", domains.size()); + return ResponseEntity.ok(Map.of("domains", domains, "count", domains.size())); + } + + /** Constant-time comparison so the token cannot be recovered by timing. */ + private boolean verify(String token) { + if (expectedToken == null || expectedToken.isEmpty()) { + log.error("[BBB] custom-domains rejected: internal.service.token is not configured"); + return false; + } + if (token == null || token.isEmpty()) { + log.warn("[BBB] custom-domains rejected: missing X-Internal-Service-Token header"); + return false; + } + return MessageDigest.isEqual( + token.getBytes(StandardCharsets.UTF_8), + expectedToken.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/controller/LiveSessionProviderController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/controller/LiveSessionProviderController.java index 75d5c0ffeb..316e901d87 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/controller/LiveSessionProviderController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/controller/LiveSessionProviderController.java @@ -665,7 +665,11 @@ public ResponseEntity bbbAnalyticsCallback( } } } - attendanceCriteriaEvaluator.evaluate(criteriaSession, schedule, roster, adminMarked); + // callback.getDuration() is how long the meeting really ran, in + // seconds — the threshold is capped to it so an early finish + // cannot fail learners who stayed for the whole class. + attendanceCriteriaEvaluator.evaluate(criteriaSession, schedule, roster, adminMarked, + callback.getDuration()); } schedule.setLastAttendanceSyncAt(new java.util.Date()); @@ -689,8 +693,18 @@ private void processAnalyticsAttendee(String sessionId, String scheduleId, Optional existing = liveSessionLogsRepository .findExistingAttendanceRecord(scheduleId, attendee.getExtUserId()); + // Floored, as it always has been. Rounding would have shifted the minutes + // reported for every BBB class on the platform — including institutes + // that never enabled the attendance rule — and those figures are read by + // reports, CSV exports and the workflow query layer. Precision now lives + // in provider_total_duration_seconds, which is what the rule compares and + // what the learner's mail renders, so this column has no need to change. int durationMinutes = attendee.getDuration() != null ? (int) (attendee.getDuration() / 60) : 0; + // Keep the exact seconds too — the minutes value above floors away up to + // 59 seconds, which decides borderline minimum-attendance verdicts. + int durationSeconds = attendee.getDuration() != null + ? attendee.getDuration().intValue() : 0; String engagementJson = buildEngagementJson(attendee.getEngagement()); @@ -700,7 +714,24 @@ private void processAnalyticsAttendee(String sessionId, String scheduleId, // Sum duration with existing (for retry/recreate scenario) int existingDuration = log.getProviderTotalDurationMinutes() != null ? log.getProviderTotalDurationMinutes() : 0; - log.setProviderTotalDurationMinutes(existingDuration + durationMinutes); + int existingSeconds = log.getProviderTotalDurationSeconds() != null + ? log.getProviderTotalDurationSeconds() : 0; + int totalSeconds = existingSeconds + durationSeconds; + log.setProviderTotalDurationSeconds(totalSeconds); + // Derive minutes from the seconds total rather than summing a second, + // independent series. Two columns holding one fact will drift the + // moment a future edit touches only one of them — and summing + // per-callback floors compounded the loss: floor(90s)+floor(90s) = 2 + // minutes for 3 minutes of attendance. floor(total) is both correct + // and impossible to disagree with the seconds column. + // floor(total) rather than sum-of-floors: identical for the single + // callback that virtually every meeting produces, and it stops a + // retry from compounding the truncation (floor(90s) + floor(90s) + // reported 2 minutes for 3 minutes of attendance). Still a floor, so + // no institute sees its existing figures move. + log.setProviderTotalDurationMinutes( + totalSeconds > 0 ? totalSeconds / 60 + : existingDuration + durationMinutes); // Merge engagement data (sum counts) log.setEngagementData(mergeEngagementJson(log.getEngagementData(), engagementJson)); @@ -729,6 +760,7 @@ private void processAnalyticsAttendee(String sessionId, String scheduleId, .details(attendee.getName() + " | role=" + (Boolean.TRUE.equals(attendee.getModerator()) ? "MODERATOR" : "VIEWER")) .providerMeetingId(providerMeetingId) .providerTotalDurationMinutes(durationMinutes) + .providerTotalDurationSeconds(durationSeconds) .engagementData(engagementJson) .createdAt(new Timestamp(System.currentTimeMillis())) .updatedAt(new Timestamp(System.currentTimeMillis())) diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/manager/BbbMeetingManager.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/manager/BbbMeetingManager.java index cd4e710c08..2e20c8fcff 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/manager/BbbMeetingManager.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/manager/BbbMeetingManager.java @@ -324,7 +324,12 @@ boolean record = boolOrDefault(bbbCfg, "record", true); // the manifest keep it domain-agnostic across the pool. buildQueryString() URL-encodes // the JSON value. Toggle off via bbb.plugins.pip.enabled=false. if (pipPluginEnabled) { - String bbbBaseUrl = apiUrl.replaceAll("/bigbluebutton/api/?$", ""); + // The participant's BROWSER fetches this manifest, so it has to be on the + // host they are actually on. Built from apiUrl it would always be the + // canonical domain, which still loads (plugin assets send CORS headers) + // but shows meet.vacademy.io in an institute's devtools. + String bbbBaseUrl = applyInstituteLiveSessionDomain(apiUrl, instituteId, selectedServer) + .replaceAll("/bigbluebutton/api/?$", ""); String pipManifestUrl = bbbBaseUrl + "/plugins/picture-in-picture/manifest.json"; params.put("pluginManifests", "[{\"url\":\"" + pipManifestUrl + "\"}]"); } @@ -389,8 +394,13 @@ boolean record = boolOrDefault(bbbCfg, "record", true); // leaks into a real join, BBB assigns each joiner a unique internal id and they // don't eject one another via maxUserConcurrentAccesses. The name stays generic // because a stored, shared URL cannot know who is joining. - String moderatorJoinUrl = buildJoinUrl(apiUrl, secret, bbbMeetingId, "Moderator", null, "MODERATOR"); - String attendeeJoinUrl = buildJoinUrl(apiUrl, secret, bbbMeetingId, "Attendee", null, "VIEWER"); + // These are participant-facing, so they get the institute's custom host when + // one is set. Note this is a SEPARATE variable — `apiUrl` itself must stay + // canonical, because it is what the create/end/query calls above and below + // talk to. + String joinApiUrl = applyInstituteLiveSessionDomain(apiUrl, instituteId, selectedServer); + String moderatorJoinUrl = buildJoinUrl(joinApiUrl, secret, bbbMeetingId, "Moderator", null, "MODERATOR"); + String attendeeJoinUrl = buildJoinUrl(joinApiUrl, secret, bbbMeetingId, "Attendee", null, "VIEWER"); Map raw = new HashMap<>(); raw.put("meetingID", bbbMeetingId); @@ -469,13 +479,17 @@ public String buildJoinUrlForUser(String meetingId, String fullName, Boolean viewerMicLocked) { String apiUrl; String secret; + // Kept in scope so the custom-domain rewrite below can tell whether this + // meeting is on the primary pool server. + BbbServerPool server = null; if (bbbServerId != null) { try { - BbbServerPool server = serverRouter.getServer(bbbServerId); + server = serverRouter.getServer(bbbServerId); apiUrl = server.getApiUrl(); secret = server.getSecret(); } catch (Exception e) { log.warn("[BBB] Failed to resolve server {}, falling back to legacy config", bbbServerId); + server = null; Map cfg = getConfigMap(instituteId); apiUrl = (String) cfg.get("apiUrl"); secret = (String) cfg.get("secret"); @@ -486,6 +500,12 @@ public String buildJoinUrlForUser(String meetingId, String fullName, secret = (String) cfg.get("secret"); } + // This URL is opened by the participant, so it carries the institute's own + // live-class host when one is configured. The checksum below is computed + // over the query string and secret only — BBB does not sign the hostname — + // so swapping the origin here keeps the link valid. + apiUrl = applyInstituteLiveSessionDomain(apiUrl, instituteId, server); + // Build base join params Map params = new LinkedHashMap<>(); params.put("meetingID", meetingId); @@ -1073,6 +1093,90 @@ private static int intOrDefault(Map map, String key, int default } } + // ----------------------------------------------------------------------- + // Per-institute live-class domain (white-labelling) + // ----------------------------------------------------------------------- + + /** Plain hostname: dot-separated labels ending in a 2+ letter TLD. */ + private static final java.util.regex.Pattern LIVE_SESSION_HOST = java.util.regex.Pattern + .compile("^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}$"); + + /** + * Normalise a custom live-class host to a bare hostname, or null when it is + * absent or is not a plain hostname. + * + * This value ends up as the origin of a URL we redirect learners to, so it is + * validated rather than trusted: scheme, path and userinfo are stripped, and + * anything left carrying a port or not matching a hostname shape is rejected + * outright (null) instead of being silently patched into something plausible. + * + * Accepts "meet.zoeedtech.com", "https://meet.zoeedtech.com" and + * "https://meet.zoeedtech.com/" alike; all normalise to the bare host. + */ + public static String normalizeLiveSessionHost(String raw) { + if (raw == null) { + return null; + } + String host = raw.trim().toLowerCase(); + if (host.isEmpty()) { + return null; + } + host = host.replaceFirst("^[a-z][a-z0-9+.\\-]*://", ""); // scheme + host = host.replaceFirst("/.*$", ""); // path + host = host.replaceFirst("^[^@]*@", ""); // userinfo + if (host.isEmpty() || host.indexOf(':') >= 0) { // no ports + return null; + } + return LIVE_SESSION_HOST.matcher(host).matches() ? host : null; + } + + /** + * Swap the origin of a BBB API URL for the institute's own live-class host, + * leaving the path intact. Returns {@code apiUrl} unchanged whenever the + * rewrite does not apply — this method never throws. + * + * Call this ONLY for URLs a participant will open. Control-plane calls + * (create / isMeetingRunning / getRecordings / getAttendance) must keep using + * the pool server's own api_url, so that a broken custom domain costs + * branding on a link and never a class. + * + * @param server the pool server the meeting was placed on, or null when the + * legacy single-server config path was used. The rewrite is skipped for + * any non-primary server: the institute's A record points at the + * primary box, so branding a URL that resolves elsewhere would send the + * learner to a server without their meeting. + */ + private String applyInstituteLiveSessionDomain(String apiUrl, String instituteId, + BbbServerPool server) { + if (apiUrl == null || instituteId == null) { + return apiUrl; + } + if (server != null && !serverRouter.isPrimary(server)) { + log.debug("[BBB] Meeting is on non-primary server {} — keeping canonical join host", + server.getSlug()); + return apiUrl; + } + try { + Institute institute = instituteRepository.findById(instituteId).orElse(null); + if (institute == null) { + return apiUrl; + } + String host = normalizeLiveSessionHost(institute.getLiveSessionBaseUrl()); + if (host == null) { + return apiUrl; + } + URI uri = URI.create(apiUrl); + String path = uri.getRawPath() == null ? "" : uri.getRawPath(); + String rewritten = "https://" + host + path; + log.info("[BBB] Join host for institute {}: {} -> {}", instituteId, uri.getHost(), host); + return rewritten; + } catch (Exception e) { + log.warn("[BBB] Could not apply custom live-class domain for institute {}: {}", + instituteId, e.getMessage()); + return apiUrl; + } + } + private String buildQueryString(Map params) { StringBuilder sb = new StringBuilder(); for (Map.Entry entry : params.entrySet()) { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/manager/ZoomMeetingManager.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/manager/ZoomMeetingManager.java index 222edd262e..93d215090a 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/manager/ZoomMeetingManager.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/manager/ZoomMeetingManager.java @@ -52,6 +52,9 @@ @Slf4j public class ZoomMeetingManager implements LiveSessionProviderStrategy { + /** Newest-N past instances fetched per sync; see fetchRecordings for why this is safe. */ + private static final int MAX_INSTANCES_PER_SYNC = 30; + private static final DateTimeFormatter ZOOM_UTC = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"); @@ -179,16 +182,130 @@ public List getAttendance(String providerMeetingId, String i /** * Fetches cloud recordings for a meeting from Zoom. Account-aware variant used - * by the polling job and webhook (they already hold the schedule's account). + * by the recording.completed webhook, which already holds the schedule's account. + * The hourly sweep uses {@link #fetchAllInstanceRecordings} instead. * GET /v2/meetings/{meetingId}/recordings */ public List fetchRecordings(ZoomAccount account, String meetingId) { + // Cheap path: one call, the meeting's LATEST instance. This is what the + // recording.completed webhook wants — it already names the meeting that just + // finished, and Zoom expects a prompt response, so fanning out over history here + // would only add latency and rate-limit pressure at the worst moment. + return fetchRecordingsForInstance(account, meetingId, meetingId); + } + + /** + * Every instance of a recurring meeting, not just the latest. + * + *

A numeric meeting id addresses only the most recent instance. Trainers routinely + * rejoin the same meeting every day, so asking by id alone returns today's files and + * silently drops any earlier occurrence the poll happened to miss — those are then lost + * for good once Zoom's ~30-day retention expires. The hourly sweep uses this; the webhook + * does not need it. + */ + public List fetchAllInstanceRecordings(ZoomAccount account, String meetingId) { + List all = new ArrayList<>(); + java.util.Set seen = new java.util.HashSet<>(); + List instances = fetchPastInstanceUuids(account, meetingId); + // Each instance is one more Zoom call, and this job already competes for a shared + // rate limit. Zoom deletes cloud recordings after ~30 days and the poll runs hourly + // and merges (never removes), so the newest slice is all that can still be new. + if (instances.size() > MAX_INSTANCES_PER_SYNC) { + log.info("zoom.recordings.instances.capped meetingId={} total={} fetching newest={}", + meetingId, instances.size(), MAX_INSTANCES_PER_SYNC); + instances = instances.subList(instances.size() - MAX_INSTANCES_PER_SYNC, instances.size()); + } + for (String uuid : instances) { + for (MeetingRecordingDTO rec : fetchRecordingsForInstance(account, meetingId, encodeUuid(uuid))) { + if (rec.getRecordingId() == null || seen.add(rec.getRecordingId())) { + all.add(rec); + } + } + } + if (all.isEmpty()) { + // Never ran, or the instance list was unavailable — fall back to the plain id call. + return fetchRecordingsForInstance(account, meetingId, meetingId); + } + return all; + } + + /** + * Mints a FRESH host start url for a meeting. + * GET /v2/meetings/{meetingId} -> start_url, whose embedded ZAK is valid ~2 hours. + * + *

Why this exists: {@code session_schedules.provider_host_url} is written once, at + * scheduling time, and its ZAK expires about two hours later. Every occurrence of a + * recurring session therefore ships a host link that is already dead by the time the + * class runs — the host cannot start the meeting, so it never starts, so it never + * records. Anything that hands a host link to a human must refresh it through here. + * Returns null on failure so callers can fall back to the stored value. + */ + public String fetchStartUrl(ZoomAccount account, String meetingId) { + try { + JsonNode resp = webClientBuilder.build() + .get() + .uri(ZoomEndpoints.API_BASE_URL + "/meetings/" + meetingId) + .header("Authorization", "Bearer " + accessTokenService.getAccessToken(account)) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + return resp != null && resp.hasNonNull("start_url") ? resp.get("start_url").asText() : null; + } catch (Exception e) { + log.warn("zoom.start_url.fetch.fail meetingId={} reason={}", meetingId, + e.getClass().getSimpleName()); + return null; + } + } + + /** + * Lists the UUIDs of every past instance of a meeting. + * GET /v2/past_meetings/{meetingId}/instances + * Returns empty when the meeting never ran (404) or the call fails — callers then + * fall back to addressing the meeting by its numeric id. + */ + private List fetchPastInstanceUuids(ZoomAccount account, String meetingId) { + List uuids = new ArrayList<>(); + try { + JsonNode resp = webClientBuilder.build() + .get() + .uri(ZoomEndpoints.API_BASE_URL + "/past_meetings/" + meetingId + "/instances") + .header("Authorization", "Bearer " + accessTokenService.getAccessToken(account)) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + if (resp != null && resp.has("meetings")) { + for (JsonNode m : resp.get("meetings")) { + String uuid = m.path("uuid").asText(null); + if (uuid != null && !uuid.isBlank()) { + uuids.add(uuid); + } + } + } + } catch (Exception e) { + log.debug("zoom.past_instances.fetch.fail meetingId={} reason={}", meetingId, + e.getClass().getSimpleName()); + } + return uuids; + } + + /** + * Zoom requires a meeting UUID to be double URL-encoded when it starts with "/" or + * contains "//" — and double-encoding is harmless for the rest, so always do it. + */ + static String encodeUuid(String uuid) { + String once = java.net.URLEncoder.encode(uuid, java.nio.charset.StandardCharsets.UTF_8); + return java.net.URLEncoder.encode(once, java.nio.charset.StandardCharsets.UTF_8); + } + + /** Fetches the recording files for one meeting instance (by UUID) or meeting id. */ + private List fetchRecordingsForInstance( + ZoomAccount account, String meetingId, String addressBy) { String token = accessTokenService.getAccessToken(account); JsonNode response; try { response = webClientBuilder.build() .get() - .uri(ZoomEndpoints.API_BASE_URL + "/meetings/" + meetingId + "/recordings") + .uri(ZoomEndpoints.API_BASE_URL + "/meetings/" + addressBy + "/recordings") .header("Authorization", "Bearer " + token) .retrieve() .bodyToMono(JsonNode.class) diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/GoogleMeetRecordingSyncProcessor.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/GoogleMeetRecordingSyncProcessor.java index 79f7a49e24..39b67ae54b 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/GoogleMeetRecordingSyncProcessor.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/GoogleMeetRecordingSyncProcessor.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import vacademy.io.admin_core_service.features.live_session.entity.SessionSchedule; @@ -31,6 +32,7 @@ public class GoogleMeetRecordingSyncProcessor { private final GoogleRecordingService googleRecordingService; /** Runs every hour at minute 27 (offset to avoid colliding with the Zoom/BBB jobs). */ + @SchedulerLock(name = "GoogleMeetRecordingSync", lockAtMostFor = "PT50M", lockAtLeastFor = "PT30S") @Scheduled(cron = "${google.recording.sync.cron:0 27 * * * ?}") public void syncGoogleRecordings() { Date before = new Date(System.currentTimeMillis() - STALE_MILLIS); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomAttendanceSyncProcessor.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomAttendanceSyncProcessor.java index fcc6c2d349..7efb2da4a7 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomAttendanceSyncProcessor.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomAttendanceSyncProcessor.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import vacademy.io.admin_core_service.features.live_session.entity.SessionSchedule; @@ -32,6 +33,7 @@ public class ZoomAttendanceSyncProcessor { private final ZoomAttendanceService zoomAttendanceService; /** Runs every 15 minutes (offset to minute :07 to avoid colliding with other jobs). */ + @SchedulerLock(name = "ZoomAttendanceSync", lockAtMostFor = "PT14M", lockAtLeastFor = "PT30S") @Scheduled(cron = "${zoom.attendance.sync.cron:0 7/15 * * * ?}") public void syncZoomAttendance() { Date before = new Date(System.currentTimeMillis() - STALE_MILLIS); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomMeetingProvisionRetryProcessor.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomMeetingProvisionRetryProcessor.java index c83823cf2a..aff6575e6f 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomMeetingProvisionRetryProcessor.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomMeetingProvisionRetryProcessor.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import vacademy.io.admin_core_service.features.live_session.entity.LiveSession; @@ -36,6 +37,7 @@ public class ZoomMeetingProvisionRetryProcessor { private final ProviderMeetingBatchService providerMeetingBatchService; /** Runs every 5 minutes (offset to minute :02). */ + @SchedulerLock(name = "ZoomProvisionRetry", lockAtMostFor = "PT4M", lockAtLeastFor = "PT20S") @Scheduled(cron = "${zoom.provision.retry.cron:0 2/5 * * * ?}") public void retryStuckProvisioning() { Date staleBefore = new Date(System.currentTimeMillis() - STALE_MILLIS); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomRecordingS3SyncProcessor.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomRecordingS3SyncProcessor.java index 98576e2ad0..331aa25748 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomRecordingS3SyncProcessor.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomRecordingS3SyncProcessor.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -34,6 +35,7 @@ public class ZoomRecordingS3SyncProcessor { private int rescueWithinDays; /** Runs every 6 hours (offset to minute :37). */ + @SchedulerLock(name = "ZoomRecordingS3Rescue", lockAtMostFor = "PT5H", lockAtLeastFor = "PT1M") @Scheduled(cron = "${zoom.recording.s3.rescue.cron:0 37 */6 * * ?}") public void rescueExpiringRecordings() { if (!enabled) { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomRecordingSyncProcessor.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomRecordingSyncProcessor.java index be33b788f6..be954c1f76 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomRecordingSyncProcessor.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/scheduler/ZoomRecordingSyncProcessor.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import vacademy.io.admin_core_service.features.live_session.entity.SessionSchedule; @@ -31,6 +32,7 @@ public class ZoomRecordingSyncProcessor { private final ZoomRecordingService zoomRecordingService; /** Runs every hour at minute 17 (offset to avoid colliding with other jobs). */ + @SchedulerLock(name = "ZoomRecordingSync", lockAtMostFor = "PT50M", lockAtLeastFor = "PT30S") @Scheduled(cron = "${zoom.recording.sync.cron:0 17 * * * ?}") public void syncZoomRecordings() { Date before = new Date(System.currentTimeMillis() - STALE_MILLIS); @@ -44,7 +46,9 @@ public void syncZoomRecordings() { int totalAdded = 0; for (SessionSchedule schedule : due) { try { - totalAdded += zoomRecordingService.syncFromApi(schedule); + // true: sweep every past instance — this job exists to catch what the webhook missed, + // and a day skipped here is unrecoverable once Zoom's ~30-day retention lapses. + totalAdded += zoomRecordingService.syncFromApi(schedule, true); } catch (Exception e) { log.error("ZoomRecordingSync: failed for scheduleId={}: {}", schedule.getId(), e.getMessage()); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/service/BbbServerRouter.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/service/BbbServerRouter.java index 4d73fcede3..8a4a16d191 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/service/BbbServerRouter.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/service/BbbServerRouter.java @@ -104,4 +104,29 @@ public List getAllEnabledServers() { public List getRunningServers() { return poolRepository.findByStatusAndEnabledTrue("RUNNING"); } + + /** + * True when {@code server} is the primary pool server — the lowest-priority + * enabled row. + * + * This matters for per-institute custom live-class domains. An institute has a + * single A record for its domain and it points at exactly one box, the primary. + * If a meeting spills to a lower-priority server and we still rewrote the join + * URL to the institute's host, we would send the learner to a server that does + * not hold their meeting — strictly worse than an off-brand URL that works. + * + * Deliberately based on priority rather than status: a server can be the + * primary while briefly not RUNNING, and the DNS record still points at it. + * + * @return false when {@code server} is null or the pool is empty. + */ + public boolean isPrimary(BbbServerPool server) { + if (server == null || server.getId() == null) { + return false; + } + return poolRepository.findByEnabledTrueOrderByPriorityAsc().stream() + .findFirst() + .map(primary -> primary.getId().equals(server.getId())) + .orElse(false); + } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/service/LiveSessionProviderService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/service/LiveSessionProviderService.java index 7bc01bc2d7..99618e0fe5 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/service/LiveSessionProviderService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/service/LiveSessionProviderService.java @@ -24,6 +24,7 @@ import vacademy.io.admin_core_service.features.live_session.provider.support.ScheduleConflicts; import vacademy.io.admin_core_service.features.media_service.service.MediaService; import vacademy.io.common.media.dto.FileDetailsDTO; +import vacademy.io.common.tracing.ExternalCallTimer; import vacademy.io.common.exceptions.VacademyException; import vacademy.io.common.meeting.dto.CreateMeetingRequestDTO; import vacademy.io.common.meeting.dto.CreateMeetingResponseDTO; @@ -68,6 +69,7 @@ public class LiveSessionProviderService { private final BbbServerRouter bbbServerRouter; private final RecordingAutoLinkService recordingAutoLinkService; private final vacademy.io.admin_core_service.features.live_session.provider.service.zoom.ZoomRecordingS3Service zoomRecordingS3Service; + private final vacademy.io.admin_core_service.features.live_session.provider.manager.ZoomMeetingManager zoomMeetingManager; private static final List ACTIVE = List.of("ACTIVE"); @@ -229,7 +231,11 @@ public CreateMeetingResponseDTO ensureMeetingForSchedule( // BBB /create call — if this throws, the @Transactional rolls back and // the row's providerMeetingId stays null. No orphan/partial state. - CreateMeetingResponseDTO response = strategy.createMeeting(meetingRequest, request.getInstituteId()); + // Attributed to `ext`: this is BBB/Zoom creating the room, not our compute. + // Without the split, the first learner into a live class sees a ~2s request + // and the speed indicator blames us for the provider's work. + CreateMeetingResponseDTO response = ExternalCallTimer.time( + () -> strategy.createMeeting(meetingRequest, request.getInstituteId())); // Persist provider IDs onto the locked entity. Same field-set rules as // the legacy createMeeting() to preserve frontend-friendly linkType @@ -305,16 +311,21 @@ public CreateMeetingResponseDTO createMeeting(ProviderMeetingCreateRequestDTO re .zoomConfig(request.getZoomConfig()) .build(); - CreateMeetingResponseDTO response = strategy.createMeeting(meetingRequest, request.getInstituteId()); + // Attributed to `ext`: this is BBB/Zoom creating the room, not our compute. + // Without the split, the first learner into a live class sees a ~2s request + // and the speed indicator blames us for the provider's work. + CreateMeetingResponseDTO response = ExternalCallTimer.time( + () -> strategy.createMeeting(meetingRequest, request.getInstituteId())); // Write everything back to the schedule row — no extra table needed if (request.getScheduleId() != null) { scheduleRepository.findById(request.getScheduleId()).ifPresent(schedule -> { schedule.setCustomMeetingLink(response.getJoinUrl()); // learner join URL - // Only set linkType if not already set — preserve the frontend-friendly - // value (e.g. "bbb") instead of overwriting with enum name ("BBB_MEETING") - if (schedule.getLinkType() == null || schedule.getLinkType().isBlank()) { - schedule.setLinkType(providerName); + // Set linkType when the row does not already carry a real one — preserving a + // frontend-friendly value ("bbb") the wizard already chose, but replacing the + // "UNKNOWN" placeholder, which is NOT a choice (see isUnsetLinkType). + if (isUnsetLinkType(schedule.getLinkType())) { + schedule.setLinkType(frontendLinkType(providerName)); } schedule.setProviderMeetingId(response.getProviderMeetingId()); schedule.setProviderHostUrl(response.getHostUrl()); @@ -808,13 +819,92 @@ public Map getSessionLinks(String scheduleId) { Map links = new java.util.HashMap<>(); if (schedule.getCustomMeetingLink() != null) links.put("joinUrl", schedule.getCustomMeetingLink()); - if (schedule.getProviderHostUrl() != null) - links.put("hostUrl", schedule.getProviderHostUrl()); + String hostUrl = freshZoomHostUrlOrStored(schedule); + if (hostUrl != null) + links.put("hostUrl", hostUrl); if (schedule.getProviderMeetingId() != null) links.put("providerMeetingId", schedule.getProviderMeetingId()); return links; } + /** + * Returns a host url that actually works. + * + *

{@code provider_host_url} is a Zoom start url captured once, when the occurrence was + * provisioned. The ZAK embedded in it lives about two hours, so for every recurring session + * the stored link is dead long before the class runs — the host cannot claim host role, the + * meeting is never started, and nothing is ever recorded. (The embedded SDK path never hit + * this because {@code ZoomSdkController} mints a ZAK per request; only the redirect path + * handed out the stale value.) Re-mint on read for Zoom; fall back to the stored url if Zoom + * is unreachable, since a stale link still beats no link. + */ + private String freshZoomHostUrlOrStored(SessionSchedule schedule) { + String stored = schedule.getProviderHostUrl(); + String meetingId = schedule.getProviderMeetingId(); + if (meetingId == null || meetingId.isBlank() || !isZoom(schedule.getLinkType())) { + return stored; + } + try { + String fresh = zoomMeetingManager.fetchStartUrl( + zoomMeetingManager.resolveAccountByMeeting(meetingId), meetingId); + return fresh != null ? fresh : stored; + } catch (Exception e) { + log.warn("zoom.host_url.refresh.fail scheduleId={} meetingId={} reason={}", + schedule.getId(), meetingId, e.getClass().getSimpleName()); + return stored; + } + } + + + /** + * True when a schedule's linkType carries no real provider. + * + *

{@code "UNKNOWN"} is what URL sniffing returns for a schedule created before its meeting + * link exists — which is every provider-provisioned occurrence, because the link is minted + * right here. It is neither null nor blank, so the old guard mistook it for a deliberate + * choice and left it in place permanently. Rows stuck on it are broken at both ends: the + * dashboards match linkType against StreamingPlatform literals, so "Start as Host" degrades + * to a plain participant link and the teacher never claims host role; and on the server + * {@code MeetingProvider.fromString("UNKNOWN")} throws, so strategy resolution fails outright. + */ + static boolean isUnsetLinkType(String linkType) { + return linkType == null || linkType.isBlank() || "UNKNOWN".equalsIgnoreCase(linkType); + } + + /** + * The literal the dashboards actually match on, for a given provider. + * + *

Storing the enum name instead ("GOOGLE_MEET", "ZOOM_MEETING") is the same + * case-sensitivity trap: some frontend branches accept the enum name, others only the + * StreamingPlatform literal, so the enum name works in one place and silently fails in + * another. The backend accepts either — {@code MeetingProvider.fromString} normalises. + */ + static String frontendLinkType(String providerName) { + if (providerName == null || providerName.isBlank()) { + return providerName; + } + try { + switch (MeetingProvider.fromString(providerName)) { + case ZOOM_MEETING: + return "zoom"; + case GOOGLE_MEET: + return "google meet"; + case BBB_MEETING: + return "bbb"; + case ZOHO_MEETING: + return "zoho"; + } + } catch (Exception e) { + log.debug("unrecognised provider name {} — storing as-is", providerName); + } + return providerName; + } + + /** True for both the frontend-friendly "zoom"/"ZOOM" and the enum name "ZOOM_MEETING". */ + static boolean isZoom(String linkType) { + return linkType != null && linkType.toUpperCase().startsWith("ZOOM"); + } + // ----------------------------------------------------------------------- // Schedule availability check // ----------------------------------------------------------------------- diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/service/zoom/ZoomRecordingService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/service/zoom/ZoomRecordingService.java index 8e6835772d..46cdb66d86 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/service/zoom/ZoomRecordingService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/provider/service/zoom/ZoomRecordingService.java @@ -9,11 +9,14 @@ import vacademy.io.admin_core_service.features.live_session.entity.SessionSchedule; import vacademy.io.admin_core_service.features.live_session.provider.dto.zoom.ZoomAccount; import vacademy.io.admin_core_service.features.live_session.provider.manager.ZoomMeetingManager; +import vacademy.io.admin_core_service.features.live_session.repository.LiveSessionRepository; import vacademy.io.admin_core_service.features.live_session.repository.SessionScheduleRepository; import vacademy.io.admin_core_service.features.live_session.service.RecordingAutoLinkService; import vacademy.io.common.meeting.dto.MeetingRecordingDTO; import java.time.Instant; +import java.time.ZoneId; +import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Date; @@ -37,6 +40,7 @@ public class ZoomRecordingService { private final ZoomMeetingManager zoomMeetingManager; private final ZoomAccountStore zoomAccountStore; private final SessionScheduleRepository scheduleRepository; + private final LiveSessionRepository liveSessionRepository; private final ObjectMapper objectMapper; private final RecordingAutoLinkService recordingAutoLinkService; @@ -47,6 +51,16 @@ public class ZoomRecordingService { */ @Transactional public int syncFromApi(SessionSchedule schedule) { + return syncFromApi(schedule, false); + } + + /** + * @param allInstances true to sweep EVERY past instance of a recurring meeting (the hourly + * job, which is catching up on anything missed), false for just the latest (the + * recording.completed webhook, which already knows which meeting finished and must + * answer Zoom promptly). + */ + public int syncFromApi(SessionSchedule schedule, boolean allInstances) { if (schedule.getProviderAccountId() == null || schedule.getProviderMeetingId() == null) { return 0; } @@ -58,8 +72,22 @@ public int syncFromApi(SessionSchedule schedule) { return 0; } - List fetched = - zoomMeetingManager.fetchRecordings(account, schedule.getProviderMeetingId()); + List fetched; + if (allInstances) { + List everyInstance = + zoomMeetingManager.fetchAllInstanceRecordings(account, schedule.getProviderMeetingId()); + fetched = onlyThisOccurrence(everyInstance, schedule); + if (fetched.isEmpty() && !everyInstance.isEmpty()) { + // The meeting ran, but not on this row's date — another occurrence owns those + // files. Take nothing rather than guess, so a row cannot inherit a sibling's class. + log.debug("zoom.recording.sync scheduleId={} date={} — {} instance recording(s) all " + + "belong to other dates, skipping", schedule.getId(), schedule.getMeetingDate(), + everyInstance.size()); + fetched = List.of(); + } + } else { + fetched = zoomMeetingManager.fetchRecordings(account, schedule.getProviderMeetingId()); + } int added = persist(schedule, fetched); schedule.setLastRecordingSyncAt(new Date()); @@ -84,7 +112,6 @@ private int persist(SessionSchedule schedule, List fetched) } } int added = 0; - String defaultExpiresAt = defaultExpiryIso(); for (MeetingRecordingDTO rec : fetched) { if (rec.getRecordingId() == null) continue; if (!byId.containsKey(rec.getRecordingId())) { @@ -94,7 +121,7 @@ private int persist(SessionSchedule schedule, List fetched) // ~30 days. Only set when the recording hasn't been mirrored to S3 // (fileId present → recording lives on S3 now, no provider expiry). if (rec.getFileId() == null && rec.getExpiresAt() == null) { - rec.setExpiresAt(defaultExpiresAt); + rec.setExpiresAt(expiryFor(rec)); } // Tag storage so the admin UI can show a "Zoom Cloud (expires in N days)" // vs "Library/S3" badge. The S3 mirror flips this to "S3" once uploaded. @@ -118,6 +145,59 @@ private int persist(SessionSchedule schedule, List fetched) return added; } + + /** + * Keeps only the recordings that belong to THIS occurrence's date. + * + *

One Zoom meeting is routinely reused for every session in a recurring series, so asking + * for all of its instances returns several days at once. A schedule row is a single + * occurrence: merging the whole history into it would stack other days' classes onto one + * date and duplicate files already held correctly by their own rows. Dates are compared in + * the session's own timezone — {@code meeting_date} is local while a recording's + * {@code startTime} is UTC, so a 9:30 IST class carries a 04:00Z stamp. + */ + private List onlyThisOccurrence( + List recordings, SessionSchedule schedule) { + LocalDate occurrence = toLocalDate(schedule.getMeetingDate()); + if (occurrence == null) { + return recordings; + } + ZoneId zone = sessionZone(schedule.getSessionId()); + List kept = new ArrayList<>(); + for (MeetingRecordingDTO rec : recordings) { + if (rec.getStartTime() == null || rec.getStartTime().isBlank()) { + continue; + } + try { + if (Instant.parse(rec.getStartTime()).atZone(zone).toLocalDate().equals(occurrence)) { + kept.add(rec); + } + } catch (Exception e) { + log.debug("zoom.recording.sync unparseable startTime={} — dropped", rec.getStartTime()); + } + } + return kept; + } + + private ZoneId sessionZone(String sessionId) { + try { + String tz = sessionId == null ? null : liveSessionRepository.findById(sessionId) + .map(ls -> ls.getTimezone()).orElse(null); + if (tz != null && !tz.isBlank()) { + return ZoneId.of(tz); + } + } catch (Exception e) { + log.debug("zoom.recording.sync unusable session timezone for {} — defaulting", sessionId); + } + return ZoneId.of("Asia/Kolkata"); + } + + private static LocalDate toLocalDate(java.util.Date d) { + if (d == null) return null; + return (d instanceof java.sql.Date sql) ? sql.toLocalDate() + : d.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + } + /** Public read of the stored recordings (used by the S3 mirror service). */ public List getStoredRecordings(SessionSchedule schedule) { return parseExisting(schedule); @@ -155,4 +235,28 @@ private List parseExisting(SessionSchedule schedule) { private static String defaultExpiryIso() { return Instant.now().plus(DEFAULT_RETENTION_DAYS, ChronoUnit.DAYS).toString(); } + + /** + * Expiry counted from when the recording was MADE, not from when we noticed it. + * + *

Zoom deletes a cloud recording ~30 days after it was created. Stamping + * {@code now + 30d} was close enough while the sync only ever saw the meeting's latest + * instance, but the sweep surfaces instances up to 30 days old — for those, now-based + * expiry overstates the deadline by weeks. The near-expiry S3 rescue only mirrors + * recordings due within a few days, so an overstated date means it never fires and Zoom + * deletes the file before it is ever copied to our storage. Falls back to the + * conservative now-based value when the recording carries no usable start time. + */ + private static String expiryFor(MeetingRecordingDTO rec) { + String start = rec.getStartTime(); + if (start != null && !start.isBlank()) { + try { + return Instant.parse(start).plus(DEFAULT_RETENTION_DAYS, ChronoUnit.DAYS).toString(); + } catch (Exception e) { + log.debug("zoom.recording.expiry unparseable startTime={} — using now+{}d", + start, DEFAULT_RETENTION_DAYS); + } + } + return defaultExpiryIso(); + } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/repository/LiveSessionParticipantRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/repository/LiveSessionParticipantRepository.java index 89cdc84f4d..22b1e7b65b 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/repository/LiveSessionParticipantRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/repository/LiveSessionParticipantRepository.java @@ -93,6 +93,7 @@ WITH all_participants AS ( lsl.status_type AS statusType, lsl.engagement_data AS engagementData, lsl.provider_total_duration_minutes AS providerTotalDurationMinutes, + lsl.provider_total_duration_seconds AS providerTotalDurationSeconds, fbl.details AS feedbackDetails, 1 AS priority FROM live_session_participants lsp @@ -134,6 +135,7 @@ WITH all_participants AS ( lsl.status_type AS statusType, lsl.engagement_data AS engagementData, lsl.provider_total_duration_minutes AS providerTotalDurationMinutes, + lsl.provider_total_duration_seconds AS providerTotalDurationSeconds, fbl.details AS feedbackDetails, 2 AS priority FROM live_session_participants lsp @@ -204,6 +206,7 @@ List getAttendanceReportBySessionIds( fbl.details AS feedbackDetails, lsp.source_id AS packageSessionId, lsl.provider_total_duration_minutes AS providerTotalDurationMinutes, + lsl.provider_total_duration_seconds AS providerTotalDurationSeconds, lsl.engagement_data AS engagementData FROM live_session_participants lsp JOIN student_session_institute_group_mapping m @@ -314,6 +317,7 @@ Page findDistinctStudentIdsWithFilters( fbl.details AS feedbackDetails, lsp.source_id AS packageSessionId, lsl.provider_total_duration_minutes AS providerTotalDurationMinutes, + lsl.provider_total_duration_seconds AS providerTotalDurationSeconds, lsl.engagement_data AS engagementData FROM live_session_participants lsp JOIN student_session_institute_group_mapping m diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/repository/SessionGuestRegistrationRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/repository/SessionGuestRegistrationRepository.java index 8f353b3efd..b7b0e9ff23 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/repository/SessionGuestRegistrationRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/repository/SessionGuestRegistrationRepository.java @@ -59,7 +59,8 @@ public interface SessionGuestRegistrationRepository extends JpaRepository extractUserIds(List rows) { * Checks LiveSessionNotificationConfig for ATTENDANCE type. */ public void sendAttendanceNotification(String sessionId, String userId, String status) { + sendAttendanceNotification(sessionId, userId, status, null); + } + + /** + * @param reasonDetail plain-language explanation appended to the message, e.g. + * why a learner fell short of the minimum-attendance rule. + * Null/blank keeps the original wording. + */ + public void sendAttendanceNotification(String sessionId, String userId, String status, + String reasonDetail) { try { Optional configOpt = notificationConfigRepository .findBySessionIdAndNotificationType(sessionId, NotificationTypeEnum.ATTENDANCE.name()); @@ -1001,6 +1012,11 @@ public void sendAttendanceNotification(String sessionId, String userId, String s String sessionTitle = session.getTitle() != null ? session.getTitle() : "Live Class"; String title = "Attendance Marked: " + status; String body = "You have been marked as " + status + " for " + sessionTitle; + if (reasonDetail != null && !reasonDetail.isBlank()) { + // A learner told they are absent for a class they attended part of + // deserves the arithmetic, not just the verdict. + body = body + ". " + reasonDetail; + } if (channels.contains("PUSH_NOTIFICATION")) { notificationService.sendPushViaUnified( @@ -1026,6 +1042,9 @@ public void sendAttendanceNotification(String sessionId, String userId, String s Map placeholders = new HashMap<>(); placeholders.put("NAME", student.getFullName() != null ? student.getFullName() : "Student"); placeholders.put("SESSION_TITLE", sessionTitle); + // {{ACTION}} lands in the template's 24px

, so it stays a + // short label. The explanation reaches the learner through the + // push/system body and the daily attendance report card. placeholders.put("ACTION", "Attendance: " + status); placeholders.put("THEME_COLOR", getThemeColor(session.getInstituteId())); placeholders.put("INSTITUTE_NAME", getInstituteName(session.getInstituteId())); @@ -1047,6 +1066,7 @@ public void sendAttendanceNotification(String sessionId, String userId, String s } } + private String getThemeColor(String instituteId) { try { if (instituteId != null && !instituteId.trim().isEmpty()) { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/AttendanceCriteriaEvaluator.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/AttendanceCriteriaEvaluator.java index adf45d973a..f90ab9c88f 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/AttendanceCriteriaEvaluator.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/AttendanceCriteriaEvaluator.java @@ -109,6 +109,19 @@ public Set snapshotAdminMarked(String scheduleId) { */ public void evaluate(LiveSession session, SessionSchedule schedule, Map roster, Set adminMarked) { + evaluate(session, schedule, roster, adminMarked, null); + } + + /** + * @param providerMeetingSeconds how long the meeting actually ran, when the + * provider reports it (BBB does). The threshold is measured against + * whichever is SHORTER, this or the scheduled slot — a teacher who + * ends a 60-minute class after 30 must not make every learner who + * stayed the whole time fall short of a 36-minute bar. + */ + public void evaluate(LiveSession session, SessionSchedule schedule, + Map roster, Set adminMarked, + Long providerMeetingSeconds) { String scheduleId = schedule != null ? schedule.getId() : null; try { AttendanceCriteriaConfigDTO config = attendanceCriteriaService.resolve(session); @@ -134,7 +147,24 @@ public void evaluate(LiveSession session, SessionSchedule schedule, log.warn("attendance_criteria.no_scheduled_duration scheduleId={} - skipping", scheduleId); return; } - double requiredMinutes = scheduledMinutes * (config.getMinDurationPercent() / 100.0); + // Compare in seconds. Minutes are a floor of the real figure, so a + // learner present for 4m50s of a 7-minute class (69%) was truncated to + // 4 and failed a 4.2-minute bar — an absence for a class they attended. + int scheduledSeconds = scheduledMinutes * 60; + // Measure against the class that actually happened. A 60-minute slot + // finished in 30 would otherwise demand 36 minutes of a 30-minute + // class, failing every learner who stayed to the end. Only ever + // shortens the bar, never lengthens it — a class that overruns is + // still judged against the slot the learners were promised. + int effectiveSeconds = scheduledSeconds; + boolean cappedToActual = false; + if (providerMeetingSeconds != null && providerMeetingSeconds > 0 + && providerMeetingSeconds < scheduledSeconds) { + effectiveSeconds = providerMeetingSeconds.intValue(); + cappedToActual = true; + } + double requiredSeconds = effectiveSeconds * (config.getMinDurationPercent() / 100.0); + double requiredMinutes = requiredSeconds / 60.0; List rows = liveSessionLogsRepository.findAllAttendanceByScheduleId(scheduleId); @@ -165,13 +195,19 @@ public void evaluate(LiveSession session, SessionSchedule schedule, // The provider says they were here but gave no minutes; we // cannot judge how long, and they demonstrably attended. writeAudit(row, config, STATUS_PRESENT, "NO_DURATION_DATA", - null, scheduledMinutes, requiredMinutes); + null, scheduledMinutes, requiredMinutes, null, requiredSeconds, false, + effectiveSeconds, cappedToActual); liveSessionLogsRepository.save(row); continue; } int attendedMinutes = inRoster ? reported : 0; - boolean meets = attendedMinutes >= requiredMinutes; + // Exact seconds when the provider gave them (BBB); otherwise fall + // back to the floored minutes (Zoom reports whole minutes only). + Integer exactSeconds = row.getProviderTotalDurationSeconds(); + int attendedSeconds = !inRoster ? 0 + : (exactSeconds != null ? exactSeconds : attendedMinutes * 60); + boolean meets = attendedSeconds >= requiredSeconds; String verdict = meets ? STATUS_PRESENT : STATUS_ABSENT; String reason = meets ? "MET_THRESHOLD" : (inRoster ? "BELOW_THRESHOLD" : "NO_SHOW"); @@ -185,10 +221,21 @@ public void evaluate(LiveSession session, SessionSchedule schedule, // when the underlying numbers do. String previousVerdict = previousVerdict(row); String previousStatus = row.getStatus(); - boolean verdictMoved = !verdict.equalsIgnoreCase( - previousVerdict != null ? previousVerdict : previousStatus); + // The first evaluation always notifies: while a rule is in force the + // join-time mail is suppressed, so this is the learner's only word on + // the class — including a PRESENT confirmation. + // + // After that, only a real change of verdict notifies. Keying off the + // stored verdict rather than the row's status matters because both + // provider paths reset status to PRESENT on every sync, and Zoom + // re-syncs an ended schedule every 15 minutes for two days — status + // would look "changed" every time and mail ~190 times. + boolean verdictMoved = previousVerdict == null + || !verdict.equalsIgnoreCase(previousVerdict); - writeAudit(row, config, verdict, reason, attendedMinutes, scheduledMinutes, requiredMinutes); + writeAudit(row, config, verdict, reason, attendedMinutes, scheduledMinutes, + requiredMinutes, attendedSeconds, requiredSeconds, exactSeconds != null, + effectiveSeconds, cappedToActual); if (!verdict.equalsIgnoreCase(previousStatus)) { row.setStatus(verdict); @@ -198,7 +245,9 @@ public void evaluate(LiveSession session, SessionSchedule schedule, liveSessionLogsRepository.save(row); if (verdictMoved) { - notify(session, row, verdict); + notify(session, row, verdict, + explain(verdict, reason, attendedSeconds, effectiveSeconds, + requiredSeconds, config.getMinDurationPercent())); } } @@ -230,7 +279,8 @@ private Integer scheduledMinutes(SessionSchedule schedule) { private void writeAudit(LiveSessionLogs row, AttendanceCriteriaConfigDTO config, String verdict, String reason, Integer attendedMinutes, Integer scheduledMinutes, - double requiredMinutes) { + double requiredMinutes, Integer attendedSeconds, Double requiredSeconds, + boolean exact, int effectiveSeconds, boolean cappedToActual) { Map audit = new LinkedHashMap<>(); audit.put("verdict", verdict); audit.put("reason", reason); @@ -238,9 +288,17 @@ private void writeAudit(LiveSessionLogs row, AttendanceCriteriaConfigDTO config, audit.put("scheduledMinutes", scheduledMinutes); audit.put("thresholdPercent", config.getMinDurationPercent()); audit.put("requiredMinutes", Math.round(requiredMinutes)); - if (attendedMinutes != null && scheduledMinutes != null && scheduledMinutes > 0) { - audit.put("attendedPercent", Math.round(attendedMinutes * 1000.0 / scheduledMinutes) / 10.0); + if (attendedSeconds != null && effectiveSeconds > 0) { + audit.put("attendedPercent", + Math.round(attendedSeconds * 1000.0 / effectiveSeconds) / 10.0); } + audit.put("attendedSeconds", attendedSeconds); + audit.put("requiredSeconds", requiredSeconds == null ? null : Math.round(requiredSeconds)); + audit.put("attendedDisplay", attendedSeconds == null ? null : hms(attendedSeconds)); + // false = provider gave only whole minutes, so the figures are floored. + audit.put("exactSeconds", exact); + audit.put("effectiveSeconds", effectiveSeconds); + audit.put("cappedToActualMeeting", cappedToActual); audit.put("previousStatus", row.getStatus()); audit.put("evaluatedAt", Instant.now().toString()); try { @@ -292,10 +350,41 @@ private String previousVerdict(LiveSessionLogs row) { } } - private void notify(LiveSession session, LiveSessionLogs row, String newStatus) { + /** + * Plain-language arithmetic for the learner. Being told you are absent for a + * class you sat through most of is only defensible if the message says how + * many minutes were counted and how many were needed. + */ + private String explain(String verdict, String reason, int attendedSeconds, + int scheduledSeconds, double requiredSeconds, Integer pct) { + if (STATUS_PRESENT.equals(verdict)) { + // The confirmation carries the time too: a learner who is told they + // passed should be able to see by how much, not just that they did. + return "You were in the class for " + hms(attendedSeconds) + + " of its " + hms(scheduledSeconds) + "."; + } + // The threshold itself is deliberately not disclosed to learners — telling + // them the exact bar invites gaming it. The audit JSON keeps the full + // numbers so an admin can always justify the verdict. + if ("NO_SHOW".equals(reason)) { + return "Our records show you did not join the class."; + } + return "You were in the class for " + hms(attendedSeconds) + " of its " + + hms(scheduledSeconds) + ", which is below the minimum attendance" + + " required for this class."; + } + + /** "4m 50s", or "50s" under a minute — the learner-facing form of a duration. */ + private static String hms(int totalSeconds) { + int m = totalSeconds / 60, sec = totalSeconds % 60; + if (m == 0) return sec + "s"; + return sec == 0 ? m + "m" : m + "m " + sec + "s"; + } + + private void notify(LiveSession session, LiveSessionLogs row, String newStatus, String reasonDetail) { try { notificationProcessor.sendAttendanceNotification( - session.getId(), row.getUserSourceId(), newStatus); + session.getId(), row.getUserSourceId(), newStatus, reasonDetail); } catch (Exception e) { log.warn("attendance_criteria.notify_failed userId={}: {}", row.getUserSourceId(), e.getMessage()); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/AttendanceReportService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/AttendanceReportService.java index 2d4873e901..adb4ca6978 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/AttendanceReportService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/AttendanceReportService.java @@ -298,6 +298,7 @@ public static AttendanceReportDTO convertGuestToAttendanceReport(GuestAttendance .statusType(guestDto.getStatusType()) .engagementData(guestDto.getEngagementData()) .providerTotalDurationMinutes(guestDto.getProviderTotalDurationMinutes()) + .providerTotalDurationSeconds(guestDto.getProviderTotalDurationSeconds()) .feedbackDetails(null) .build(); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/LIveSessionAttendanceService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/LIveSessionAttendanceService.java index 5c8ffb1dcf..a5e7ba96fb 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/LIveSessionAttendanceService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/LIveSessionAttendanceService.java @@ -7,7 +7,9 @@ import vacademy.io.admin_core_service.features.live_session.dto.MarkAttendanceRequestDTO; import vacademy.io.admin_core_service.features.live_session.entity.LiveSessionLogs; import vacademy.io.admin_core_service.features.live_session.enums.SessionLog; +import vacademy.io.admin_core_service.features.live_session.dto.AttendanceCriteriaConfigDTO; import vacademy.io.admin_core_service.features.live_session.repository.LiveSessionLogsRepository; +import vacademy.io.admin_core_service.features.live_session.repository.LiveSessionRepository; import vacademy.io.admin_core_service.features.live_session.scheduler.LiveSessionNotificationProcessor; import vacademy.io.common.auth.model.CustomUserDetails; @@ -25,6 +27,12 @@ public class LIveSessionAttendanceService { @Autowired private LiveSessionNotificationProcessor notificationProcessor; + @Autowired + private AttendanceCriteriaService attendanceCriteriaService; + + @Autowired + private LiveSessionRepository liveSessionRepository; + public void markAttendance(MarkAttendanceRequestDTO request , CustomUserDetails user) { String userId = request.getUserSourceId().isEmpty() ? user.getUserId() : request.getUserSourceId(); @@ -34,7 +42,13 @@ public void markAttendance(MarkAttendanceRequestDTO request , CustomUserDetails // waiting room re-marks on a 30s poll (and again on every rejoin or // refresh), so an unconditional call here mailed the learner dozens of // times for one class. The row was always idempotent; the mail was not. - if (isStatusChange(previousStatus, "PRESENT")) { + // + // And stay silent entirely while a minimum-attendance rule is in force: + // this row only records that the learner opened the join link, so + // "you were marked present" is not yet true. The provider callback + // decides, and notifies once with the real verdict — otherwise a learner + // who leaves early is told PRESENT and then ABSENT for the same class. + if (isStatusChange(previousStatus, "PRESENT") && !criteriaDecidesLater(request.getSessionId())) { notificationProcessor.sendAttendanceNotification(request.getSessionId(), userId, "PRESENT"); } } @@ -65,6 +79,22 @@ private String upsertPresent(MarkAttendanceRequestDTO request, String userSource request.getDetails()); } + /** + * Whether this session defers the attendance verdict to the provider callback. + * Any failure resolves to false, so a lookup problem can only cost the old + * behaviour (a join-time mail), never silence a learner who gets no other one. + */ + private boolean criteriaDecidesLater(String sessionId) { + try { + return liveSessionRepository.findById(sessionId) + .map(attendanceCriteriaService::resolve) + .map(AttendanceCriteriaConfigDTO::isActive) + .orElse(false); + } catch (Exception e) { + return false; + } + } + /** * True when a mark is worth notifying about: either the first mark for this * schedule+user (no previous status) or a genuine transition such as an diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/Step1Service.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/Step1Service.java index fb5ff60dc6..a8a5738622 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/Step1Service.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/live_session/service/Step1Service.java @@ -31,6 +31,12 @@ @Slf4j public class Step1Service { + /** Frontend StreamingPlatform.ZOOM — must stay lowercase, see getLinkTypeFromUrl. */ + static final String ZOOM_LINK_TYPE = "zoom"; + /** Frontend StreamingPlatform.MEET — must stay exactly this, see getLinkTypeFromUrl. */ + static final String GMEET_LINK_TYPE = "google meet"; + + @Autowired private LiveSessionRepository sessionRepository; @@ -456,7 +462,11 @@ private void updateScheduleProperties(SessionSchedule schedule, LiveSessionStep1 } } - if (dto.getLink() != null) { + String incomingLink = dto.getLink() != null ? dto.getLink() : request.getDefaultMeetLink(); + if (wouldStealProviderMeeting(incomingLink, schedule.getProviderMeetingId(), schedule.getLinkType())) { + log.info("keeping provider link for scheduleId={} meetingId={} (incoming link is a " + + "different meeting of the same provider)", schedule.getId(), schedule.getProviderMeetingId()); + } else if (dto.getLink() != null) { schedule.setCustomMeetingLink(dto.getLink()); schedule.setLinkType(resolveScheduleLinkType(dto.getLink(), request.getLinkType())); } else if (request.getDefaultMeetLink() != null) { @@ -760,6 +770,45 @@ private void updateSingleSchedule(LiveSessionStep1RequestDTO.ScheduleDTO dto, Li scheduleRepository.save(schedule); } + /** + * Detects the provider from a meeting URL. + * + *

Returns the values the FRONTEND matches on ({@code StreamingPlatform}: "zoom", + * "google meet"), NOT the enum names. The admin dashboard's recurring-session branch + * compares linkType case-sensitively against those literals; anything else — including + * {@code LinkType.ZOOM.name()} ("ZOOM") and {@code LinkType.GMEET.name()} ("GMEET") — + * falls through to a plain participant link, so the teacher's "Start as Host" silently + * joined them as an attendee and the class never actually started. The backend is + * unaffected either way: it reads this field through + * {@code MeetingProvider.fromString()}, which upper-cases and accepts both spellings. + */ + /** + * True when applying {@code incomingLink} would point a provider-managed occurrence at a + * DIFFERENT meeting of the SAME provider. + * + *

The edit DTO carries one link per weekday, but each occurrence gets its own provider + * meeting. Applying the DTO link blindly re-pointed every later occurrence of that weekday + * at the FIRST occurrence's meeting while provider_meeting_id kept its own — host and + * learners then joined different rooms and the class recorded where nobody was. + * + *

Deliberately narrow: switching provider (Zoom to YouTube, say), clearing the link, or + * supplying a link for this row's own meeting all return false and are applied as before. + * Only the same-provider-wrong-meeting case is refused, because that one is never intentional. + */ + static boolean wouldStealProviderMeeting(String incomingLink, String providerMeetingId, String currentLinkType) { + if (providerMeetingId == null || providerMeetingId.isBlank() + || incomingLink == null || incomingLink.isBlank() + || currentLinkType == null || currentLinkType.isBlank()) { + return false; + } + // A link for this very meeting is harmless — same room, so let it through. + if (incomingLink.contains(providerMeetingId)) { + return false; + } + // Different provider entirely means a deliberate switch, not the weekday collision. + return getLinkTypeFromUrl(incomingLink).equalsIgnoreCase(currentLinkType); + } + public static String getLinkTypeFromUrl(String link) { if (link == null || link.isEmpty()) { return "UNKNOWN"; @@ -768,9 +817,9 @@ public static String getLinkTypeFromUrl(String link) { if (lowerLink.contains("youtube.com") || lowerLink.contains("youtu.be")) { return LinkType.YOUTUBE.name(); } else if (lowerLink.contains("zoom.us") || lowerLink.contains("zoom.com")) { - return LinkType.ZOOM.name(); + return ZOOM_LINK_TYPE; } else if (lowerLink.contains("meet.google.com")) { - return LinkType.GMEET.name(); + return GMEET_LINK_TYPE; } else if (List.of("meeting.zoho.com", "meeting.zoho.in", "meeting.zoho.eu").stream() .anyMatch(lowerLink::contains)) { return LinkType.ZOHO_MEETING.name(); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/controller/OpenProductPageController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/controller/OpenProductPageController.java index 5aa8af8cda..dd1be93064 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/controller/OpenProductPageController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/controller/OpenProductPageController.java @@ -32,8 +32,10 @@ public ResponseEntity getByCode( public ResponseEntity validateCoupon( @RequestParam("coursePageCode") String coursePageCode, @RequestParam("couponCode") String couponCode, - @RequestParam("totalAmount") double totalAmount) { - return ResponseEntity.ok(coursePageService.validateCoupon(coursePageCode, couponCode, totalAmount)); + @RequestParam("totalAmount") double totalAmount, + @RequestParam(value = "itemCount", required = false) Integer itemCount) { + return ResponseEntity + .ok(coursePageService.validateCoupon(coursePageCode, couponCode, totalAmount, itemCount)); } /** Step 1: create user + ABANDONED_CART entries per selected invite. */ diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/controller/ProductPageController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/controller/ProductPageController.java index 88b6d35b1a..bf01a853af 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/controller/ProductPageController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/controller/ProductPageController.java @@ -85,4 +85,28 @@ public ResponseEntity removeCustomField( @RequestParam("instituteId") String instituteId) { return ResponseEntity.ok(coursePageService.removeCustomFieldFromPage(productPageId, customFieldId, instituteId)); } + + /** + * Edits a field on this page's form. The properties edited live on the + * shared custom field, so the change reaches every form using it. + */ + @PutMapping("/{productPageId}/custom-fields/{customFieldId}") + public ResponseEntity updateCustomField( + @PathVariable("productPageId") String productPageId, + @PathVariable("customFieldId") String customFieldId, + @RequestParam("instituteId") String instituteId, + @RequestBody ProductPageCustomFieldUpdateRequest request) { + return ResponseEntity.ok( + coursePageService.updateCustomFieldOnPage(productPageId, customFieldId, request, instituteId)); + } + + /** Body is the custom field ids in the order the checkout form should ask for them. */ + @PutMapping("/{productPageId}/custom-fields/order") + public ResponseEntity reorderCustomFields( + @PathVariable("productPageId") String productPageId, + @RequestParam("instituteId") String instituteId, + @RequestBody List orderedCustomFieldIds) { + return ResponseEntity + .ok(coursePageService.reorderCustomFieldsOnPage(productPageId, orderedCustomFieldIds, instituteId)); + } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/dto/ProductPageCouponRequest.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/dto/ProductPageCouponRequest.java index 8b4dd56765..e3a38e28e9 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/dto/ProductPageCouponRequest.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/dto/ProductPageCouponRequest.java @@ -21,6 +21,9 @@ public class ProductPageCouponRequest { private Integer maxUses; + /** Smallest basket this coupon may be used on. Null = no minimum. */ + private Integer minItems; + private LocalDateTime redeemStartDate; private LocalDateTime redeemEndDate; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/dto/ProductPageCustomFieldUpdateRequest.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/dto/ProductPageCustomFieldUpdateRequest.java new file mode 100644 index 0000000000..9733e1751a --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/dto/ProductPageCustomFieldUpdateRequest.java @@ -0,0 +1,30 @@ +package vacademy.io.admin_core_service.features.product_page.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Edits to a field already on a product page's form. + * + * Every property is optional and only a non-null one is applied, so a caller + * that only wants to flip "required" does not have to echo the label and type + * back correctly to avoid clobbering them. + * + * These live on the shared `custom_fields` row, so an edit reaches every form + * in the institute that uses this field — which is why the admin dialog says + * so. The product page is the entry point, not the scope. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class ProductPageCustomFieldUpdateRequest { + private String fieldName; + private String fieldType; + private Boolean isMandatory; + /** Full config JSON — options, help text, and the `verification` block. */ + private String config; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/service/BasketPricingCalculator.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/service/BasketPricingCalculator.java new file mode 100644 index 0000000000..7618065006 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/service/BasketPricingCalculator.java @@ -0,0 +1,409 @@ +package vacademy.io.admin_core_service.features.product_page.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Prices a basket as a WHOLE, instead of adding up what each course costs. + * + * Some catalogues do not sell courses at a price each — they sell "pick any 3 + * for ₹799, ₹150 for each one after that". iThinkers Olympiad is one: every + * course on the page is ₹0 because the money is a function of how many subjects + * the parent picks, not which. Summing item prices there yields ₹0 forever, so + * when this is configured it REPLACES the sum rather than discounting it. + * + * Configured under `basketPricing` in the product page's settings_json: + * + * pricingBasis FLAT (default) reads `ladder` as ABSOLUTE prices — the + * only thing that works when the courses are ₹0 and carry + * no price to discount. DISCOUNT reads `tiers` as a + * reduction off what the selected courses actually cost on + * their enroll invites, so the money has ONE source: the + * payment plan. Prefer DISCOUNT wherever the courses are + * priced — under FLAT the single-subject rate is written + * down twice (here and on the plan) and the two drift. + * tiers for DISCOUNT. Each is + * {minCourses?, minAmount?, maxAmount?, + * type: PERCENT|AMOUNT, value, maxDiscount?} + * gated on count, on spend, or on both (both must hold + * when both are set). maxAmount closes a band; maxDiscount + * caps a percentage. The BEST qualifying tier wins, so a + * bigger basket never loses a discount it had. + * ladder prices[] for a basket of 1, 2, 3 … plus perExtra for each + * one beyond the last listed price. FLAT basis only. + * groups label → the level names belonging to it. No groups + * configured means the whole basket is one group. + * ladderScope GROUP (default) runs the ladder inside each group, so a + * parent buying one subject each for two children pays two + * single-subject prices rather than collecting a sibling + * discount. BASKET runs it across everything, so those two + * subjects reach the two-subject price. Which one is right + * is a commercial decision, not a technical one — hence the + * switch. Full packs and combos stay per-group either way, + * because a "full grade pack" only means something within + * one grade. + * groups[].packPrice price for taking EVERY level in that group — the "full + * grade pack". Exact per group, so it keeps working when a + * class gains or loses a subject. + * wholeGroupPrices count → price, the older fallback for groups with no + * packPrice of their own. Fragile: a class that gains a + * subject lands on a different count's price. + * combos a named set of package names at a fixed price, matched + * within a group ("English + Maths + Science = ₹749"). + * Matched on package rather than level so one entry covers + * every class. + * + * Whichever of those yields the LOWEST price for a group wins — a bigger + * selection must never cost more than a smaller one. + * + * Groups are matched against admin-authored level lists, never by parsing a + * class out of a level name: the real names drift ("Cyber AI- Class 6", + * "Social Science Class - 5") and one course is filed under another subject's + * level entirely. + * + * THIS IS THE AUTHORITATIVE COPY — ProductPageEnrollmentService overwrites the + * client's amount with what this returns. basket-pricing.ts mirrors it for + * display and the two must be changed together. + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class BasketPricingCalculator { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * One selected course. `price` is what its payment plan charges on the + * enroll invite — the base a DISCOUNT basis reduces, and the honest + * "before" figure the checkout shows even under a FLAT basis. + */ + public record BasketItem(String levelName, String packageName, double price) { + /** Kept for callers that price on count alone (FLAT pages with ₹0 courses). */ + public BasketItem(String levelName, String packageName) { + this(levelName, packageName, 0d); + } + } + + /** What a basket costs, and the per-group breakdown behind it. */ + @Getter + public static class BasketPrice { + private final double total; + /** + * What the same courses cost bought separately. The checkout needs it to + * say "₹1,047 → ₹799, you save ₹248" instead of quoting a bare ₹799 the + * parent has no way to judge. + */ + private final double itemTotal; + private final List breakdown = new ArrayList<>(); + + BasketPrice(double total, double itemTotal) { + this.total = total; + this.itemTotal = itemTotal; + } + } + + private static String key(String value) { + return value == null ? "" : value.trim().toLowerCase(); + } + + /** + * The basket's price, or null when this page does not use basket pricing — + * in which case the caller keeps summing item prices as before. + */ + public BasketPrice price(String settingsJson, List items) { + if (settingsJson == null || settingsJson.isBlank() || items == null || items.isEmpty()) { + return null; + } + + try { + JsonNode cfg = objectMapper.readTree(settingsJson).path("basketPricing"); + if (!cfg.path("enabled").asBoolean(false)) { + return null; + } + + List ladder = new ArrayList<>(); + for (JsonNode p : cfg.path("ladder").path("prices")) { + ladder.add(p.asDouble(0)); + } + double perExtra = cfg.path("ladder").path("perExtra").asDouble(0); + if (ladder.isEmpty() && !discountBasis(cfg)) { + // A FLAT page prices by count alone, so an empty ladder has + // nothing to price with. Better to fall back to item prices than + // to hand back a free basket. A DISCOUNT page needs no ladder — + // its base is what the courses cost on their enroll invites. + return null; + } + + Map> grouped = groupItems(cfg.path("groups"), items); + boolean ladderAcrossBasket = "BASKET".equalsIgnoreCase(cfg.path("ladderScope").asText("GROUP")); + + double total = 0; + double itemTotal = 0; + List lines = new ArrayList<>(); + + for (Map.Entry> entry : grouped.entrySet()) { + GroupQuote quote = priceGroup(cfg, entry.getKey(), entry.getValue(), ladder, perExtra); + total += quote.amount; + itemTotal += quote.baseAmount; + lines.add(quote.label); + } + + if (ladderAcrossBasket) { + // One ladder over every subject in the basket. Still never worse + // than pricing the groups apart — a full pack or combo inside one + // group can beat it, so take whichever is cheaper. + double whole = discountBasis(cfg) + ? itemTotal - tierDiscount(cfg, itemTotal, items.size()) + : ladderPrice(ladder, perExtra, items.size()); + if (whole < total) { + total = whole; + lines.clear(); + lines.add(items.size() + " subject" + (items.size() == 1 ? "" : "s")); + } + } + + BasketPrice priced = new BasketPrice( + Math.max(0, Math.round(total)), + Math.max(0, Math.round(itemTotal))); + priced.breakdown.addAll(lines); + return priced; + + } catch (Exception e) { + // A page must stay sellable through a bad settings blob. + log.warn("Could not read basketPricing from product page settings: {}", e.getMessage()); + return null; + } + } + + /** Splits the basket by configured group; anything unmatched shares one bucket. */ + private Map> groupItems(JsonNode groups, List items) { + Map> out = new LinkedHashMap<>(); + + Map levelToGroup = new LinkedHashMap<>(); + if (groups.isArray()) { + for (JsonNode group : groups) { + String label = group.path("label").asText(""); + for (JsonNode level : group.path("levels")) { + levelToGroup.put(key(level.asText()), label); + } + } + } + + for (BasketItem item : items) { + String label = levelToGroup.getOrDefault(key(item.levelName()), ""); + out.computeIfAbsent(label, k -> new ArrayList<>()).add(item); + } + return out; + } + + private record GroupQuote(double amount, double baseAmount, String label) { + } + + private boolean discountBasis(JsonNode cfg) { + return "DISCOUNT".equalsIgnoreCase(cfg.path("pricingBasis").asText("FLAT")); + } + + /** + * What the courses in a group cost on their own. + * + * Falls back to the ladder's single-subject rate when the courses are ₹0 — + * a FLAT page with free courses still needs SOMETHING to measure the saving + * against, and the one-subject rung is the figure its own price card + * advertises. + */ + private double baseFor(List picked, List ladder) { + double sum = 0; + for (BasketItem item : picked) { + sum += item.price(); + } + if (sum > 0) { + return sum; + } + double single = ladder.isEmpty() ? 0 : ladder.get(0); + return single * picked.size(); + } + + /** + * The discount for this basket: the BEST of every tier it qualifies for. + * + * A tier is gated on how MANY courses (minCourses), on how MUCH they cost + * (minAmount / maxAmount), or on both — and when both are set both must + * hold, the same reading OfferCalculator gives the same two field names. A + * closed band makes "₹500–₹999 → 10%, ₹1,000+ → 15%" expressible without + * the two rules fighting. + * + * A PERCENT tier may carry maxDiscount, a ceiling in currency; absent or + * zero means no ceiling, again matching OfferCalculator. + * + * Best, not highest-threshold: a tier list where a later rung happens to be + * worth less ("2+ → ₹500 off, 5+ → 10% off") would otherwise take the + * discount AWAY from a parent for adding a fifth subject. Picking the best + * qualifying tier makes that misconfiguration merely useless rather than + * punitive, and is identical for the normal increasing ladder. + * + * `base` is the group's own item total under GROUP scope, so an amount + * threshold is judged against what THAT class costs — the same figure the + * tier then discounts. Judging it against the whole basket would let one + * child's subjects unlock a band for another's. + */ + private double tierDiscount(JsonNode cfg, double base, int count) { + double best = 0; + for (JsonNode tier : cfg.path("tiers")) { + if (!tierApplies(tier, base, count)) { + continue; + } + best = Math.max(best, tierAmount(tier, base)); + } + return Math.min(Math.max(0, best), base); + } + + /** Whether a basket of this size and value reaches a tier's conditions. */ + private boolean tierApplies(JsonNode tier, double base, int count) { + int minCourses = tier.path("minCourses").asInt(0); + double minAmount = tier.path("minAmount").asDouble(0); + // A tier with neither condition would fire on any basket at all, + // including a single free course. Treat it as unconfigured rather than + // as "always on" — an admin who wants that writes minCourses 1. + if (minCourses <= 0 && minAmount <= 0) { + return false; + } + if (minCourses > 0 && count < minCourses) { + return false; + } + if (minAmount > 0 && base < minAmount) { + return false; + } + double maxAmount = tier.path("maxAmount").asDouble(0); + // Zero means open-ended, so only a positive ceiling closes the band. + return !(maxAmount > 0 && base > maxAmount); + } + + /** What a qualifying tier takes off, before the caller caps it at the base. */ + private double tierAmount(JsonNode tier, double base) { + double value = tier.path("value").asDouble(0); + if (value <= 0) { + return 0; + } + double off = "PERCENT".equalsIgnoreCase(tier.path("type").asText("PERCENT")) + ? base * value / 100.0 + : value; + double cap = tier.path("maxDiscount").asDouble(0); + if (cap > 0) { + off = Math.min(off, cap); + } + return Math.max(0, off); + } + + private GroupQuote priceGroup(JsonNode cfg, String groupLabel, List picked, + List ladder, double perExtra) { + int count = picked.size(); + double base = baseFor(picked, ladder); + + double best; + String how = count + " subject" + (count == 1 ? "" : "s"); + if (discountBasis(cfg)) { + // The courses' own prices are the base, so the single-subject rate + // lives in exactly one place: the payment plan on the enroll invite. + best = base - tierDiscount(cfg, base, count); + } else { + best = ladderPrice(ladder, perExtra, count); + } + + // Full pack: every level configured for this group is in the basket. + Double wholeGroup = wholeGroupPrice(cfg, groupLabel, picked, count); + if (wholeGroup != null && wholeGroup < best) { + best = wholeGroup; + how = "full pack"; + } + + // Named combo: the group's packages are exactly a combo's set. + Set pickedPackages = new LinkedHashSet<>(); + for (BasketItem item : picked) { + pickedPackages.add(key(item.packageName())); + } + for (JsonNode combo : cfg.path("combos")) { + Set comboPackages = new LinkedHashSet<>(); + for (JsonNode name : combo.path("packages")) { + comboPackages.add(key(name.asText())); + } + if (!comboPackages.isEmpty() && comboPackages.equals(pickedPackages)) { + double comboPrice = combo.path("price").asDouble(Double.MAX_VALUE); + if (comboPrice < best) { + best = comboPrice; + how = combo.path("label").asText("combo"); + } + } + } + + String label = (groupLabel == null || groupLabel.isBlank() ? "Basket" : groupLabel) + + " — " + how; + // Under DISCOUNT the base IS the starting point, so a misconfigured tier + // must never push the basket above it. Under FLAT the ladder deliberately + // REPLACES the item sum in both directions — capping there would silently + // reprice every existing page whose courses undercut its own ladder. + if (discountBasis(cfg) && base > 0) { + best = Math.min(best, base); + } + return new GroupQuote(best, base, label); + } + + private Double wholeGroupPrice(JsonNode cfg, String groupLabel, List picked, int count) { + if (groupLabel == null || groupLabel.isBlank()) { + return null; + } + Set configured = new LinkedHashSet<>(); + Double ownPackPrice = null; + for (JsonNode group : cfg.path("groups")) { + if (groupLabel.equals(group.path("label").asText(""))) { + for (JsonNode level : group.path("levels")) { + configured.add(key(level.asText())); + } + JsonNode own = group.path("packPrice"); + if (!own.isMissingNode() && own.asDouble(0) > 0) { + ownPackPrice = own.asDouble(); + } + } + } + if (configured.isEmpty()) { + return null; + } + Set pickedLevels = new LinkedHashSet<>(); + for (BasketItem item : picked) { + pickedLevels.add(key(item.levelName())); + } + if (!pickedLevels.containsAll(configured)) { + return null; + } + // A price on the group itself is exact and survives the catalogue + // changing shape. The count map is the older, fragile fallback: add one + // subject to a class and its count silently lands on another class's + // price. + if (ownPackPrice != null) { + return ownPackPrice; + } + JsonNode price = cfg.path("wholeGroupPrices").path(String.valueOf(count)); + return price.isMissingNode() ? null : price.asDouble(); + } + + /** prices[n-1] while the list lasts, then the last price plus perExtra each. */ + private double ladderPrice(List prices, double perExtra, int count) { + if (count <= 0) { + return 0; + } + if (count <= prices.size()) { + return prices.get(count - 1); + } + return prices.get(prices.size() - 1) + perExtra * (count - prices.size()); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/service/OfferCalculator.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/service/OfferCalculator.java new file mode 100644 index 0000000000..a38877b9a6 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/service/OfferCalculator.java @@ -0,0 +1,130 @@ +package vacademy.io.admin_core_service.features.product_page.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * Predefined offers on a product page — the "₹99 off on orders above ₹500" strip + * a food-delivery app shows you. + * + * These are NOT coupons. A coupon is a code someone types, with its own row, its + * own redemption limit and its own lifecycle, so its conditions live on that + * row. An offer is part of how THIS page sells: it needs no code, applies by + * itself, and every visitor sees the same list. That belongs with the page's + * other selling rules in `settings_json`, which is also why it needs no schema + * change to add one. + * + * Configured under `offers` in the product page's settings_json: + * + * rules[] each with the conditions it needs and the discount it gives: + * minAmount cart total at or above this, in currency units + * minCourses at least this many courses in the basket + * discountType FIXED | PERCENTAGE + * discountValue the amount, or the percent + * maxDiscount ceiling for a percentage (optional) + * A rule with neither condition applies to every basket. + * + * The BEST qualifying rule wins — never several stacked, which is what stops two + * innocuous-looking offers from adding up to a free order. Applied after basket + * pricing and before any coupon, so a coupon discounts what the visitor would + * actually have paid. + * + * THIS IS THE AUTHORITATIVE COPY — ProductPageEnrollmentService overwrites the + * client's amount with what this returns. offers.ts mirrors it for display and + * the two must be changed together. + */ +@Component +@Slf4j +public class OfferCalculator { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** The offer a basket earned: how much off, and which rule gave it. */ + @Getter + public static class AppliedOffer { + private final double amount; + private final String label; + private final String id; + + AppliedOffer(double amount, String label, String id) { + this.amount = amount; + this.label = label; + this.id = id; + } + } + + /** + * Best qualifying offer, or null when none applies. + * + * @param settingsJson the product page's settings_json + * @param amount cart total AFTER basket pricing + * @param courseCount how many courses are in the basket + */ + public AppliedOffer bestOffer(String settingsJson, double amount, int courseCount) { + if (settingsJson == null || settingsJson.isBlank() || amount <= 0) { + return null; + } + + try { + JsonNode cfg = objectMapper.readTree(settingsJson).path("offers"); + if (!cfg.path("enabled").asBoolean(false)) { + return null; + } + JsonNode rules = cfg.path("rules"); + if (!rules.isArray()) { + return null; + } + + AppliedOffer best = null; + for (JsonNode rule : rules) { + if (!qualifies(rule, amount, courseCount)) { + continue; + } + double off = discountFor(rule, amount); + if (off <= 0) { + continue; + } + if (best == null || off > best.getAmount()) { + best = new AppliedOffer(off, rule.path("label").asText("Offer"), + rule.path("id").asText("")); + } + } + return best; + + } catch (Exception e) { + // A page must stay sellable through a bad settings blob. + log.warn("Could not read offers from product page settings: {}", e.getMessage()); + return null; + } + } + + private boolean qualifies(JsonNode rule, double amount, int courseCount) { + double minAmount = rule.path("minAmount").asDouble(0); + int minCourses = rule.path("minCourses").asInt(0); + // Both conditions must hold when both are set — an offer for "3 courses + // over ₹1000" means exactly that. + return amount >= minAmount && courseCount >= minCourses; + } + + private double discountFor(JsonNode rule, double amount) { + double value = rule.path("discountValue").asDouble(0); + if (value <= 0) { + return 0; + } + double off = "PERCENTAGE".equalsIgnoreCase(rule.path("discountType").asText("FIXED")) + ? amount * value / 100.0 + : value; + + JsonNode cap = rule.path("maxDiscount"); + if (!cap.isMissingNode() && cap.asDouble(0) > 0) { + off = Math.min(off, cap.asDouble()); + } + + // Whole currency units — vendors take integer minor units — and never + // more than the cart, so a misconfigured offer cannot mint money. + return Math.max(0, Math.min(Math.round(off), amount)); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/service/ProductPageEnrollmentService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/service/ProductPageEnrollmentService.java index abda60dab5..d30f15643f 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/service/ProductPageEnrollmentService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/service/ProductPageEnrollmentService.java @@ -10,6 +10,7 @@ import vacademy.io.admin_core_service.features.common.service.CustomFieldValueService; import vacademy.io.admin_core_service.features.institute.repository.InstituteRepository; import vacademy.io.admin_core_service.features.product_page.dto.*; +import vacademy.io.admin_core_service.features.product_page.entity.ProductPage; import vacademy.io.admin_core_service.features.product_page.entity.ProductPageInviteMapping; import vacademy.io.admin_core_service.features.institute.service.InstitutePaymentGatewayMappingService; import vacademy.io.admin_core_service.features.product_page.repository.ProductPageInviteMappingRepository; @@ -104,6 +105,12 @@ public class ProductPageEnrollmentService { @Autowired private AppliedCouponDiscountRepository appliedCouponDiscountRepository; + @Autowired + private BasketPricingCalculator basketPricingCalculator; + + @Autowired + private OfferCalculator offerCalculator; + @Autowired private ProductPageService coursePageService; @@ -219,12 +226,49 @@ public ProductPageEnrollResponse enrollForProductPage(ProductPageEnrollRequest r serverTotal += plan.getActualPrice(); } + // Basket pricing. On a page that sells "any 3 for ₹799" the money is a + // function of HOW MANY courses were picked, not of what each costs — so + // when it is configured it REPLACES the sum above rather than + // discounting it. Recomputed here because the client's figure is never + // trusted; see BasketPricingCalculator. + ProductPage pricingPage = coursePageRepository.findByCode(request.getProductPageCode()) + .orElseThrow(() -> new VacademyException( + "Course page not found: " + request.getProductPageCode())); + + // Each course's own price rides along: a DISCOUNT-basis page reduces + // that sum rather than replacing it, so the single-subject rate is read + // from the enroll invite's payment plan instead of being written down a + // second time in the page settings. + List basketItems = selectedMappings.stream() + .map(m -> { + var ps = m.getPsInvitePaymentOption().getPackageSession(); + PaymentPlan plan = planByMappingId.get(m.getPsInvitePaymentOption().getId()); + return new BasketPricingCalculator.BasketItem( + ps.getLevel() != null ? ps.getLevel().getLevelName() : null, + ps.getPackageEntity() != null ? ps.getPackageEntity().getPackageName() : null, + plan != null ? plan.getActualPrice() : 0d); + }) + .collect(Collectors.toList()); + + BasketPricingCalculator.BasketPrice basketPrice = basketPricingCalculator.price( + pricingPage.getSettingsJson(), basketItems); + + double afterBundle = basketPrice != null ? basketPrice.getTotal() : serverTotal; + + // Predefined page offers ("₹99 off above ₹500"). Best one only, applied + // before any coupon so a coupon discounts what would actually be paid. + OfferCalculator.AppliedOffer offer = offerCalculator.bestOffer( + pricingPage.getSettingsJson(), afterBundle, request.getSelectedMappings().size()); + double offerDiscount = offer != null ? offer.getAmount() : 0.0; + double afterOffer = Math.max(0.0, afterBundle - offerDiscount); + // Apply coupon discount if provided AppliedCouponDiscount couponDiscount = null; double discountAmount = 0.0; if (request.getCouponCode() != null && !request.getCouponCode().isBlank()) { ProductPageCouponValidateResponse couponResp = coursePageService.validateCoupon( - request.getProductPageCode(), request.getCouponCode(), serverTotal); + request.getProductPageCode(), request.getCouponCode(), afterOffer, + request.getSelectedMappings().size()); if (!couponResp.isValid()) { throw new VacademyException("Coupon invalid: " + couponResp.getMessage()); } @@ -233,7 +277,7 @@ public ProductPageEnrollResponse enrollForProductPage(ProductPageEnrollRequest r discountAmount = couponResp.getDiscountValue() != null ? couponResp.getDiscountValue() : 0.0; } - double finalTotal = serverTotal - discountAmount; + double finalTotal = Math.max(0.0, afterOffer - discountAmount); PaymentInitiationRequestDTO payReq = request.getPaymentInitiationRequest(); payReq.setAmount(finalTotal); @@ -281,14 +325,33 @@ public ProductPageEnrollResponse enrollForProductPage(ProductPageEnrollRequest r // Free payment options (amount = 0) must never reach a payment gateway if (isRazorpay && !isRazorpayPhase2 && finalTotal > 0.0) { - // Phase 1: create Razorpay order + payment log (PAYMENT_PENDING) — do NOT - // enroll yet + // Phase 1: create the Razorpay order + payment log (PAYMENT_PENDING), then + // provision the enrollment in INVITED state — exactly like the redirect-gateway + // branch below, and like /v1/learner/enroll does for the invite flow. + // + // Fulfilment used to live ONLY in Phase 2, which the learner's browser calls + // after Razorpay Checkout succeeds. If that call never happened — tab closed, + // redirect lost, network dropped between capture and callback — the money was + // collected and nothing else: no UserPlan, an unlinked PaymentLog (so the + // payment was invisible in Manage Payments), and the learner left sitting in the + // ABANDONED_CART placeholder session with no course. The webhook could not + // repair it either, because handlePostPaymentLogic() keys post-payment + // processing off paymentLog.getUserPlan(), which was null. + // + // Creating the plan up-front makes the webhook self-sufficient: order.paid / + // payment.captured now finds a linked plan and completes the enrollment on its + // own. The Phase 2 callback stays the fast path, but it is no longer the only + // path, so a lost browser can no longer cost a learner their course. PaymentResponseDTO gatewayResponse = paymentService.handlePaymentWithUser( payReq, request.getInstituteId(), user, null); String paymentLogId = payReq.getOrderId(); log.info("Razorpay Phase 1: order created, paymentLogId={}", paymentLogId); + List pendingEnrolledSessionIds = provisionPendingEnrollments( + request, selectedMappings, planByMappingId, couponDiscount, discountAmount, + offer, offerDiscount, basketPrice, user, payReq, paymentLogId); + String razorpayKeyId = null; String razorpayOrderId = null; if (gatewayResponse != null && gatewayResponse.getResponseData() != null) { @@ -306,6 +369,7 @@ public ProductPageEnrollResponse enrollForProductPage(ProductPageEnrollRequest r .status(PaymentStatusEnum.PAYMENT_PENDING.name()) .orderId(razorpayOrderId) .razorpayKeyId(razorpayKeyId) + .enrolledPackageSessionIds(pendingEnrolledSessionIds) .message("Razorpay order created. Please complete payment.") .build(); } @@ -318,6 +382,55 @@ public ProductPageEnrollResponse enrollForProductPage(ProductPageEnrollRequest r if (isRazorpayPhase2) { verifyRazorpaySignature(payReq, request.getInstituteId(), firstInvite); + // Phase 1 already created the plan and linked it to the order's payment log, so + // this callback must COMPLETE that enrollment rather than start a second one — + // otherwise every paid learner ends up with a duplicate UserPlan and an orphan + // gateway order. Mirrors completeGatewayPaymentConfirmation() in + // LearnerEnrollRequestService, which solves the same problem for the invite flow. + String gatewayOrderRef = payReq.getRazorpayRequest().getRazorpayOrderId(); + PaymentLog phase1Log = findPhase1PaymentLog(gatewayOrderRef); + if (phase1Log != null) { + log.info("Razorpay Phase 2: completing Phase 1 enrollment, parentPaymentLog={}", phase1Log.getId()); + + // Drive this through the gateway order reference, exactly as the webhook + // does, so the parent AND every child log it registered are marked PAID and + // each child activates its own UserPlan. Idempotent with the webhook: + // whichever of the two arrives second is a no-op, because + // updatePaymentLogsByOrderId claims the PAID transition conditionally. + paymentLogService.updatePaymentLog( + gatewayOrderRef, PaymentStatusEnum.PAID.name(), request.getInstituteId()); + + // Activation (batch shift, credential mail, enrollment notifications, workflow) + // is driven from applyOperationsOnFirstPayment via the line above. The coupon + // code is the one post-enrollment action it does not cover, and generating it + // is idempotent, so issue it here. + try { + String inviteCode = selectedMappings.isEmpty() ? null + : selectedMappings.get(0).getPsInvitePaymentOption().getEnrollInvite().getInviteCode(); + learnerCouponService.generateCouponCodeForLearner( + user.getId(), request.getInstituteId(), inviteCode); + } catch (Exception e) { + log.error("Failed to generate coupon code for user={}: {}", user.getId(), e.getMessage(), e); + } + + return ProductPageEnrollResponse.builder() + .paymentLogId(phase1Log.getId()) + .userId(user.getId()) + .status(PaymentStatusEnum.PAID.name()) + .enrolledPackageSessionIds(selectedMappings.stream() + .map(m -> m.getPsInvitePaymentOption().getPackageSession().getId()) + .collect(Collectors.toList())) + .message("Enrollment successful") + .build(); + } + + // No provisioned Phase 1 order to complete — an order created before this + // two-phase provisioning shipped, or one whose Phase 1 enrollment failed. Fall + // back to the original behaviour so those payments still enrol. + log.warn("Razorpay Phase 2: no provisioned Phase 1 enrollment found for razorpayOrderId={}. " + + "Falling back to enrolling from the confirmation call.", + payReq.getRazorpayRequest().getRazorpayOrderId()); + // Create a payment log with PAID status (payment already collected by Razorpay) parentPaymentLogId = paymentLogService.createPaymentLog( user.getId(), finalTotal, @@ -377,90 +490,9 @@ public ProductPageEnrollResponse enrollForProductPage(ProductPageEnrollRequest r // Redirect-based gateway (Cashfree/PhonePe): create UserPlan + SSIGM entries // in INVITED status now so the webhook can activate them when the gateway // confirms payment via applyOperationsOnFirstPayment(). - List redirectEnrolledSessionIds = new ArrayList<>(); - List childPaymentLogIds = new ArrayList<>(); - vacademy.io.admin_core_service.features.user_subscription.entity.UserPlan firstUserPlan = null; - - for (ProductPageSelectedMappingDTO sel : request.getSelectedMappings()) { - ProductPageInviteMapping rdMapping = selectedMappings.stream() - .filter(m -> m.getPsInvitePaymentOption().getId().equals(sel.getPsInvitePaymentOptionId())) - .findFirst().orElseThrow(); - - PackageSessionLearnerInvitationToPaymentOption rdBridge = rdMapping.getPsInvitePaymentOption(); - EnrollInvite rdInvite = rdBridge.getEnrollInvite(); - PaymentPlan rdPlan = planByMappingId.get(sel.getPsInvitePaymentOptionId()); - - PaymentInitiationRequestDTO rdPayReq = clonePaymentRequest(payReq); - rdPayReq.setAmount(rdPlan.getActualPrice()); - - LearnerPackageSessionsEnrollDTO rdEnrollDTO = new LearnerPackageSessionsEnrollDTO(); - rdEnrollDTO.setPackageSessionIds(List.of(rdBridge.getPackageSession().getId())); - rdEnrollDTO.setPlanId(rdPlan.getId()); - rdEnrollDTO.setPaymentOptionId(rdBridge.getPaymentOption().getId()); - rdEnrollDTO.setEnrollInviteId(rdInvite.getId()); - rdEnrollDTO.setReferRequest(request.getReferRequest()); - rdEnrollDTO.setCustomFieldValues( - filterFieldsForInvite(request.getCustomFieldValues(), rdInvite.getId())); - rdEnrollDTO.setPaymentInitiationRequest(rdPayReq); - - vacademy.io.admin_core_service.features.user_subscription.entity.UserPlan rdUserPlan = - userPlanService.createUserPlan( - user.getId(), rdPlan, couponDiscount, rdInvite, - rdBridge.getPaymentOption(), rdPayReq, "INVITED"); - - Map rdExtraData = new HashMap<>(); - rdExtraData.put("SKIP_PAYMENT_INITIATION", true); - rdExtraData.put("PARENT_PAYMENT_LOG_ID", parentPaymentLogId); - - LearnerEnrollResponseDTO rdEnrollResp = oneTimePaymentOptionOperation.enrollLearnerToBatch( - user, rdEnrollDTO, request.getInstituteId(), - rdInvite, rdBridge.getPaymentOption(), rdUserPlan, rdExtraData, - request.getLearnerExtraDetails()); - - redirectEnrolledSessionIds.add(rdBridge.getPackageSession().getId()); - - if (firstUserPlan == null) { - firstUserPlan = rdUserPlan; - } else { - // Subsequent mappings: collect child PaymentLog IDs so the webhook - // can process them via the childPaymentLogIds multi-package mechanism. - if (rdEnrollResp.getPaymentResponse() != null - && StringUtils.hasText(rdEnrollResp.getPaymentResponse().getOrderId())) { - childPaymentLogIds.add(rdEnrollResp.getPaymentResponse().getOrderId()); - } - } - - if (parentPaymentLogId != null) { - createLineItem(parentPaymentLogId, rdInvite.getId(), (int) Math.round(rdPlan.getActualPrice())); - } - } - - if (parentPaymentLogId != null && discountAmount > 0 && request.getCouponCode() != null) { - createLineItem(parentPaymentLogId, "COUPON:" + request.getCouponCode(), - -(int) Math.round(discountAmount)); - } - - appendUtmToPaymentLog(parentPaymentLogId, request.getUtmParams()); - - // Link the first UserPlan to the parent PaymentLog. Without this link, - // handlePostPaymentLogic() treats the payment as a donation (userPlan == null) - // and skips applyOperationsOnFirstPayment(). - if (firstUserPlan != null) { - final vacademy.io.admin_core_service.features.user_subscription.entity.UserPlan planToLink = - firstUserPlan; - paymentLogRepository.findById(parentPaymentLogId).ifPresent(parentLog -> { - parentLog.setUserPlan(planToLink); - if (!childPaymentLogIds.isEmpty()) { - String existingData = parentLog.getPaymentSpecificData(); - Map data = existingData != null - ? JsonUtil.fromJson(existingData, Map.class) : new HashMap<>(); - if (data == null) data = new HashMap<>(); - data.put("childPaymentLogIds", childPaymentLogIds); - parentLog.setPaymentSpecificData(JsonUtil.toJson(data)); - } - paymentLogRepository.save(parentLog); - }); - } + List redirectEnrolledSessionIds = provisionPendingEnrollments( + request, selectedMappings, planByMappingId, couponDiscount, discountAmount, + offer, offerDiscount, basketPrice, user, payReq, parentPaymentLogId); log.info("Redirect gateway: created {} enrollment entries, linked userPlan to paymentLog={}", redirectEnrolledSessionIds.size(), parentPaymentLogId); @@ -531,6 +563,12 @@ public ProductPageEnrollResponse enrollForProductPage(ProductPageEnrollRequest r } } + if (parentPaymentLogId != null && basketPrice != null) { + createLineItem(parentPaymentLogId, + "BASKET:" + request.getSelectedMappings().size() + "_COURSES", + (int) Math.round(basketPrice.getTotal())); + } + if (parentPaymentLogId != null && discountAmount > 0 && request.getCouponCode() != null) { createLineItem(parentPaymentLogId, "COUPON:" + request.getCouponCode(), -(int) Math.round(discountAmount)); } @@ -639,6 +677,183 @@ public ProductPageEnrollResponse enrollCpoForProductPage(ProductPageCpoEnrollReq .build(); } + /** + * Creates the UserPlan + enrollment entries for a payment that has been initiated + * but not yet confirmed, and links the first plan to the order's PaymentLog. + * + *

Plans are created in {@code INVITED} rather than {@code PENDING_FOR_PAYMENT} + * deliberately: the learner has no access and no ledger obligation until the gateway + * confirms, so an abandoned checkout leaves no phantom Due behind. The link to the + * PaymentLog is what makes the payment webhook self-sufficient — without it + * {@code handlePostPaymentLogic()} sees a null UserPlan, treats the payment as a + * donation, and skips {@code applyOperationsOnFirstPayment()} entirely. + * + * @return the package session ids the learner was provisionally enrolled into + */ + private List provisionPendingEnrollments( + ProductPageEnrollRequest request, + List selectedMappings, + Map planByMappingId, + AppliedCouponDiscount couponDiscount, + double discountAmount, + OfferCalculator.AppliedOffer offer, + double offerDiscount, + BasketPricingCalculator.BasketPrice basketPrice, + UserDTO user, + PaymentInitiationRequestDTO payReq, + String parentPaymentLogId) { + + List enrolledSessionIds = new ArrayList<>(); + List childPaymentLogIds = new ArrayList<>(); + + for (ProductPageSelectedMappingDTO sel : request.getSelectedMappings()) { + ProductPageInviteMapping mapping = selectedMappings.stream() + .filter(m -> m.getPsInvitePaymentOption().getId().equals(sel.getPsInvitePaymentOptionId())) + .findFirst().orElseThrow(); + + PackageSessionLearnerInvitationToPaymentOption bridge = mapping.getPsInvitePaymentOption(); + EnrollInvite invite = bridge.getEnrollInvite(); + PaymentPlan plan = planByMappingId.get(sel.getPsInvitePaymentOptionId()); + + PaymentInitiationRequestDTO invitePayReq = clonePaymentRequest(payReq); + invitePayReq.setAmount(plan.getActualPrice()); + + LearnerPackageSessionsEnrollDTO enrollDTO = new LearnerPackageSessionsEnrollDTO(); + enrollDTO.setPackageSessionIds(List.of(bridge.getPackageSession().getId())); + enrollDTO.setPlanId(plan.getId()); + enrollDTO.setPaymentOptionId(bridge.getPaymentOption().getId()); + enrollDTO.setEnrollInviteId(invite.getId()); + enrollDTO.setReferRequest(request.getReferRequest()); + enrollDTO.setCustomFieldValues( + filterFieldsForInvite(request.getCustomFieldValues(), invite.getId())); + enrollDTO.setPaymentInitiationRequest(invitePayReq); + + vacademy.io.admin_core_service.features.user_subscription.entity.UserPlan userPlan = + userPlanService.createUserPlan( + user.getId(), plan, couponDiscount, invite, + bridge.getPaymentOption(), invitePayReq, "INVITED"); + + Map extraData = new HashMap<>(); + extraData.put("SKIP_PAYMENT_INITIATION", true); + extraData.put("PARENT_PAYMENT_LOG_ID", parentPaymentLogId); + + LearnerEnrollResponseDTO enrollResp = oneTimePaymentOptionOperation.enrollLearnerToBatch( + user, enrollDTO, request.getInstituteId(), + invite, bridge.getPaymentOption(), userPlan, extraData, + request.getLearnerExtraDetails()); + + enrolledSessionIds.add(bridge.getPackageSession().getId()); + + // Register EVERY child PaymentLog — including the first mapping's — on the + // parent, so updatePaymentLogsByOrderId cascades the PAID transition to all + // of them and each one activates its own UserPlan. + // + // The parent is deliberately left unlinked to a plan. It is tempting to link + // it (that is what the redirect-gateway branch used to do for its first + // mapping), but a linked PAID log posts a ledger CREDIT_PAYMENT, and + // recordCreditPayment de-duplicates on the PaymentLog id — not the plan. A + // linked parent carrying the ORDER TOTAL plus one linked child per course + // therefore credits the learner total + every extra course price on a + // multi-course checkout. Crediting only the children sums to exactly the + // order total, and leaves the same row shape a successful checkout produces + // today: parent PAID/unlinked, one PAID/linked child per course. + if (enrollResp.getPaymentResponse() != null + && StringUtils.hasText(enrollResp.getPaymentResponse().getOrderId())) { + childPaymentLogIds.add(enrollResp.getPaymentResponse().getOrderId()); + } + + if (parentPaymentLogId != null) { + createLineItem(parentPaymentLogId, invite.getId(), (int) Math.round(plan.getActualPrice())); + } + } + + // Pricing breakdown on the parent, in the order the money was computed: + // basket total, then the page offer, then the coupon. + if (parentPaymentLogId != null && basketPrice != null) { + createLineItem(parentPaymentLogId, + "BASKET:" + request.getSelectedMappings().size() + "_COURSES", + (int) Math.round(basketPrice.getTotal())); + } + + if (parentPaymentLogId != null && offer != null && offerDiscount > 0) { + createLineItem(parentPaymentLogId, "OFFER:" + offer.getId(), + -(int) Math.round(offerDiscount)); + } + + if (parentPaymentLogId != null && discountAmount > 0 && request.getCouponCode() != null) { + createLineItem(parentPaymentLogId, "COUPON:" + request.getCouponCode(), + -(int) Math.round(discountAmount)); + } + + appendUtmToPaymentLog(parentPaymentLogId, request.getUtmParams()); + + // Record the children on the parent. This is what lets the payment webhook reach + // them: updatePaymentLogsByOrderId resolves the parent from the gateway order + // reference, reads childPaymentLogIds out of its payment_specific_data, and marks + // the whole set PAID — at which point each child drives applyOperationsOnFirstPayment + // for its own UserPlan. Without this the webhook would see a parent with no plan, + // treat the payment as a donation, and enrol nobody. + if (!childPaymentLogIds.isEmpty() && parentPaymentLogId != null) { + paymentLogRepository.findById(parentPaymentLogId).ifPresent(parentLog -> { + String existingData = parentLog.getPaymentSpecificData(); + Map data = existingData != null + ? JsonUtil.fromJson(existingData, Map.class) : new HashMap<>(); + if (data == null) data = new HashMap<>(); + data.put("childPaymentLogIds", childPaymentLogIds); + parentLog.setPaymentSpecificData(JsonUtil.toJson(data)); + paymentLogRepository.save(parentLog); + }); + } + + return enrolledSessionIds; + } + + /** + * Finds the parent PaymentLog of a gateway order that already carries a provisioned + * enrollment, so the confirmation call can complete it instead of duplicating it. + * + *

The gateway order reference lives inside {@code payment_specific_data}, and the + * parent is the earliest log mentioning it. Provisioning is recognised by the + * {@code childPaymentLogIds} entry that {@link #provisionPendingEnrollments} writes + * there — that, not a linked UserPlan, is what the parent carries. + * + *

Returns {@code null} when there is nothing to complete: an order opened before + * this provisioning shipped, or one whose Phase 1 enrollment failed. The caller then + * falls back to enrolling from the confirmation call itself. + */ + private PaymentLog findPhase1PaymentLog(String gatewayOrderRef) { + if (!StringUtils.hasText(gatewayOrderRef)) { + return null; + } + try { + return paymentLogRepository.findAllByOrderIdInJson(gatewayOrderRef) + .stream() + .filter(pl -> hasProvisionedChildren(pl)) + .min(Comparator.comparing(PaymentLog::getCreatedAt)) + .orElse(null); + } catch (Exception e) { + log.warn("Could not look up the originating payment log for gateway order {}: {}", + gatewayOrderRef, e.getMessage()); + return null; + } + } + + private boolean hasProvisionedChildren(PaymentLog paymentLog) { + if (!StringUtils.hasText(paymentLog.getPaymentSpecificData())) { + return false; + } + try { + Map data = JsonUtil.fromJson(paymentLog.getPaymentSpecificData(), Map.class); + if (data == null) { + return false; + } + Object children = data.get("childPaymentLogIds"); + return children instanceof List && !((List) children).isEmpty(); + } catch (Exception e) { + return false; + } + } + private void verifyRazorpaySignature(PaymentInitiationRequestDTO payReq, String instituteId, EnrollInvite firstInvite) { try { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/service/ProductPageService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/service/ProductPageService.java index 9904e5c193..79fff4b15d 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/service/ProductPageService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/product_page/service/ProductPageService.java @@ -173,6 +173,108 @@ public ProductPageResponse addCustomFieldToPage(String productPageId, String cus return buildAdminResponseWithCustomFields(page, activeMappings); } + /** + * Edits a field already on this page's form — its label, input type, + * whether it is required, and its config (which now carries the + * verification block that gates submission behind a WhatsApp OTP). + * + * All four live on the shared `custom_fields` row, NOT on the mapping, so + * the edit reaches every form in the institute using this field. That is + * the existing model — required-ness and input type have always been read + * from the master row — and the admin dialog says so rather than pretending + * the change is page-local. + * + * The page is still the authorisation scope: a field that is not on it is + * rejected, so this endpoint cannot be used to edit arbitrary fields of + * another institute. + */ + @Transactional + public ProductPageResponse updateCustomFieldOnPage( + String productPageId, String customFieldId, + ProductPageCustomFieldUpdateRequest request, String instituteId) { + ProductPage page = loadPageForInstitute(productPageId, instituteId); + + List activeMappings = mappingRepository + .findByProductPageIdAndStatusIn(productPageId, List.of(STATUS_ACTIVE)); + + boolean onThisPage = activeMappings.stream().anyMatch(mapping -> customFieldService + .getByInstituteIdAndFieldIdAndTypeAndTypeId( + instituteId, customFieldId, + CustomFieldTypeEnum.ENROLL_INVITE.name(), + mapping.getPsInvitePaymentOption().getEnrollInvite().getId()) + .isPresent()); + + if (!onThisPage) { + throw new VacademyException("That field is not on this product page"); + } + + CustomFieldDTO update = new CustomFieldDTO(); + update.setFieldName(request.getFieldName()); + update.setFieldType(request.getFieldType()); + update.setIsMandatory(request.getIsMandatory()); + update.setConfig(request.getConfig()); + customFieldService.updateCustomField(update, customFieldId); + + return buildAdminResponseWithCustomFields(page, activeMappings); + } + + /** + * Sets the order the page's form asks for its fields in. + * + * The order lives on the mapping (`individual_order`), and there is one + * mapping per enroll invite — so a page selling 28 courses stores the same + * position 28 times. Nothing set it before, which left every field on 999 + * and the form's order down to whatever order the rows came back in: a + * checkout collecting both "Full Name" and "School Name" could ask for them + * either way round on consecutive loads. + * + * Ids not on the page are ignored rather than rejected: an admin reordering + * a stale tab should not lose the whole save over one field somebody else + * removed in the meantime. + */ + @Transactional + public ProductPageResponse reorderCustomFieldsOnPage( + String productPageId, List orderedCustomFieldIds, String instituteId) { + ProductPage page = loadPageForInstitute(productPageId, instituteId); + + List activeMappings = mappingRepository + .findByProductPageIdAndStatusIn(productPageId, List.of(STATUS_ACTIVE)); + + if (orderedCustomFieldIds != null) { + for (ProductPageInviteMapping mapping : activeMappings) { + String enrollInviteId = mapping.getPsInvitePaymentOption().getEnrollInvite().getId(); + + for (int position = 0; position < orderedCustomFieldIds.size(); position++) { + String customFieldId = orderedCustomFieldIds.get(position); + if (customFieldId == null || customFieldId.isBlank()) { + continue; + } + // Only touch fields this invite already carries — addOrUpdate + // would otherwise ATTACH a field that was never on the page. + if (customFieldService.getByInstituteIdAndFieldIdAndTypeAndTypeId( + instituteId, customFieldId, + CustomFieldTypeEnum.ENROLL_INVITE.name(), enrollInviteId).isEmpty()) { + continue; + } + + CustomFieldDTO cfDto = new CustomFieldDTO(); + cfDto.setId(customFieldId); + + InstituteCustomFieldDTO dto = new InstituteCustomFieldDTO(); + dto.setInstituteId(instituteId); + dto.setType(CustomFieldTypeEnum.ENROLL_INVITE.name()); + dto.setTypeId(enrollInviteId); + dto.setCustomField(cfDto); + dto.setIndividualOrder(position); + + customFieldService.addOrUpdateCustomField(List.of(dto)); + } + } + } + + return buildAdminResponseWithCustomFields(page, activeMappings); + } + @Transactional public ProductPageResponse createAndLinkCustomFieldToPage( String productPageId, ProductPageCustomFieldCreateRequest request, String instituteId) { @@ -294,6 +396,10 @@ public String createCoupon(String coursePageId, ProductPageCouponRequest request : null); if (request.getMaxUses() != null) couponCode.setUsageLimit(request.getMaxUses().longValue()); + // Quantity condition — "₹99 off when you take 2 or more". Only stored when + // it is a real condition; 1 or less is no condition at all. + if (request.getMinItems() != null && request.getMinItems() > 1) + couponCode.setMinItems(request.getMinItems()); couponCode = couponCodeRepository.save(couponCode); AppliedCouponDiscount discount = new AppliedCouponDiscount(); @@ -325,6 +431,15 @@ public String deleteCoupon(String couponCodeId) { public ProductPageCouponValidateResponse validateCoupon(String coursePageCode, String couponCode, double totalAmount) { + return validateCoupon(coursePageCode, couponCode, totalAmount, null); + } + + /** + * @param itemCount how many courses are in the basket, for coupons carrying a + * minimum. Null reads as one item. + */ + public ProductPageCouponValidateResponse validateCoupon(String coursePageCode, String couponCode, + double totalAmount, Integer itemCount) { ProductPage page = coursePageRepository.findByCode(coursePageCode) .orElseThrow(() -> new VacademyException("Course page not found")); @@ -336,6 +451,7 @@ public ProductPageCouponValidateResponse validateCoupon(String coursePageCode, S .instituteId(page.getInstituteId()) .productPageCode(coursePageCode) .totalAmount(totalAmount) + .itemCount(itemCount) .build(); CouponValidateResponseDTO resp = couponValidationService.validate(req); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/AdmissionsSection.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/AdmissionsSection.java new file mode 100644 index 0000000000..333578a41c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/AdmissionsSection.java @@ -0,0 +1,257 @@ +package vacademy.io.admin_core_service.features.reporting.sections; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import vacademy.io.admin_core_service.features.reporting.spi.ReportContext; +import vacademy.io.admin_core_service.features.reporting.spi.ReportSection; +import vacademy.io.admin_core_service.features.reporting.spi.SectionFacts; + +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The admissions pipeline: leads arriving, converting, and being dropped. + * + * The first section about the BUSINESS rather than the teaching. Everything else in + * the digest describes learners an institute already has; this one describes the + * ones it is trying to win. + * + *

"Stalled" means abandoned, not unreachable

+ * A first version counted every non-converted lead with no recent activity, which + * at one institute was 44,037 of 72,683 — a number so large it describes the + * database rather than a task. Most of it was DNP and NOT_REACHABLE: leads nobody + * can get hold of, which is a different problem from leads nobody tried. Stalled is + * therefore restricted to the actively-worked states (LEAD, CALL_BACK, FOLLOWUP) + * that have gone quiet — someone promised a follow-up and did not make it. Narrowed + * that way the same institute reports 12,926 stalled out of 13,455 in play, which + * is still alarming but is now a statement about work, not about data. + * + *

Counsellor rows are the point, and only for periodic readers

+ * Per-counsellor backlog is where this becomes actionable — measured on real data, + * several counsellors held over a thousand leads each with 100% of them stale and + * zero conversions in a month. But a workload is standing state: identical + * tomorrow. So it appears in weekly and monthly reports, while a daily reader gets + * the pipeline movement instead. + * + *

Two fields that look useful and are not

+ * {@code first_response_at} is NULL on every row in production, so there is no + * response-time metric here. And {@code assigned_counselor_id} is set on only 47 of + * 6,035 converted leads, so conversions cannot be attributed to a counsellor — the + * counsellor table reports backlog, which is reliable, and shows conversions only + * as context. + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class AdmissionsSection implements ReportSection { + + /** Beyond this with no activity, an actively-worked lead has been dropped. */ + private static final int STALE_DAYS = 7; + private static final int MAX_STATUS_ROWS = 8; + private static final int MAX_COUNSELLOR_ROWS = 8; + + /** The states in which somebody is supposed to be working the lead. */ + private static final String IN_PLAY = "('LEAD', 'CALL_BACK', 'FOLLOWUP')"; + + private final JdbcTemplate jdbcTemplate; + + @Override + public String key() { + return "admissions"; + } + + @Override + public String title() { + return "Leads & admissions"; + } + + @Override + public String description() { + return "Leads arriving and converting in the period, how many are sitting " + + "untouched, and which counsellors are carrying a stalled pipeline."; + } + + @Override + public Set visibleToRoles() { + // Sales pipeline is an owner and admissions-manager concern, not a teaching one. + return Set.of("ADMIN"); + } + + @Override + public Set supportedScopes() { + // A lead has no batch: they have not enrolled in anything yet. + return Set.of(ReportContext.ScopeType.INSTITUTE); + } + + @Override + public boolean isAvailableFor(String instituteId) { + Integer n = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_lead_profile " + + "WHERE institute_id = ? " + + "AND created_at > now() - INTERVAL '90 days'", + Integer.class, instituteId); + return n != null && n > 0; + } + + @Override + public SectionFacts compute(ReportContext ctx) { + Timestamp from = Timestamp.from(ctx.getWindowStart()); + Timestamp to = Timestamp.from(ctx.getWindowEnd()); + + // Argument order follows the ORDER OF '?' IN THE SQL TEXT, not any logical + // grouping — instituteId sits in the WHERE clause, which comes last. + Map s = jdbcTemplate.queryForMap(SUMMARY_SQL, + from, to, from, to, STALE_DAYS, ctx.getInstituteId()); + + int newLeads = num(s.get("new_leads")); + int converted = num(s.get("converted")); + int inPlay = num(s.get("in_play")); + int stalled = num(s.get("stalled")); + int unassigned = num(s.get("unassigned")); + int unreachable = num(s.get("unreachable")); + + List rows = new ArrayList<>(); + + // Pipeline movement — the part that differs from yesterday. + for (Map r : jdbcTemplate.queryForList(BY_STATUS_SQL, + from, to, STALE_DAYS, ctx.getInstituteId(), from, to, MAX_STATUS_ROWS)) { + int stale = num(r.get("stalled")); + rows.add(SectionFacts.Row.builder() + .value(str(r.get("conversion_status"), "(no status)")) + .value(String.valueOf(num(r.get("new_in_window")))) + .value(stale == 0 ? "—" : String.valueOf(stale)) + .value(String.valueOf(num(r.get("total")))) + .build()); + } + + // Workload is standing state, so it goes to readers who are not hearing from + // us every morning. A daily reader would see the same names indefinitely. + if (!ctx.isDailyCadence()) { + List> counsellors = jdbcTemplate.queryForList(BY_COUNSELLOR_SQL, + STALE_DAYS, from, to, ctx.getInstituteId(), MAX_COUNSELLOR_ROWS); + for (Map r : counsellors) { + int held = num(r.get("in_play")); + int stale = num(r.get("stalled")); + rows.add(SectionFacts.Row.builder() + .value(str(r.get("counsellor"), "(unnamed counsellor)")) + .value(String.valueOf(held)) + .value(held > 0 + ? stale + " (" + (int) Math.round(100.0 * stale / held) + "%)" + : String.valueOf(stale)) + .value(String.valueOf(num(r.get("converted")))) + .build()); + } + } + + SectionFacts.SectionFactsBuilder facts = SectionFacts.builder() + .sectionKey(key()) + .title(title()) + .identifying(false) // counsellors are staff; no lead is named + .empty(newLeads == 0 && converted == 0) + .headline("New leads", String.valueOf(newLeads)) + .headline("Converted", String.valueOf(converted)) + .headline("Being worked", String.valueOf(inPlay)) + .headline("Untouched " + STALE_DAYS + "+ days", inPlay > 0 + ? stalled + " of " + inPlay : String.valueOf(stalled)) + .headline("Nobody assigned", String.valueOf(unassigned)) + // Unreachable is a separate problem from neglected, and conflating + // them is what made the first version of this unusable. + .headline("Unreachable", String.valueOf(unreachable)) + .tone("Untouched " + STALE_DAYS + "+ days", + inPlay == 0 || stalled == 0 ? "good" + : stalled * 2 >= inPlay ? "bad" : "warn") + .tone("Nobody assigned", unassigned == 0 ? "good" : "warn") + .column(ctx.isDailyCadence() ? "Status" : "Status / counsellor") + .column("New") + .column("Untouched") + .column("Total"); + + return facts.rows(rows).build(); + } + + private static int num(Object o) { + return o == null ? 0 : ((Number) o).intValue(); + } + + private static String str(Object o, String fallback) { + String v = o == null ? null : String.valueOf(o).trim(); + return (v == null || v.isEmpty()) ? fallback : v; + } + + /** + * Params: instituteId, windowStart, windowEnd, windowStart, windowEnd, staleDays. + * + * {@code last_activity_at} falls back to {@code created_at}: a lead that has + * never been touched has no activity timestamp, and treating that as "active" + * would hide exactly the leads nobody has worked. + */ + private static final String SUMMARY_SQL = """ + SELECT count(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS new_leads, + count(*) FILTER (WHERE converted_at >= ? AND converted_at < ?) AS converted, + count(*) FILTER (WHERE conversion_status IN """ + IN_PLAY + """ + ) AS in_play, + count(*) FILTER (WHERE conversion_status IN """ + IN_PLAY + """ + AND COALESCE(last_activity_at, created_at) + < now() - make_interval(days => ?)) AS stalled, + count(*) FILTER (WHERE conversion_status = 'LEAD' + AND assigned_counselor_id IS NULL) AS unassigned, + count(*) FILTER (WHERE conversion_status + IN ('DNP', 'NOT_REACHABLE')) AS unreachable + FROM user_lead_profile + WHERE institute_id = ? + """; + + /** + * Pipeline by status, most new first — this is the part that moves day to day. + * + * Params: instituteId, windowStart, windowEnd, windowStart, windowEnd, staleDays, limit. + */ + private static final String BY_STATUS_SQL = """ + SELECT conversion_status, + count(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS new_in_window, + count(*) FILTER (WHERE conversion_status IN """ + IN_PLAY + """ + AND COALESCE(last_activity_at, created_at) + < now() - make_interval(days => ?)) AS stalled, + count(*) AS total + FROM user_lead_profile + WHERE institute_id = ? + GROUP BY conversion_status + ORDER BY count(*) FILTER (WHERE created_at >= ? AND created_at < ?) DESC, + count(*) DESC + LIMIT ? + """; + + /** + * Counsellors carrying the largest stalled backlog. + * + * Conversions are shown for context only, NOT as a performance measure: only 47 + * of 6,035 converted leads in production carry a counsellor id, so a zero here + * means the attribution is missing far more often than it means nobody sold + * anything. + * + * Params: instituteId, staleDays, windowStart, windowEnd, limit. + */ + private static final String BY_COUNSELLOR_SQL = """ + SELECT COALESCE(NULLIF(btrim(assigned_counselor_name), ''), + '(unnamed counsellor)') AS counsellor, + count(*) FILTER (WHERE conversion_status IN """ + IN_PLAY + """ + ) AS in_play, + count(*) FILTER (WHERE conversion_status IN """ + IN_PLAY + """ + AND COALESCE(last_activity_at, created_at) + < now() - make_interval(days => ?)) AS stalled, + count(*) FILTER (WHERE converted_at >= ? AND converted_at < ?) AS converted + FROM user_lead_profile + WHERE institute_id = ? + AND assigned_counselor_id IS NOT NULL + GROUP BY 1 + HAVING count(*) FILTER (WHERE conversion_status IN """ + IN_PLAY + """ + ) > 0 + ORDER BY stalled DESC, in_play DESC + LIMIT ? + """; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/AssessmentsSection.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/AssessmentsSection.java index 184159491a..a2208a0dbb 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/AssessmentsSection.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/AssessmentsSection.java @@ -164,8 +164,12 @@ public SectionFacts compute(ReportContext ctx) { : "—"); if (attempts > 0) { - // A third of a cohort waiting on a mark is a backlog, not a statistic. + // A third of a cohort waiting on a mark is a backlog worth reddening — but + // NOT on a daily report, where the attempts were sat hours ago and nobody + // could reasonably have marked them yet. Same figure, different meaning + // depending on how long the window has had to clear. facts.tone("Awaiting evaluation", awaiting == 0 ? "good" + : ctx.isDailyCadence() ? "warn" : awaiting * 4 >= attempts ? "bad" : "warn"); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/CallingSection.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/CallingSection.java new file mode 100644 index 0000000000..31a3dfa93b --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/CallingSection.java @@ -0,0 +1,264 @@ +package vacademy.io.admin_core_service.features.reporting.sections; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import vacademy.io.admin_core_service.features.reporting.spi.ReportContext; +import vacademy.io.admin_core_service.features.reporting.spi.ReportSection; +import vacademy.io.admin_core_service.features.reporting.spi.SectionFacts; + +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Counsellor calling activity — the other half of the admissions picture. + * + * {@code AdmissionsSection} says which leads are being neglected; this says whether + * anyone is picking up the phone, and who. + * + *

"Connected" cannot come from answer_time

+ * {@code answer_time} is NULL on every row in production, so a call is counted as + * connected when {@code status = 'COMPLETED'} AND it lasted a non-zero number of + * seconds. Of 57,940 completed calls, 384 have zero duration — connected-but-silent + * is not connected. There is likewise no callbacks-due metric here, because + * {@code callback_at} is also never populated. + * + *

A zero-connection counsellor is usually not an idle one

+ * The stuck queue is not spread evenly. Four counsellors at one institute made + * 1,866 calls in a week and connected almost none — because 97% of those calls sat + * in QUEUED. They were dialling; the platform never placed the calls. So a row + * whose calls are mostly queued says "stuck in queue" instead of a connect rate, + * because a bare "0 connected" in a report to their manager is an accusation, and + * it would be pointed at the wrong person. + * + *

Calls stuck in the queue

+ * 14,276 of 14,699 QUEUED calls are more than a day old. That is reported as its + * own figure and worded as "stuck", not as "calls not made", because the data + * cannot distinguish between the call never being placed and the provider never + * telling us what happened to it. Either reading is worth an admin's attention, and + * asserting the wrong one would be worse than describing what we can see. + * + *

Counsellor names

+ * The call log stores only {@code counsellor_user_id}, and admin_core has no user + * table — names live in auth_service. Rather than make a cross-service call per + * counsellor, the name is recovered from {@code user_lead_profile + * .assigned_counselor_name}, which resolves 32 of the 39 counsellors seen calling. + * The rest fall back to a truncated id, which is still enough for an admissions head + * to know who to ask about. + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class CallingSection implements ReportSection { + + private static final int MAX_ROWS = 10; + /** Queued longer than this and it is not "in flight" any more. */ + private static final int STUCK_HOURS = 24; + + private final JdbcTemplate jdbcTemplate; + + @Override + public String key() { + return "calling"; + } + + @Override + public String title() { + return "Calling activity"; + } + + @Override + public String description() { + return "Outbound and inbound calls in the period, how many connected, and " + + "which counsellors are actually dialling."; + } + + @Override + public Set visibleToRoles() { + return Set.of("ADMIN"); + } + + @Override + public Set supportedScopes() { + return Set.of(ReportContext.ScopeType.INSTITUTE); + } + + @Override + public boolean isAvailableFor(String instituteId) { + // Telephony is used by only a handful of institutes, so this hides itself + // for everyone else rather than offering a section that can only be empty. + Integer n = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM telephony_call_log " + + "WHERE institute_id = ? " + + "AND created_at > now() - INTERVAL '30 days'", + Integer.class, instituteId); + return n != null && n > 0; + } + + @Override + public SectionFacts compute(ReportContext ctx) { + Timestamp from = Timestamp.from(ctx.getWindowStart()); + Timestamp to = Timestamp.from(ctx.getWindowEnd()); + + // Argument order follows the '?' order in the SQL TEXT — instituteId sits in + // the WHERE clause, after every FILTER above it. + Map s = jdbcTemplate.queryForMap(SUMMARY_SQL, + STUCK_HOURS, from, to, ctx.getInstituteId()); + + int outbound = num(s.get("outbound")); + int connected = num(s.get("connected")); + int inbound = num(s.get("inbound")); + int inboundMissed = num(s.get("inbound_missed")); + int stuck = num(s.get("stuck_in_queue")); + long talkSeconds = lng(s.get("talk_seconds")); + + List rows = new ArrayList<>(); + for (Map r : jdbcTemplate.queryForList(BY_COUNSELLOR_SQL, + ctx.getInstituteId(), from, to, MAX_ROWS)) { + int calls = num(r.get("calls")); + int conn = num(r.get("connected")); + int queued = num(r.get("queued")); + + // Say WHY a counsellor shows no connections. Measured on real data, four + // counsellors made 1,866 calls between them and connected almost none — + // and 97% of those calls were stuck in QUEUED. They were dialling; the + // platform never placed the calls. "0 connected" on its own reads as an + // accusation, and it would have been aimed at the wrong people. + String connectedCell; + if (queued * 2 >= calls && conn * 4 < calls) { + connectedCell = conn + " · " + queued + " stuck in queue"; + } else if (calls > 0) { + connectedCell = conn + " (" + (int) Math.round(100.0 * conn / calls) + "%)"; + } else { + connectedCell = String.valueOf(conn); + } + + rows.add(SectionFacts.Row.builder() + .value(str(r.get("counsellor"), "(unknown counsellor)")) + .value(String.valueOf(calls)) + .value(connectedCell) + .value(describeSeconds(lng(r.get("talk_seconds")))) + .build()); + } + + SectionFacts.SectionFactsBuilder facts = SectionFacts.builder() + .sectionKey(key()) + .title(title()) + .identifying(false) // counsellors are staff; no lead is named + .empty(outbound == 0 && inbound == 0) + .headline("Calls made", String.valueOf(outbound)) + .headline("Connected", outbound > 0 + ? connected + " (" + (int) Math.round(100.0 * connected / outbound) + "%)" + : String.valueOf(connected)) + .headline("Talk time", describeSeconds(talkSeconds)) + .headline("Inbound", inboundMissed > 0 + ? inbound + " · " + inboundMissed + " missed" : String.valueOf(inbound)) + .tone("Connected", outbound == 0 ? "warn" + : connected * 2 >= outbound ? "good" : "warn"); + + // Only mentioned when it is actually happening, and worded as "stuck" + // because the data cannot say whose fault it is. + if (stuck > 0) { + facts.headline("Stuck in queue", String.valueOf(stuck)) + .tone("Stuck in queue", "bad"); + } + + return facts + .column("Counsellor") + .column("Calls") + .column("Connected") + .column("Talk time") + .rows(rows) + .build(); + } + + private static String describeSeconds(long seconds) { + if (seconds <= 0) return "—"; + long hours = seconds / 3600; + long mins = (seconds % 3600) / 60; + if (hours > 0) return hours + "h" + (mins > 0 ? " " + mins + "m" : ""); + return mins > 0 ? mins + " min" : seconds + "s"; + } + + private static int num(Object o) { + return o == null ? 0 : ((Number) o).intValue(); + } + + private static long lng(Object o) { + return o == null ? 0L : ((Number) o).longValue(); + } + + private static String str(Object o, String fallback) { + String v = o == null ? null : String.valueOf(o).trim(); + return (v == null || v.isEmpty()) ? fallback : v; + } + + /** + * Params: stuckHours, windowStart, windowEnd, instituteId — in that order, + * because that is the order the placeholders appear in the TEXT below. The + * institute filter sits in the inner query's WHERE, after the window flag. + * + * The stuck-queue count is deliberately NOT window-bounded: a call queued three + * weeks ago and never placed is still stuck today, and restricting it to the + * window would quietly shrink a backlog the institute needs to see. + */ + private static final String SUMMARY_SQL = """ + SELECT count(*) FILTER (WHERE direction = 'OUTBOUND' + AND in_window) AS outbound, + count(*) FILTER (WHERE direction = 'OUTBOUND' AND in_window + AND status = 'COMPLETED' + AND COALESCE(duration_seconds, 0) > 0) AS connected, + COALESCE(sum(duration_seconds) FILTER ( + WHERE in_window AND status = 'COMPLETED'), 0) AS talk_seconds, + count(*) FILTER (WHERE direction = 'INBOUND' AND in_window) AS inbound, + count(*) FILTER (WHERE direction = 'INBOUND' AND in_window + AND status <> 'COMPLETED') AS inbound_missed, + count(*) FILTER (WHERE status = 'QUEUED' + AND created_at + < now() - make_interval(hours => ?)) AS stuck_in_queue + FROM ( + SELECT direction, status, duration_seconds, created_at, + (created_at >= ? AND created_at < ?) AS in_window + FROM telephony_call_log + WHERE institute_id = ? + ) c + """; + + /** + * Who dialled, busiest first. Window-bounded, so this table genuinely differs + * from one day to the next. + * + * Params: instituteId, windowStart, windowEnd, limit. + */ + private static final String BY_COUNSELLOR_SQL = """ + SELECT COALESCE(NULLIF(btrim(nm.name), ''), + 'id ' || left(c.counsellor_user_id, 8)) AS counsellor, + count(*) AS calls, + count(*) FILTER (WHERE c.status = 'COMPLETED' + AND COALESCE(c.duration_seconds, 0) > 0) AS connected, + count(*) FILTER (WHERE c.status = 'QUEUED') AS queued, + COALESCE(sum(c.duration_seconds) FILTER ( + WHERE c.status = 'COMPLETED'), 0) AS talk_seconds + FROM telephony_call_log c + LEFT JOIN LATERAL ( + -- admin_core has no user table; the name is recovered from the lead + -- profile that the same counsellor is assigned to. + SELECT p.assigned_counselor_name AS name + FROM user_lead_profile p + WHERE p.assigned_counselor_id = c.counsellor_user_id + AND p.assigned_counselor_name IS NOT NULL + LIMIT 1 + ) nm ON TRUE + WHERE c.institute_id = ? + AND c.direction = 'OUTBOUND' + AND c.created_at >= ? AND c.created_at < ? + AND c.counsellor_user_id IS NOT NULL + GROUP BY 1 + ORDER BY calls DESC + LIMIT ? + """; +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/DoubtsSection.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/DoubtsSection.java index be7f71e1aa..6ff75ba601 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/DoubtsSection.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/DoubtsSection.java @@ -126,7 +126,8 @@ public SectionFacts compute(ReportContext ctx) { Map s = jdbcTemplate.queryForMap(SUMMARY_SQL, ctx.getInstituteId(), batchScoped, batchId, cohortRestricted, cohortCsv, - STALE_DAYS, from, to, from, to, from, to); + STALE_DAYS, from, to, from, to, from, to, + from, to, from, to, from, to); int openNow = num(s.get("open_now")); int unanswered = num(s.get("unanswered")); @@ -135,7 +136,7 @@ public SectionFacts compute(ReportContext ctx) { int resolved = num(s.get("resolved_in_window")); Object medianH = s.get("median_h"); Object medianFirst = s.get("median_first_reply_h"); - int everReplied = num(s.get("ever_replied")); + int answeredInWindow = num(s.get("answered_in_window")); int withinDay = num(s.get("replied_within_day")); // A daily reader gets what changed; a weekly or monthly reader gets how @@ -198,7 +199,7 @@ public SectionFacts compute(ReportContext ctx) { .build()); } - return SectionFacts.builder() + SectionFacts.SectionFactsBuilder facts = SectionFacts.builder() .sectionKey(key()) .title(title()) .identifying(true) @@ -208,28 +209,40 @@ public SectionFacts compute(ReportContext ctx) { .empty(incremental ? named == 0 && raised == 0 && resolved == 0 : openNow == 0 && raised == 0 && resolved == 0) - .headline("Open now", String.valueOf(openNow)) - .headline("No reply yet", String.valueOf(unanswered)) - .headline("Waiting " + STALE_DAYS + "+ days", String.valueOf(stale)) - // The turnaround pair. First reply is the number that matters to a - // waiting learner; resolution can lag it by weeks. + // What MOVED in the period, first. Every one of these changes daily; + // the standing counts below do not, which is why they are held back + // for readers who hear from us less often. + .headline("Raised", String.valueOf(raised)) + .headline("Answered", String.valueOf(answeredInWindow)) .headline("Median first reply", medianFirst == null ? "—" : describeHours(((Number) medianFirst).intValue())) - .headline("Answered in a day", everReplied == 0 - ? "—" : withinDay + " of " + everReplied) - .headline("Median to resolve", medianH == null - ? "—" : describeHours(((Number) medianH).intValue())) + .headline("Answered within a day", answeredInWindow == 0 + ? "—" : withinDay + " of " + answeredInWindow) + // One standing number even on a daily report, because a growing + // backlog is the thing a daily reader must not be allowed to forget. + .headline("Waiting " + STALE_DAYS + "+ days", String.valueOf(stale)) // Colour asserts something, so only where the data is unambiguous: // a doubt nobody has answered in three days is simply bad. .tone("Waiting " + STALE_DAYS + "+ days", stale > 0 ? "bad" : "good") - .tone("No reply yet", unanswered > 0 ? "warn" : "good") .tone("Median first reply", medianFirst == null ? "warn" : ((Number) medianFirst).intValue() <= 24 ? "good" : ((Number) medianFirst).intValue() <= 72 ? "warn" : "bad") .column("Learner") .column("Type") .column("Waiting") - .column("Question") + .column("Question"); + + if (!incremental) { + // The shape of the queue, for a weekly or monthly reader — the one who + // should be judging the backlog rather than working today's arrivals. + facts.headline("Open now", String.valueOf(openNow)) + .headline("No reply yet", String.valueOf(unanswered)) + .headline("Median to resolve", medianH == null + ? "—" : describeHours(((Number) medianH).intValue())) + .tone("No reply yet", unanswered > 0 ? "warn" : "good"); + } + + return facts .rows(rows) .build(); } @@ -301,12 +314,17 @@ ORDER BY EXTRACT(EPOCH FROM (resolved_time - raised_time)) / 3600) AND resolved_time >= ? AND resolved_time < ? -- Guard against clock skew producing a negative age. AND resolved_time >= raised_time)) AS median_h, + -- Bounded to doubts ANSWERED IN THE WINDOW. Computed over all + -- history these never move: measured across two consecutive daily + -- reports, median-first-reply and answered-in-a-day were byte + -- identical, which is a lifetime average wearing a period's + -- clothes. round(percentile_cont(0.5) WITHIN GROUP ( ORDER BY EXTRACT(EPOCH FROM (first_reply - raised_time)) / 3600) - FILTER (WHERE first_reply IS NOT NULL + FILTER (WHERE first_reply >= ? AND first_reply < ? AND first_reply >= raised_time)) AS median_first_reply_h, - count(*) FILTER (WHERE first_reply IS NOT NULL) AS ever_replied, - count(*) FILTER (WHERE first_reply IS NOT NULL + count(*) FILTER (WHERE first_reply >= ? AND first_reply < ?) AS answered_in_window, + count(*) FILTER (WHERE first_reply >= ? AND first_reply < ? AND first_reply - raised_time < INTERVAL '24 hours') AS replied_within_day FROM root diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/LearnerEngagementSection.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/LearnerEngagementSection.java index d00df2838c..b6afe56366 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/LearnerEngagementSection.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/LearnerEngagementSection.java @@ -142,15 +142,22 @@ public SectionFacts compute(ReportContext ctx) { List rows = new ArrayList<>(); + // Daily readers get where learning actually happened; periodic readers get + // the ranking of who is falling behind. + boolean daily = ctx.isDailyCadence(); for (Map r : jdbcTemplate.queryForList(BATCH_SQL, - concat(scope, MAX_MS_PER_ACTIVITY, from, to, true, true, MAX_ACTIVE_ROWS))) { + concat(scope, MAX_MS_PER_ACTIVITY, from, to, + true, daily, !daily, MAX_ACTIVE_ROWS))) { rows.add(batchRow(r, true)); } - int dormant = batchesTotal - batchesActive; + // A dormant-batch list is the same list every morning, so it belongs in a + // periodic report, not a daily one. + int dormant = daily ? 0 : batchesTotal - batchesActive; if (dormant > 0) { List> quiet = jdbcTemplate.queryForList(BATCH_SQL, - concat(scope, MAX_MS_PER_ACTIVITY, from, to, false, false, MAX_DORMANT_ROWS)); + concat(scope, MAX_MS_PER_ACTIVITY, from, to, + false, false, false, MAX_DORMANT_ROWS)); for (Map r : quiet) { rows.add(batchRow(r, false)); } @@ -300,7 +307,7 @@ AND COALESCE(a.engaged_ms, 0) > 0 * batches the rate term collapses to a constant, leaving size as the order. * * Params: ENROLLED_CTE params, clampMs, windowStart, windowEnd, - * wantActive, wantActive, limit. + * wantActive, byActivity, wantRateOrder, limit. */ private static final String BATCH_SQL = ENROLLED_CTE + """ , per_learner AS ( @@ -351,7 +358,14 @@ WHEN l.level_name IS NULL OR btrim(l.level_name) = '' FROM by_batch WHERE enrolled > 0 AND (CASE WHEN CAST(? AS boolean) THEN active > 0 ELSE active = 0 END) - ORDER BY CASE WHEN CAST(? AS boolean) + -- Two orderings, because the question differs by cadence. A daily reader + -- wants "where did learning happen today", so the busiest cohorts lead. + -- A weekly or monthly reader wants "which cohorts are failing", so the + -- worst rate leads. Ranking worst-first every day showed the same dead + -- demo batches repeatedly: measured across two consecutive daily reports, + -- 6 of 10 rows were identical. + ORDER BY CASE WHEN CAST(? AS boolean) THEN active ELSE 0 END DESC, + CASE WHEN CAST(? AS boolean) THEN active::numeric / enrolled ELSE 0 END ASC, enrolled DESC LIMIT ? diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/LiveAttendanceSection.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/LiveAttendanceSection.java index fa71085376..760d103bf4 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/LiveAttendanceSection.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/sections/LiveAttendanceSection.java @@ -174,9 +174,16 @@ public SectionFacts compute(ReportContext ctx) { int invited = num(r.get("invited")); String rate; String attendedCell; - if (invited > 0) { + if (invited > 0 && a <= invited) { attendedCell = a + " of " + invited; rate = (int) Math.round(100.0 * a / invited) + "%"; + } else if (invited > 0) { + // More attended than were invited — link joins, learners since + // deactivated, or people from another batch. Measured on real data: + // 39 of 1,018 classes, the worst rendering as 500%. A rate needs a + // denominator that actually contains the numerator. + attendedCell = a + " joined"; + rate = "more than invited"; } else { // Attendance is real but nobody was formally invited — an open or // link-joined class. A headcount is honest; a percentage is not. @@ -367,15 +374,24 @@ SELECT count(*) AS held, count(*) FILTER (WHERE a.schedule_id IS NOT NULL OR o.last_attendance_sync_at IS NOT NULL) AS known, count(*) FILTER (WHERE a.schedule_id IS NOT NULL - AND COALESCE(i.invited, 0) > 0) AS rated, + AND COALESCE(i.invited, 0) > 0 + AND a.attended <= i.invited) AS rated, COALESCE(sum(a.attended) FILTER ( - WHERE COALESCE(i.invited, 0) > 0), 0) AS sum_attended, + WHERE COALESCE(i.invited, 0) > 0 + AND a.attended <= i.invited), 0) AS sum_attended, COALESCE(sum(i.invited) FILTER ( WHERE a.schedule_id IS NOT NULL - AND COALESCE(i.invited, 0) > 0), 0) AS sum_invited, + AND COALESCE(i.invited, 0) > 0 + AND a.attended <= i.invited), 0) AS sum_invited, + count(*) FILTER (WHERE a.schedule_id IS NOT NULL + AND COALESCE(i.invited, 0) > 0 + AND a.attended <= i.invited + AND a.attended::numeric / i.invited < ?) AS poor, + -- More people joined than were invited: the invite list is not a + -- valid population for this class, so it yields no percentage. count(*) FILTER (WHERE a.schedule_id IS NOT NULL AND COALESCE(i.invited, 0) > 0 - AND a.attended::numeric / i.invited < ?) AS poor + AND a.attended > i.invited) AS over_invited FROM occ o LEFT JOIN att a ON a.schedule_id = o.schedule_id LEFT JOIN inv i ON i.schedule_id = o.schedule_id diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/service/ReportRenderer.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/service/ReportRenderer.java index 586b72b8e5..6868be7874 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/service/ReportRenderer.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/reporting/service/ReportRenderer.java @@ -54,16 +54,20 @@ public class ReportRenderer { * remote images by default, and an icon that vanishes takes its meaning with it, * whereas an emoji is text and always arrives. */ - private static final java.util.Map ICONS = java.util.Map.of( - "inactivity", "🌙", - "live_attendance", "🎥", - "learner_engagement", "📈", - "doubts", "❓", - "session_feedback", "⭐", - "payments", "💳", - "ai_assistant", "🤖", - "ai_spend", "⚡", - "assessments", "📝"); + // Map.ofEntries, not Map.of: the latter is capped at ten key-value pairs and + // this list has already reached it. + private static final java.util.Map ICONS = java.util.Map.ofEntries( + java.util.Map.entry("inactivity", "🌙"), + java.util.Map.entry("live_attendance", "🎥"), + java.util.Map.entry("learner_engagement", "📈"), + java.util.Map.entry("doubts", "❓"), + java.util.Map.entry("session_feedback", "⭐"), + java.util.Map.entry("payments", "💳"), + java.util.Map.entry("ai_assistant", "🤖"), + java.util.Map.entry("ai_spend", "⚡"), + java.util.Map.entry("assessments", "📝"), + java.util.Map.entry("admissions", "🎯"), + java.util.Map.entry("calling", "📞")); private static final String GOOD = "#12996b"; private static final String WARN = "#b4690e"; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/super_admin/dto/SuperAdminCallDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/super_admin/dto/SuperAdminCallDTO.java index ef70d9afd6..953b44fece 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/super_admin/dto/SuperAdminCallDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/super_admin/dto/SuperAdminCallDTO.java @@ -65,6 +65,16 @@ public class SuperAdminCallDTO { private java.util.Map costBreakdown; private Boolean costIsModelled; + /** + * Characters the bot actually synthesised on this call (diagnostics.tts.chars). + * + *

Non-null means the {@code tts} cost line is the vendor's METERED quantity + * rather than duration x the 779 chars/call-min fleet average. Null means the blob + * was absent and the average was used — a distinction worth showing, because the + * average is a mean and a monologue-heavy agent sits well above it. + */ + private Integer ttsCharsMeasured; + /** Sentences served from the TTS speech cache instead of the vendor, and the * characters they represent. NULL (never 0) when the bot did not measure — * the cache was off, or the row predates it. */ diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/super_admin/service/SuperAdminCallService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/super_admin/service/SuperAdminCallService.java index d37088ee1b..61e863e2a3 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/super_admin/service/SuperAdminCallService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/super_admin/service/SuperAdminCallService.java @@ -153,13 +153,24 @@ public Map rateCard() { * telephony leg fractionally understated every short call. */ private Map breakdown(Map card, String ttsModel, - double minutes, int seconds) { + double minutes, int seconds, Integer ttsChars) { String engine = (ttsModel == null || ttsModel.isBlank()) ? "sarvam" : ttsModel.trim().toLowerCase(); long billedMinutes = seconds <= 0 ? 0 : (seconds + 59) / 60; Map b = new LinkedHashMap<>(); b.put("plivo", round(card.getOrDefault("plivo", 0d) * billedMinutes)); b.put("stt", round(card.getOrDefault("stt_sarvam", 0d) * minutes)); - b.put("tts", round(card.getOrDefault("tts_" + engine, 0d) * minutes)); + // TTS is the one component the bot meters EXACTLY: the vendor bills per + // character and diagnostics.tts.chars is the count it actually synthesised. + // Prefer it over duration x the fleet average, because 779 chars/call-min is + // a mean and every real call sits somewhere off it — an agent that monologues + // exceeds it and was being under-costed, one whose caller does the talking was + // being over-costed. Same divisor as the savings line, so the two still cannot + // disagree, and the average stays as the fallback for any call whose blob is + // absent (cache off, older row, a bot that crashed before reporting). + double ttsPerMin = card.getOrDefault("tts_" + engine, 0d); + b.put("tts", round(ttsChars != null && ttsChars > 0 + ? ttsChars / CHARS_PER_CALL_MINUTE * ttsPerMin + : ttsPerMin * minutes)); b.put("llm", round(card.getOrDefault("llm", 0d) * minutes)); return b; } @@ -370,7 +381,8 @@ l.provider_type, COALESCE(l.provider_call_id, r.call_uuid) int secs = r[14] == null ? 0 : ((Number) r[14]).intValue(); double minutes = secs / 60.0; String tts = (String) r[6]; - Map b = breakdown(card, tts, minutes, secs); + Integer ttsChars = diagInt((String) r[18], "chars"); + Map b = breakdown(card, tts, minutes, secs, ttsChars); double cost = round(b.values().stream().mapToDouble(Double::doubleValue).sum()); double billed = round(billedInr(card, surcharge, pricing, tts, (String) r[10], (String) r[19], secs)); @@ -394,7 +406,11 @@ l.provider_type, COALESCE(l.provider_call_id, r.call_uuid) .diagnostics((String) r[18]) .costInr(cost).billedInr(billed).marginInr(margin) .marginPct(billed > 0 ? round(margin / billed * 100) : null) + // Still true overall: plivo, stt and llm remain duration-modelled. + // ttsCharsMeasured is what tells the UI that the TTS line, at least, + // is the vendor's own metered quantity on this particular call. .costBreakdown(b).costIsModelled(true) + .ttsCharsMeasured(ttsChars) .ttsCacheHits(cacheHits) .ttsCacheMisses(diagInt(diagJson, "cacheMisses")) .ttsCacheCharsSaved(cacheHits == null ? null : cacheChars) @@ -470,7 +486,12 @@ THEN CAST(r.diagnostics->'tts'->>'cacheHits' AS bigint) END), 0), THEN CAST(r.diagnostics->'tts'->>'cacheMisses' AS bigint) END), 0), COALESCE(sum(CASE WHEN r.diagnostics->'tts'->>'cacheCharsSaved' ~ '^[0-9]+$' THEN CAST(r.diagnostics->'tts'->>'cacheCharsSaved' AS bigint) END), 0), - count(*) FILTER (WHERE r.diagnostics->'tts'->>'cacheHits' ~ '^[0-9]+$') + count(*) FILTER (WHERE r.diagnostics->'tts'->>'cacheHits' ~ '^[0-9]+$'), + COALESCE(sum(CASE WHEN r.diagnostics->'tts'->>'chars' ~ '^[0-9]+$' + THEN CAST(r.diagnostics->'tts'->>'chars' AS bigint) END), 0), + COALESCE(sum(r.duration_seconds) FILTER ( + WHERE r.diagnostics->'tts'->>'chars' IS NULL + OR NOT (r.diagnostics->'tts'->>'chars' ~ '^[0-9]+$')), 0) """ + BASE_FROM + " GROUP BY 1"); bind(q, instituteId, from, to, health, disposition, agentId); @@ -503,7 +524,19 @@ THEN CAST(r.diagnostics->'tts'->>'cacheCharsSaved' AS bigint) END), 0), Map b = new LinkedHashMap<>(); b.put("plivo", round(card.getOrDefault("plivo", 0d) * billedMins)); b.put("stt", round(card.getOrDefault("stt_sarvam", 0d) * mins)); - b.put("tts", round(card.getOrDefault("tts_" + engine, 0d) * mins)); + // Mirror of breakdown(): metered characters where the bot reported them, + // the 779 chars/call-min average only for the calls it did not. Computed + // the same way in both places so the summary can never drift from the + // rows it is summing. + // [13] is the count of calls that MEASURED; the characters are [14] + // and the unmeasured seconds [15]. Reading 13/14 here billed a call + // count as characters and characters as seconds — a group with 390k + // characters invoiced ~6,500 phantom minutes of TTS. + long measuredChars = ((Number) r[14]).longValue(); + double unmeasuredMins = ((Number) r[15]).longValue() / 60.0; + double ttsPerMin = card.getOrDefault("tts_" + engine, 0d); + b.put("tts", round(measuredChars / CHARS_PER_CALL_MINUTE * ttsPerMin + + unmeasuredMins * ttsPerMin)); b.put("llm", round(card.getOrDefault("llm", 0d) * mins)); b.forEach((k, v) -> agg.merge(k, v, Double::sum)); cost += b.values().stream().mapToDouble(Double::doubleValue).sum(); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/controller/AiCallController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/controller/AiCallController.java index 3df0682e38..3e137035d0 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/controller/AiCallController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/controller/AiCallController.java @@ -1,6 +1,7 @@ package vacademy.io.admin_core_service.features.telephony.controller; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; @@ -8,13 +9,36 @@ import vacademy.io.admin_core_service.features.telephony.core.dto.AiCallRequestDTO; import vacademy.io.admin_core_service.features.telephony.core.dto.AiCallResponseDTO; import vacademy.io.admin_core_service.features.telephony.enums.CallTrigger; +import vacademy.io.admin_core_service.features.telephony.queue.AiCallQueueDrainJob; +import vacademy.io.admin_core_service.features.telephony.queue.AiCallQueueService; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.EnqueueResult; import vacademy.io.common.auth.model.CustomUserDetails; +import java.util.Optional; + /** - * Manual "Click to AI call" — a counsellor/admin triggers an Aavtaar AI call for - * a lead. Authenticated (not in the webhook allow-list); the actor becomes the - * call's counsellor_user_id. Workflow-driven AI calls bypass this controller and - * call {@link AiCallService#placeCall} directly with counsellorUserId = null. + * Manual "Click to AI call" — a counsellor/admin triggers an AI call for a lead. + * Authenticated (not in the webhook allow-list); the actor becomes the call's + * counsellor_user_id. Workflow-driven AI calls bypass this controller and go through + * {@code AiCallNodeDispatcher}. + * + *

The click always joins the queue; whether it waits there depends on the fleet. + * The call is written to {@code ai_call_queue} and then dialled immediately, on this + * request thread, if a line is actually free and this institute has nothing already + * waiting — which is the normal case. Then the response is exactly what it always was: + * {@code dispatched = true} with a {@code callLogId}, and a phone rings. + * + *

Only when the fleet is busy, or this institute already has calls queued, does the + * click take its turn at the back. That response says {@code status = "QUEUED"}, + * {@code dispatched = false}, and carries the position and ETA so the UI can say when + * rather than pretending nothing happened. A UI that reads {@code dispatched} as "did it + * work?" needs to treat QUEUED as accepted-but-waiting. + * + *

What a manual call keeps either way: {@link CallTrigger#MANUAL}, so the + * already-assigned / daily-cap / duplicate throttles do not apply to it and the + * institute's calling window does not hold it back. Credit exhaustion and a deleted lead + * still error loudly on the spot, exactly as before, rather than becoming a silent + * deferral — see {@code AiCallQueueDrainJob.dispatchNowIfLineFree}. */ @RestController @RequestMapping("/admin-core-service/v1/telephony/ai-call") @@ -22,8 +46,14 @@ public class AiCallController { private final AiCallService aiCallService; + private final AiCallQueueService queueService; + private final AiCallQueueDrainJob drainJob; private final InstituteAccessValidator instituteAccessValidator; + /** Rollback lever: false = dial inline, exactly as before the queue existed. */ + @Value("${telephony.ai.queue.enabled:true}") + private boolean queueEnabled; + @PostMapping("/connect") public ResponseEntity connect( @RequestBody AiCallRequestDTO req, @@ -33,10 +63,31 @@ public ResponseEntity connect( // credits / dial another's lead by passing a foreign instituteId). instituteAccessValidator.validateUserAccess(user, req.getInstituteId()); String actorUserId = user == null ? null : user.getUserId(); - // MANUAL: someone pressed Call and is waiting for a phone to ring. If they ask for - // a call, they get a call — the already-assigned / daily-cap / duplicate throttles - // exist to bound automation and must never silently swallow an explicit request. - // Only credit exhaustion can stop it, and that errors loudly. - return ResponseEntity.ok(aiCallService.placeCall(req, actorUserId, CallTrigger.MANUAL)); + + if (!queueEnabled) { + return ResponseEntity.ok(aiCallService.placeCall(req, actorUserId, CallTrigger.MANUAL)); + } + + EnqueueResult result = queueService.enqueue(req, CallTrigger.MANUAL, + AiCallQueueService.SOURCE_MANUAL, null, actorUserId); + + // Fast path: with a free line and nothing of this institute's already waiting, + // dial here and now so the counsellor gets a ringing phone and the old response + // rather than a queue position for a call that would have gone out two seconds + // later anyway. Returns empty when the fleet is busy, and the item simply waits. + if (result.getQueueItemId() != null) { + Optional dialled = + drainJob.dispatchNowIfLineFree(result.getQueueItemId()); + if (dialled.isPresent()) return ResponseEntity.ok(dialled.get()); + } + + return ResponseEntity.ok(AiCallResponseDTO.builder() + .status("QUEUED") + .dispatched(false) + .providerMessage(result.getMessage()) + .queueItemId(result.getQueueItemId()) + .queuePosition(result.getAheadInLane()) + .queueEtaMinutes(result.getEtaMinutes()) + .build()); } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/controller/AiCallQueueAdminController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/controller/AiCallQueueAdminController.java new file mode 100644 index 0000000000..16afc895d8 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/controller/AiCallQueueAdminController.java @@ -0,0 +1,239 @@ +package vacademy.io.admin_core_service.features.telephony.controller; + +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import vacademy.io.admin_core_service.features.telephony.queue.AiCallQueueService; +import vacademy.io.admin_core_service.features.telephony.queue.AiCallQueueSnapshotService; +import vacademy.io.admin_core_service.features.telephony.queue.AiVoiceBoxService; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.BoxUpsertRequest; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.BoxView; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.CapacityView; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.LaneUpsertRequest; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.LaneView; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.QueueItemView; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.QueueSnapshot; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.auth.util.SuperAdminAuthUtil; + +import java.util.List; +import java.util.Map; + +/** + * Fleet-wide AI call capacity: how many simultaneous calls exist, which boxes provide + * them, and how much of the fleet any one institute may hold. + * + *

Super-admin only, and deliberately so — these are cross-tenant numbers. Raising an + * institute's lane cap takes slots from every other institute, and changing the fleet + * capacity changes how hard we drive hardware that leads are talking to. + * + *

+ *   GET    /admin-core-service/super-admin/v1/ai-queue/overview      (fleet + lanes, + optional items)
+ *   GET    /admin-core-service/super-admin/v1/ai-queue/items         (the calls themselves, paged)
+ *   GET    /admin-core-service/super-admin/v1/ai-queue/capacity
+ *   PUT    /admin-core-service/super-admin/v1/ai-queue/capacity        {"maxConcurrentCalls":2}
+ *   PUT    /admin-core-service/super-admin/v1/ai-queue/settings/{key}   {"value":"4"}
+ *   GET    /admin-core-service/super-admin/v1/ai-queue/boxes
+ *   POST   /admin-core-service/super-admin/v1/ai-queue/boxes            (create)
+ *   PUT    /admin-core-service/super-admin/v1/ai-queue/boxes/{id}       (update)
+ *   DELETE /admin-core-service/super-admin/v1/ai-queue/boxes/{id}
+ *   GET    /admin-core-service/super-admin/v1/ai-queue/lanes
+ *   GET|PUT /admin-core-service/super-admin/v1/ai-queue/lanes/{instituteId}
+ * 
+ */ +@RestController +@RequestMapping("/admin-core-service/super-admin/v1/ai-queue") +@RequiredArgsConstructor +public class AiCallQueueAdminController { + + private final AiVoiceBoxService boxService; + private final AiCallQueueService queueService; + private final AiCallQueueSnapshotService snapshotService; + + /** + * Everything the internal dashboard's landing view needs, in ONE request: fleet + * capacity, each voice box and its health, and a row per institute with a queue — + * institute name, depth, calls in flight, its share of the fleet, how long it has + * been waiting and when it will clear. + * + *

One endpoint, and one capacity snapshot behind it, so a screen polling every few + * seconds cannot show a capacity read from one instant next to lanes computed at + * another — which is exactly how "the numbers do not add up" bug reports start. + * + *

Pass {@code limit} to include the head of the queue too; the default of 0 keeps + * this light and leaves paging to {@code /items}. + * + *

This is also the feed for the Vacademy Health dashboard. It reads with an + * ordinary root JWT rather than a bespoke credential: an earlier {@code /internal/} + * variant of this endpoint existed, authenticated by a static shared secret in + * {@code client_secret_key}, and was removed — it returned exactly this payload from + * exactly this assembler, so all it bought was a second auth path to maintain and a + * per-environment DB row to forget. + */ + @GetMapping("/overview") + public ResponseEntity overview( + @RequestParam(value = "limit", defaultValue = "0") int limit, + @RequestParam(value = "instituteId", required = false) String instituteId, + @RequestAttribute("user") CustomUserDetails user) { + SuperAdminAuthUtil.requireSuperAdmin(user); + // limit=0 keeps the landing view light; the item list is paged separately by + // /items. instituteId narrows the waiting list only — capacity and lanes stay + // fleet-wide, because a lane's share means nothing in isolation. + return ResponseEntity.ok(snapshotService.snapshot(Math.max(0, limit), instituteId)); + } + + /** + * The queued calls themselves, across every institute, each row carrying the + * institute name and the AI agent name rather than raw ids. + * + *

Defaults to what is WAITING, in the order it will actually dial. Pass + * {@code status=ALL} (or a specific status like DIALED / FAILED / EXPIRED) to look at + * history instead, which comes back newest-first. + * + * @param instituteId narrow to one institute + * @param status a lifecycle state, or ALL. Default: QUEUED + * @param provider VACADEMY_AI | AAVTAAR | MOCK + * @param source WORKFLOW | BULK | MANUAL + */ + @GetMapping("/items") + public ResponseEntity> items( + @RequestParam(value = "instituteId", required = false) String instituteId, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "provider", required = false) String provider, + @RequestParam(value = "source", required = false) String source, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "50") int size, + @RequestAttribute("user") CustomUserDetails user) { + SuperAdminAuthUtil.requireSuperAdmin(user); + return ResponseEntity.ok( + queueService.search(instituteId, status, provider, source, page, size)); + } + + /** Fleet capacity, live occupancy, queue depth, and every box behind the number. */ + @GetMapping("/capacity") + public ResponseEntity capacity(@RequestAttribute("user") CustomUserDetails user) { + SuperAdminAuthUtil.requireSuperAdmin(user); + return ResponseEntity.ok(boxService.capacity()); + } + + @Data + public static class FleetLimitBody { + /** + * Simultaneous AI calls to allow. null clears the limit (hardware decides); + * 0 pauses dialing — the queue keeps accepting, so nothing is lost. + */ + private Integer maxConcurrentCalls; + } + + /** + * Change how many AI calls may run at once, fleet-wide. + * + *

+         *   PUT /admin-core-service/super-admin/v1/ai-queue/capacity
+         *   {"maxConcurrentCalls": 2}      throttle to 2
+         *   {"maxConcurrentCalls": 0}      pause dialing (queue holds)
+         *   {"maxConcurrentCalls": null}   clear the limit, hardware decides
+         * 
+ * + *

This caps what the boxes provide, it never raises it — a limit above the + * hardware is accepted but non-binding, so this control can never promise + * capacity that does not exist. Read {@code vacademyAiCapacity} in the response + * for what is now actually enforced, and {@code physicalCapacity} for what the + * hardware could carry; showing the requested number alone would be a lie + * whenever the two differ. + * + *

Takes effect within one drain tick (~2s) on every replica, with no + * restart: the drainer resolves capacity from the database each pass. Lowering + * below the calls already in flight never cuts a live call off — it just stops + * new ones until the number comes back under the limit. + */ + @PutMapping("/capacity") + public ResponseEntity setCapacity( + @RequestBody FleetLimitBody body, + @RequestAttribute("user") CustomUserDetails user) { + SuperAdminAuthUtil.requireSuperAdmin(user); + return ResponseEntity.ok( + boxService.setFleetLimit(body == null ? null : body.getMaxConcurrentCalls())); + } + @Data + public static class SettingBody { + private String value; + } + + /** + * Change one runtime knob. The writable keys are an allow-list on the service — + * {@code app_config} is shared with other features and this endpoint must not be a + * general-purpose editor for it. + */ + @PutMapping("/settings/{key}") + public ResponseEntity updateSetting( + @PathVariable String key, + @RequestBody SettingBody body, + @RequestAttribute("user") CustomUserDetails user) { + SuperAdminAuthUtil.requireSuperAdmin(user); + return ResponseEntity.ok(boxService.updateSetting(key, body == null ? null : body.getValue())); + } + + @GetMapping("/boxes") + public ResponseEntity> boxes(@RequestAttribute("user") CustomUserDetails user) { + SuperAdminAuthUtil.requireSuperAdmin(user); + return ResponseEntity.ok(boxService.listBoxes()); + } + + @PostMapping("/boxes") + public ResponseEntity createBox( + @RequestBody BoxUpsertRequest body, + @RequestAttribute("user") CustomUserDetails user) { + SuperAdminAuthUtil.requireSuperAdmin(user); + return ResponseEntity.ok(boxService.upsertBox(null, body)); + } + + @PutMapping("/boxes/{id}") + public ResponseEntity updateBox( + @PathVariable String id, + @RequestBody BoxUpsertRequest body, + @RequestAttribute("user") CustomUserDetails user) { + SuperAdminAuthUtil.requireSuperAdmin(user); + return ResponseEntity.ok(boxService.upsertBox(id, body)); + } + + @DeleteMapping("/boxes/{id}") + public ResponseEntity> deleteBox( + @PathVariable String id, + @RequestAttribute("user") CustomUserDetails user) { + SuperAdminAuthUtil.requireSuperAdmin(user); + boxService.deleteBox(id); + return ResponseEntity.ok(Map.of("deleted", true)); + } + + /** Every institute that has an override or currently has work waiting. */ + @GetMapping("/lanes") + public ResponseEntity> lanes(@RequestAttribute("user") CustomUserDetails user) { + SuperAdminAuthUtil.requireSuperAdmin(user); + return ResponseEntity.ok(queueService.allLanes()); + } + + @GetMapping("/lanes/{instituteId}") + public ResponseEntity lane( + @PathVariable String instituteId, + @RequestAttribute("user") CustomUserDetails user) { + SuperAdminAuthUtil.requireSuperAdmin(user); + return ResponseEntity.ok(queueService.laneView(instituteId)); + } + + /** + * Set or clear an institute's overrides. A null {@code maxConcurrent} clears the + * override and returns the institute to the dynamic default rather than leaving the + * previous number in place. + */ + @PutMapping("/lanes/{instituteId}") + public ResponseEntity upsertLane( + @PathVariable String instituteId, + @RequestBody(required = false) LaneUpsertRequest body, + @RequestAttribute("user") CustomUserDetails user) { + SuperAdminAuthUtil.requireSuperAdmin(user); + return ResponseEntity.ok(queueService.upsertLane(instituteId, body)); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/controller/AiCallQueueController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/controller/AiCallQueueController.java new file mode 100644 index 0000000000..fdde86e30a --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/controller/AiCallQueueController.java @@ -0,0 +1,98 @@ +package vacademy.io.admin_core_service.features.telephony.controller; + +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import vacademy.io.admin_core_service.core.security.InstituteAccessValidator; +import vacademy.io.admin_core_service.features.telephony.queue.AiCallQueueService; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.QueueItemView; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.QueueSummary; +import vacademy.io.common.auth.model.CustomUserDetails; + +import java.util.Map; + +/** + * An institute's own view of the AI call queue: what is waiting, how long it will take, + * and the ability to call it off. + * + *

Read and cancel only, and it discloses no capacity figures. How many lines the + * fleet has, and how many an institute may hold, are internal operating facts that live + * on the super-admin and internal endpoints — an institute does not need to know it is + * sharing a small pool, only how long its own calls will wait. + */ +@RestController +@RequestMapping("/admin-core-service/v1/telephony/ai-queue") +@RequiredArgsConstructor +public class AiCallQueueController { + + private final AiCallQueueService queueService; + private final InstituteAccessValidator instituteAccessValidator; + + /** Paged queue rows. {@code status} filters to one lifecycle state (e.g. QUEUED). */ + @GetMapping + public ResponseEntity> list( + @RequestParam String instituteId, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "25") int size, + @RequestAttribute("user") CustomUserDetails user) { + instituteAccessValidator.validateUserAccess(user, instituteId); + return ResponseEntity.ok(queueService.list(instituteId, status, page, size)); + } + + /** Depth, in-flight, the lane's share of the fleet, and a rough time-to-clear. */ + @GetMapping("/summary") + public ResponseEntity summary( + @RequestParam String instituteId, + @RequestAttribute("user") CustomUserDetails user) { + instituteAccessValidator.validateUserAccess(user, instituteId); + return ResponseEntity.ok(queueService.summary(instituteId)); + } + + /** Queue-side counts for one bulk run, for the campaign progress dialog. */ + @GetMapping("/bulk-run") + public ResponseEntity> bulkRun( + @RequestParam String instituteId, + @RequestParam String audienceId, + @RequestAttribute("user") CustomUserDetails user) { + instituteAccessValidator.validateUserAccess(user, instituteId); + return ResponseEntity.ok(queueService.bulkRunCounts(instituteId, audienceId)); + } + + @Data + public static class CancelBody { + /** Optional: cancel only one bulk run's items (the audience id it was started from). */ + private String sourceRef; + private String reason; + } + + /** + * Cancel everything this institute still has waiting — optionally narrowed to one + * bulk run. Only QUEUED items are affected: a call already dialling is not something + * this can take back. + */ + @PostMapping("/cancel") + public ResponseEntity> cancel( + @RequestParam String instituteId, + @RequestBody(required = false) CancelBody body, + @RequestAttribute("user") CustomUserDetails user) { + instituteAccessValidator.validateUserAccess(user, instituteId); + int cancelled = queueService.cancelForInstitute(instituteId, + body == null ? null : body.getSourceRef(), + body == null ? null : body.getReason()); + return ResponseEntity.ok(Map.of("cancelled", cancelled)); + } + + @DeleteMapping("/{id}") + public ResponseEntity> cancelOne( + @PathVariable String id, + @RequestParam String instituteId, + @RequestParam(value = "reason", required = false) String reason, + @RequestAttribute("user") CustomUserDetails user) { + instituteAccessValidator.validateUserAccess(user, instituteId); + boolean cancelled = queueService.cancelOne(instituteId, id, reason); + return ResponseEntity.ok(Map.of("cancelled", cancelled)); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/AiCallCampaignService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/AiCallCampaignService.java index 60e7a95d97..16642db1cf 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/AiCallCampaignService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/AiCallCampaignService.java @@ -12,6 +12,7 @@ import vacademy.io.admin_core_service.features.audience.repository.AudienceResponseRepository; import vacademy.io.admin_core_service.features.telephony.core.dto.AiCallRequestDTO; import vacademy.io.admin_core_service.features.telephony.core.dto.AiCallingSettingsPojo; +import vacademy.io.admin_core_service.features.telephony.queue.AiCallQueueService; import vacademy.io.common.exceptions.VacademyException; import java.util.List; @@ -33,17 +34,23 @@ * in an audience; each call's outcome (and the counsellor assignment that follows) * is driven by the end-of-call webhook + {@link AiCallOutcomeProcessor}. * - *

Dispatch is async + paced: validation + counting run on the request - * thread (so the caller gets an immediate "queued N" answer), then the per-lead - * click-to-calls run on a bounded background pool ({@code aiCallDispatchExecutor}) - * with a small gap between calls so we don't burst Aavtaar's rate limit. Phone - * numbers are resolved per lead at call time ({@code parent_mobile} → user profile), - * so a lead with only a profile number still gets called. + *

Dispatch is the shared AI call queue: validation + counting run on the + * request thread, then every eligible lead is INSERTED into {@code ai_call_queue} in + * one batch and {@code AiCallQueueDrainJob} dials them as lines free up. Phone numbers + * are still resolved per lead at call time ({@code parent_mobile} → user profile), so a + * lead with only a profile number still gets called. * - *

For very large lists the scalable alternative is Aavtaar's native - * {@code /upload-contacts} (push the whole list, they dial) — pending vendor - * confirmation of how an uploaded list actually starts dialing. This loop is the - * proven path and dials for certain. + *

What that replaced: a background thread per campaign running its own + * completion-aware sliding window, sized by a {@code MAX_PARALLEL} constant. That + * window was per CAMPAIGN, so two institutes running campaigns put twice the intended + * number of calls on a voice box that carries a fixed few — the overflow came back to + * leads as a spoken "all lines busy". It also lived in one replica's heap, so a deploy + * mid-campaign dropped whatever had not dialled yet. The queue is fleet-wide, durable, + * visible and cancellable; fairness between institutes comes from the per-lane + * concurrency cap rather than from each campaign politely limiting itself. + * + *

{@code telephony.ai.queue.enabled=false} restores the old in-memory loop. That is + * a rollback lever, not a supported mode — it dials without a fleet-wide limit. */ @Service @RequiredArgsConstructor @@ -58,13 +65,20 @@ public class AiCallCampaignService { private final vacademy.io.admin_core_service.features.telephony.persistence.repository .TelephonyCallLogRepository callLogRepo; + private final AiCallQueueService queueService; + // Field-injected (not via @RequiredArgsConstructor) because there are multiple // Executor beans and this project's lombok.config doesn't copy @Qualifier onto - // the generated constructor — by-type injection would be ambiguous. + // the generated constructor — by-type injection would be ambiguous. Used by the + // legacy path only. @Autowired @Qualifier("aiCallDispatchExecutor") private Executor dispatchExecutor; + /** Rollback lever: false = the pre-queue background sliding window. */ + @Value("${telephony.ai.queue.enabled:true}") + private boolean queueEnabled; + @jakarta.persistence.PersistenceContext private jakarta.persistence.EntityManager entityManager; @@ -79,9 +93,16 @@ public class AiCallCampaignService { public record StartResult(int total, int eligible, boolean dispatched, String message) {} - /** UI cap for calls-in-parallel. Bounded by the Mumbai voice box (1 vCPU — ~5 - * concurrent clean) and the bot's global MAX_CONCURRENT_CALLS=10 shared across - * ALL institutes: one campaign must not starve everyone else's calls. */ + /** + * Legacy cap for calls-in-parallel, and the upper bound still applied to the + * {@code parallel} request field. + * + *

Under the queue this number no longer decides anything: how many of this + * campaign's calls run at once is the institute's LANE capacity, which is a + * fleet-wide decision made in {@code AiCallCapacityService} rather than something a + * campaign gets to ask for. The field is accepted and ignored so existing clients + * keep working. + */ public static final int MAX_PARALLEL = 3; /** Providers that place AI-agent calls — the dial the cooldown de-duplicates. @@ -178,6 +199,34 @@ public StartResult startForAudience(String instituteId, String audienceId, boole claimed = true; } + if (queueEnabled) { + int queued; + try { + queued = queueService.enqueueBatch(instituteId, toRequests(instituteId, callable, + campaignId, preferredNumberId), CallTrigger.BULK_MANUAL, + AiCallQueueService.SOURCE_BULK, audienceId, actorUserId); + } catch (RuntimeException e) { + // Nothing was queued, so the claim must not outlive the attempt — + // otherwise this list is locked for the full cooldown having placed no calls. + if (claimed) audienceRepository.releaseAiCampaignClaim(audienceId); + throw e; + } + // Honest up front: the fleet carries a fixed number of simultaneous calls, so + // a big list is hours of dialing. An admin who can see that can decide to + // trim the list or cancel; an admin told only "Queued 500" finds out by + // watching nothing happen. + long eta = queueService.etaMinutes(instituteId, settings.getProvider(), queued); + log.info("ai-call bulk: audience={} total={} eligible={} callable={} queued={} (eta ~{} min)", + audienceId, leads.size(), refs.size(), callable.size(), queued, eta); + return new StartResult(leads.size(), queued, queued > 0, + "Queued " + queued + " AI call" + (queued == 1 ? "" : "s") + + (skippedRecent > 0 + ? " (" + skippedRecent + " skipped — already called in the last few minutes)" + : "") + + (eta > 0 ? "; roughly " + formatEta(eta) + " to work through the list" : "") + + ". Outcomes arrive as each call finishes."); + } + try { // The refs are plain records (snapshot) — safe to hand to another thread; // no managed JPA entities cross the boundary. @@ -190,7 +239,7 @@ public StartResult startForAudience(String instituteId, String audienceId, boole throw new VacademyException("Too many AI bulk campaigns are running right now — try again shortly."); } - log.info("ai-call bulk: audience={} total={} eligible={} callable={} dispatched async (pace={}ms)", + log.info("ai-call bulk (legacy): audience={} total={} eligible={} callable={} dispatched async (pace={}ms)", audienceId, leads.size(), refs.size(), callable.size(), paceMs); return new StartResult(leads.size(), callable.size(), true, "Queued " + callable.size() + " AI calls" @@ -200,6 +249,31 @@ public StartResult startForAudience(String instituteId, String audienceId, boole + "; outcomes will arrive via the webhook."); } + /** One queue request per eligible lead. Mirrors what the legacy loop built per call. */ + private List toRequests(String instituteId, List refs, + String campaignId, String preferredNumberId) { + List out = new ArrayList<>(refs.size()); + for (LeadRef ref : refs) { + AiCallRequestDTO req = new AiCallRequestDTO(); + req.setInstituteId(instituteId); + req.setUserId(ref.userId()); + req.setPhoneNumber(ref.phone()); // may be blank → placeCall resolves from profile + req.setResponseId(ref.responseId()); + req.setCampaignId(campaignId); + req.setPreferredNumberId(preferredNumberId); + out.add(req); + } + return out; + } + + /** "2 h 40 min" reads better than "160 minutes" on a campaign confirmation. */ + private static String formatEta(long minutes) { + if (minutes < 60) return minutes + " min"; + long hours = minutes / 60; + long rest = minutes % 60; + return rest == 0 ? hours + " h" : hours + " h " + rest + " min"; + } + /** * Drop leads that already received an AI call inside the campaign cooldown window, * so a re-fire (whole list or overlapping selection) never dials — or bills — the diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/AiCallNodeDispatcher.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/AiCallNodeDispatcher.java index f5ca4e8e53..99f28c69c6 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/AiCallNodeDispatcher.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/AiCallNodeDispatcher.java @@ -6,24 +6,35 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import vacademy.io.admin_core_service.features.telephony.core.dto.AiCallRequestDTO; +import vacademy.io.admin_core_service.features.telephony.enums.CallTrigger; +import vacademy.io.admin_core_service.features.telephony.queue.AiCallQueueService; import java.util.concurrent.Executor; /** - * Enqueues AI calls for the CALL_AI workflow node onto a serial, paced background - * worker ({@code aiCallQueueExecutor}). + * Where the CALL_AI workflow node hands off an AI call. * - *

Why: workflow runs are synchronous and, for a bulk sheet upload, fire one - * CALL_AI node per uploaded lead on the request thread. Dialing Aavtaar inline - * there would block the upload for minutes and burst the provider. Instead the - * node {@link #enqueue}s (returns instantly) and a single worker drains the queue - * one call at a time, with a small gap between calls — so big uploads turn into a - * steady, batched stream of calls instead of a spike. + *

Since the AI call queue this is a durable {@code ai_call_queue} INSERT rather than + * a task on an in-memory executor. Three things changed for the better: * - *

Caveat: the queue is in-memory (per replica, lost on restart). For - * at-least-once durability across restarts/replicas the robust path is a - * DB-backed queue + scheduled drainer (same machinery as the timed retry - * re-dialer) — tracked as a follow-up. + *

    + *
  • It survives a restart. The old queue lived in one replica's heap, so a + * deploy during a bulk sheet upload silently dropped every call still waiting — + * a caveat this class used to carry as a known follow-up.
  • + *
  • It is fleet-aware. The old executor paced calls 300 ms apart with no + * knowledge of how many were already live, so a big upload simply overran the + * voice box and leads heard "all lines busy".
  • + *
  • It de-duplicates properly. The workflow engine resumes a run by + * RESTARTING it, so a CALL_AI node can re-enter many times for the same lead + * before its first call ever goes out. The queue's partial unique index collapses + * those into one pending call.
  • + *
+ * + *

The method signatures are unchanged, so {@code CallAiNodeHandler} is untouched. + * + *

{@code telephony.ai.queue.enabled=false} restores the old in-memory executor + * exactly as it was. That is a rollback lever, not a supported mode — it dials without + * a concurrency limit. */ @Component public class AiCallNodeDispatcher { @@ -31,25 +42,65 @@ public class AiCallNodeDispatcher { private static final Logger log = LoggerFactory.getLogger(AiCallNodeDispatcher.class); private final AiCallService aiCallService; + private final AiCallQueueService queueService; private final Executor executor; - /** Gap between consecutive queued calls — keeps us under Aavtaar's rate limit. */ + /** Gap between consecutive calls on the LEGACY path only. */ @Value("${aavtaar.queue.pace-ms:300}") private long paceMs; + /** Rollback lever: false = the pre-queue in-memory executor. See the class note. */ + @Value("${telephony.ai.queue.enabled:true}") + private boolean queueEnabled; + public AiCallNodeDispatcher(AiCallService aiCallService, + AiCallQueueService queueService, @Qualifier("aiCallQueueExecutor") Executor executor) { this.aiCallService = aiCallService; + this.queueService = queueService; this.executor = executor; } - /** Place this AI call on the paced background worker; returns immediately. */ + /** Queue this AI call; returns immediately. */ public void enqueue(AiCallRequestDTO req) { + enqueue(req, CallTrigger.AUTOMATION); + } + + /** + * As {@link #enqueue(AiCallRequestDTO)}, but with the caller declaring WHICH + * throttle profile applies. The trigger is chosen by the calling code from the + * node's authored config — never read off the request body — so a workflow can + * opt out of the already-assigned guard only when an admin explicitly built it + * that way. It rides on the queue row, so the dial made an hour later still + * carries the decision the node made. + */ + public void enqueue(AiCallRequestDTO req, CallTrigger trigger) { + CallTrigger effective = trigger == null ? CallTrigger.AUTOMATION : trigger; + if (queueEnabled) { + try { + queueService.enqueue(req, effective, AiCallQueueService.SOURCE_WORKFLOW, null, null); + } catch (Exception e) { + // Never let a queue write break the workflow step that asked for the + // call. The node has already recorded its attempt and paused; a lost + // enqueue costs one call, a thrown exception costs the whole run. + log.warn("ai-call queue: could not queue the CALL_AI node's call for lead {} " + + "(response {}): {}", req.getUserId(), req.getResponseId(), e.getMessage()); + } + return; + } + legacyEnqueue(req, effective); + } + + /** + * The pre-queue path, kept verbatim behind the rollback flag: a single paced worker + * thread placing calls one at a time with no fleet-wide limit. + */ + private void legacyEnqueue(AiCallRequestDTO req, CallTrigger trigger) { executor.execute(() -> { try { - aiCallService.placeCall(req, null); + aiCallService.placeCall(req, null, trigger); } catch (Exception e) { - log.warn("ai-call queue: failed to place call for lead {} (response {}): {}", + log.warn("ai-call queue (legacy): failed to place call for lead {} (response {}): {}", req.getUserId(), req.getResponseId(), e.getMessage()); } if (paceMs > 0) { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/AiCallOutcomeProcessor.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/AiCallOutcomeProcessor.java index e80e9485a9..a530f09258 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/AiCallOutcomeProcessor.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/AiCallOutcomeProcessor.java @@ -12,8 +12,10 @@ import org.springframework.transaction.support.TransactionSynchronizationManager; import vacademy.io.admin_core_service.features.audience.entity.AudienceResponse; import vacademy.io.admin_core_service.features.audience.entity.LeadStatus; +import vacademy.io.admin_core_service.features.audience.entity.UserLeadProfile; import vacademy.io.admin_core_service.features.audience.repository.AudienceResponseRepository; import vacademy.io.admin_core_service.features.audience.repository.LeadStatusRepository; +import vacademy.io.admin_core_service.features.audience.repository.UserLeadProfileRepository; import vacademy.io.admin_core_service.features.audience.service.LeadStatusService; import vacademy.io.admin_core_service.features.audience.service.UserLeadProfileService; import vacademy.io.admin_core_service.features.counselor_pool.service.CounselorAssignmentService; @@ -72,6 +74,7 @@ public class AiCallOutcomeProcessor { private final LeadStatusService leadStatusService; private final CounselorAssignmentService counselorAssignmentService; private final UserLeadProfileService userLeadProfileService; + private final UserLeadProfileRepository userLeadProfileRepository; private final AiCallingSettingsService settingsService; private final AiCallOutcomeClassifier classifier; private final CallLogService callLogService; @@ -730,6 +733,27 @@ private void assignCounsellor(Lead lead) { log.info("ai-call assign: skipped (no audience/user) for lead {}", lead.userId()); return; } + // A lead that ALREADY has a counsellor keeps them. The rotation exists to give an + // UNOWNED lead an owner, not to move one between people. + // + // Before CALL_AI could opt out of the already-assigned guard, this could not happen: + // automation never dialled an owned lead, so every lead reaching this method was + // unowned and the rotation was always the right answer. With ignoreAssignedGuard the + // canonical flow is "counsellor rings the lead, marks it DNP, the bot re-calls" — and + // without this check that flow ENDS by handing their lead to whoever is next in the + // rotation and ringing that person's bell, silently taking it off the counsellor who + // is actively working it. assignCounselor() overwrites assigned_counselor_id with no + // guard of its own (it is also the manual-reassignment path, so it must not grow one). + String currentOwner = userLeadProfileRepository + .findByUserIdAndInstituteId(lead.userId(), lead.instituteId()) + .map(UserLeadProfile::getAssignedCounselorId) + .filter(id -> id != null && !id.isBlank()) + .orElse(null); + if (currentOwner != null) { + log.info("ai-call assign: lead {} already owned by counsellor {} — keeping them (no rotation)", + lead.userId(), currentOwner); + return; + } Optional counselorId = counselorAssignmentService.assignCounselorForLead(lead.audienceId()); if (counselorId.isEmpty()) { log.info("ai-call assign: no counsellor returned (manual/empty pool) for audience {}", lead.audienceId()); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/CallOrchestrator.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/CallOrchestrator.java index 79c3517ecf..bce953f924 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/CallOrchestrator.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/CallOrchestrator.java @@ -18,6 +18,7 @@ import vacademy.io.admin_core_service.features.telephony.spi.dto.ProviderError; import vacademy.io.admin_core_service.features.telephony.spi.dto.ProviderNumberView; import vacademy.io.admin_core_service.features.telephony.spi.dto.SelectionContext; +import vacademy.io.common.tracing.ExternalCallTimer; import vacademy.io.common.auth.model.CustomUserDetails; import vacademy.io.common.exceptions.VacademyException; @@ -70,7 +71,12 @@ public ConnectCallResponseDTO connect(ConnectCallRequestDTO req, CustomUserDetai // ── Phase 2: external HTTP (no DB connection held) ─────────────────── OutboundCallHandle handle; try { - handle = registry.initiator(p.providerType()).initiate(p.bridge(), p.creds()); + // Attributed to `ext`, not to us: this call is the provider dialling a real + // phone and it costs ~2s by nature. Counting it as our latency is what made + // the speed indicator tell counsellors "Vacademy is slow" while the platform + // was serving p50 16ms. See ExternalCallTimer. + handle = ExternalCallTimer.timeChecked( + () -> registry.initiator(p.providerType()).initiate(p.bridge(), p.creds())); } catch (Exception e) { circuitBreaker.recordFailure(p.providerType(), e); tx.markFailedAfterDispatch(p.callLogId(), "provider_initiate_failure"); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/CallingWindowUtil.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/CallingWindowUtil.java new file mode 100644 index 0000000000..4ea16524bd --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/CallingWindowUtil.java @@ -0,0 +1,90 @@ +package vacademy.io.admin_core_service.features.telephony.core; + +import vacademy.io.admin_core_service.features.telephony.core.dto.AiCallingSettingsPojo; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.List; + +/** + * The institute's calling shifts, evaluated in its own timezone. + * + *

Lifted verbatim out of {@code CallAiNodeHandler}, which had these as private + * helpers because it was the only thing that needed them: dialling used to be + * immediate, so only the timed retry re-dialer could ever land outside a shift. + * The AI call queue changes that — an item can wait hours for a slot — so the + * drainer has to make the same judgement, and there must be exactly one + * implementation of "is 21:04 inside 09:00–21:00" in the codebase. + * + *

{@code CallAiNodeHandler} now delegates here; its behaviour is unchanged. + */ +public final class CallingWindowUtil { + + public static final ZoneId IST = ZoneId.of("Asia/Kolkata"); + + private CallingWindowUtil() {} + + /** Inside any [start,end] shift (institute tz); handles windows wrapping midnight. */ + public static boolean withinAnyShift(Instant now, List shifts, ZoneId tz) { + if (shifts == null || shifts.isEmpty()) return true; + LocalTime t = LocalTime.ofInstant(now, tz); + for (AiCallingSettingsPojo.Shift sh : shifts) { + LocalTime start = parseTime(sh.getStart()); + LocalTime end = parseTime(sh.getEnd()); + if (start == null || end == null) continue; + if (start.equals(end)) return true; // 24h + boolean within = start.isBefore(end) + ? (!t.isBefore(start) && !t.isAfter(end)) + : (!t.isBefore(start) || !t.isAfter(end)); + if (within) return true; + } + return false; + } + + /** + * Earliest upcoming shift-open instant in the institute tz: the smallest shift + * start that is still ahead of {@code now} today; if none remain today, the + * smallest shift start tomorrow. Returns null if no usable shift starts (caller + * falls back to its own recheck time). + */ + public static Instant nextShiftOpen(Instant now, List shifts, ZoneId tz) { + if (shifts == null || shifts.isEmpty()) return null; + LocalDate today = LocalDate.now(tz); + LocalTime nowT = LocalTime.ofInstant(now, tz); + + LocalTime earliestToday = null; // smallest start still ahead today + LocalTime earliestOverall = null; // smallest start of the day (for tomorrow) + for (AiCallingSettingsPojo.Shift sh : shifts) { + LocalTime start = parseTime(sh.getStart()); + if (start == null) continue; + if (earliestOverall == null || start.isBefore(earliestOverall)) earliestOverall = start; + if (start.isAfter(nowT) && (earliestToday == null || start.isBefore(earliestToday))) { + earliestToday = start; + } + } + if (earliestToday != null) return today.atTime(earliestToday).atZone(tz).toInstant(); + if (earliestOverall != null) return today.plusDays(1).atTime(earliestOverall).atZone(tz).toInstant(); + return null; + } + + public static LocalTime parseTime(String hhmm) { + if (hhmm == null || hhmm.isBlank()) return null; + try { + return LocalTime.parse(hhmm.trim()); + } catch (DateTimeParseException e) { + return null; + } + } + + public static ZoneId resolveZone(String tz) { + if (tz == null || tz.isBlank()) return IST; + try { + return ZoneId.of(tz.trim()); + } catch (Exception e) { + return IST; + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/dto/AiCallResponseDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/dto/AiCallResponseDTO.java index f2fe9e8c01..e80e6bbdba 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/dto/AiCallResponseDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/core/dto/AiCallResponseDTO.java @@ -4,15 +4,30 @@ import lombok.Data; /** - * Result of placing an Aavtaar AI call. {@code callLogId} is our correlation id + * Result of placing an AI call. {@code callLogId} is our correlation id * (telephony_call_log.id) — the same id Aavtaar echoes back in metadata on the * end-of-call webhook. + * + *

Since the AI call queue, a request may be ACCEPTED without a call having gone out + * yet: the fleet carries a fixed number of simultaneous calls, so a click can land in + * line behind other work. Those responses come back with {@code status = "QUEUED"}, + * {@code dispatched = false}, and the three queue fields below populated. The fields are + * additive — a dial that goes out immediately looks exactly as it always did, with the + * queue fields null. */ @Data @Builder public class AiCallResponseDTO { private String callLogId; private String status; + /** True only when a provider accepted a real dial. A queued call is NOT dispatched. */ private boolean dispatched; private String providerMessage; + + /** Set when the request was queued: the {@code ai_call_queue} row that now owns it. */ + private String queueItemId; + /** Calls ahead of this one in this institute's lane. 0 = next up. */ + private Long queuePosition; + /** Rough wait before this call goes out, in minutes. */ + private Long queueEtaMinutes; } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/enums/CallTrigger.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/enums/CallTrigger.java index fc2c0dcb3a..f9d8f24c4e 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/enums/CallTrigger.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/enums/CallTrigger.java @@ -27,7 +27,21 @@ public enum CallTrigger { * these leads deliberately — but KEEPS the daily cap and the duplicate window, * because a fan-out is exactly what those two are there to bound. */ - BULK_MANUAL; + BULK_MANUAL, + + /** + * A workflow whose CALL_AI node was explicitly authored with + * {@code ignoreAssignedGuard = true}. Same throttle profile as + * {@link #BULK_MANUAL}: the already-assigned guard is skipped because an admin + * deliberately built a graph that targets leads a counsellor already owns (e.g. + * "when a manual call is dispositioned DNP, let the bot try again"), but the + * daily cap and the duplicate window still apply — this path IS automation and + * can loop, so the two throttles that bound fan-out must stay on. + * + *

Distinct from {@link #AUTOMATION} so the skip is visible in logs and can + * never be the default: a node without the flag still comes in as AUTOMATION. + */ + WORKFLOW_EXPLICIT; /** True when the lead already having a counsellor should block the dial. */ public boolean enforcesAssignedLeadGuard() { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/persistence/repository/AiAgentRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/persistence/repository/AiAgentRepository.java index c947b5833a..8e475e11fb 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/persistence/repository/AiAgentRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/persistence/repository/AiAgentRepository.java @@ -13,4 +13,12 @@ public interface AiAgentRepository extends JpaRepository { List findByInstituteIdOrderByCreatedAtDesc(String instituteId); Optional findByIdAndInstituteId(String id, String instituteId); + + /** + * Bulk id -> agent name. Saving an agent auto-registers it as a VACADEMY_AI campaign + * with {@code campaignId = agent id}, so this resolves the agent behind a queued or + * placed call without loading the prompts, send rules and voice config with it. + */ + @org.springframework.data.jpa.repository.Query("SELECT a.id, a.name FROM AiAgent a WHERE a.id IN :ids") + List findIdAndNameByIds(@org.springframework.data.repository.query.Param("ids") java.util.Collection ids); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/persistence/repository/TelephonyCallLogRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/persistence/repository/TelephonyCallLogRepository.java index bee6710c94..b90991b32f 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/persistence/repository/TelephonyCallLogRepository.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/persistence/repository/TelephonyCallLogRepository.java @@ -275,4 +275,51 @@ Optional findAirtelCallForRecording( @Param("since") java.sql.Timestamp since, @Param("until") java.sql.Timestamp until, @Param("anchor") java.sql.Timestamp anchor); + + /** + * Live state of a batch of calls, as {@code [id, status, durationSeconds, toNumber]}. + * + *

The queue's own row stops at DIALED — that means "handed to the provider" and + * never changes again, so a call ringing right now and one that ended three hours ago + * look identical on the queue row alone. This is what tells them apart, resolved in + * one query per page rather than per row. + * + *

{@code toNumber} rides along because a queued row often has no phone of its + * own: the manual click and the CALL_AI node pass only a lead id, and the number is + * resolved downstream at dial time. Without this the queue table would show a raw + * user UUID where the lead's number belongs. + */ + @Query(""" + SELECT t.id, t.status, t.durationSeconds, t.toNumber FROM TelephonyCallLog t + WHERE t.id IN :ids + """) + List findStatusByIds(@Param("ids") java.util.Collection ids); + + /** + * Occupied AI-call slots right now, as {@code [instituteId, providerType, count]}. + * + *

The AI call queue counts capacity from THIS — the call log — rather than from + * a counter it maintains itself. Two reasons. A counter leaks: a call whose final + * webhook never lands would hold its slot forever, and lost AI webhooks are a + * documented failure mode. And a counter is blind: calls placed outside the queue + * (the legacy path under the kill switch, a MOCK, an inbound IVR hand-off to the + * bot) really do occupy the box, and a derived count sees them for free. + * + *

{@code since} is the stuck-call grace — a non-terminal row older than that + * has lost its webhook and stops holding a slot. The terminal statuses are + * enumerated inline rather than negated so this matches the partial index added in + * V472 ({@code idx_tcl_ai_in_flight}). + */ + @Query(value = """ + SELECT t.institute_id, t.provider_type, COUNT(*) + FROM telephony_call_log t + WHERE t.provider_type IN (:providers) + AND t.direction = 'OUTBOUND' + AND t.status IN ('INITIATED', 'QUEUED', 'COUNSELLOR_RINGING', + 'COUNSELLOR_ANSWERED', 'IN_PROGRESS') + AND t.created_at >= :since + GROUP BY t.institute_id, t.provider_type + """, nativeQuery = true) + List countAiCallsInFlight(@Param("providers") List providers, + @Param("since") java.sql.Timestamp since); } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/providers/airtel/AirtelCcrImportScheduler.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/providers/airtel/AirtelCcrImportScheduler.java index 3ebb4ca763..c72282c153 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/providers/airtel/AirtelCcrImportScheduler.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/providers/airtel/AirtelCcrImportScheduler.java @@ -58,7 +58,19 @@ public class AirtelCcrImportScheduler { @Scheduled( fixedDelayString = "${telephony.airtel.import.poll-ms:120000}", initialDelayString = "${telephony.airtel.import.initial-delay-ms:60000}") - @SchedulerLock(name = "AirtelCcrImportScheduler_poll", lockAtMostFor = "PT15M", lockAtLeastFor = "PT30S") + // lockAtLeastFor is just UNDER the 2m poll interval, not a token 30s. ShedLock + // shortens the lease to locked_at + lockAtLeastFor when the run finishes, so a + // short value only prevents CONCURRENT runs, not extra ones: with 4 replicas + // ticking on staggered 2m timers, a 30s floor left the lock free again after + // ~30s and whichever pod ticked next simply re-ran the sweep. Observed in + // production as the lock changing hands every 50-80s across all four pods, + // which held the s3-key probe reduction to 47% instead of the ~75% that + // single-pod execution should give. Holding the lease for almost the whole + // interval makes the sweep genuinely once-per-interval. + // + // Keep this strictly below the poll interval -- at or above it, a tick can find + // the lock still held and skip the cycle entirely. + @SchedulerLock(name = "AirtelCcrImportScheduler_poll", lockAtMostFor = "PT15M", lockAtLeastFor = "PT110S") public void poll() { try { int imported = 0; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/providers/airtel/AirtelImportPromoterScheduler.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/providers/airtel/AirtelImportPromoterScheduler.java index 997ec102a1..636e8a5e7d 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/providers/airtel/AirtelImportPromoterScheduler.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/providers/airtel/AirtelImportPromoterScheduler.java @@ -42,6 +42,12 @@ public class AirtelImportPromoterScheduler { // max-per-run rows), but each row is promoted in its own transaction and a // recording promotion can reach media_service, so 200 rows is not reliably a // sub-2-minute unit of work. + // + // lockAtLeastFor stays at 30s here, unlike the importer. The waste the importer + // needed to suppress was its re-probe of every key in the lookback window; this + // job instead pulls a bounded, indexed batch of RECEIVED rows, so an extra run + // costs one cheap query and drains any backlog sooner. Only concurrency needs + // preventing, which is what the lock already does. @SchedulerLock(name = "AirtelImportPromoterScheduler_poll", lockAtMostFor = "PT15M", lockAtLeastFor = "PT30S") public void poll() { List batch; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallCapacityService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallCapacityService.java new file mode 100644 index 0000000000..c93a61a1a3 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallCapacityService.java @@ -0,0 +1,330 @@ +package vacademy.io.admin_core_service.features.telephony.queue; + +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import vacademy.io.admin_core_service.features.live_session.provider.repository.AppConfigRepository; +import vacademy.io.admin_core_service.features.telephony.enums.ProviderType; +import vacademy.io.admin_core_service.features.telephony.persistence.repository.TelephonyCallLogRepository; +import vacademy.io.admin_core_service.features.telephony.queue.entity.AiCallLane; +import vacademy.io.admin_core_service.features.telephony.queue.entity.AiVoiceBox; +import vacademy.io.admin_core_service.features.telephony.queue.repository.AiCallLaneRepository; +import vacademy.io.admin_core_service.features.telephony.queue.repository.AiCallQueueItemRepository; +import vacademy.io.admin_core_service.features.telephony.queue.repository.AiVoiceBoxRepository; + +import java.sql.Timestamp; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * How many AI calls may be in flight, and how many of them one institute may hold. + * + *

Two numbers, deliberately kept apart: + * + *

    + *
  • Fleet capacity is per provider, because the providers do not share + * hardware. {@code VACADEMY_AI} runs on our own voice boxes, so its ceiling is + * the sum of {@code ai_voice_box.max_concurrent} — that is the "3". {@code + * AAVTAAR} dials on Aavtaar's infrastructure, so counting it against our boxes + * would throttle it for no physical reason; it gets its own configurable number. + * {@code MOCK} never leaves the process and is unlimited.
  • + *
  • Lane capacity is per institute, and it is what makes strict-FIFO + * ordering fair. Without it, the first institute to queue 500 leads owns every + * slot until it finishes.
  • + *
+ * + *

Occupancy is derived from {@code telephony_call_log}, never counted — see + * {@code TelephonyCallLogRepository.countAiCallsInFlight} for why. + */ +@Service +@RequiredArgsConstructor +public class AiCallCapacityService { + + private static final Logger log = LoggerFactory.getLogger(AiCallCapacityService.class); + + /** Providers whose dials are AI-agent calls, and therefore queue-governed. */ + public static final List AI_PROVIDERS = + List.of(ProviderType.VACADEMY_AI, ProviderType.AAVTAAR, ProviderType.MOCK); + + static final String KEY_CAPACITY_ENABLED = "ai_call_capacity_enabled"; + static final String KEY_AAVTAAR_MAX = "ai_call_aavtaar_max_concurrent"; + static final String KEY_STUCK_GRACE = "ai_call_stuck_grace_sec"; + static final String KEY_TTL_HOURS = "ai_call_queue_ttl_hours"; + static final String KEY_AVG_SECS = "ai_call_avg_secs"; + static final String KEY_RESERVED_INTERACTIVE = "ai_call_reserved_interactive"; + static final String KEY_DRAIN_BATCH = "ai_call_drain_batch"; + /** Ops ceiling on simultaneous calls. Caps the box sum; blank = no limit. */ + public static final String KEY_FLEET_LIMIT = "ai_call_fleet_limit"; + + /** + * Effectively unlimited. Used for MOCK, and for AAVTAAR when its limit is set to 0 + * — a real Integer.MAX_VALUE would overflow the moment anything adds to it. + */ + private static final int UNLIMITED = 1_000_000; + + private final AiVoiceBoxRepository boxRepository; + private final AiCallLaneRepository laneRepository; + private final AiCallQueueItemRepository queueRepository; + private final TelephonyCallLogRepository callLogRepository; + private final AppConfigRepository appConfigRepository; + + // ── config reads ──────────────────────────────────────────────────────────── + + public int stuckGraceSeconds() { + return Math.max(60, appConfigRepository.getIntConfig(KEY_STUCK_GRACE, 720)); + } + + public int queueTtlHours() { + return Math.max(1, appConfigRepository.getIntConfig(KEY_TTL_HOURS, 48)); + } + + public int avgCallSeconds() { + return Math.max(30, appConfigRepository.getIntConfig(KEY_AVG_SECS, 180)); + } + + public int reservedInteractiveSlots() { + return Math.max(0, appConfigRepository.getIntConfig(KEY_RESERVED_INTERACTIVE, 0)); + } + + public int drainBatch() { + return Math.max(10, appConfigRepository.getIntConfig(KEY_DRAIN_BATCH, 200)); + } + + /** + * The emergency lever. Stored as a string because {@code app_config} is a string + * table; anything other than a literal "false" leaves the limit ON, so a typo can + * never silently uncap the fleet. + */ + public boolean capacityEnabled() { + return appConfigRepository.findByConfigKey(KEY_CAPACITY_ENABLED) + .map(c -> !"false".equalsIgnoreCase(String.valueOf(c.getConfigValue()).trim())) + .orElse(true); + } + + // ── fleet capacity ────────────────────────────────────────────────────────── + + /** + * Simultaneous calls allowed on a provider. + * + *

A box with health UNKNOWN still counts: a poller that is switched off, or a + * box whose {@code base_url} was never configured, must not take the fleet to zero + * and stop all AI calling. Only a box we asked and that failed to answer is + * excluded. + */ + public int fleetCapacity(String provider) { + if (!capacityEnabled()) return UNLIMITED; + if (ProviderType.MOCK.equalsIgnoreCase(provider)) return UNLIMITED; + if (ProviderType.AAVTAAR.equalsIgnoreCase(provider)) { + int configured = appConfigRepository.getIntConfig(KEY_AAVTAAR_MAX, 20); + return configured <= 0 ? UNLIMITED : configured; + } + int sum = physicalCapacity(); + // Zero boxes (all deleted or all DOWN) means genuinely no capacity, and the + // drainer will correctly dial nothing. Logged because it is indistinguishable + // from a bug when you are staring at a queue that will not move. + if (sum <= 0) { + log.warn("AI call queue: no voice box is lending capacity — {} calls will not dial " + + "until a box is enabled or its health recovers", ProviderType.VACADEMY_AI); + } + // The ops limit CAPS the hardware, never raises it: a limit above what the + // boxes can carry is simply non-binding, so setting one can never promise + // capacity that does not exist. 0 is a real answer — dial nothing — and the + // queue goes on accepting work, so a pause defers calls rather than losing them. + Integer limit = fleetLimit(); + if (limit != null) return Math.max(0, Math.min(sum, limit)); + return Math.max(0, sum); + } + + /** What the hardware can carry, before any ops limit is applied. */ + public int physicalCapacity() { + return boxRepository.findAll().stream() + .filter(AiVoiceBox::countsTowardCapacity) + .mapToInt(AiVoiceBox::getMaxConcurrent) + .sum(); + } + + /** + * The ops ceiling, or null when none is set and the hardware decides. + * + *

Blank rather than a sentinel number means "no limit", so the absence of a + * policy is distinguishable from a policy of zero — which is a real and very + * different instruction. + */ + public Integer fleetLimit() { + return appConfigRepository.findByConfigKey(KEY_FLEET_LIMIT) + .map(c -> { + String raw = c.getConfigValue() == null ? "" : c.getConfigValue().trim(); + if (raw.isEmpty()) return null; + try { + return Integer.valueOf(raw); + } catch (NumberFormatException e) { + // An unparseable limit must not silently uncap the fleet. + log.warn("AI call queue: ai_call_fleet_limit is not a number ({}) — " + + "ignoring it and using the boxes", raw); + return null; + } + }) + .orElse(null); + } + + /** Whether this provider's ceiling is effectively absent (MOCK, or an uncapped Aavtaar). */ + public boolean isUnlimited(int capacity) { + return capacity >= UNLIMITED; + } + + // ── occupancy ─────────────────────────────────────────────────────────────── + + /** + * A snapshot of everything the drainer needs for one tick, read once so a tick is + * internally consistent (and so a 2-second schedule does not re-query per candidate). + */ + public Snapshot snapshot() { + Timestamp since = Timestamp.from(Instant.now().minus(Duration.ofSeconds(stuckGraceSeconds()))); + Map perProvider = new HashMap<>(); + Map perLane = new HashMap<>(); + for (Object[] row : callLogRepository.countAiCallsInFlight(AI_PROVIDERS, since)) { + String instituteId = (String) row[0]; + String provider = (String) row[1]; + int count = ((Number) row[2]).intValue(); + perProvider.merge(provider, count, Integer::sum); + perLane.merge(instituteId, count, Integer::sum); + } + + Map capacityByProvider = new HashMap<>(); + for (String p : AI_PROVIDERS) capacityByProvider.put(p, fleetCapacity(p)); + + // Boxes report their own view of activeCalls. Where that reading is fresh we + // take the LARGER of the two — the box knows about calls we did not place + // (an inbound IVR hand-off to the bot occupies a slot exactly like an outbound + // one), and over-counting costs throughput while under-counting costs a lead a + // spoken "all lines busy". A stale reading is ignored rather than trusted. + Integer boxActive = freshBoxActiveCalls(); + if (boxActive != null) { + perProvider.merge(ProviderType.VACADEMY_AI, boxActive, Math::max); + } + + List lanesWithWork = queueRepository.findInstitutesWithQueuedWork(); + return new Snapshot(perProvider, perLane, capacityByProvider, + lanesWithWork.size(), loadLaneOverrides()); + } + + /** + * Summed {@code activeCalls} across boxes polled within the last two minutes, or + * null when no box has a fresh reading (poller off, URLs unconfigured, network + * down) — in which case the call log is the only authority. + */ + private Integer freshBoxActiveCalls() { + Instant cutoff = Instant.now().minus(Duration.ofMinutes(2)); + int sum = 0; + boolean any = false; + for (AiVoiceBox box : boxRepository.findByEnabledTrue()) { + if (box.getActiveCalls() == null || box.getLastHealthCheck() == null) continue; + if (box.getLastHealthCheck().isBefore(cutoff)) continue; + sum += Math.max(0, box.getActiveCalls()); + any = true; + } + return any ? sum : null; + } + + private Map loadLaneOverrides() { + Map byInstitute = new HashMap<>(); + for (AiCallLane lane : laneRepository.findAll()) { + byInstitute.put(lane.getInstituteId(), lane); + } + return byInstitute; + } + + /** + * One tick's worth of capacity state. Mutable in the in-flight maps: the drainer + * increments them as it dispatches, so a single tick that fills three slots never + * hands out a fourth on stale numbers. + */ + public static final class Snapshot { + private final Map inFlightByProvider; + private final Map inFlightByLane; + private final Map capacityByProvider; + private final int lanesWithWork; + private final Map laneOverrides; + + Snapshot(Map inFlightByProvider, Map inFlightByLane, + Map capacityByProvider, int lanesWithWork, + Map laneOverrides) { + this.inFlightByProvider = inFlightByProvider; + this.inFlightByLane = inFlightByLane; + this.capacityByProvider = capacityByProvider; + this.lanesWithWork = lanesWithWork; + this.laneOverrides = laneOverrides; + } + + public int capacityFor(String provider) { + return capacityByProvider.getOrDefault(provider, 0); + } + + public int inFlightFor(String provider) { + return inFlightByProvider.getOrDefault(provider, 0); + } + + public int inFlightForLane(String instituteId) { + return inFlightByLane.getOrDefault(instituteId, 0); + } + + public int lanesWithWork() { + return lanesWithWork; + } + + public boolean isPaused(String instituteId) { + AiCallLane lane = laneOverrides.get(instituteId); + return lane != null && lane.isPaused(); + } + + /** + * How many simultaneous calls this institute may hold. + * + *

An explicit override wins. Otherwise the default is + * {@code max(1, ceil(fleetCapacity / lanesWithWork))}, which is + * work-conserving at both ends: one institute queuing alone gets the whole + * fleet, three institutes at capacity 3 get one slot each, and the ceiling + * (rather than the floor) of the division means capacity 3 split two ways is + * 2+1 rather than 1+1 with a slot left idle. + */ + public int laneCapacityFor(String instituteId, String provider) { + AiCallLane lane = laneOverrides.get(instituteId); + if (lane != null && lane.getMaxConcurrent() != null && lane.getMaxConcurrent() > 0) { + return lane.getMaxConcurrent(); + } + return defaultLaneCapacity(provider); + } + + /** + * The cap an institute with no override gets: {@code max(1, ceil(fleet / lanes))}. + * The CEILING, not the floor — capacity 3 split two ways is 2+1, so the third + * slot is used, where flooring would leave it idle at 1+1. + */ + public int defaultLaneCapacity(String provider) { + int fleet = capacityFor(provider); + int lanes = Math.max(1, lanesWithWork); + return Math.max(1, (fleet + lanes - 1) / lanes); + } + + /** Book a slot for a dispatch this tick, so later candidates see it. */ + public void reserve(String instituteId, String provider) { + inFlightByProvider.merge(provider, 1, Integer::sum); + inFlightByLane.merge(instituteId, 1, Integer::sum); + } + } + + // ── read models for the APIs ──────────────────────────────────────────────── + + public Optional findLane(String instituteId) { + return laneRepository.findById(instituteId); + } + + public List allBoxes() { + return boxRepository.findAllByOrderByPriorityAscSlugAsc(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueDirectory.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueDirectory.java new file mode 100644 index 0000000000..cf13157d88 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueDirectory.java @@ -0,0 +1,169 @@ +package vacademy.io.admin_core_service.features.telephony.queue; + +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import vacademy.io.admin_core_service.features.institute.repository.InstituteRepository; +import vacademy.io.admin_core_service.features.telephony.core.AiCallingSettingsService; +import vacademy.io.admin_core_service.features.telephony.core.dto.AiCallingSettingsPojo; +import vacademy.io.admin_core_service.features.telephony.enums.CallStatus; +import vacademy.io.admin_core_service.features.telephony.persistence.repository.AiAgentRepository; +import vacademy.io.admin_core_service.features.telephony.persistence.repository.TelephonyCallLogRepository; +import vacademy.io.admin_core_service.features.telephony.queue.entity.AiCallQueueItem; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Turns the ids on a queue row into the names a person reading a dashboard needs: + * which institute, and which AI agent. + * + *

Resolved in BULK for a whole page rather than per row. A queue listing is polled + * every few seconds by an ops screen, and a per-row institute lookup would turn one + * page into fifty queries against a table whose rows carry a large settings blob. + * + *

Agent naming is not a single lookup because the two providers register campaigns + * differently. Saving a {@code VACADEMY_AI} agent auto-registers it as a campaign whose + * id IS the agent id, so {@code ai_agent} answers directly. An {@code AAVTAAR} campaign + * id is the vendor's own, and its human name lives in the institute's AI_CALLING_SETTING + * campaigns registry. Both are tried, and the raw id is the last resort — a dashboard + * showing an opaque id is worse than one showing a name, but far better than one showing + * a blank. + */ +@Service +@RequiredArgsConstructor +public class AiCallQueueDirectory { + + private static final Logger log = LoggerFactory.getLogger(AiCallQueueDirectory.class); + + private final InstituteRepository instituteRepository; + private final AiAgentRepository aiAgentRepository; + private final TelephonyCallLogRepository callLogRepository; + private final AiCallingSettingsService settingsService; + + /** + * Live call state for a page of already-dialled rows: is this call still on a line, + * and for how long? + * + *

Needed because a queue row's own status stops at DIALED. Without this, a queue + * screen cannot distinguish a call in progress from one that finished this morning — + * which is precisely the question someone opens a queue screen to answer. + */ + public Map callStates(Collection callLogIds) { + Map out = new HashMap<>(); + if (callLogIds == null || callLogIds.isEmpty()) return out; + for (Object[] row : callLogRepository.findStatusByIds(callLogIds)) { + String status = (String) row[1]; + Integer duration = row[2] == null ? null : ((Number) row[2]).intValue(); + String toNumber = row.length > 3 ? (String) row[3] : null; + out.put((String) row[0], new CallState(status, duration, + !CallStatus.parseOrDefault(status).isTerminal(), toNumber)); + } + return out; + } + + /** + * What a dialled call is doing right now, plus the number it actually reached — + * which is often the only place the lead's phone exists, since a queued row + * frequently carries only a lead id. + */ + public record CallState(String status, Integer durationSeconds, boolean live, + String toNumber) {} + + /** Names for one page of rows, resolved in as few queries as the page allows. */ + public Names forItems(Collection items) { + Set instituteIds = new HashSet<>(); + Set campaignIds = new HashSet<>(); + for (AiCallQueueItem item : items) { + if (notBlank(item.getInstituteId())) instituteIds.add(item.getInstituteId()); + if (notBlank(item.getCampaignId())) campaignIds.add(item.getCampaignId()); + } + Map institutes = instituteNames(instituteIds); + Map agents = agentNames(campaignIds); + + // Only reach for an institute's settings when a campaign id is STILL unnamed + // after ai_agent — i.e. an Aavtaar campaign. Most deployments never pay for this. + Set unresolvedInstitutes = new HashSet<>(); + for (AiCallQueueItem item : items) { + String campaignId = item.getCampaignId(); + if (notBlank(campaignId) && !agents.containsKey(campaignId) + && !notBlank(item.getCampaignName()) && notBlank(item.getInstituteId())) { + unresolvedInstitutes.add(item.getInstituteId()); + } + } + for (String instituteId : unresolvedInstitutes) { + try { + AiCallingSettingsPojo settings = settingsService.get(instituteId); + if (settings.getCampaigns() == null) continue; + for (AiCallingSettingsPojo.CampaignConfig campaign : settings.getCampaigns()) { + if (notBlank(campaign.getCampaignId()) && notBlank(campaign.getName())) { + agents.putIfAbsent(campaign.getCampaignId(), campaign.getName()); + } + } + } catch (Exception e) { + // One institute with an unparseable setting_json must not blank out the + // names on every other row of the page. + log.debug("ai-call queue: could not read AI settings for institute {} while " + + "naming agents: {}", instituteId, e.getMessage()); + } + } + return new Names(institutes, agents); + } + + public Map instituteNames(Collection instituteIds) { + Map out = new HashMap<>(); + if (instituteIds == null || instituteIds.isEmpty()) return out; + for (Object[] row : instituteRepository.findIdAndNameByIds(instituteIds)) { + if (row[0] != null) out.put((String) row[0], (String) row[1]); + } + return out; + } + + private Map agentNames(Collection campaignIds) { + Map out = new HashMap<>(); + if (campaignIds == null || campaignIds.isEmpty()) return out; + List rows = aiAgentRepository.findIdAndNameByIds(campaignIds); + for (Object[] row : rows) { + if (row[0] != null && row[1] != null) out.put((String) row[0], (String) row[1]); + } + return out; + } + + /** Resolved names for one page. */ + public static final class Names { + private final Map institutes; + private final Map agents; + + Names(Map institutes, Map agents) { + this.institutes = institutes; + this.agents = agents; + } + + public String instituteName(String instituteId) { + return institutes.get(instituteId); + } + + /** + * The agent as a person would name it: the name the caller already carried, else + * the registered agent/campaign name, else the raw campaign id so the row is + * still identifiable. + */ + public String agentName(String campaignName, String campaignId) { + if (notBlank(campaignName)) return campaignName; + if (notBlank(campaignId)) { + String resolved = agents.get(campaignId); + return notBlank(resolved) ? resolved : campaignId; + } + return null; + } + } + + private static boolean notBlank(String s) { + return s != null && !s.isBlank(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueDrainJob.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueDrainJob.java new file mode 100644 index 0000000000..8d3b229ddf --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueDrainJob.java @@ -0,0 +1,471 @@ +package vacademy.io.admin_core_service.features.telephony.queue; + +import lombok.RequiredArgsConstructor; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import vacademy.io.admin_core_service.features.telephony.core.AiCallService; +import vacademy.io.admin_core_service.features.telephony.core.AiCallingSettingsService; +import vacademy.io.admin_core_service.features.telephony.core.CallingWindowUtil; +import vacademy.io.admin_core_service.features.telephony.core.dto.AiCallRequestDTO; +import vacademy.io.admin_core_service.features.telephony.core.dto.AiCallResponseDTO; +import vacademy.io.admin_core_service.features.telephony.core.dto.AiCallingSettingsPojo; +import vacademy.io.admin_core_service.features.telephony.enums.CallStatus; +import vacademy.io.admin_core_service.features.telephony.enums.CallTrigger; +import vacademy.io.admin_core_service.features.telephony.enums.ProviderType; +import vacademy.io.admin_core_service.features.telephony.queue.entity.AiCallQueueItem; +import vacademy.io.admin_core_service.features.telephony.queue.repository.AiCallLaneRepository; +import vacademy.io.admin_core_service.features.telephony.queue.repository.AiCallQueueItemRepository; +import vacademy.io.common.exceptions.ConflictException; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * The only thing in the platform that places an AI call. + * + *

Every producer — the CALL_AI workflow node, a bulk campaign, a counsellor's click + * — writes a row to {@code ai_call_queue}. This job is what turns those rows into + * dials, and because it is the single dialler the fleet-wide concurrency limit is + * exact: there is no distributed counting to get wrong, no per-replica share to + * rebalance when the deployment scales. + * + *

Why one drainer is safe with 2-4 replicas

+ * {@code @SchedulerLock} means only one pod runs a tick. That lock can still lapse + * ({@code lockAtMostFor}) and let two ticks overlap, so send-once does NOT rest on it: + * every dispatch first wins a conditional {@code QUEUED -> DISPATCHING} update, and + * only the winner dials. + * + *

Order

+ * Strict FIFO on {@code (priority DESC, created_at)}. The scan SKIPS an item whose + * institute is already at its lane cap, which is what stops one institute's 500-lead + * upload from owning every slot — a latecomer with five leads takes the next free line + * instead of waiting out the backlog. See {@code AiCallCapacityService} for the cap. + * + *

Guards

+ * The job calls {@code AiCallService.placeCall} unmodified, so every pre-dial guard + * (credits, daily cap, already-assigned, deleted lead, duplicate window) is + * re-evaluated against the world at DIAL time rather than at enqueue time. That + * matters: an item can wait hours, during which the lead may be assigned to a human or + * the institute may run out of credits. + */ +@Component +@RequiredArgsConstructor +public class AiCallQueueDrainJob { + + private static final Logger log = LoggerFactory.getLogger(AiCallQueueDrainJob.class); + + /** + * Substrings of the two {@code ConflictException} messages {@code placeCall} + * throws, which need opposite handling: an out-of-credits institute should keep its + * queue and retry later, a deleted lead must never be dialled again. + * + *

Matching on message text is a coupling, so the DEFAULT is the safe one — an + * unrecognised conflict re-queues with backoff rather than destroying the call. + * Only an explicit "deleted" match cancels. + */ + private static final String CONFLICT_LEAD_DELETED = "deleted"; + + /** Statuses {@code placeCall} returns with {@code dispatched=false}. */ + private static final String SKIPPED_ASSIGNED = "SKIPPED_ASSIGNED"; + private static final String SKIPPED_DAILY_CAP = "SKIPPED_DAILY_CAP"; + private static final String SKIPPED_DUPLICATE = "SKIPPED_DUPLICATE"; + + /** Give up on an item after this many dial attempts. */ + private static final int MAX_ATTEMPTS = 3; + + /** A claim older than this belongs to a drainer that died mid-tick. */ + private static final Duration CLAIM_GRACE = Duration.ofMinutes(5); + + /** Backoff after a transient failure, indexed by attempt number. */ + private static final Duration[] RETRY_BACKOFF = { + Duration.ofMinutes(1), Duration.ofMinutes(5), Duration.ofMinutes(15) + }; + + /** How long an institute that just hit its daily cap is left alone. */ + private static final Duration DAILY_CAP_BACKOFF = Duration.ofHours(1); + + /** How long an institute with no credits is left alone. */ + private static final Duration NO_CREDITS_BACKOFF = Duration.ofMinutes(15); + + private final AiCallQueueItemRepository repository; + private final AiCallLaneRepository laneRepository; + private final AiCallCapacityService capacityService; + private final AiCallQueueService queueService; + private final AiCallingSettingsService settingsService; + + /** + * {@code @Lazy} for the same reason {@code CallAiNodeHandler} needs it: the AI call + * path reaches the workflow engine (a dial resumes paused runs through the outcome + * processor), and a scheduled bean wired eagerly into that graph reintroduces the + * startup cycle that {@code @Autowired @Lazy} exists to break here. + */ + @Autowired + @Lazy + private AiCallService aiCallService; + + @Scheduled(fixedDelayString = "${telephony.ai.queue.drain-delay-ms:2000}") + @SchedulerLock(name = "AiCallQueueDrain", lockAtMostFor = "PT5M", lockAtLeastFor = "PT1S") + public void drain() { + try { + drainOnce(); + } catch (Exception e) { + // A scheduled method that throws is silently dropped by Spring's scheduler + // and, worse, keeps its schedule — so a recurring failure would look exactly + // like an idle queue. Log it loudly instead. + log.error("ai-call queue: drain tick failed", e); + } + } + + private void drainOnce() { + Instant now = Instant.now(); + + int expired = repository.expireOverdue(now); + if (expired > 0) { + log.info("ai-call queue: expired {} item(s) that waited past their time limit", expired); + } + int released = repository.releaseStuckClaims(now.minus(CLAIM_GRACE)); + if (released > 0) { + log.warn("ai-call queue: released {} claim(s) left behind by an interrupted drain", released); + } + + AiCallCapacityService.Snapshot snap = capacityService.snapshot(); + int reserved = capacityService.reservedInteractiveSlots(); + + // Enough rows per lane that a lane could fill the whole fleet on its own, and no + // more — the point of the per-lane fetch is coverage of every waiting institute, + // not depth within one. Combined with the LATERAL this keeps the candidate set at + // roughly (lanes x fleet capacity) rows however deep the queue gets, so a + // two-second tick over a 5,000-item backlog still reads a handful of rows. + int perLane = Math.max(1, Math.min(50, snap.capacityFor(ProviderType.VACADEMY_AI))); + List candidates = + repository.findDrainCandidates(now, perLane, capacityService.drainBatch()); + if (candidates.isEmpty()) return; + + // Institutes taken out of play for the rest of this tick: paused, out of + // credits, or at their daily cap. Without this, an institute with 400 queued + // items would re-run the same failing guard 400 times per tick. + Set blockedInstitutes = new HashSet<>(); + Map settingsCache = new HashMap<>(); + + int dialled = 0, skipped = 0; + for (AiCallQueueItem item : candidates) { + String instituteId = item.getInstituteId(); + String provider = item.getProvider(); + + if (blockedInstitutes.contains(instituteId)) continue; + if (snap.isPaused(instituteId)) { + blockedInstitutes.add(instituteId); + continue; + } + + CallTrigger trigger = parseTrigger(item.getCallTrigger()); + + // Fleet capacity for THIS provider. Reserved slots are held back from + // automation only, so a human's click can still get a line when the fleet is + // otherwise full. Defaults to 0 reserved, i.e. manual queues like everything + // else, which is the configured behaviour. + int providerCapacity = snap.capacityFor(provider); + if (trigger != CallTrigger.MANUAL) providerCapacity = Math.max(0, providerCapacity - reserved); + if (snap.inFlightFor(provider) >= providerCapacity) { + skipped++; + continue; + } + + // The lane cap. THIS is the fairness mechanism: FIFO order is preserved, but + // an institute already holding its share is stepped over so the next + // institute in line gets the slot. + int laneCapacity = snap.laneCapacityFor(instituteId, provider); + if (snap.inFlightForLane(instituteId) >= laneCapacity) { + skipped++; + continue; + } + + // Calling window, re-checked at dispatch — the settings may have changed, or + // the shift may simply have closed while this item waited. MANUAL is exempt: + // it never had a window before the queue existed and must not gain one. + if (trigger != CallTrigger.MANUAL) { + AiCallingSettingsPojo settings = settingsCache.computeIfAbsent( + instituteId, settingsService::get); + ZoneId tz = CallingWindowUtil.resolveZone(settings.getTimezone()); + if (!CallingWindowUtil.withinAnyShift(now, settings.getCallingShifts(), tz)) { + Instant nextOpen = CallingWindowUtil.nextShiftOpen( + now, settings.getCallingShifts(), tz); + // Lane-wide: the whole institute is out of hours, not just this call. + repository.deferLane(instituteId, + nextOpen != null ? nextOpen : now.plus(Duration.ofMinutes(30)), + "Outside this institute's calling hours."); + blockedInstitutes.add(instituteId); + continue; + } + } + + // Send-once. Losing this race is normal under an overlapping tick. + if (repository.claimForDispatch(item.getId()) == 0) continue; + // The claim incremented attempts in the DATABASE; mirror it onto the detached + // copy we are holding. Without this every later save() writes the pre-claim + // value back and the counter never advances — an item that keeps failing + // would retry for ever instead of giving up after MAX_ATTEMPTS. + item.setAttempts(item.getAttempts() + 1); + + DispatchOutcome outcome = dispatch(item, trigger, false); + if (outcome == DispatchOutcome.DIALLED) { + snap.reserve(instituteId, provider); + laneRepository.touchDispatched(instituteId, Instant.now()); + dialled++; + } else { + if (outcome == DispatchOutcome.INSTITUTE_BLOCKED) blockedInstitutes.add(instituteId); + skipped++; + } + } + + if (dialled > 0 || !blockedInstitutes.isEmpty()) { + log.info("ai-call queue: dialled {}, skipped {} of {} candidate(s); {} lane(s) with work, " + + "{} institute(s) held back this tick", + dialled, skipped, candidates.size(), snap.lanesWithWork(), blockedInstitutes.size()); + } + } + + /** + * Dial one queued call RIGHT NOW if a line is genuinely free, instead of waiting for + * the next tick. + * + *

This exists for the manual click. Queuing it is correct — a counsellor takes + * their turn like everyone else — but when the fleet is idle "their turn" is two + * seconds away, and returning {@code QUEUED} for a call that rings immediately + * afterwards makes working calling look broken. So: if this item is at the head of + * its own lane and there is a free slot, it goes out on the request thread and the + * caller gets the same answer they have always got. + * + *

It cannot jump the line. {@code countAheadInLane == 0} means nothing in this + * institute's lane is waiting; the lane cap still applies, so a busy institute's + * click queues normally. And the claim is the same CAS the drainer uses, so this and + * a concurrent tick can never both dial the item. + * + *

Two drainers on two replicas can still both read a free slot in the same + * instant and briefly put the fleet one call over capacity. That is bounded by how + * fast humans click, and the alternative — locking the fleet on every click — costs + * more than the occasional extra call. + * + * @return the dial result when a call actually went out, else empty (the item stays + * queued and the drainer will take it). + */ + public Optional dispatchNowIfLineFree(String queueItemId) { + AiCallQueueItem item = repository.findById(queueItemId).orElse(null); + if (item == null || !AiCallQueueStatus.QUEUED.name().equals(item.getStatus())) { + return Optional.empty(); + } + Instant now = Instant.now(); + if (item.getNotBefore() != null && item.getNotBefore().isAfter(now)) { + return Optional.empty(); + } + + AiCallCapacityService.Snapshot snap = capacityService.snapshot(); + String instituteId = item.getInstituteId(); + String provider = item.getProvider(); + if (snap.isPaused(instituteId)) return Optional.empty(); + if (snap.inFlightFor(provider) >= snap.capacityFor(provider)) return Optional.empty(); + if (snap.inFlightForLane(instituteId) >= snap.laneCapacityFor(instituteId, provider)) { + return Optional.empty(); + } + // Strict FIFO still holds: only the head of the lane may take the fast path. + if (repository.countAheadInLane(instituteId, item.getPriority(), item.getCreatedAt()) > 0) { + return Optional.empty(); + } + + if (repository.claimForDispatch(item.getId()) == 0) return Optional.empty(); + item.setAttempts(item.getAttempts() + 1); + + DispatchOutcome outcome = dispatch(item, parseTrigger(item.getCallTrigger()), true); + if (outcome != DispatchOutcome.DIALLED) return Optional.empty(); + + laneRepository.touchDispatched(instituteId, Instant.now()); + return Optional.of(AiCallResponseDTO.builder() + .callLogId(item.getCallLogId()) + .status(CallStatus.QUEUED.name()) + .dispatched(true) + .providerMessage("Calling now.") + .queueItemId(item.getId()) + .queuePosition(0L) + .queueEtaMinutes(0L) + .build()); + } + + private enum DispatchOutcome { + /** A call went out and is occupying a slot. */ + DIALLED, + /** This item is done or re-queued; other items for the institute may still dial. */ + ITEM_HANDLED, + /** The whole institute is out of play for this tick (credits, daily cap). */ + INSTITUTE_BLOCKED + } + + /** + * Place one queued call through the normal {@code AiCallService} path, and record + * what came back. Nothing here re-implements a guard — the point of routing through + * {@code placeCall} is that the queue inherits all of them, evaluated now. + */ + private DispatchOutcome dispatch(AiCallQueueItem item, CallTrigger trigger, + boolean surfaceConflicts) { + AiCallRequestDTO req = toRequest(item); + try { + AiCallResponseDTO resp = aiCallService.placeCall(req, item.getActorUserId(), trigger); + if (resp == null) { + return failOrRetry(item, "The dialler returned no result."); + } + if (resp.isDispatched()) { + item.setStatus(AiCallQueueStatus.DIALED.name()); + item.setCallLogId(resp.getCallLogId()); + item.setDispatchedAt(Instant.now()); + item.setStatusReason(null); + repository.save(item); + return DispatchOutcome.DIALLED; + } + + String status = resp.getStatus() == null ? "" : resp.getStatus(); + switch (status) { + case SKIPPED_ASSIGNED -> { + // The lead picked up a counsellor while this sat in the queue. The + // bot's job ends once a human owns the lead, so this is a clean + // cancel, not a failure. + finish(item, AiCallQueueStatus.CANCELLED, + "A counsellor took this lead over while the call was queued."); + return DispatchOutcome.ITEM_HANDLED; + } + case SKIPPED_DUPLICATE -> { + finish(item, AiCallQueueStatus.CANCELLED, + "This lead was called by another path moments ago."); + return DispatchOutcome.ITEM_HANDLED; + } + case SKIPPED_DAILY_CAP -> { + // Institute-wide, so hold the whole lane rather than burning through + // its remaining items one refused dial at a time. + String reason = "This institute has hit its daily AI-call limit."; + Instant until = Instant.now().plus(DAILY_CAP_BACKOFF); + deferItem(item, until, reason); + repository.deferLane(item.getInstituteId(), until, reason); + return DispatchOutcome.INSTITUTE_BLOCKED; + } + default -> { + // Includes a provider rejection (FAILED with a call-log row). + return failOrRetry(item, resp.getProviderMessage() == null + ? "The provider refused the call." : resp.getProviderMessage()); + } + } + } catch (ConflictException e) { + String message = e.getMessage() == null ? "" : e.getMessage(); + DispatchOutcome outcome; + if (message.toLowerCase().contains(CONFLICT_LEAD_DELETED)) { + finish(item, AiCallQueueStatus.CANCELLED, "This lead was deleted."); + outcome = DispatchOutcome.ITEM_HANDLED; + } else { + // Everything else — chiefly credit exhaustion — is a condition that + // clears on its own AND applies to the whole institute. Hold the lane and + // try again later rather than throwing away calls an admin can rescue + // with a top-up. + Instant until = Instant.now().plus(NO_CREDITS_BACKOFF); + deferItem(item, until, message); + repository.deferLane(item.getInstituteId(), until, truncate(message)); + outcome = DispatchOutcome.INSTITUTE_BLOCKED; + } + // A person is waiting on the other end of the interactive path, so "you are + // out of credits" and "this lead was deleted" must reach them as the errors + // they always were rather than becoming a silent deferral. The queue state + // above is recorded either way. + if (surfaceConflicts) throw e; + return outcome; + } catch (Exception e) { + log.warn("ai-call queue: dial failed for item {} (lead {}): {}", + item.getId(), item.getUserId(), e.getMessage()); + return failOrRetry(item, e.getMessage()); + } + } + + /** + * Re-queue with backoff, or give up. {@code attempts} was already incremented by the + * claim, so it counts dials tried rather than dials planned. + */ + private DispatchOutcome failOrRetry(AiCallQueueItem item, String error) { + item.setLastError(truncate(error)); + if (item.getAttempts() >= MAX_ATTEMPTS) { + finish(item, AiCallQueueStatus.FAILED, + "Could not be placed after " + item.getAttempts() + " attempts."); + return DispatchOutcome.ITEM_HANDLED; + } + Duration backoff = RETRY_BACKOFF[Math.min(RETRY_BACKOFF.length - 1, + Math.max(0, item.getAttempts() - 1))]; + item.setStatus(AiCallQueueStatus.QUEUED.name()); + item.setNotBefore(Instant.now().plus(backoff)); + item.setStatusReason("Retrying after a failed attempt."); + repository.save(item); + return DispatchOutcome.ITEM_HANDLED; + } + + /** + * Put a CLAIMED item back in the queue, eligible again at {@code notBefore}. + * + *

The attempt the claim charged is handed back: being out of credits or behind a + * daily cap is not a failed dial, and must not eat an item's retry budget — a lead + * queued overnight would otherwise exhaust itself before dawn. + * + *

Callers pair this with {@code deferLane} when the condition is institute-wide, + * so the rest of the backlog moves with it. + */ + private void deferItem(AiCallQueueItem item, Instant notBefore, String reason) { + if (item.getAttempts() > 0) item.setAttempts(item.getAttempts() - 1); + item.setStatus(AiCallQueueStatus.QUEUED.name()); + item.setNotBefore(notBefore); + item.setStatusReason(truncate(reason)); + repository.save(item); + } + + private void finish(AiCallQueueItem item, AiCallQueueStatus status, String reason) { + item.setStatus(status.name()); + item.setStatusReason(truncate(reason)); + repository.save(item); + } + + private AiCallRequestDTO toRequest(AiCallQueueItem item) { + AiCallRequestDTO req = new AiCallRequestDTO(); + req.setInstituteId(item.getInstituteId()); + req.setProvider(item.getProvider()); + req.setUserId(item.getUserId()); + req.setPhoneNumber(item.getPhoneNumber()); + req.setResponseId(item.getResponseId()); + req.setCampaignId(item.getCampaignId()); + req.setCampaignName(item.getCampaignName()); + req.setPreferredNumberId(item.getPreferredNumberId()); + req.setSubjectType(item.getSubjectType()); + req.setSubjectId(item.getSubjectId()); + req.setCustomerName(item.getCustomerName()); + req.setCustomerEmail(item.getCustomerEmail()); + req.setMetadata(queueService.readMetadata(item.getMetadata())); + return req; + } + + private static CallTrigger parseTrigger(String value) { + if (value == null) return CallTrigger.AUTOMATION; + try { + return CallTrigger.valueOf(value); + } catch (IllegalArgumentException e) { + return CallTrigger.AUTOMATION; + } + } + + /** status_reason / last_error are bounded columns; a provider stack trace is not. */ + private static String truncate(String s) { + if (s == null) return null; + return s.length() <= 240 ? s : s.substring(0, 240); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueService.java new file mode 100644 index 0000000000..a7ea9c14da --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueService.java @@ -0,0 +1,600 @@ +package vacademy.io.admin_core_service.features.telephony.queue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.telephony.core.AiCallingSettingsService; +import vacademy.io.admin_core_service.features.telephony.core.CallingWindowUtil; +import vacademy.io.admin_core_service.features.telephony.core.dto.AiCallRequestDTO; +import vacademy.io.admin_core_service.features.telephony.core.dto.AiCallingSettingsPojo; +import vacademy.io.admin_core_service.features.telephony.enums.CallTrigger; +import vacademy.io.admin_core_service.features.telephony.enums.ProviderType; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.*; +import vacademy.io.admin_core_service.features.telephony.queue.entity.AiCallLane; +import vacademy.io.admin_core_service.features.telephony.queue.entity.AiCallQueueItem; +import vacademy.io.admin_core_service.features.telephony.queue.repository.AiCallLaneRepository; +import vacademy.io.admin_core_service.features.telephony.queue.repository.AiCallQueueItemRepository; +import vacademy.io.common.exceptions.VacademyException; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * The queue's front door: everything that puts an AI call INTO the queue, and + * everything that reads it back out for a human. + * + *

Nothing here dials. {@link AiCallQueueDrainJob} is the only dialler, which is what + * makes the fleet-wide concurrency limit exact. + * + *

Sources

+ *
    + *
  • {@code WORKFLOW} — the CALL_AI node, via {@code AiCallNodeDispatcher}
  • + *
  • {@code BULK} — a bulk campaign over an audience
  • + *
  • {@code MANUAL} — a counsellor's click-to-AI-call
  • + *
+ * + *

Calling windows

+ * A queued item can wait hours, so the institute's calling shifts — which previously + * only gated the timed retry re-dialer, since every other path dialled instantly — now + * gate the queue too. MANUAL is the deliberate exception: a person pressing Call at + * 21:30 is making a decision, and refusing it would be a behaviour change on the one + * path that never had a window. The drainer re-checks the window at dispatch, so this + * enqueue-time stamp is only a head start. + */ +@Service +@RequiredArgsConstructor +public class AiCallQueueService { + + private static final Logger log = LoggerFactory.getLogger(AiCallQueueService.class); + + public static final String SOURCE_WORKFLOW = "WORKFLOW"; + public static final String SOURCE_BULK = "BULK"; + public static final String SOURCE_MANUAL = "MANUAL"; + + /** Rows per insert transaction for a bulk enqueue. */ + private static final int CHUNK = 100; + + /** + * Pseudo-status for "on a line right now". Not a real {@link AiCallQueueStatus} — + * the queue row stops at DIALED, so liveness is a join against the call log. + */ + public static final String LIVE_FILTER = "LIVE"; + + /** Waiting plus already dialling — the default view, and what "the queue" means. */ + public static final String ACTIVE_FILTER = "ACTIVE"; + + /** Explicit "show me everything, finished included". */ + public static final String ALL_FILTER = "ALL"; + + private final AiCallQueueItemRepository repository; + private final AiCallLaneRepository laneRepository; + private final AiCallQueueTxOps txOps; + private final AiCallQueueDirectory directory; + private final AiCallCapacityService capacityService; + private final AiCallingSettingsService settingsService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + // ── enqueue ───────────────────────────────────────────────────────────────── + + /** + * Queue one AI call. + * + *

Idempotent per lead: if this lead already has an undialled item on this + * provider, the existing item is returned with {@code queued=false} rather than a + * second one being created. That is load-bearing for workflows — the engine resumes + * a run by RESTARTING it, so a CALL_AI node can re-enter many times for the same + * lead before its first call ever goes out. + */ + public EnqueueResult enqueue(AiCallRequestDTO req, CallTrigger trigger, + String source, String sourceRef, String actorUserId) { + if (req == null || isBlank(req.getInstituteId())) { + throw new VacademyException("instituteId is required to queue an AI call."); + } + AiCallingSettingsPojo settings = settingsService.get(req.getInstituteId()); + String provider = resolveProvider(req, settings); + String subjectKey = subjectKey(req); + String dedupeKey = dedupeKey(req.getInstituteId(), provider, subjectKey); + + Optional pending = repository.findPendingByDedupeKey(dedupeKey); + if (pending.isPresent()) { + AiCallQueueItem existing = pending.get(); + return describe(existing, false, + "This lead already has an AI call waiting for a line."); + } + + AiCallQueueItem item = buildItem(req, settings, provider, trigger, source, sourceRef, + actorUserId, dedupeKey); + AiCallQueueItem saved = txOps.insertOne(item); + if (saved == null) { + // Lost the unique-index race — someone queued the same lead microseconds + // ago. Report THEIR item; the caller's intent is satisfied either way. + return repository.findPendingByDedupeKey(dedupeKey) + .map(existing -> describe(existing, false, + "This lead already has an AI call waiting for a line.")) + .orElseGet(() -> EnqueueResult.builder() + .queued(false) + .message("This lead already has an AI call waiting for a line.") + .build()); + } + return describe(saved, true, "Queued for the next free line."); + } + + /** + * Queue many AI calls at once (a bulk campaign). + * + *

Leads that already hold an undialled item are dropped up front from a single + * query rather than probed one at a time — a 500-lead campaign runs on the request + * thread and must return promptly. + * + * @return the number of items actually queued. + */ + public int enqueueBatch(String instituteId, List requests, + CallTrigger trigger, String source, String sourceRef, + String actorUserId) { + if (requests == null || requests.isEmpty()) return 0; + AiCallingSettingsPojo settings = settingsService.get(instituteId); + Set alreadyPending = new HashSet<>(repository.findPendingDedupeKeys(instituteId)); + + List toInsert = new ArrayList<>(requests.size()); + // Also de-duplicates WITHIN this batch: an audience holding the same lead twice + // would otherwise submit two rows and have the second rejected by the index. + Set seen = new HashSet<>(); + for (AiCallRequestDTO req : requests) { + String provider = resolveProvider(req, settings); + String dedupeKey = dedupeKey(instituteId, provider, subjectKey(req)); + if (alreadyPending.contains(dedupeKey) || !seen.add(dedupeKey)) continue; + toInsert.add(buildItem(req, settings, provider, trigger, source, sourceRef, + actorUserId, dedupeKey)); + } + if (toInsert.isEmpty()) return 0; + + int inserted = 0; + for (int i = 0; i < toInsert.size(); i += CHUNK) { + List chunk = toInsert.subList(i, Math.min(toInsert.size(), i + CHUNK)); + List ok = txOps.insertChunk(chunk); + if (ok != null) { + inserted += ok.size(); + continue; + } + // The chunk lost a race. Retry its rows individually so one contended lead + // cannot cost the rest of the campaign its calls. + for (AiCallQueueItem item : chunk) { + if (txOps.insertOne(item) != null) inserted++; + } + } + log.info("ai-call queue: enqueued {} of {} requested item(s) for institute {} (source={} ref={})", + inserted, requests.size(), instituteId, source, sourceRef); + return inserted; + } + + private AiCallQueueItem buildItem(AiCallRequestDTO req, AiCallingSettingsPojo settings, + String provider, CallTrigger trigger, String source, + String sourceRef, String actorUserId, String dedupeKey) { + Instant now = Instant.now(); + return AiCallQueueItem.builder() + .instituteId(req.getInstituteId()) + .provider(provider) + .priority(100) + .source(source) + .sourceRef(sourceRef) + .callTrigger((trigger == null ? CallTrigger.AUTOMATION : trigger).name()) + .responseId(req.getResponseId()) + .userId(req.getUserId()) + .phoneNumber(req.getPhoneNumber()) + .campaignId(req.getCampaignId()) + .campaignName(req.getCampaignName()) + .preferredNumberId(req.getPreferredNumberId()) + .subjectType(req.getSubjectType()) + .subjectId(req.getSubjectId()) + .customerName(req.getCustomerName()) + .customerEmail(req.getCustomerEmail()) + .metadata(writeMetadata(req.getMetadata())) + .actorUserId(actorUserId) + .dedupeKey(dedupeKey) + .status(AiCallQueueStatus.QUEUED.name()) + .attempts(0) + .notBefore(initialNotBefore(settings, trigger, now)) + .expiresAt(now.plus(Duration.ofHours(capacityService.queueTtlHours()))) + .createdAt(now) + .build(); + } + + /** + * When this item first becomes eligible. Null (= immediately) inside the calling + * window, and for MANUAL regardless — see the class note on windows. + */ + private Instant initialNotBefore(AiCallingSettingsPojo settings, CallTrigger trigger, Instant now) { + if (trigger == CallTrigger.MANUAL) return null; + ZoneId tz = CallingWindowUtil.resolveZone(settings.getTimezone()); + if (CallingWindowUtil.withinAnyShift(now, settings.getCallingShifts(), tz)) return null; + return CallingWindowUtil.nextShiftOpen(now, settings.getCallingShifts(), tz); + } + + private String resolveProvider(AiCallRequestDTO req, AiCallingSettingsPojo settings) { + String provider = isBlank(req.getProvider()) ? settings.getProvider() : req.getProvider(); + // Mirrors AiCallService.placeCall's own fallback, so the provider this item is + // accounted against is the provider it will actually dial on. + return isBlank(provider) ? ProviderType.AAVTAAR : provider; + } + + /** + * What "the same call" means for de-duplication: one undialled AI call per lead per + * provider. Matches {@code AiCallService}'s own 30-second duplicate window, which + * keys on institute + user + provider. + */ + private String subjectKey(AiCallRequestDTO req) { + String key = firstNonBlank(req.getUserId(), req.getSubjectId(), req.getResponseId(), + req.getPhoneNumber()); + return key == null ? "unknown" : key; + } + + private String dedupeKey(String instituteId, String provider, String subjectKey) { + return instituteId + "|" + provider + "|" + subjectKey; + } + + private String writeMetadata(Map metadata) { + if (metadata == null || metadata.isEmpty()) return null; + try { + return objectMapper.writeValueAsString(metadata); + } catch (Exception e) { + log.warn("ai-call queue: could not serialise call metadata — dropping it: {}", e.getMessage()); + return null; + } + } + + /** Replay the stored metadata blob. Package-private: the drainer is the only reader. */ + @SuppressWarnings("unchecked") + Map readMetadata(String json) { + if (json == null || json.isBlank()) return null; + try { + return objectMapper.readValue(json, Map.class); + } catch (Exception e) { + log.warn("ai-call queue: unreadable metadata on a queued item — dialing without it: {}", + e.getMessage()); + return null; + } + } + + // ── reads ─────────────────────────────────────────────────────────────────── + + private EnqueueResult describe(AiCallQueueItem item, boolean queued, String message) { + long ahead = repository.countAheadInLane(item.getInstituteId(), item.getPriority(), + item.getCreatedAt()); + long eta = etaMinutes(item.getInstituteId(), item.getProvider(), ahead); + return EnqueueResult.builder() + .queueItemId(item.getId()) + .queued(queued) + .aheadInLane(ahead) + .etaMinutes(eta) + .message(message + (ahead > 0 + ? " " + (ahead + 1) + " in line, roughly " + eta + " min." + : " Next up.")) + .build(); + } + + /** + * Rough wait for an item with {@code ahead} items in front of it in its own lane. + * + *

Deliberately computed from the LANE, not the whole queue: the per-lane + * concurrency cap means an institute drains at its own rate no matter how much + * other institutes have queued. Honest to within the accuracy of the assumed call + * length, which is what an admin needs to decide whether to wait or cancel. + */ + public long etaMinutes(String instituteId, String provider, long ahead) { + return etaMinutes(capacityService.snapshot(), instituteId, provider, ahead); + } + + /** As above, against a snapshot the caller already holds (one per page, not one per row). */ + public long etaMinutes(AiCallCapacityService.Snapshot snap, String instituteId, + String provider, long ahead) { + if (ahead <= 0) return 0; + int laneSlots = Math.max(1, snap.laneCapacityFor(instituteId, provider)); + double batches = Math.ceil((double) ahead / laneSlots); + return Math.max(1, Math.round(batches * capacityService.avgCallSeconds() / 60.0)); + } + + public QueueSummary summary(String instituteId) { + AiCallCapacityService.Snapshot snap = capacityService.snapshot(); + AiCallingSettingsPojo settings = settingsService.get(instituteId); + String provider = isBlank(settings.getProvider()) ? ProviderType.AAVTAAR : settings.getProvider(); + + long queued = repository.countQueuedForInstitute(instituteId); + Map byStatus = new LinkedHashMap<>(); + for (Object[] row : repository.countByInstituteGroupedByStatus(instituteId)) { + byStatus.put((String) row[0], ((Number) row[1]).longValue()); + } + return QueueSummary.builder() + .instituteId(instituteId) + .queued(queued) + .inFlight(snap.inFlightForLane(instituteId)) + .paused(snap.isPaused(instituteId)) + // The lane capacity still drives this number, it is just never handed + // out on its own -- a wait in minutes says what an institute needs to + // know without disclosing the size of the pool behind it. + .etaMinutes(etaMinutes(snap, instituteId, provider, queued)) + .byStatus(byStatus) + .build(); + } + + /** Position lookups are capped: past this depth an item's place in line is not shown. */ + private static final int POSITION_LOOKUP_DEPTH = 5000; + + public Page list(String instituteId, String status, int page, int size) { + PageRequest pageable = PageRequest.of(Math.max(0, page), Math.min(200, Math.max(1, size))); + Page rows; + if (LIVE_FILTER.equalsIgnoreCase(status)) { + rows = repository.findLive(instituteId, pageable); + } else if (isBlank(status) || ACTIVE_FILTER.equalsIgnoreCase(status)) { + // Blank means ACTIVE, not "everything": the queue page's job is what has + // not finished, and defaulting to the full history buries it. + rows = repository.findActive(instituteId, pageable); + } else if (ALL_FILTER.equalsIgnoreCase(status)) { + rows = repository.findByInstituteIdOrderByCreatedAtDesc(instituteId, pageable); + } else { + rows = repository.findByInstituteIdAndStatusOrderByCreatedAtDesc( + instituteId, status.toUpperCase(), pageable); + } + + // One snapshot and one ordered id list for the whole page — see + // findQueuedIdsInDispatchOrder for why this is not a per-row count. + AiCallCapacityService.Snapshot snap = capacityService.snapshot(); + Map positions = new HashMap<>(); + List ordered = repository.findQueuedIdsInDispatchOrder( + instituteId, PageRequest.of(0, POSITION_LOOKUP_DEPTH)); + for (int i = 0; i < ordered.size(); i++) positions.put(ordered.get(i), i); + + AiCallQueueDirectory.Names names = directory.forItems(rows.getContent()); + Map callStates = callStatesFor(rows.getContent()); + return rows.map(item -> toView(item, snap, positions, names, callStates)); + } + + /** One live-state lookup for a whole page — see AiCallQueueDirectory.callStates. */ + private Map callStatesFor(List items) { + Set ids = new HashSet<>(); + for (AiCallQueueItem item : items) { + if (item.getCallLogId() != null) ids.add(item.getCallLogId()); + } + return directory.callStates(ids); + } + + /** + * Cross-institute listing for the internal dashboard. + * + *

Defaults to what is WAITING, in the order it will dial — that is the question a + * queue screen exists to answer. Pass an explicit status (or {@code ALL}) to look at + * history instead, which is then ordered newest-first. + * + *

Positions are per-lane, so an item can read "2nd in line" while sitting far down + * a cross-institute page: the lane, not the global list, is what governs its wait. + */ + public Page search(String instituteId, String status, String provider, + String source, int page, int size) { + return search(instituteId, status, provider, source, page, size, capacityService.snapshot()); + } + + /** As above, against a snapshot the caller already holds — see the ops snapshot. */ + public Page search(String instituteId, String status, String provider, + String source, int page, int size, + AiCallCapacityService.Snapshot snap) { + PageRequest pageable = PageRequest.of(Math.max(0, page), Math.min(200, Math.max(1, size))); + boolean waitingOnly = isBlank(status) + || AiCallQueueStatus.QUEUED.name().equalsIgnoreCase(status); + String statusFilter = isBlank(status) + ? AiCallQueueStatus.QUEUED.name() + : ("ALL".equalsIgnoreCase(status) ? null : status.toUpperCase()); + + Page rows; + if (LIVE_FILTER.equalsIgnoreCase(status)) { + rows = repository.findLive(blankToNull(instituteId), pageable); + } else if (waitingOnly) { + rows = repository.searchInLineOrder(blankToNull(instituteId), statusFilter, + blankToNull(provider), blankToNull(source), pageable); + } else { + rows = repository.searchByRecency(blankToNull(instituteId), statusFilter, + blankToNull(provider), blankToNull(source), pageable); + } + + AiCallQueueDirectory.Names names = directory.forItems(rows.getContent()); + + // Line positions are per lane, so build one ordered id list per institute ON the + // page rather than one global list — a cross-institute page can span many lanes. + Map positions = new HashMap<>(); + Set lanesOnPage = new HashSet<>(); + for (AiCallQueueItem item : rows.getContent()) { + if (AiCallQueueStatus.QUEUED.name().equals(item.getStatus())) { + lanesOnPage.add(item.getInstituteId()); + } + } + for (String lane : lanesOnPage) { + List ordered = repository.findQueuedIdsInDispatchOrder( + lane, PageRequest.of(0, POSITION_LOOKUP_DEPTH)); + for (int i = 0; i < ordered.size(); i++) positions.put(ordered.get(i), i); + } + Map callStates = callStatesFor(rows.getContent()); + return rows.map(item -> toView(item, snap, positions, names, callStates)); + } + + private QueueItemView toView(AiCallQueueItem item, AiCallCapacityService.Snapshot snap, + Map positions, + AiCallQueueDirectory.Names names, + Map callStates) { + AiCallQueueDirectory.CallState call = item.getCallLogId() == null + ? null : callStates.get(item.getCallLogId()); + Long ahead = null; + Long eta = null; + Integer index = positions.get(item.getId()); + if (index != null) { + ahead = (long) index; + eta = etaMinutes(snap, item.getInstituteId(), item.getProvider(), ahead); + } + return QueueItemView.builder() + .id(item.getId()) + .instituteId(item.getInstituteId()) + .instituteName(names.instituteName(item.getInstituteId())) + .agentName(names.agentName(item.getCampaignName(), item.getCampaignId())) + .provider(item.getProvider()) + .source(item.getSource()) + .callTrigger(item.getCallTrigger()) + .priority(item.getPriority()) + .sourceRef(item.getSourceRef()) + .status(item.getStatus()) + .statusReason(item.getStatusReason()) + .responseId(item.getResponseId()) + .userId(item.getUserId()) + // A queued row often has no phone of its own — the number is resolved + // downstream at dial time — so fall back to what was actually dialled + // rather than leaving the column showing a raw id. + .phoneNumber(item.getPhoneNumber() != null ? item.getPhoneNumber() + : (call == null ? null : call.toNumber())) + .campaignId(item.getCampaignId()) + .campaignName(item.getCampaignName()) + .attempts(item.getAttempts()) + .notBefore(str(item.getNotBefore())) + .expiresAt(str(item.getExpiresAt())) + .callLogId(item.getCallLogId()) + .dispatchedAt(str(item.getDispatchedAt())) + .createdAt(str(item.getCreatedAt())) + .aheadInLane(ahead) + .etaMinutes(eta) + .callStatus(call == null ? null : call.status()) + .callDurationSeconds(call == null ? null : call.durationSeconds()) + .live(call != null && call.live()) + .build(); + } + + /** Queue-side counts for one bulk run, for the campaign progress dialog. */ + public Map bulkRunCounts(String instituteId, String audienceId) { + Map out = new LinkedHashMap<>(); + for (Object[] row : repository.countBySourceRefGroupedByStatus( + instituteId, SOURCE_BULK, audienceId)) { + out.put((String) row[0], ((Number) row[1]).longValue()); + } + return out; + } + + // ── cancel ────────────────────────────────────────────────────────────────── + + @Transactional + public int cancelForInstitute(String instituteId, String sourceRef, String reason) { + int n = repository.cancelQueued(instituteId, sourceRef, + isBlank(reason) ? "Cancelled by an administrator." : reason); + log.info("ai-call queue: cancelled {} queued item(s) for institute {}{}", + n, instituteId, sourceRef == null ? "" : " (run " + sourceRef + ")"); + return n; + } + + @Transactional + public boolean cancelOne(String instituteId, String id, String reason) { + return repository.cancelOne(id, instituteId, + isBlank(reason) ? "Cancelled by an administrator." : reason) > 0; + } + + // ── lane administration ───────────────────────────────────────────────────── + + public LaneView laneView(String instituteId) { + return laneView(instituteId, capacityService.snapshot(), + directory.instituteNames(List.of(instituteId)), + java.util.Map.of()); + } + + /** As above, against lookups the caller already holds — see {@link #allLanes()}. */ + private LaneView laneView(String instituteId, AiCallCapacityService.Snapshot snap, + Map instituteNames, + Map oldestQueued) { + AiCallingSettingsPojo settings = settingsService.get(instituteId); + String provider = isBlank(settings.getProvider()) ? ProviderType.AAVTAAR : settings.getProvider(); + AiCallLane lane = laneRepository.findById(instituteId).orElse(null); + long queued = repository.countQueuedForInstitute(instituteId); + return LaneView.builder() + .instituteId(instituteId) + .instituteName(instituteNames.get(instituteId)) + .maxConcurrent(lane == null ? null : lane.getMaxConcurrent()) + .effectiveMaxConcurrent(snap.laneCapacityFor(instituteId, provider)) + .weight(lane == null ? 1 : lane.getWeight()) + .paused(lane != null && lane.isPaused()) + .queued(queued) + .inFlight(snap.inFlightForLane(instituteId)) + .etaMinutes(etaMinutes(snap, instituteId, provider, queued)) + .oldestQueuedAt(str(oldestQueued.get(instituteId))) + .lastDispatchedAt(lane == null ? null : str(lane.getLastDispatchedAt())) + .build(); + } + + @Transactional + public LaneView upsertLane(String instituteId, LaneUpsertRequest body) { + AiCallLane lane = laneRepository.findById(instituteId) + .orElseGet(() -> AiCallLane.builder().instituteId(instituteId).weight(1).build()); + if (body != null) { + // A null maxConcurrent is meaningful — it CLEARS the override and returns the + // institute to the dynamic default — so it is applied unconditionally rather + // than skipped as "not supplied". + lane.setMaxConcurrent(body.getMaxConcurrent() != null && body.getMaxConcurrent() > 0 + ? body.getMaxConcurrent() : null); + if (body.getWeight() != null && body.getWeight() > 0) lane.setWeight(body.getWeight()); + if (body.getPaused() != null) lane.setPaused(body.getPaused()); + } + laneRepository.save(lane); + return laneView(instituteId); + } + + public List allLanes() { + return allLanes(capacityService.snapshot()); + } + + /** As above, against a snapshot the caller already holds. */ + public List allLanes(AiCallCapacityService.Snapshot snap) { + List out = new ArrayList<>(); + Set institutes = new java.util.LinkedHashSet<>(); + for (Object[] row : repository.countQueuedByInstitute()) institutes.add((String) row[0]); + for (AiCallLane lane : laneRepository.findAll()) institutes.add(lane.getInstituteId()); + // The snapshot is passed in, not taken per lane: the dynamic cap is derived from + // live occupancy, so lanes read at different instants could disagree about what + // the same fleet allows. + Map names = directory.instituteNames(institutes); + Map oldest = new HashMap<>(); + for (Object[] row : repository.findOldestQueuedPerInstitute()) { + oldest.put((String) row[0], (Instant) row[1]); + } + for (String instituteId : institutes) { + out.add(laneView(instituteId, snap, names, oldest)); + } + return out; + } + + + // ── helpers ───────────────────────────────────────────────────────────────── + + private static String str(Instant instant) { + return instant == null ? null : instant.toString(); + } + + private static String firstNonBlank(String... values) { + for (String v : values) { + if (v != null && !v.isBlank()) return v; + } + return null; + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + /** Optional filters arrive as empty strings from query params; the SQL wants NULL. */ + private static String blankToNull(String s) { + return isBlank(s) ? null : s.trim(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueSnapshotService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueSnapshotService.java new file mode 100644 index 0000000000..4ca17d2c5e --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueSnapshotService.java @@ -0,0 +1,77 @@ +package vacademy.io.admin_core_service.features.telephony.queue; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.QueueItemView; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.QueueSnapshot; +import vacademy.io.admin_core_service.features.telephony.queue.repository.AiCallQueueItemRepository; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Assembles the entire queue — capacity, lanes and the waiting calls — into one + * payload, from ONE capacity snapshot. + * + *

That single snapshot is the whole point of this class. Capacity, each lane's share + * of the fleet, and each item's position are all derived from live occupancy, so reading + * them through three separate calls produces a payload whose parts describe three + * different instants: a dashboard can then show "3 of 3 lines busy" beside a lane + * claiming a free slot, and the resulting bug report is about arithmetic rather than + * about the queue. Taking the snapshot once and passing it down makes that impossible. + * + *

Serves both the super-admin screen and the machine-to-machine ops feed, so the two + * can never drift into disagreeing about the same fleet. + */ +@Service +@RequiredArgsConstructor +public class AiCallQueueSnapshotService { + + /** Hard ceiling on rows returned, however large a limit is asked for. */ + private static final int MAX_ITEMS = 200; + + private final AiCallCapacityService capacityService; + private final AiCallQueueService queueService; + private final AiVoiceBoxService boxService; + private final AiCallQueueItemRepository repository; + + /** + * @param itemLimit how many waiting calls to include. 0 returns capacity + lanes + * only, which is what a landing view needs — the item list is the + * expensive half and most screens page it separately. + * @param instituteId optional filter for the waiting list; lanes and capacity stay + * fleet-wide either way, because an institute's share only means + * anything against the whole fleet. + */ + public QueueSnapshot snapshot(int itemLimit, String instituteId) { + AiCallCapacityService.Snapshot snap = capacityService.snapshot(); + + List waiting = List.of(); + if (itemLimit > 0) { + Page page = queueService.search( + instituteId, AiCallQueueStatus.QUEUED.name(), null, null, + 0, Math.min(MAX_ITEMS, itemLimit), snap); + waiting = page.getContent(); + } + + Map totals = new LinkedHashMap<>(); + for (Object[] row : repository.countAllGroupedByStatus()) { + totals.put((String) row[0], ((Number) row[1]).longValue()); + } + + return QueueSnapshot.builder() + .generatedAt(Instant.now().toString()) + .capacity(boxService.capacity(snap)) + .lanes(queueService.allLanes(snap)) + .waiting(waiting) + // Read from the status totals rather than the page: the page is capped, + // and a UI that infers the backlog from a truncated list under-reports it + // by exactly the amount that matters most. + .waitingTotal(totals.getOrDefault(AiCallQueueStatus.QUEUED.name(), 0L)) + .totalsByStatus(totals) + .build(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueStatus.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueStatus.java new file mode 100644 index 0000000000..1a9298ff2d --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueStatus.java @@ -0,0 +1,51 @@ +package vacademy.io.admin_core_service.features.telephony.queue; + +/** + * Lifecycle of one queued AI call. + * + *

{@link #QUEUED} and {@link #DISPATCHING} are the two "pending" states — the + * partial unique index on {@code dedupe_key} covers exactly those, so a lead can hold + * at most one undialled item at a time while a legitimate later retry (after the + * first has gone out) still enqueues. + */ +public enum AiCallQueueStatus { + + /** Waiting for a slot. The only state the drain scan considers. */ + QUEUED, + + /** Claimed by the drainer, provider call in flight. Transient (sub-second). */ + DISPATCHING, + + /** The provider accepted the dial; {@code call_log_id} points at the call. */ + DIALED, + + /** The dial was attempted and refused/errored past the retry budget. */ + FAILED, + + /** Sat in the queue past its TTL without ever getting a slot. */ + EXPIRED, + + /** + * Deliberately dropped without dialling: an admin cancelled it, or a pre-dial + * guard said this call must not happen (lead deleted, lead already assigned to a + * counsellor while the item waited). + */ + CANCELLED; + + public boolean isPending() { + return this == QUEUED || this == DISPATCHING; + } + + public boolean isTerminal() { + return !isPending(); + } + + public static AiCallQueueStatus parseOrDefault(String s) { + if (s == null) return QUEUED; + try { + return valueOf(s); + } catch (IllegalArgumentException e) { + return QUEUED; + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueTxOps.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueTxOps.java new file mode 100644 index 0000000000..feed186a86 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiCallQueueTxOps.java @@ -0,0 +1,68 @@ +package vacademy.io.admin_core_service.features.telephony.queue; + +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.telephony.queue.entity.AiCallQueueItem; +import vacademy.io.admin_core_service.features.telephony.queue.repository.AiCallQueueItemRepository; + +import java.util.List; + +/** + * Queue writes that must succeed or fail on their OWN transaction. + * + *

The reason this is a separate bean rather than a few methods on + * {@link AiCallQueueService}: enqueue is called from inside the workflow engine's + * transaction, and the {@code ux_ai_call_queue_pending} unique index can legitimately + * reject an insert (two replicas resuming the same workflow run in the same instant). + * A constraint violation marks the CURRENT transaction rollback-only — so without + * {@code REQUIRES_NEW} here, one de-duplicated AI call would roll back the entire + * workflow step that asked for it. Spring proxies only cross-bean calls, so the + * boundary has to live in a different class; {@code RecordingTxOps} and + * {@code CallLifecycleTxOps} are here for the same reason. + * + *

Both methods below are entry points called from {@link AiCallQueueService} — the + * chunk loop and the row-by-row fallback are orchestrated there, precisely so every + * transaction boundary is a real proxied call rather than a self-invocation that would + * silently share the failed transaction. + */ +@Component +@RequiredArgsConstructor +public class AiCallQueueTxOps { + + private final AiCallQueueItemRepository repository; + + /** + * Insert one item on its own transaction. + * + * @return the saved item, or {@code null} when the unique index rejected it + * because an undialled item for this lead already exists. That is not an + * error: the call the caller wanted IS queued, just not by them. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public AiCallQueueItem insertOne(AiCallQueueItem item) { + try { + return repository.saveAndFlush(item); + } catch (DataIntegrityViolationException e) { + return null; + } + } + + /** + * Insert a chunk on its own transaction, all-or-nothing. + * + * @return the inserted rows, or {@code null} when the chunk hit the unique index — + * the caller then retries those rows one at a time, so that with 500 leads + * a single already-queued lead cannot cost the other 499 their calls. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public List insertChunk(List chunk) { + try { + return repository.saveAllAndFlush(chunk); + } catch (DataIntegrityViolationException e) { + return null; + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiVoiceBoxHealthPoller.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiVoiceBoxHealthPoller.java new file mode 100644 index 0000000000..a7d969f687 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiVoiceBoxHealthPoller.java @@ -0,0 +1,105 @@ +package vacademy.io.admin_core_service.features.telephony.queue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import vacademy.io.admin_core_service.features.telephony.queue.entity.AiVoiceBox; +import vacademy.io.admin_core_service.features.telephony.queue.repository.AiVoiceBoxRepository; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; + +/** + * Asks each voice box how it is and how many calls it is carrying. + * + *

Two things come out of this, and only two: + *

    + *
  • A box that fails to answer is marked DOWN and stops lending its + * {@code max_concurrent} to the fleet — so the queue holds calls rather than + * dialling into a dead machine and serving leads a spoken "all lines busy".
  • + *
  • Its {@code activeCalls} reading gives the capacity service a second opinion on + * occupancy. The box counts calls we did not place (an inbound IVR hand-off to + * the bot occupies a line exactly like an outbound campaign call), so where the + * reading is fresh the drainer takes the larger of the two numbers.
  • + *
+ * + *

A box with no reachable {@code base_url} — including the seeded placeholder — is + * skipped entirely and stays UNKNOWN, which still counts toward capacity. That is + * deliberate: an unconfigured poller must not be able to switch AI calling off. + */ +@Component +@RequiredArgsConstructor +public class AiVoiceBoxHealthPoller { + + private static final Logger log = LoggerFactory.getLogger(AiVoiceBoxHealthPoller.class); + + private static final String HEALTH_PATH = "/voice-bot-service/health"; + + private final AiVoiceBoxRepository repository; + private final ObjectMapper mapper = new ObjectMapper(); + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .build(); + + @Scheduled(fixedDelayString = "${telephony.ai.queue.health-poll-ms:30000}") + @SchedulerLock(name = "AiVoiceBoxHealthPoll", lockAtMostFor = "PT2M", lockAtLeastFor = "PT5S") + public void poll() { + for (AiVoiceBox box : repository.findByEnabledTrue()) { + if (!box.isPollable()) continue; + try { + pollOne(box); + } catch (Exception e) { + markDown(box, e.getMessage()); + } + } + } + + private void pollOne(AiVoiceBox box) throws Exception { + String url = box.getBaseUrl().trim().replaceAll("/$", "") + HEALTH_PATH; + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(5)) + .GET() + .build(); + HttpResponse res = http.send(req, HttpResponse.BodyHandlers.ofString()); + if (res.statusCode() < 200 || res.statusCode() >= 300) { + markDown(box, "HTTP " + res.statusCode()); + return; + } + JsonNode body = mapper.readTree(res.body() == null ? "{}" : res.body()); + Integer activeCalls = body.hasNonNull("activeCalls") ? body.get("activeCalls").asInt() : null; + + boolean recovered = AiVoiceBox.HEALTH_DOWN.equals(box.getHealthStatus()); + box.setHealthStatus(AiVoiceBox.HEALTH_HEALTHY); + box.setActiveCalls(activeCalls); + box.setLastHealthCheck(Instant.now()); + repository.save(box); + if (recovered) { + log.info("ai voice box {} is back — {} slot(s) returned to the fleet", + box.getSlug(), box.getMaxConcurrent()); + } + } + + private void markDown(AiVoiceBox box, String reason) { + boolean wasUp = !AiVoiceBox.HEALTH_DOWN.equals(box.getHealthStatus()); + box.setHealthStatus(AiVoiceBox.HEALTH_DOWN); + // The stale reading is cleared rather than kept: an unreachable box's last known + // occupancy is not evidence of anything, and leaving it would let a dead box go + // on suppressing dials it can no longer carry. + box.setActiveCalls(null); + box.setLastHealthCheck(Instant.now()); + repository.save(box); + if (wasUp) { + log.warn("ai voice box {} is not answering ({}) — its {} slot(s) are out of the fleet", + box.getSlug(), reason, box.getMaxConcurrent()); + } + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiVoiceBoxService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiVoiceBoxService.java new file mode 100644 index 0000000000..c60873aa21 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/AiVoiceBoxService.java @@ -0,0 +1,219 @@ +package vacademy.io.admin_core_service.features.telephony.queue; + +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.live_session.provider.entity.AppConfig; +import vacademy.io.admin_core_service.features.live_session.provider.repository.AppConfigRepository; +import vacademy.io.admin_core_service.features.telephony.enums.ProviderType; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.BoxUpsertRequest; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.BoxView; +import vacademy.io.admin_core_service.features.telephony.queue.dto.AiCallQueueDTOs.CapacityView; +import vacademy.io.admin_core_service.features.telephony.queue.entity.AiVoiceBox; +import vacademy.io.admin_core_service.features.telephony.queue.repository.AiCallQueueItemRepository; +import vacademy.io.admin_core_service.features.telephony.queue.repository.AiVoiceBoxRepository; +import vacademy.io.common.exceptions.VacademyException; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * Managing the capacity pool: the boxes themselves and the handful of runtime knobs + * that sit beside them in {@code app_config}. + * + *

Capacity is server-wide, so everything here is a super-admin action — one + * institute must not be able to change how many lines the whole fleet has, nor how + * many of them it is allowed to hold. + */ +@Service +@RequiredArgsConstructor +public class AiVoiceBoxService { + + private static final Logger log = LoggerFactory.getLogger(AiVoiceBoxService.class); + + private final AiVoiceBoxRepository boxRepository; + private final AiCallQueueItemRepository queueRepository; + private final AiCallCapacityService capacityService; + private final AppConfigRepository appConfigRepository; + + // ── boxes ─────────────────────────────────────────────────────────────────── + + public List listBoxes() { + List out = new ArrayList<>(); + for (AiVoiceBox box : boxRepository.findAllByOrderByPriorityAscSlugAsc()) out.add(toView(box)); + return out; + } + + @Transactional + public BoxView upsertBox(String id, BoxUpsertRequest body) { + if (body == null) throw new VacademyException("A box definition is required."); + AiVoiceBox box; + if (id == null || id.isBlank()) { + if (body.getSlug() == null || body.getSlug().isBlank()) { + throw new VacademyException("A box needs a slug (e.g. \"mumbai-2\")."); + } + if (boxRepository.existsBySlug(body.getSlug().trim())) { + throw new VacademyException("A voice box with that slug already exists."); + } + box = AiVoiceBox.builder() + .slug(body.getSlug().trim()) + .baseUrl(AiVoiceBox.UNCONFIGURED_URL) + .maxConcurrent(3) + .priority(1) + .enabled(true) + .healthStatus(AiVoiceBox.HEALTH_UNKNOWN) + .build(); + } else { + box = boxRepository.findById(id) + .orElseThrow(() -> new VacademyException("No such voice box.")); + } + + if (body.getBaseUrl() != null && !body.getBaseUrl().isBlank()) { + box.setBaseUrl(body.getBaseUrl().trim().replaceAll("/$", "")); + // A URL change invalidates what we know about the box: the next poll decides. + box.setHealthStatus(AiVoiceBox.HEALTH_UNKNOWN); + box.setActiveCalls(null); + box.setLastHealthCheck(null); + } + if (body.getMaxConcurrent() != null) { + if (body.getMaxConcurrent() < 0) { + throw new VacademyException("A box cannot carry a negative number of calls."); + } + box.setMaxConcurrent(body.getMaxConcurrent()); + } + if (body.getPriority() != null) box.setPriority(body.getPriority()); + if (body.getEnabled() != null) box.setEnabled(body.getEnabled()); + if (body.getNotes() != null) box.setNotes(body.getNotes()); + + boxRepository.save(box); + log.info("ai voice box saved: slug={} maxConcurrent={} enabled={} — fleet capacity is now {}", + box.getSlug(), box.getMaxConcurrent(), box.isEnabled(), + capacityService.fleetCapacity(ProviderType.VACADEMY_AI)); + return toView(box); + } + + @Transactional + public void deleteBox(String id) { + AiVoiceBox box = boxRepository.findById(id) + .orElseThrow(() -> new VacademyException("No such voice box.")); + boxRepository.delete(box); + log.warn("ai voice box deleted: slug={} — fleet capacity is now {}", + box.getSlug(), capacityService.fleetCapacity(ProviderType.VACADEMY_AI)); + } + + private BoxView toView(AiVoiceBox box) { + return BoxView.builder() + .id(box.getId()) + .slug(box.getSlug()) + .baseUrl(box.getBaseUrl()) + .maxConcurrent(box.getMaxConcurrent()) + .priority(box.getPriority()) + .enabled(box.isEnabled()) + .healthStatus(box.getHealthStatus()) + .activeCalls(box.getActiveCalls()) + .lastHealthCheck(box.getLastHealthCheck() == null ? null + : box.getLastHealthCheck().toString()) + .notes(box.getNotes()) + .countsTowardCapacity(box.countsTowardCapacity()) + .build(); + } + + // ── fleet view ────────────────────────────────────────────────────────────── + + public CapacityView capacity() { + return capacity(capacityService.snapshot()); + } + + /** As above, against a snapshot the caller already holds. */ + public CapacityView capacity(AiCallCapacityService.Snapshot snap) { + int lanes = snap.lanesWithWork(); + int vacademyCapacity = snap.capacityFor(ProviderType.VACADEMY_AI); + return CapacityView.builder() + .vacademyAiCapacity(vacademyCapacity) + .vacademyAiInFlight(snap.inFlightFor(ProviderType.VACADEMY_AI)) + .aavtaarCapacity(snap.capacityFor(ProviderType.AAVTAAR)) + .aavtaarInFlight(snap.inFlightFor(ProviderType.AAVTAAR)) + .physicalCapacity(capacityService.physicalCapacity()) + .fleetLimit(capacityService.fleetLimit()) + .concurrencyLimitBypassed(!capacityService.capacityEnabled()) + .totalQueued(queueRepository.countQueuedTotal()) + .lanesWithWork(lanes) + // Shown so the number an institute is actually subject to is visible + // without opening its lane: with no override this IS its ceiling. + .dynamicLaneCapacity(snap.defaultLaneCapacity(ProviderType.VACADEMY_AI)) + .avgCallSeconds(capacityService.avgCallSeconds()) + .reservedInteractiveSlots(capacityService.reservedInteractiveSlots()) + .boxes(listBoxes()) + .build(); + } + + /** + * Set (or clear) the ops ceiling on simultaneous AI calls. + * + *

A purpose-built endpoint rather than another key on {@link #updateSetting} + * because this is the one capacity control an operator reaches for under pressure, + * and it deserves validation and a straight answer instead of a generic + * string-valued key/value write. + * + * @param maxConcurrentCalls the ceiling; {@code null} clears it and returns the + * fleet to whatever the hardware provides; {@code 0} pauses dialing (the + * queue keeps accepting, so nothing is lost). + */ + @Transactional + public CapacityView setFleetLimit(Integer maxConcurrentCalls) { + if (maxConcurrentCalls != null && maxConcurrentCalls < 0) { + throw new VacademyException("A call limit cannot be negative. Use 0 to pause dialing."); + } + String value = maxConcurrentCalls == null ? "" : String.valueOf(maxConcurrentCalls); + AppConfig config = appConfigRepository.findByConfigKey(AiCallCapacityService.KEY_FLEET_LIMIT) + .orElseGet(() -> AppConfig.builder() + .configKey(AiCallCapacityService.KEY_FLEET_LIMIT).build()); + config.setConfigValue(value); + config.setUpdatedAt(new Date()); + appConfigRepository.save(config); + + CapacityView after = capacity(); + // WARN, not INFO: this changes how hard we drive hardware that live callers are + // talking to, and it is the first thing anyone will look for afterwards. + log.warn("AI call fleet limit set to {} — hardware can carry {}, now enforcing {}", + maxConcurrentCalls == null ? "no limit" : maxConcurrentCalls, + after.getPhysicalCapacity(), after.getVacademyAiCapacity()); + return after; + } + + // ── runtime knobs ─────────────────────────────────────────────────────────── + + /** + * The keys this endpoint will write. An allow-list, not a pass-through: {@code + * app_config} is shared with unrelated features (the BBB server pool reads it too), + * and a telephony endpoint has no business writing their keys. + */ + private static final List WRITABLE_KEYS = List.of( + AiCallCapacityService.KEY_CAPACITY_ENABLED, + AiCallCapacityService.KEY_AAVTAAR_MAX, + AiCallCapacityService.KEY_STUCK_GRACE, + AiCallCapacityService.KEY_TTL_HOURS, + AiCallCapacityService.KEY_AVG_SECS, + AiCallCapacityService.KEY_RESERVED_INTERACTIVE, + AiCallCapacityService.KEY_DRAIN_BATCH); + + @Transactional + public CapacityView updateSetting(String key, String value) { + if (key == null || !WRITABLE_KEYS.contains(key)) { + throw new VacademyException("Not an AI call queue setting: " + key); + } + if (value == null || value.isBlank()) { + throw new VacademyException("A value is required."); + } + AppConfig config = appConfigRepository.findByConfigKey(key) + .orElseGet(() -> AppConfig.builder().configKey(key).build()); + config.setConfigValue(value.trim()); + config.setUpdatedAt(new Date()); + appConfigRepository.save(config); + log.warn("ai call queue setting changed: {} = {}", key, value.trim()); + return capacity(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/dto/AiCallQueueDTOs.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/dto/AiCallQueueDTOs.java new file mode 100644 index 0000000000..b4147cb0af --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/dto/AiCallQueueDTOs.java @@ -0,0 +1,225 @@ +package vacademy.io.admin_core_service.features.telephony.queue.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * Wire shapes for the AI call queue APIs, kept together because they are small and + * only ever used as a set. + */ +public final class AiCallQueueDTOs { + + private AiCallQueueDTOs() {} + + /** What an enqueue produced — returned by the manual-call and bulk-campaign paths. */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class EnqueueResult { + private String queueItemId; + /** True when this call created a new queue item; false when one was already pending. */ + private boolean queued; + /** Items ahead of this one in its own institute's lane (1-based position = ahead + 1). */ + private long aheadInLane; + /** Rough wait, from the lane's effective slot count and the assumed call length. */ + private long etaMinutes; + private String message; + } + + /** One row of the queue view. */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class QueueItemView { + private String id; + private String instituteId; + /** Display name of the institute, for the cross-institute dashboard. */ + private String instituteName; + /** The AI agent as a person names it, resolved from campaignId. */ + private String agentName; + private String provider; + private String source; + /** Which throttle profile this call carries: MANUAL, AUTOMATION, BULK_MANUAL... */ + private String callTrigger; + private int priority; + private String sourceRef; + private String status; + private String statusReason; + private String responseId; + private String userId; + private String phoneNumber; + private String campaignId; + private String campaignName; + private int attempts; + private String notBefore; + private String expiresAt; + private String callLogId; + private String dispatchedAt; + private String createdAt; + /** Only populated for QUEUED rows. */ + private Long aheadInLane; + private Long etaMinutes; + /** + * Live state of the actual call, for rows that have already dialled. The queue's + * own status stops at DIALED and never moves again, so these three are the only + * way to tell a call that is on a line right now from one that ended hours ago. + */ + private String callStatus; + private Integer callDurationSeconds; + /** True while the call is still up (non-terminal call-log status). */ + private boolean live; + } + + /** + * Institute-facing summary: "how deep is my queue and when will it clear?". + * + *

Deliberately carries NO capacity numbers. How many lines the fleet has, and + * how many of them this institute may hold, are internal operating facts — an + * institute seeing "2 of 3" learns that it shares a small pool with other tenants, + * which is not its business and invites the wrong conversation. The wait is + * expressed as time ({@link #etaMinutes}), which is the part that actually concerns + * them. The capacity figures stay on the super-admin and internal endpoints. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class QueueSummary { + private String instituteId; + private long queued; + /** This institute's own calls currently on a line. Not a share of anything. */ + private long inFlight; + private boolean paused; + private long etaMinutes; + private Map byStatus; + } + + /** Fleet capacity + live occupancy, for the ops/settings screen. */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CapacityView { + /** What is ACTUALLY enforced right now: min(hardware, ops limit). */ + private int vacademyAiCapacity; + private int vacademyAiInFlight; + /** What the boxes could carry, before any ops limit. */ + private int physicalCapacity; + /** + * The ops ceiling, or null when none is set and the hardware decides. + * 0 means dialing is paused — the queue still accepts and holds calls. + */ + private Integer fleetLimit; + private int aavtaarCapacity; + private int aavtaarInFlight; + /** + * True when the concurrency limit is BYPASSED entirely (unlimited calls). + * + *

Named for what it does. The underlying flag reads + * {@code ai_call_capacity_enabled=false}, which looks like an off switch for AI + * calling and is the opposite — surfacing it as "capacityEnabled" invited an + * operator wanting to stop calls to uncap the fleet instead. To stop or throttle + * calling, set {@code fleetLimit}; never this. + */ + private boolean concurrencyLimitBypassed; + private long totalQueued; + private int lanesWithWork; + /** The cap an institute with no override currently gets. */ + private int dynamicLaneCapacity; + private int avgCallSeconds; + private int reservedInteractiveSlots; + private List boxes; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class BoxView { + private String id; + private String slug; + private String baseUrl; + private int maxConcurrent; + private int priority; + private boolean enabled; + private String healthStatus; + private Integer activeCalls; + private String lastHealthCheck; + private String notes; + /** False when the box is enabled but known DOWN, i.e. lending no capacity. */ + private boolean countsTowardCapacity; + } + + /** Create/update payload for a box. */ + @Data + public static class BoxUpsertRequest { + private String slug; + private String baseUrl; + private Integer maxConcurrent; + private Integer priority; + private Boolean enabled; + private String notes; + } + + /** Per-institute override payload. {@code maxConcurrent = null} restores the dynamic default. */ + @Data + public static class LaneUpsertRequest { + private Integer maxConcurrent; + private Integer weight; + private Boolean paused; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class LaneView { + private String instituteId; + private String instituteName; + /** Null = no override; the institute follows {@code effectiveMaxConcurrent}. */ + private Integer maxConcurrent; + private int effectiveMaxConcurrent; + private int weight; + private boolean paused; + private long queued; + private long inFlight; + /** Rough time for this lane to clear at its current share of the fleet. */ + private long etaMinutes; + /** When the longest-waiting item was queued — the number an ops screen watches. */ + private String oldestQueuedAt; + private String lastDispatchedAt; + } + + /** + * The whole AI call queue in one payload — what the fleet can carry, who is holding + * it, and what is waiting. + * + *

Assembled from a SINGLE capacity snapshot, so every number in it describes the + * same instant. Fetching capacity and lanes through separate calls lets a dashboard + * render an occupancy read at one moment beside lane shares computed at another, + * which is exactly how "these numbers do not add up" tickets are born. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class QueueSnapshot { + /** When this snapshot was taken (ISO-8601, UTC). */ + private String generatedAt; + private CapacityView capacity; + private List lanes; + /** The head of the queue, in dial order. Bounded — see {@link #waitingTotal}. */ + private List waiting; + /** Everything waiting fleet-wide, so a UI can say "showing 50 of 487". */ + private long waitingTotal; + /** Fleet-wide counts per lifecycle state (QUEUED / DIALED / FAILED / ...). */ + private Map totalsByStatus; + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/entity/AiCallLane.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/entity/AiCallLane.java new file mode 100644 index 0000000000..ca0e3f74e9 --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/entity/AiCallLane.java @@ -0,0 +1,77 @@ +package vacademy.io.admin_core_service.features.telephony.queue.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +/** + * Per-institute queue overrides. Sparse by design — an institute with no row + * here uses the dynamic default cap, so this table stays empty until someone tunes a + * specific customer. + * + *

{@code maxConcurrent} is the knob that makes strict-FIFO ordering fair: the drain + * scan skips an item whose institute already has that many calls in flight, so a + * latecomer with five leads takes the next free slot instead of waiting out a + * 500-lead backlog ahead of it. + */ +@Entity +@Table(name = "ai_call_lane") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiCallLane { + + @Id + @Column(name = "institute_id", nullable = false, updatable = false) + private String instituteId; + + /** + * Hard ceiling on this institute's simultaneous AI calls. NULL = use the dynamic + * default, {@code max(1, ceil(fleetCapacity / lanesWithWork))}, which is + * work-conserving: one institute dialling alone gets the whole fleet. + */ + @Column(name = "max_concurrent") + private Integer maxConcurrent; + + /** Reserved for a future weighted rotation. Not read today. */ + @Column(name = "weight", nullable = false) + private int weight; + + /** True = this institute's queued calls are held (nothing dialled, nothing lost). */ + @Column(name = "paused", nullable = false) + private boolean paused; + + /** + * Written on every dispatch, never read. Carried so switching from FIFO to a + * round-robin rotation — the fix if more institutes are ever busy at once than + * the fleet has slots — is an ORDER BY change rather than a migration. + */ + @Column(name = "last_dispatched_at") + private Instant lastDispatchedAt; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + void onCreate() { + Instant now = Instant.now(); + if (createdAt == null) createdAt = now; + updatedAt = now; + if (weight <= 0) weight = 1; + } + + @PreUpdate + void onUpdate() { + updatedAt = Instant.now(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/entity/AiCallQueueItem.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/entity/AiCallQueueItem.java new file mode 100644 index 0000000000..42655bad4c --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/entity/AiCallQueueItem.java @@ -0,0 +1,160 @@ +package vacademy.io.admin_core_service.features.telephony.queue.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.UuidGenerator; + +import java.time.Instant; + +/** + * One AI call waiting for a slot on the fleet. + * + *

Every dial path — the CALL_AI workflow node, a bulk campaign, a manual click — + * writes one of these instead of calling the provider itself. {@code AiCallQueueDrainJob} + * is the only thing that dials, which is what makes the fleet-wide concurrency limit + * exact: one drainer, one place that counts slots. + * + *

The row carries everything {@code AiCallService.placeCall} needs, because the + * dial happens minutes-to-hours after enqueue and nothing about the request may be + * re-derived from state that has since moved on. What deliberately IS re-derived at + * dispatch time is every pre-dial guard (credits, daily cap, already-assigned, + * deleted lead): those must reflect the world at dial time, not at enqueue time. + */ +@Entity +@Table(name = "ai_call_queue") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiCallQueueItem { + + @Id + @UuidGenerator + @Column(name = "id", nullable = false, updatable = false) + private String id; + + @Column(name = "institute_id", nullable = false) + private String instituteId; + + /** + * Resolved at enqueue time and written onto the request at dispatch, so the + * capacity this item was accounted against is the capacity it actually consumes + * even if the institute's default provider changes while it waits. + */ + @Column(name = "provider", nullable = false, length = 50) + private String provider; + + /** Higher first, ties broken by {@link #createdAt}. Everything enqueues at 100 today. */ + @Column(name = "priority", nullable = false) + private int priority; + + /** WORKFLOW | BULK | MANUAL | RETRY — provenance, for the queue view and logs. */ + @Column(name = "source", nullable = false, length = 30) + private String source; + + /** Audience id for a bulk run, workflow execution id for a node. Nullable. */ + @Column(name = "source_ref") + private String sourceRef; + + /** + * The {@code CallTrigger} this item must be dialled with. Stored rather than + * re-derived: a MANUAL click keeps its throttle exemptions after an hour in the + * queue, and a WORKFLOW_EXPLICIT node keeps its assigned-guard opt-out. + */ + @Column(name = "call_trigger", nullable = false, length = 30) + private String callTrigger; + + @Column(name = "response_id") + private String responseId; + + @Column(name = "user_id") + private String userId; + + @Column(name = "phone_number", length = 32) + private String phoneNumber; + + @Column(name = "campaign_id") + private String campaignId; + + @Column(name = "campaign_name") + private String campaignName; + + @Column(name = "preferred_number_id") + private String preferredNumberId; + + @Column(name = "subject_type", length = 32) + private String subjectType; + + @Column(name = "subject_id") + private String subjectId; + + @Column(name = "customer_name") + private String customerName; + + @Column(name = "customer_email") + private String customerEmail; + + /** JSON blob replayed onto {@code AiCallRequestDTO.metadata} at dispatch. */ + @Column(name = "metadata", columnDefinition = "TEXT") + private String metadata; + + /** The actor who asked for the call; becomes {@code counsellor_user_id} on the call log. */ + @Column(name = "actor_user_id") + private String actorUserId; + + /** {@code institute:subject:provider} — unique among pending rows. */ + @Column(name = "dedupe_key", nullable = false, length = 512) + private String dedupeKey; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "attempts", nullable = false) + private int attempts; + + @Column(name = "last_error", columnDefinition = "TEXT") + private String lastError; + + /** Human-readable reason this item ended where it did; surfaced in the queue view. */ + @Column(name = "status_reason") + private String statusReason; + + /** Not eligible for a slot before this instant (calling window, or a backoff). */ + @Column(name = "not_before") + private Instant notBefore; + + /** Past this instant the item is EXPIRED instead of dialled. */ + @Column(name = "expires_at") + private Instant expiresAt; + + @Column(name = "call_log_id") + private String callLogId; + + @Column(name = "dispatched_at") + private Instant dispatchedAt; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + void onCreate() { + Instant now = Instant.now(); + if (createdAt == null) createdAt = now; + updatedAt = now; + if (status == null) status = "QUEUED"; + if (priority == 0) priority = 100; + } + + @PreUpdate + void onUpdate() { + updatedAt = Instant.now(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/entity/AiVoiceBox.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/entity/AiVoiceBox.java new file mode 100644 index 0000000000..787c8da97d --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/entity/AiVoiceBox.java @@ -0,0 +1,112 @@ +package vacademy.io.admin_core_service.features.telephony.queue.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.UuidGenerator; + +import java.time.Instant; + +/** + * One voice-bot box in the fleet, and the number of simultaneous calls it can carry. + * + *

Fleet capacity for {@code VACADEMY_AI} is {@code SUM(max_concurrent)} over the + * enabled boxes that are not known-DOWN. Adding a second Mumbai box is therefore an + * INSERT through the API rather than a redeploy — which is the whole reason capacity + * is a table and not a constant. + * + *

This table does not route calls. Dialling still resolves the bot address + * from {@code telephony.vacademy-ai.bot-base-url} exactly as before; {@code base_url} + * here exists so the health poller knows which box to ask. Keeping routing on the + * existing property means a bad row in this table can never send a call to the wrong + * host — the worst it can do is mis-state capacity. + */ +@Entity +@Table(name = "ai_voice_box") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiVoiceBox { + + /** A box whose base_url is still the seeded placeholder is never polled. */ + public static final String UNCONFIGURED_URL = "CONFIGURE_ME"; + + public static final String HEALTH_HEALTHY = "HEALTHY"; + public static final String HEALTH_DOWN = "DOWN"; + public static final String HEALTH_UNKNOWN = "UNKNOWN"; + + @Id + @UuidGenerator + @Column(name = "id", nullable = false, updatable = false) + private String id; + + @Column(name = "slug", nullable = false, length = 50, unique = true) + private String slug; + + @Column(name = "base_url", nullable = false) + private String baseUrl; + + /** Simultaneous calls this box can carry. The "3" the fleet limit is made of. */ + @Column(name = "max_concurrent", nullable = false) + private int maxConcurrent; + + @Column(name = "priority", nullable = false) + private int priority; + + @Column(name = "enabled", nullable = false) + private boolean enabled; + + /** HEALTHY | DOWN | UNKNOWN. Only DOWN removes the box's capacity. */ + @Column(name = "health_status", nullable = false, length = 20) + private String healthStatus; + + /** Last {@code /voice-bot-service/health} activeCalls reading; null if never polled. */ + @Column(name = "active_calls") + private Integer activeCalls; + + @Column(name = "last_health_check") + private Instant lastHealthCheck; + + @Column(name = "notes") + private String notes; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + /** + * True when this box contributes capacity. UNKNOWN counts — a box we have never + * managed to poll (no base_url configured, poller disabled) must not silently + * take the fleet to zero and stop all AI calling. Only a box we asked and that + * failed to answer is excluded. + */ + public boolean countsTowardCapacity() { + return enabled && !HEALTH_DOWN.equals(healthStatus); + } + + public boolean isPollable() { + return enabled && baseUrl != null && !baseUrl.isBlank() + && !UNCONFIGURED_URL.equals(baseUrl.trim()) + && baseUrl.trim().startsWith("http"); + } + + @PrePersist + void onCreate() { + Instant now = Instant.now(); + if (createdAt == null) createdAt = now; + updatedAt = now; + if (healthStatus == null) healthStatus = HEALTH_UNKNOWN; + } + + @PreUpdate + void onUpdate() { + updatedAt = Instant.now(); + } +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/repository/AiCallLaneRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/repository/AiCallLaneRepository.java new file mode 100644 index 0000000000..314af9519f --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/repository/AiCallLaneRepository.java @@ -0,0 +1,28 @@ +package vacademy.io.admin_core_service.features.telephony.queue.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.telephony.queue.entity.AiCallLane; + +import java.time.Instant; + +@Repository +public interface AiCallLaneRepository extends JpaRepository { + + /** + * Stamp the rotation cursor without loading the entity. Written on every dispatch + * and never read today — see {@link AiCallLane#getLastDispatchedAt()}. Silently a + * no-op for an institute with no override row, which is most of them. + */ + @Modifying + @Transactional + @Query(value = """ + UPDATE ai_call_lane SET last_dispatched_at = :at, updated_at = NOW() + WHERE institute_id = :instituteId + """, nativeQuery = true) + int touchDispatched(@Param("instituteId") String instituteId, @Param("at") Instant at); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/repository/AiCallQueueItemRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/repository/AiCallQueueItemRepository.java new file mode 100644 index 0000000000..6a4ba2582b --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/repository/AiCallQueueItemRepository.java @@ -0,0 +1,371 @@ +package vacademy.io.admin_core_service.features.telephony.queue.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.admin_core_service.features.telephony.queue.entity.AiCallQueueItem; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Repository +public interface AiCallQueueItemRepository extends JpaRepository { + + /** + * The drain candidate set: the oldest eligible items of EVERY lane that has work, + * not the oldest N items overall. + * + *

This distinction is the whole reason the query has a LATERAL in it. A flat + * {@code ORDER BY created_at LIMIT 200} over a queue holding one institute's + * 500-lead upload returns 500 rows belonging to that one institute — the drainer + * would never SEE the lane that arrived later, so skipping a capped lane could not + * help it. Taking each lane's head first bounds the candidate set while + * guaranteeing every waiting institute is represented in it; the caller then walks + * that set in strict FIFO order. + * + * @param perLane rows per lane — fleet capacity is enough, since no lane can take + * more slots than the whole fleet has. + */ + @Query(value = """ + SELECT q.* FROM ( + SELECT DISTINCT institute_id FROM ai_call_queue WHERE status = 'QUEUED' + ) l + JOIN LATERAL ( + SELECT * FROM ai_call_queue x + WHERE x.institute_id = l.institute_id + AND x.status = 'QUEUED' + AND (x.not_before IS NULL OR x.not_before <= :now) + ORDER BY x.priority DESC, x.created_at + LIMIT :perLane + ) q ON TRUE + ORDER BY q.priority DESC, q.created_at + LIMIT :maxRows + """, nativeQuery = true) + List findDrainCandidates(@Param("now") Instant now, + @Param("perLane") int perLane, + @Param("maxRows") int maxRows); + + /** + * Claim an item for dispatch. This CAS — not the scheduler lock — is the + * send-once guarantee: ShedLock's {@code lockAtMostFor} can lapse and let two + * drainers overlap, and only a conditional UPDATE survives that. + * + * @return 1 when this caller won the claim, 0 when someone else already had it. + */ + @Modifying + @Transactional + @Query(value = """ + UPDATE ai_call_queue + SET status = 'DISPATCHING', attempts = attempts + 1, updated_at = NOW() + WHERE id = :id AND status = 'QUEUED' + """, nativeQuery = true) + int claimForDispatch(@Param("id") String id); + + /** + * TTL sweep. A call queued yesterday for a lead who has since been worked by a + * human is not a call anyone wants placed; expiring it is visible (status + + * reason) rather than a silent drop. + */ + @Modifying + @Transactional + @Query(value = """ + UPDATE ai_call_queue + SET status = 'EXPIRED', + status_reason = 'Waited past its time limit without a free line.', + updated_at = NOW() + WHERE status = 'QUEUED' AND expires_at IS NOT NULL AND expires_at <= :now + """, nativeQuery = true) + int expireOverdue(@Param("now") Instant now); + + /** + * Release items stuck in DISPATCHING — only reachable if the drainer died between + * the claim and the dial (pod evicted mid-tick). Bounded by a generous grace so a + * live dispatch is never yanked out from under itself. + */ + @Modifying + @Transactional + @Query(value = """ + UPDATE ai_call_queue + SET status = 'QUEUED', updated_at = NOW() + WHERE status = 'DISPATCHING' AND updated_at <= :before + """, nativeQuery = true) + int releaseStuckClaims(@Param("before") Instant before); + + /** + * Push back EVERY waiting item of one institute. + * + *

Used for the conditions that are lane-wide rather than per-call: the institute + * is out of credits, has hit its daily cap, or is outside its calling hours. Without + * this the drainer would rediscover the same condition on the next item every tick + * — an institute with 400 queued leads and an empty wallet would make 400 credit + * checks working through its own backlog before settling down. + * + *

The {@code not_before} guard means an item already deferred FURTHER out keeps + * its later time: this only ever delays, never pulls a call forward. + */ + @Modifying + @Transactional + @Query(value = """ + UPDATE ai_call_queue + SET not_before = :notBefore, status_reason = :reason, updated_at = NOW() + WHERE institute_id = :instituteId AND status = 'QUEUED' + AND (not_before IS NULL OR not_before < :notBefore) + """, nativeQuery = true) + int deferLane(@Param("instituteId") String instituteId, + @Param("notBefore") Instant notBefore, + @Param("reason") String reason); + + /** The pending item for this lead, if any — so a repeat enqueue reports its place. */ + @Query(""" + SELECT q FROM AiCallQueueItem q + WHERE q.dedupeKey = :dedupeKey AND q.status IN ('QUEUED', 'DISPATCHING') + """) + Optional findPendingByDedupeKey(@Param("dedupeKey") String dedupeKey); + + /** + * Every dedupe key this institute currently has undialled. A bulk enqueue reads + * this ONCE and filters in memory rather than probing per lead: a 500-lead campaign + * would otherwise fire 500 existence checks on the request thread, and the pending + * set for one institute is small by construction (it is bounded by what has not + * dialled yet, not by history). + */ + @Query(""" + SELECT q.dedupeKey FROM AiCallQueueItem q + WHERE q.instituteId = :instituteId AND q.status IN ('QUEUED', 'DISPATCHING') + """) + List findPendingDedupeKeys(@Param("instituteId") String instituteId); + + /** + * How many items sit ahead of this one in ITS OWN lane. The lane, not the whole + * queue, is what governs its wait: the per-lane cap means an institute drains at + * its own rate regardless of how much other institutes have queued. + */ + @Query(""" + SELECT COUNT(q) FROM AiCallQueueItem q + WHERE q.instituteId = :instituteId AND q.status = 'QUEUED' + AND (q.priority > :priority + OR (q.priority = :priority AND q.createdAt < :createdAt)) + """) + long countAheadInLane(@Param("instituteId") String instituteId, + @Param("priority") int priority, + @Param("createdAt") Instant createdAt); + + @Query("SELECT COUNT(q) FROM AiCallQueueItem q WHERE q.instituteId = :instituteId AND q.status = 'QUEUED'") + long countQueuedForInstitute(@Param("instituteId") String instituteId); + + @Query("SELECT COUNT(q) FROM AiCallQueueItem q WHERE q.status = 'QUEUED'") + long countQueuedTotal(); + + /** Institutes with at least one waiting item — the denominator of the dynamic lane cap. */ + @Query("SELECT DISTINCT q.instituteId FROM AiCallQueueItem q WHERE q.status = 'QUEUED'") + List findInstitutesWithQueuedWork(); + + @Query("SELECT q.instituteId, COUNT(q) FROM AiCallQueueItem q WHERE q.status = 'QUEUED' GROUP BY q.instituteId") + List countQueuedByInstitute(); + + /** Fleet-wide status breakdown for the ops snapshot. */ + @Query("SELECT q.status, COUNT(q) FROM AiCallQueueItem q GROUP BY q.status") + List countAllGroupedByStatus(); + + @Query(""" + SELECT q.status, COUNT(q) FROM AiCallQueueItem q + WHERE q.instituteId = :instituteId GROUP BY q.status + """) + List countByInstituteGroupedByStatus(@Param("instituteId") String instituteId); + + /** + * This institute's waiting items in dispatch order, ids only. + * + *

The queue list view needs each row's place in line. Asking the database + * "how many are ahead of this one" per row turns a 200-row page into 200 counts; + * pulling the ordered id list once and reading the index off it is one query. The + * cap keeps that list bounded — past it, positions are simply not shown, which is + * the honest answer for an item several thousand deep. + */ + @Query(""" + SELECT q.id FROM AiCallQueueItem q + WHERE q.instituteId = :instituteId AND q.status = 'QUEUED' + ORDER BY q.priority DESC, q.createdAt + """) + List findQueuedIdsInDispatchOrder(@Param("instituteId") String instituteId, + Pageable pageable); + + /** + * Cross-institute listing for the internal dashboard, in DISPATCH order — the order + * calls will actually go out, which is the only ordering that answers "who is next?". + * + *

Every filter is optional. The CASTs are load-bearing rather than decorative: + * Postgres cannot infer a bare parameter's type on the NULL side of an OR and rejects + * the statement without them. + */ + @Query(value = """ + SELECT * FROM ai_call_queue q + WHERE (CAST(:instituteId AS VARCHAR) IS NULL OR q.institute_id = CAST(:instituteId AS VARCHAR)) + AND (CAST(:status AS VARCHAR) IS NULL OR q.status = CAST(:status AS VARCHAR)) + AND (CAST(:provider AS VARCHAR) IS NULL OR q.provider = CAST(:provider AS VARCHAR)) + AND (CAST(:source AS VARCHAR) IS NULL OR q.source = CAST(:source AS VARCHAR)) + ORDER BY q.priority DESC, q.created_at ASC + """, + countQuery = """ + SELECT COUNT(*) FROM ai_call_queue q + WHERE (CAST(:instituteId AS VARCHAR) IS NULL OR q.institute_id = CAST(:instituteId AS VARCHAR)) + AND (CAST(:status AS VARCHAR) IS NULL OR q.status = CAST(:status AS VARCHAR)) + AND (CAST(:provider AS VARCHAR) IS NULL OR q.provider = CAST(:provider AS VARCHAR)) + AND (CAST(:source AS VARCHAR) IS NULL OR q.source = CAST(:source AS VARCHAR)) + """, + nativeQuery = true) + Page searchInLineOrder(@Param("instituteId") String instituteId, + @Param("status") String status, + @Param("provider") String provider, + @Param("source") String source, + Pageable pageable); + + /** + * The same listing newest-first, for looking at what already happened. Line order is + * meaningless once a row has left the queue, and an ops screen reading history wants + * the most recent call at the top. + */ + @Query(value = """ + SELECT * FROM ai_call_queue q + WHERE (CAST(:instituteId AS VARCHAR) IS NULL OR q.institute_id = CAST(:instituteId AS VARCHAR)) + AND (CAST(:status AS VARCHAR) IS NULL OR q.status = CAST(:status AS VARCHAR)) + AND (CAST(:provider AS VARCHAR) IS NULL OR q.provider = CAST(:provider AS VARCHAR)) + AND (CAST(:source AS VARCHAR) IS NULL OR q.source = CAST(:source AS VARCHAR)) + ORDER BY q.created_at DESC + """, + countQuery = """ + SELECT COUNT(*) FROM ai_call_queue q + WHERE (CAST(:instituteId AS VARCHAR) IS NULL OR q.institute_id = CAST(:instituteId AS VARCHAR)) + AND (CAST(:status AS VARCHAR) IS NULL OR q.status = CAST(:status AS VARCHAR)) + AND (CAST(:provider AS VARCHAR) IS NULL OR q.provider = CAST(:provider AS VARCHAR)) + AND (CAST(:source AS VARCHAR) IS NULL OR q.source = CAST(:source AS VARCHAR)) + """, + nativeQuery = true) + Page searchByRecency(@Param("instituteId") String instituteId, + @Param("status") String status, + @Param("provider") String provider, + @Param("source") String source, + Pageable pageable); + + /** Longest-waiting item per lane — the "how far behind are they?" column. */ + @Query(""" + SELECT q.instituteId, MIN(q.createdAt) FROM AiCallQueueItem q + WHERE q.status = 'QUEUED' GROUP BY q.instituteId + """) + List findOldestQueuedPerInstitute(); + + /** + * Everything not finished yet: waiting, plus already dialling. + * + *

This is the queue view a person actually wants on arrival. Filtering to + * QUEUED alone shows an EMPTY table exactly when calling is working normally — + * a manual click dials immediately when a line is free, so it is never "waiting", + * and a busy fleet is the only state in which anything sits in QUEUED at all. A + * page that is blank during normal operation reads as broken. + * + *

LEFT JOIN, not JOIN: a QUEUED row has no call_log_id yet, and an inner join + * would silently drop exactly the rows the queue is named after. + */ + @Query(value = """ + SELECT q.* FROM ai_call_queue q + LEFT JOIN telephony_call_log t ON t.id = q.call_log_id + WHERE q.institute_id = :instituteId + AND (q.status = 'QUEUED' + OR (q.status = 'DIALED' + AND t.status IN ('INITIATED', 'QUEUED', 'COUNSELLOR_RINGING', + 'COUNSELLOR_ANSWERED', 'IN_PROGRESS'))) + ORDER BY CASE WHEN q.status = 'DIALED' THEN 0 ELSE 1 END, + q.priority DESC, q.created_at + """, + countQuery = """ + SELECT COUNT(*) FROM ai_call_queue q + LEFT JOIN telephony_call_log t ON t.id = q.call_log_id + WHERE q.institute_id = :instituteId + AND (q.status = 'QUEUED' + OR (q.status = 'DIALED' + AND t.status IN ('INITIATED', 'QUEUED', 'COUNSELLOR_RINGING', + 'COUNSELLOR_ANSWERED', 'IN_PROGRESS'))) + """, + nativeQuery = true) + Page findActive(@Param("instituteId") String instituteId, Pageable pageable); + + /** + * Calls that are on a line RIGHT NOW. + * + *

Not expressible as a queue-status filter: the queue row stops at DIALED the + * moment the provider accepts, and never moves again. Whether the call is still up + * lives in {@code telephony_call_log}, so "live" is the join — a dialled queue row + * whose call has not reached a terminal status. + */ + @Query(value = """ + SELECT q.* FROM ai_call_queue q + JOIN telephony_call_log t ON t.id = q.call_log_id + WHERE q.status = 'DIALED' + AND t.status IN ('INITIATED', 'QUEUED', 'COUNSELLOR_RINGING', + 'COUNSELLOR_ANSWERED', 'IN_PROGRESS') + AND (CAST(:instituteId AS VARCHAR) IS NULL + OR q.institute_id = CAST(:instituteId AS VARCHAR)) + ORDER BY q.dispatched_at DESC + """, + countQuery = """ + SELECT COUNT(*) FROM ai_call_queue q + JOIN telephony_call_log t ON t.id = q.call_log_id + WHERE q.status = 'DIALED' + AND t.status IN ('INITIATED', 'QUEUED', 'COUNSELLOR_RINGING', + 'COUNSELLOR_ANSWERED', 'IN_PROGRESS') + AND (CAST(:instituteId AS VARCHAR) IS NULL + OR q.institute_id = CAST(:instituteId AS VARCHAR)) + """, + nativeQuery = true) + Page findLive(@Param("instituteId") String instituteId, Pageable pageable); + + Page findByInstituteIdOrderByCreatedAtDesc(String instituteId, Pageable pageable); + + Page findByInstituteIdAndStatusOrderByCreatedAtDesc( + String instituteId, String status, Pageable pageable); + + /** + * Cancel everything still waiting for one institute (optionally narrowed to one + * source run). The CASTs are not decoration: Postgres cannot infer a bare + * parameter's type on the NULL side of an OR and rejects the statement without + * them. + */ + @Modifying + @Transactional + @Query(value = """ + UPDATE ai_call_queue + SET status = 'CANCELLED', status_reason = :reason, updated_at = NOW() + WHERE institute_id = :instituteId AND status = 'QUEUED' + AND (CAST(:sourceRef AS VARCHAR) IS NULL OR source_ref = CAST(:sourceRef AS VARCHAR)) + """, nativeQuery = true) + int cancelQueued(@Param("instituteId") String instituteId, + @Param("sourceRef") String sourceRef, + @Param("reason") String reason); + + @Modifying + @Transactional + @Query(value = """ + UPDATE ai_call_queue + SET status = 'CANCELLED', status_reason = :reason, updated_at = NOW() + WHERE id = :id AND institute_id = :instituteId AND status = 'QUEUED' + """, nativeQuery = true) + int cancelOne(@Param("id") String id, + @Param("instituteId") String instituteId, + @Param("reason") String reason); + + /** Queue-side view of a bulk run, for the campaign progress dialog. */ + @Query(""" + SELECT q.status, COUNT(q) FROM AiCallQueueItem q + WHERE q.instituteId = :instituteId AND q.source = :source AND q.sourceRef = :sourceRef + GROUP BY q.status + """) + List countBySourceRefGroupedByStatus(@Param("instituteId") String instituteId, + @Param("source") String source, + @Param("sourceRef") String sourceRef); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/repository/AiVoiceBoxRepository.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/repository/AiVoiceBoxRepository.java new file mode 100644 index 0000000000..7d9810656d --- /dev/null +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/queue/repository/AiVoiceBoxRepository.java @@ -0,0 +1,20 @@ +package vacademy.io.admin_core_service.features.telephony.queue.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import vacademy.io.admin_core_service.features.telephony.queue.entity.AiVoiceBox; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface AiVoiceBoxRepository extends JpaRepository { + + Optional findBySlug(String slug); + + boolean existsBySlug(String slug); + + List findAllByOrderByPriorityAscSlugAsc(); + + List findByEnabledTrue(); +} diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/dto/CouponCodeDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/dto/CouponCodeDTO.java index 957b62d58b..99cb2b49ce 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/dto/CouponCodeDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/dto/CouponCodeDTO.java @@ -27,6 +27,9 @@ public class CouponCodeDTO { private Date redeemStartDate; private Date redeemEndDate; private Long usageLimit; + + /** Smallest basket this coupon may be used on. Null = no minimum. */ + private Integer minItems; private Timestamp createdAt; private Timestamp updatedAt; private boolean canBeAdded; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/dto/coupon/CouponValidateRequestDTO.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/dto/coupon/CouponValidateRequestDTO.java index d9d33d7212..c5afa97dd4 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/dto/coupon/CouponValidateRequestDTO.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/dto/coupon/CouponValidateRequestDTO.java @@ -44,4 +44,11 @@ public class CouponValidateRequestDTO { @NotNull private Double totalAmount; + + /** + * How many courses are in the basket, for coupons that carry a minimum. + * Null from callers that do not deal in baskets (a single-course enrol), + * which is treated as one item. + */ + private Integer itemCount; } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/entity/CouponCode.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/entity/CouponCode.java index 2b5f545f31..072697c736 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/entity/CouponCode.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/entity/CouponCode.java @@ -74,6 +74,14 @@ public class CouponCode { @Column(name = "usage_limit") // Maximum number of times this coupon can be used private Long usageLimit; + /** + * Smallest basket this coupon may be used on — "₹99 off when you take 2 or + * more". NULL means no condition, which is every coupon that predates this, + * so nothing changes until an admin sets one. + */ + @Column(name = "min_items") + private Integer minItems; + @Column(name = "created_at", insertable = false, updatable = false) private Timestamp createdAt; diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/service/coupon/CouponValidationMessages.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/service/coupon/CouponValidationMessages.java index 0dc9f6470f..86a627934d 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/service/coupon/CouponValidationMessages.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/service/coupon/CouponValidationMessages.java @@ -17,6 +17,8 @@ public final class CouponValidationMessages { public static final String NOT_APPLICABLE = "COUPON_NOT_APPLICABLE"; public static final String NOT_FOR_PLAN_TYPE = "COUPON_NOT_FOR_PLAN_TYPE"; public static final String DISCOUNT_MISSING = "COUPON_DISCOUNT_NOT_CONFIGURED"; + /** Basket is smaller than the coupon's minimum — see CouponCode.minItems. */ + public static final String BELOW_MIN_ITEMS = "COUPON_BELOW_MIN_ITEMS"; private CouponValidationMessages() {} } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/service/coupon/CouponValidationService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/service/coupon/CouponValidationService.java index 2b670470f0..32e397bc5d 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/service/coupon/CouponValidationService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/user_subscription/service/coupon/CouponValidationService.java @@ -111,7 +111,16 @@ public CouponValidateResponseDTO validate(CouponValidateRequestDTO request) { return invalid(CouponValidationMessages.NOT_FOR_PLAN_TYPE); } - // 8. Discount + // 8. Quantity condition — "₹99 off when you take 2 or more". A caller that + // does not deal in baskets sends no count, which reads as one item. + if (coupon.getMinItems() != null && coupon.getMinItems() > 1) { + int items = request.getItemCount() != null ? request.getItemCount() : 1; + if (items < coupon.getMinItems()) { + return invalid(CouponValidationMessages.BELOW_MIN_ITEMS); + } + } + + // 9. Discount Optional discountOpt = appliedCouponDiscountRepository.findFirstByCouponCode_IdAndStatusOrderByCreatedAtDesc( coupon.getId(), STATUS_ACTIVE); diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/white_label/controller/WhiteLabelController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/white_label/controller/WhiteLabelController.java index f5b5f15511..f106bf7405 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/white_label/controller/WhiteLabelController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/white_label/controller/WhiteLabelController.java @@ -57,4 +57,34 @@ public ResponseEntity getStatus( WhiteLabelStatusResponse response = whiteLabelService.getStatus(user, instituteId); return ResponseEntity.ok(response); } + + /** + * Returns the institute's custom live-class host, or null when it serves live + * classes from the platform default domain. + */ + @GetMapping("/live-session-domain") + public ResponseEntity> getLiveSessionDomain( + @RequestAttribute("user") CustomUserDetails user, + @RequestParam("instituteId") String instituteId) { + + return ResponseEntity.ok(whiteLabelService.getLiveSessionDomain(user, instituteId)); + } + + /** + * Sets the institute's custom live-class host, e.g. + * {@code {"domain": "meet.zoeedtech.com"}}. Pass null or an empty string to + * clear it and fall back to the platform default. + * + * The host must also resolve to the primary BBB pool server and be covered by + * that server's certificate — this endpoint only records the intent. + */ + @PutMapping("/live-session-domain") + public ResponseEntity> setLiveSessionDomain( + @RequestAttribute("user") CustomUserDetails user, + @RequestParam("instituteId") String instituteId, + @RequestBody java.util.Map body) { + + String domain = body == null ? null : body.get("domain"); + return ResponseEntity.ok(whiteLabelService.setLiveSessionDomain(user, instituteId, domain)); + } } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/white_label/service/WhiteLabelService.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/white_label/service/WhiteLabelService.java index e10b99601b..2c81a18cdb 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/white_label/service/WhiteLabelService.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/white_label/service/WhiteLabelService.java @@ -11,6 +11,7 @@ import vacademy.io.admin_core_service.features.domain_routing.repository.InstituteDomainRoutingRepository; import vacademy.io.admin_core_service.features.domain_routing.service.DomainRoutingAdminService; import vacademy.io.admin_core_service.features.institute.repository.InstituteRepository; +import vacademy.io.admin_core_service.features.live_session.provider.manager.BbbMeetingManager; import vacademy.io.admin_core_service.features.white_label.dto.*; import vacademy.io.common.auth.model.CustomUserDetails; import vacademy.io.common.auth.repository.UserRoleRepository; @@ -328,6 +329,63 @@ public WhiteLabelStatusResponse getStatus(CustomUserDetails user, String institu .build(); } + // ── Live-class domain ───────────────────────────────────────────────────── + + /** + * Current custom live-class host for the institute, or null when it uses the + * platform default. + */ + @Transactional(readOnly = true) + public Map getLiveSessionDomain(CustomUserDetails user, String instituteId) { + assertInstituteAccess(user, instituteId); + Institute institute = instituteRepository.findById(instituteId) + .orElseThrow(() -> new VacademyException("Institute not found: " + instituteId)); + + Map out = new LinkedHashMap<>(); + out.put("instituteId", instituteId); + out.put("liveSessionBaseUrl", institute.getLiveSessionBaseUrl()); + return out; + } + + /** + * Set (or clear, by passing null/blank) the institute's custom live-class host. + * + * Validation is strict rather than forgiving: this value becomes the origin of + * a URL learners are redirected into, so a malformed one is rejected outright + * instead of being coerced into something that merely looks plausible. + * + * Setting this row is only half the job — the host must also resolve to the + * PRIMARY BBB pool server and be present as a SAN on that server's + * certificate. Until both are true, participants sent to it will hit a DNS or + * TLS error. + */ + @Transactional + public Map setLiveSessionDomain(CustomUserDetails user, String instituteId, + String rawDomain) { + assertInstituteAccess(user, instituteId); + Institute institute = instituteRepository.findById(instituteId) + .orElseThrow(() -> new VacademyException("Institute not found: " + instituteId)); + + String normalized = null; + if (StringUtils.hasText(rawDomain)) { + normalized = BbbMeetingManager.normalizeLiveSessionHost(rawDomain); + if (normalized == null) { + throw new VacademyException("Invalid live-class domain '" + rawDomain + + "'. Use a plain hostname such as meet.yourschool.com — no path, port or credentials."); + } + } + + institute.setLiveSessionBaseUrl(normalized); + instituteRepository.save(institute); + log.info("[WhiteLabel] Live-class domain for institute {} set to {}", instituteId, + normalized == null ? "(default)" : normalized); + + Map out = new LinkedHashMap<>(); + out.put("instituteId", instituteId); + out.put("liveSessionBaseUrl", normalized); + return out; + } + // ── Private helpers ─────────────────────────────────────────────────────── private void assertInstituteAccess(CustomUserDetails user, String instituteId) { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/controller/WorkflowCatalogController.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/controller/WorkflowCatalogController.java index a991f26e42..341a4b2198 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/controller/WorkflowCatalogController.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/controller/WorkflowCatalogController.java @@ -342,6 +342,10 @@ public ResponseEntity>>> getTriggerContextV ctxVar("changeType", "Change type (CONVERSION_STATUS / TIER / ENQUIRY_STATUS / LEAD_STATUS)"), ctxVar("oldStatus", "Previous status"), ctxVar("newStatus", "New status"), + // Only emitted by the per-lead LEAD_STATUS path (LeadStatusService); the + // profile-level conversion/tier emitters don't set them. + ctxVar("statusChangeSource", "Who changed it: MANUAL | MANUAL_DISPOSITION | AI_CALLING | AI_WORKFLOW"), + ctxVar("statusChangedByUserId", "User ID of whoever changed it (blank for system changes)"), ctxVar("conversionStatus", "Conversion status"))); // Assessment events. Emitted cross-service by assessment_service's diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/engine/CallAiNodeHandler.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/engine/CallAiNodeHandler.java index 2cf059d2f2..3402e49ed3 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/engine/CallAiNodeHandler.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/engine/CallAiNodeHandler.java @@ -13,8 +13,10 @@ import vacademy.io.admin_core_service.features.telephony.core.AiCallNodeDispatcher; import vacademy.io.admin_core_service.features.telephony.core.AiCallOutcomeProcessor; import vacademy.io.admin_core_service.features.telephony.core.AiCallingSettingsService; +import vacademy.io.admin_core_service.features.telephony.core.CallingWindowUtil; import vacademy.io.admin_core_service.features.telephony.core.dto.AiCallRequestDTO; import vacademy.io.admin_core_service.features.telephony.core.dto.AiCallingSettingsPojo; +import vacademy.io.admin_core_service.features.telephony.enums.CallTrigger; import vacademy.io.admin_core_service.features.workflow.entity.NodeTemplate; import vacademy.io.admin_core_service.features.workflow.entity.WorkflowExecutionState; import vacademy.io.admin_core_service.features.workflow.enums.WorkflowExecutionStatus; @@ -27,7 +29,6 @@ import java.time.LocalDate; import java.time.LocalTime; import java.time.ZoneId; -import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; import java.util.HashMap; import java.util.List; @@ -116,6 +117,13 @@ public Map handle(Map context, String nodeConfig // falls back to the institute's AI_CALLING_SETTING default). Lets one builder pick // the provider for this node without touching global settings. String provider = firstNonBlank(readConfig(nodeConfigJson, "provider"), str(context.get("provider"))); + // Opt-in escape from the already-assigned guard. OFF by default: automation that + // re-dials leads a counsellor already owns is exactly what the guard exists to stop. + // Turned ON only for graphs deliberately built to target owned leads — the canonical + // case being "a counsellor manually called and dispositioned this lead DNP, now let + // the bot try again", where the lead is assigned BY DEFINITION and the guard would + // silently stop every dial. The daily cap and duplicate window still apply. + boolean ignoreAssignedGuard = readConfigBool(nodeConfigJson, "ignoreAssignedGuard"); // Arbitrary call metadata handed to the AI agent (e.g. studentName, sessionName, // courseName — whatever the conversation needs). Static keys come from the node // config's "metadata" object; dynamic per-run values from the context's @@ -152,7 +160,7 @@ public Map handle(Map context, String nodeConfig return out; // routes to next node via normal traversal; does NOT pause/dial; does NOT call giveUpAfterRetries } - Plan plan = plan(instituteId, userId, attempts, callsToday, callsDay, provider); + Plan plan = plan(instituteId, userId, attempts, callsToday, callsDay, provider, ignoreAssignedGuard); switch (plan.action()) { case STOP -> { @@ -184,7 +192,11 @@ public Map handle(Map context, String nodeConfig req.setSubjectId(subjectId); if (!callMetadata.isEmpty()) req.setMetadata(callMetadata); - aiCallDispatcher.enqueue(req); // paced; placeCall guards already-assigned leads + // paced; placeCall re-applies the same guards server-side. The trigger must + // match the decision plan() just made, or placeCall would refuse a dial the + // node already counted as an attempt. + aiCallDispatcher.enqueue(req, ignoreAssignedGuard + ? CallTrigger.WORKFLOW_EXPLICIT : CallTrigger.AUTOMATION); int newAttempts = attempts + 1; String today = LocalDate.now(IST).toString(); @@ -248,6 +260,19 @@ private String readConfig(String json, String key) { } } + /** Read a boolean flag from the node config; absent / unparseable ⇒ false. */ + private boolean readConfigBool(String json, String key) { + if (json == null || json.isBlank()) return false; + try { + JsonNode v = mapper.readTree(json).get(key); + // asBoolean() also accepts the string "true", which is what the builder's + // config panel writes for a checkbox. + return v != null && !v.isNull() && v.asBoolean(false); + } catch (Exception e) { + return false; + } + } + /** Read a JSON object node from the node config as a Map (for the metadata bag). */ @SuppressWarnings("unchecked") private Map readConfigMap(String json, String key) { @@ -392,10 +417,12 @@ private enum Action { DIAL, DEFER, STOP } private record Plan(Action action, Instant resumeAt, String reason) {} private Plan plan(String instituteId, String userId, int attempts, int callsToday, String callsDay, - String provider) { + String provider, boolean ignoreAssignedGuard) { AiCallingSettingsPojo s = settingsService.get(instituteId); if (s == null || !s.isEnabled()) return new Plan(Action.STOP, null, "ai_calling_disabled"); - if (leadAlreadyAssigned(userId, instituteId)) return new Plan(Action.STOP, null, "assigned"); + if (!ignoreAssignedGuard && leadAlreadyAssigned(userId, instituteId)) { + return new Plan(Action.STOP, null, "assigned"); + } if (attempts >= Math.max(1, s.getMaxRetries())) return new Plan(Action.STOP, null, "exhausted"); ZoneId tz = resolveZone(s.getTimezone()); @@ -443,66 +470,33 @@ private boolean leadAlreadyAssigned(String userId, String instituteId) { .isPresent(); } + // The four shift helpers below moved to CallingWindowUtil unchanged, so the AI call + // queue's drainer can apply the SAME window rule when it dispatches an item that has + // been waiting (dialing used to be immediate, so only this node could ever land + // outside a shift). These remain as thin delegates: every call site in this file, and + // its behaviour, is untouched. + /** Inside any [start,end] shift (institute tz); handles windows wrapping midnight. */ private boolean withinAnyShift(Instant now, List shifts, ZoneId tz) { - if (shifts == null || shifts.isEmpty()) return true; - LocalTime t = LocalTime.ofInstant(now, tz); - for (AiCallingSettingsPojo.Shift sh : shifts) { - LocalTime start = parseTime(sh.getStart()); - LocalTime end = parseTime(sh.getEnd()); - if (start == null || end == null) continue; - if (start.equals(end)) return true; // 24h - boolean within = start.isBefore(end) - ? (!t.isBefore(start) && !t.isAfter(end)) - : (!t.isBefore(start) || !t.isAfter(end)); - if (within) return true; - } - return false; + return CallingWindowUtil.withinAnyShift(now, shifts, tz); } /** * Earliest upcoming shift-open instant in the institute tz: the smallest shift * start that is still ahead of {@code now} today; if none remain today, the * smallest shift start tomorrow. Returns null if no usable shift starts (caller - * falls back to the recheck time). Uses the same parse/tz helpers as - * {@link #withinAnyShift}. + * falls back to the recheck time). */ private Instant nextShiftOpen(Instant now, List shifts, ZoneId tz) { - if (shifts == null || shifts.isEmpty()) return null; - LocalDate today = LocalDate.now(tz); - LocalTime nowT = LocalTime.ofInstant(now, tz); - - LocalTime earliestToday = null; // smallest start still ahead today - LocalTime earliestOverall = null; // smallest start of the day (for tomorrow) - for (AiCallingSettingsPojo.Shift sh : shifts) { - LocalTime start = parseTime(sh.getStart()); - if (start == null) continue; - if (earliestOverall == null || start.isBefore(earliestOverall)) earliestOverall = start; - if (start.isAfter(nowT) && (earliestToday == null || start.isBefore(earliestToday))) { - earliestToday = start; - } - } - if (earliestToday != null) return today.atTime(earliestToday).atZone(tz).toInstant(); - if (earliestOverall != null) return today.plusDays(1).atTime(earliestOverall).atZone(tz).toInstant(); - return null; + return CallingWindowUtil.nextShiftOpen(now, shifts, tz); } private LocalTime parseTime(String hhmm) { - if (isBlank(hhmm)) return null; - try { - return LocalTime.parse(hhmm.trim()); - } catch (DateTimeParseException e) { - return null; - } + return CallingWindowUtil.parseTime(hhmm); } private ZoneId resolveZone(String tz) { - if (isBlank(tz)) return IST; - try { - return ZoneId.of(tz.trim()); - } catch (Exception e) { - return IST; - } + return CallingWindowUtil.resolveZone(tz); } private boolean isBlank(String s) { diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/enums/WorkflowTriggerEvent.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/enums/WorkflowTriggerEvent.java index 2509448561..3bf2c4cf10 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/enums/WorkflowTriggerEvent.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/enums/WorkflowTriggerEvent.java @@ -90,6 +90,23 @@ public enum WorkflowTriggerEvent { ONBOARDING_FLOW_COMPLETED, ONBOARDING_STEP_ENTERED, ONBOARDING_STEP_COMPLETED, - ONBOARDING_STEP_SKIPPED + ONBOARDING_STEP_SKIPPED, + + /** + * HR & Payroll events (Phase F5) — institutes attach approval/notification/ + * escalation workflows to these; HR's own emails remain as direct sends, + * these enable AUTOMATION on top. + */ + HR_LEAVE_REQUESTED, + HR_LEAVE_DECIDED, + HR_COMP_OFF_DECIDED, + HR_LOAN_REQUESTED, + HR_LOAN_DECIDED, + HR_REIMBURSEMENT_REQUESTED, + HR_REIMBURSEMENT_DECIDED, + HR_PAYROLL_PROCESSED, + HR_PAYROLL_APPROVED, + HR_PAYROLL_PAID, + HR_EMPLOYEE_STATUS_CHANGED } diff --git a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/service/QueryServiceImpl.java b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/service/QueryServiceImpl.java index 65f20c2d25..59936578cd 100644 --- a/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/service/QueryServiceImpl.java +++ b/admin_core_service/src/main/java/vacademy/io/admin_core_service/features/workflow/service/QueryServiceImpl.java @@ -2294,6 +2294,7 @@ private Map fetchBatchAttendanceReport(Map param logMap.put("engagementData", logEntry.getEngagementData()); logMap.put("providerTotalDurationMinutes", logEntry.getProviderTotalDurationMinutes()); logMap.put("statusType", logEntry.getStatusType()); + logMap.put("attendanceEvaluationJson", logEntry.getAttendanceEvaluationJson()); engagementLogsByStudent.computeIfAbsent(userId, k -> new ArrayList<>()).add(logMap); } } catch (Exception e) { @@ -2383,6 +2384,9 @@ private Map fetchBatchAttendanceReport(Map param // tags or @media queries needed), and are readable on every // screen size. StringBuilder tableHtml = new StringBuilder(); + // Set when at least one card explains a criteria-driven + // absence — the closing note below is only meaningful then. + boolean anyCriteriaAbsence = false; tableHtml.append("

"); // Index engagement logs by sessionId for quick lookup @@ -2514,6 +2518,30 @@ private Map fetchBatchAttendanceReport(Map param // One card per session — uses a 2-cell table for the header row // (title + status pill) so it works in Outlook (no flexbox). // Body uses simple
s for label/value rows. + // When a minimum-attendance rule decided this row, say so on the + // card. "Absent" next to "Duration: 4 min" reads like a + // contradiction unless the learner is told what the bar was. + String absenceReason = null; + if ("ABSENT".equals(status) && eng != null + && eng.get("attendanceEvaluationJson") != null) { + try { + var ev = objectMapper.readTree( + String.valueOf(eng.get("attendanceEvaluationJson"))); + String why = ev.path("reason").asText(""); + long attSec = ev.path("attendedSeconds").asLong(0); + // The threshold is not disclosed to learners; only that + // the time fell short of it. + if ("BELOW_THRESHOLD".equals(why)) { + absenceReason = "Marked absent — you were in the class for " + + fmtHms(attSec) + ", which is below the minimum" + + " attendance required for this class."; + } else if ("NO_SHOW".equals(why)) { + absenceReason = "Marked absent — our records show you did not" + + " join the class."; + } + } catch (Exception ignored) {} + } + String sessionTitle = String.valueOf(session.getOrDefault("title", "-")); String meetingDate = String.valueOf(session.getOrDefault("meetingDate", "-")); String statusBg = "PRESENT".equals(status) ? "#dcfce7" : "#fee2e2"; @@ -2541,8 +2569,23 @@ private Map fetchBatchAttendanceReport(Map param .append("") .append(engagementStr).append("") .append("
"); + if (absenceReason != null) { + anyCriteriaAbsence = true; + tableHtml.append("
") + .append(absenceReason).append("
"); + } tableHtml.append("
"); } + if (anyCriteriaAbsence) { + // Rendered inside {{sessionsTableHtml}} so the institute's + // stored Attendance Report template is not touched. + tableHtml.append("

") + .append("If there is any discrepancy, please contact the faculty.") + .append("

"); + } tableHtml.append(""); s.put("sessionsTableHtml", tableHtml.toString()); @@ -2759,5 +2802,12 @@ private String extractOrderIdFromPaymentSpecificData(String json) { } return null; } + + /** "4m 50s" / "6m" / "45s" — learner-facing duration for the attendance report. */ + private static String fmtHms(long totalSeconds) { + long m = totalSeconds / 60, sec = totalSeconds % 60; + if (m == 0) return sec + "s"; + return sec == 0 ? m + "m" : m + "m " + sec + "s"; + } } diff --git a/admin_core_service/src/main/resources/application-dev.properties b/admin_core_service/src/main/resources/application-dev.properties index 1ccb76083d..c73be2c0b0 100644 --- a/admin_core_service/src/main/resources/application-dev.properties +++ b/admin_core_service/src/main/resources/application-dev.properties @@ -18,6 +18,7 @@ notification.server.baseurl=${NOTIFICATION_SERVER_BASE_URL} media.server.baseurl=${MEDIA_SERVER_BASE_URL:http://localhost:8075} media.service.baseurl=${MEDIA_SERVER_BASE_URL:http://localhost:8075} assessment.server.baseurl=${ASSESSMENT_SERVER_BASE_URL} +community.server.baseurl=${COMMUNITY_SERVICE_BASE_URL:http://localhost:8073} spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=10MB logging.level.org.springframework.security=DEBUG diff --git a/admin_core_service/src/main/resources/application-k8s-local.properties b/admin_core_service/src/main/resources/application-k8s-local.properties index dba0d522b6..ae0d020981 100644 --- a/admin_core_service/src/main/resources/application-k8s-local.properties +++ b/admin_core_service/src/main/resources/application-k8s-local.properties @@ -22,6 +22,7 @@ spring.mvc.dispatch-options-request=true auth.server.baseurl=http://auth-service.vacademy.svc.cluster.local:8071 notification.server.baseurl=http://notification-service.vacademy.svc.cluster.local:8076 assessment.server.baseurl=http://assessment-service.vacademy.svc.cluster.local:8074 +community.server.baseurl=http://community-service.vacademy.svc.cluster.local:8073 ## File Upload spring.servlet.multipart.max-file-size=10MB diff --git a/admin_core_service/src/main/resources/application-stage.properties b/admin_core_service/src/main/resources/application-stage.properties index 4e7ae80427..5c58bb7dda 100644 --- a/admin_core_service/src/main/resources/application-stage.properties +++ b/admin_core_service/src/main/resources/application-stage.properties @@ -30,6 +30,7 @@ notification.server.baseurl=${NOTIFICATION_SERVER_BASE_URL} media.server.baseurl=${MEDIA_SERVER_BASE_URL} media.service.baseurl=${MEDIA_SERVER_BASE_URL} assessment.server.baseurl=${ASSESSMENT_SERVER_BASE_URL:https://backend-stage.vacademy.io} +community.server.baseurl=${COMMUNITY_SERVICE_BASE_URL:http://community-service:8073} # SCORM zips routinely exceed 10MB; keep in sync with the ingress # proxy-body-size (200m) in vacademy_devops/vacademy-services/templates/ingress.yaml spring.servlet.multipart.max-file-size=150MB diff --git a/admin_core_service/src/main/resources/application.properties b/admin_core_service/src/main/resources/application.properties index f39d0828ca..d335eded7e 100644 --- a/admin_core_service/src/main/resources/application.properties +++ b/admin_core_service/src/main/resources/application.properties @@ -217,6 +217,28 @@ telephony.airtel.import.poll-ms=${TELEPHONY_AIRTEL_IMPORT_POLL_MS:120000} telephony.airtel.import.initial-delay-ms=${TELEPHONY_AIRTEL_IMPORT_INITIAL_DELAY_MS:60000} telephony.airtel.import.max-per-run=${TELEPHONY_AIRTEL_IMPORT_MAX_PER_RUN:500} spring.task.scheduling.pool.size=4 +# ── AI call queue ──────────────────────────────────────────────────────────── +# Every AI dial (workflow node, bulk campaign, manual click) is queued in +# ai_call_queue and placed by AiCallQueueDrainJob, which is the only dialler and +# therefore the only place the fleet-wide concurrency limit has to be enforced. +# +# HOW MANY calls may run at once is NOT set here — it is the sum of +# ai_voice_box.max_concurrent, managed at runtime through +# /admin-core-service/super-admin/v1/ai-queue/capacity so adding a box needs no +# redeploy. The rest of the knobs live in app_config for the same reason. +# +# false = ROLLBACK to the pre-queue behaviour: the CALL_AI node's in-memory paced +# executor, the bulk campaign's per-campaign sliding window, and a synchronous +# manual click. That path has no fleet-wide limit; it exists to get out of trouble, +# not as a supported mode. +telephony.ai.queue.enabled=${TELEPHONY_AI_QUEUE_ENABLED:true} +# How often the drainer looks for a free line. Cheap: the candidate query is bounded +# by (lanes with work x fleet capacity) rows however deep the queue is. +telephony.ai.queue.drain-delay-ms=${TELEPHONY_AI_QUEUE_DRAIN_MS:2000} +# How often each voice box is asked /voice-bot-service/health. A box that stops +# answering is marked DOWN and its slots leave the fleet until it recovers. +telephony.ai.queue.health-poll-ms=${TELEPHONY_AI_QUEUE_HEALTH_MS:30000} + # Vacademy AI Agent — public HTTPS base of the dedicated voice-bot service # (ap-south-1). Empty = the VACADEMY_AI provider refuses to dial with a clear # error. The bot authenticates back via InternalAuthFilter (client_secret_key diff --git a/admin_core_service/src/main/resources/db/migration/V470__Institute_live_session_base_url.sql b/admin_core_service/src/main/resources/db/migration/V470__Institute_live_session_base_url.sql new file mode 100644 index 0000000000..e8a35a427d --- /dev/null +++ b/admin_core_service/src/main/resources/db/migration/V470__Institute_live_session_base_url.sql @@ -0,0 +1,40 @@ +-- ===================================================================== +-- V470: Per-institute live-class domain (BBB white-labelling) +-- ===================================================================== +-- Institutes that serve live classes from their own subdomain (e.g. +-- meet.zoeedtech.com) record it here. NULL means "use the platform +-- default", i.e. the pool server's own domain (meet.vacademy.io). +-- +-- Sits alongside learner_portal_base_url / admin_portal_base_url / +-- teacher_portal_base_url, which are the same kind of fact about the +-- same entity. +-- +-- Two deliberate constraints on how this value is used, both enforced in +-- BbbMeetingManager rather than here: +-- +-- 1. ONLY the join URL handed to a participant is rewritten to this +-- host. Control-plane calls (create / isMeetingRunning / +-- getRecordings / getAttendance) always use the pool server's own +-- api_url. A broken custom domain therefore costs branding on a +-- link, never a class: meetings still get created and recorded. +-- +-- 2. The rewrite is skipped unless the meeting was placed on the +-- PRIMARY pool server. The institute's A record points at exactly +-- one box; if a meeting spills to a lower-priority server and we +-- still rewrote the host, we would send the learner to a server +-- that does not have their meeting. Falling back to the canonical +-- domain gives an off-brand URL that works, instead of a branded +-- URL that does not. +-- +-- Stored as a bare hostname (no scheme, no path, no port) and +-- normalised on write. Note the sibling *_portal_base_url columns are +-- inconsistent about this -- their defaults are bare hosts while +-- WhiteLabelService writes https://-prefixed values -- so this column +-- deliberately does not follow that precedent. +-- ===================================================================== + +ALTER TABLE institutes + ADD COLUMN IF NOT EXISTS live_session_base_url VARCHAR(255); + +COMMENT ON COLUMN institutes.live_session_base_url IS + 'Custom live-class hostname, e.g. meet.zoeedtech.com. NULL = use the default pool domain. Must resolve to the PRIMARY BBB pool server, and must be present as a SAN on that server''s certificate.'; diff --git a/admin_core_service/src/main/resources/db/migration/V471__attendance_duration_seconds.sql b/admin_core_service/src/main/resources/db/migration/V471__attendance_duration_seconds.sql new file mode 100644 index 0000000000..5424c51fee --- /dev/null +++ b/admin_core_service/src/main/resources/db/migration/V471__attendance_duration_seconds.sql @@ -0,0 +1,18 @@ +-- BBB reports each attendee's time in the room in SECONDS, but we stored only +-- (seconds / 60) as an integer — a floor, so every learner silently lost up to +-- 59 seconds and the loss always counted against them. +-- +-- That is invisible on a long class but decides borderline cases: on a 7-minute +-- class at 60% the bar is 4.2 minutes, so a learner present for 4m50s (69%) was +-- truncated to 4 and marked ABSENT for a class they clearly attended. +-- +-- Keep provider_total_duration_minutes exactly as it is — reports, exports and +-- the workflow query layer all read it. This adds the precise value alongside, +-- so the attendance rule can compare in seconds and the UI can show m:ss. +-- NULL means the provider gave us no better than minutes (Zoom reports whole +-- minutes only), and callers fall back to the minutes column. +ALTER TABLE live_session_logs + ADD COLUMN IF NOT EXISTS provider_total_duration_seconds INTEGER; + +COMMENT ON COLUMN live_session_logs.provider_total_duration_seconds IS + 'Exact seconds the attendee was in the meeting, as reported by the provider (BBB). NULL when only whole minutes are available (Zoom). provider_total_duration_minutes remains the floored minute value for existing consumers.'; diff --git a/admin_core_service/src/main/resources/db/migration/V472__ai_call_queue.sql b/admin_core_service/src/main/resources/db/migration/V472__ai_call_queue.sql new file mode 100644 index 0000000000..877b2af3c4 --- /dev/null +++ b/admin_core_service/src/main/resources/db/migration/V472__ai_call_queue.sql @@ -0,0 +1,173 @@ +-- V472: AI call queue — one durable, fleet-wide queue in front of every AI dial. +-- +-- Until now there were THREE uncoordinated in-memory pacers (the CALL_AI node's +-- single-thread executor, the bulk campaign's per-campaign sliding window with its +-- own MAX_PARALLEL=3, and the manual click going straight to the provider). None of +-- them knew about the others, none survived a restart, and admin-core runs 2-4 +-- replicas so each held its own copy. Overload was absorbed by the voice bot's +-- admission control, which answers "all lines busy" to a real lead. +-- +-- This migration adds the three tables that replace them: +-- +-- ai_voice_box the capacity pool. Fleet capacity for our own bot is the SUM of +-- max_concurrent over enabled, healthy boxes -- so adding a second +-- Mumbai box is an INSERT, not a redeploy. +-- ai_call_queue the durable queue itself. FIFO on (priority DESC, created_at). +-- ai_call_lane per-institute OVERRIDES only. A lane with no row uses the dynamic +-- default cap, so this table stays empty until someone tunes a +-- customer. +-- +-- Fairness note: ordering is strict FIFO. What stops one institute's 500-lead bulk +-- upload from blocking another institute is the per-lane concurrency cap -- the drain +-- scan SKIPS an item whose institute is already at its cap, so a latecomer with 5 +-- leads takes the next free slot instead of waiting out the backlog. That holds while +-- the number of simultaneously-busy institutes is <= fleet capacity; beyond that the +-- tail lane starves and the fix is a rotation. ai_call_lane.last_dispatched_at is +-- carried (written, never read) so switching to round-robin later is an ORDER BY +-- change rather than another migration. + +-- ── 1. Capacity pool ──────────────────────────────────────────────────────────── +-- Modelled on bbb_server_pool (V192), which already proved this shape out. base_url +-- is recorded for routing + health polling; DIALING still resolves the bot address +-- from telephony.vacademy-ai.bot-base-url, so this table cannot change where a call +-- goes -- it only decides how many may be in flight. +CREATE TABLE IF NOT EXISTS ai_voice_box ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug VARCHAR(50) NOT NULL UNIQUE, -- 'mumbai-1' + base_url VARCHAR(255) NOT NULL, -- https:// (no trailing slash) + max_concurrent INT NOT NULL DEFAULT 3, -- simultaneous calls this box can carry + priority INT NOT NULL DEFAULT 1, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + health_status VARCHAR(20) NOT NULL DEFAULT 'UNKNOWN', -- HEALTHY | DOWN | UNKNOWN + active_calls INT, -- last /health activeCalls reading + last_health_check TIMESTAMP, + notes VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Seed the one box that exists today, at the capacity the docs record for it +-- (MAX_CONCURRENT_CALLS=10 is the bot's own abuse bound; 3 is what a 1 vCPU box +-- actually carries cleanly, and is the number the bulk campaign was already using). +-- base_url is a placeholder: the health poller skips a box whose URL is not a real +-- host, so an unedited seed row contributes capacity without ever being polled. +INSERT INTO ai_voice_box (slug, base_url, max_concurrent, notes) +VALUES ('default', 'CONFIGURE_ME', 3, + 'Seeded by V472. Set base_url to the Mumbai bot host to enable health polling.') +ON CONFLICT (slug) DO NOTHING; + +-- ── 2. Runtime knobs ──────────────────────────────────────────────────────────── +-- app_config already exists (V192) and is read through AppConfigRepository, so these +-- are tunable without a redeploy. +INSERT INTO app_config (config_key, config_value, description) VALUES + ('ai_call_capacity_enabled', 'true', 'false = drain the queue with NO concurrency limit (emergency lever; the queue still applies windows + dedupe)'), + ('ai_call_aavtaar_max_concurrent','20', 'Concurrent AAVTAAR AI calls. Aavtaar dials on THEIR infrastructure, so this is a courtesy rate limit, not our capacity. 0 = unlimited'), + ('ai_call_stuck_grace_sec', '720', 'A non-terminal call older than this stops occupying a slot (lost webhook backstop). Max call is 6-10 min'), + ('ai_call_queue_ttl_hours', '48', 'A queued call older than this is EXPIRED rather than dialled'), + ('ai_call_avg_secs', '180', 'Assumed call duration, used only for the ETA shown to admins'), + ('ai_call_reserved_interactive', '0', 'Slots held back for MANUAL clicks. 0 = manual queues behind everything, which is the configured behaviour'), + ('ai_call_drain_batch', '200', 'Max queue rows examined per drain tick') +ON CONFLICT (config_key) DO NOTHING; + +-- ── 3. The queue ──────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS ai_call_queue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + institute_id VARCHAR(255) NOT NULL, + -- Resolved at ENQUEUE time (never left blank) because capacity is accounted per + -- provider: VACADEMY_AI draws on ai_voice_box, AAVTAAR on its own limit, MOCK is + -- unlimited because it never leaves the box. + provider VARCHAR(50) NOT NULL, + -- Ordering WITHIN a lane. Higher first; ties broken by created_at. Everything + -- enqueues at 100 today -- the column exists so a future "retries before fresh + -- bulk" policy needs no migration. + priority INT NOT NULL DEFAULT 100, + source VARCHAR(30) NOT NULL, -- WORKFLOW | BULK | MANUAL | RETRY + source_ref VARCHAR(255), -- audience id / workflow execution id + -- The CallTrigger this item must be dialled with. Carried on the row, not + -- re-derived at dispatch, so a MANUAL click keeps its throttle exemptions after + -- sitting in the queue for an hour. + call_trigger VARCHAR(30) NOT NULL, + + response_id VARCHAR(255), + user_id VARCHAR(255), + phone_number VARCHAR(32), + campaign_id VARCHAR(255), + campaign_name VARCHAR(255), + preferred_number_id VARCHAR(255), + subject_type VARCHAR(32), + subject_id VARCHAR(255), + customer_name VARCHAR(255), + customer_email VARCHAR(255), + metadata TEXT, -- JSON, replayed onto AiCallRequestDTO + actor_user_id VARCHAR(255), -- becomes counsellor_user_id on the call log + + -- One PENDING call per (institute, subject, provider). See the partial unique + -- index below -- this is what makes a workflow resume (the engine restarts runs) + -- or a re-fired bulk idempotent. + dedupe_key VARCHAR(512) NOT NULL, + + status VARCHAR(20) NOT NULL DEFAULT 'QUEUED', + -- QUEUED | DISPATCHING | DIALED | FAILED | EXPIRED | CANCELLED + attempts INT NOT NULL DEFAULT 0, + last_error TEXT, + status_reason VARCHAR(255), -- why it ended where it did, for the UI + + not_before TIMESTAMP, -- calling-window / backoff gate + expires_at TIMESTAMP, -- TTL; past this it is EXPIRED, not dialled + + call_log_id VARCHAR(255), -- set once dialled + dispatched_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Idempotency. Partial, so it constrains only rows that have not been dialled yet: +-- a lead may be queued again once its previous call has gone out (which is exactly +-- what a legitimate retry sequence does), but a workflow re-entering the same node +-- five times while the first item still waits inserts once. +CREATE UNIQUE INDEX IF NOT EXISTS ux_ai_call_queue_pending + ON ai_call_queue (dedupe_key) + WHERE status IN ('QUEUED', 'DISPATCHING'); + +-- The drain pick: oldest eligible item per institute. Partial on QUEUED because the +-- table is append-only and the dialled history dwarfs the pending set within a day. +CREATE INDEX IF NOT EXISTS idx_ai_call_queue_pick + ON ai_call_queue (institute_id, priority DESC, created_at) + WHERE status = 'QUEUED'; + +-- Sweeps: TTL expiry and the "how deep is my queue" reads. +CREATE INDEX IF NOT EXISTS idx_ai_call_queue_expiry + ON ai_call_queue (expires_at) + WHERE status = 'QUEUED'; + +CREATE INDEX IF NOT EXISTS idx_ai_call_queue_institute_created + ON ai_call_queue (institute_id, created_at DESC); + +-- Campaign progress dialog: "what happened to the leads I queued from this list?" +CREATE INDEX IF NOT EXISTS idx_ai_call_queue_source_ref + ON ai_call_queue (source, source_ref) + WHERE source_ref IS NOT NULL; + +-- ── 4. Per-institute overrides ────────────────────────────────────────────────── +-- Deliberately sparse: an institute with no row here gets the dynamic default cap +-- (ceil(fleetCapacity / lanesWithWork), floored at 1), which is work-conserving -- +-- one institute dialling alone at 2am uses the whole fleet. +CREATE TABLE IF NOT EXISTS ai_call_lane ( + institute_id VARCHAR(255) PRIMARY KEY, + max_concurrent INT, -- NULL = use the dynamic default + weight INT NOT NULL DEFAULT 1, -- reserved for weighted rotation + paused BOOLEAN NOT NULL DEFAULT FALSE, + last_dispatched_at TIMESTAMP, -- written, not read (see header note) + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ── 5. In-flight accounting ───────────────────────────────────────────────────── +-- The drainer counts occupied slots off telephony_call_log rather than a counter, so +-- a lost webhook cannot leak a slot permanently and a call placed OUTSIDE the queue +-- (a legacy path, a MOCK, an inbound AI answer) is still counted. This partial index +-- covers exactly that predicate; it indexes only live calls, so it stays tiny. +CREATE INDEX IF NOT EXISTS idx_tcl_ai_in_flight + ON telephony_call_log (provider_type, created_at) + WHERE status IN ('INITIATED', 'QUEUED', 'COUNSELLOR_RINGING', + 'COUNSELLOR_ANSWERED', 'IN_PROGRESS'); diff --git a/admin_core_service/src/main/resources/db/migration/V473__ai_call_queue_id_varchar.sql b/admin_core_service/src/main/resources/db/migration/V473__ai_call_queue_id_varchar.sql new file mode 100644 index 0000000000..4cb91d82dd --- /dev/null +++ b/admin_core_service/src/main/resources/db/migration/V473__ai_call_queue_id_varchar.sql @@ -0,0 +1,31 @@ +-- V473: the AI call queue's primary keys are VARCHAR, not UUID. +-- +-- V472 declared ai_call_queue.id and ai_voice_box.id as UUID with a +-- gen_random_uuid() default. Both entities map that id as a Java String annotated +-- @UuidGenerator, exactly like every other telephony entity, so Hibernate binds a +-- varchar parameter -- and Postgres will not implicitly cast varchar to uuid in an +-- INSERT or in a WHERE. Every enqueue failed on staging with: +-- +-- column "id" is of type uuid but expression is of type character varying +-- +-- and the drain job's claimForDispatch (WHERE id = :id) would have failed the same +-- way. The rest of the schema was already right; only these two columns deviated +-- from the convention every sibling table follows -- telephony_call_log (V319), +-- ai_calling_config (V344) and the rest all use VARCHAR(36) PRIMARY KEY. +-- +-- Fixed forward in a new migration rather than by editing V472: that migration has +-- already run, and rewriting an applied script breaks Flyway's checksum validation +-- for every environment that has it. +-- +-- The DEFAULT goes with the type. It was only ever used by V472's own seed row -- +-- the application always supplies its own id through @UuidGenerator -- and +-- gen_random_uuid() cannot be a default for a varchar column anyway. +-- +-- Safe on data: both tables hold at most the seeded voice-box row and queue rows +-- that never dialled, and uuid::text is lossless in any case. + +ALTER TABLE ai_call_queue ALTER COLUMN id DROP DEFAULT; +ALTER TABLE ai_call_queue ALTER COLUMN id TYPE VARCHAR(36) USING id::text; + +ALTER TABLE ai_voice_box ALTER COLUMN id DROP DEFAULT; +ALTER TABLE ai_voice_box ALTER COLUMN id TYPE VARCHAR(36) USING id::text; diff --git a/admin_core_service/src/main/resources/db/migration/V474__coupon_min_items.sql b/admin_core_service/src/main/resources/db/migration/V474__coupon_min_items.sql new file mode 100644 index 0000000000..0a3bc7325e --- /dev/null +++ b/admin_core_service/src/main/resources/db/migration/V474__coupon_min_items.sql @@ -0,0 +1,8 @@ +-- Quantity condition for a coupon: the smallest basket it may be used on. +-- +-- NULL means no condition, which is every coupon that exists today — so this is +-- inert until an admin sets it. +ALTER TABLE coupon_code ADD COLUMN IF NOT EXISTS min_items INTEGER; + +COMMENT ON COLUMN coupon_code.min_items IS + 'Minimum number of courses in the basket for this coupon to apply. NULL = no minimum.'; diff --git a/admin_core_service/src/main/resources/db/migration/V480__Hr_wave1_hardening.sql b/admin_core_service/src/main/resources/db/migration/V480__Hr_wave1_hardening.sql new file mode 100644 index 0000000000..f40b011e29 --- /dev/null +++ b/admin_core_service/src/main/resources/db/migration/V480__Hr_wave1_hardening.sql @@ -0,0 +1,58 @@ +-- HR & Payroll Wave 1 hardening (see docs/erp/hr-payroll-review-and-gap-plan.md, Phase A + B1). + +-- 1) Optimistic locking on financially sensitive tables (@Version). +ALTER TABLE hr_payroll_run ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 0; +ALTER TABLE hr_payroll_entry ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 0; +ALTER TABLE hr_leave_balance ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 0; +ALTER TABLE hr_employee_loan ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 0; +ALTER TABLE hr_employee_salary_structure ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 0; + +-- 2) Payroll runs: a CANCELLED run must not block the month forever, and +-- off-cycle runs (FNF/BONUS/OFF_CYCLE, Phase C) need a type column now. +-- Replace the hard UNIQUE(institute_id, month, year) with a partial unique +-- index over non-cancelled REGULAR runs. +ALTER TABLE hr_payroll_run ADD COLUMN IF NOT EXISTS run_type VARCHAR(30) NOT NULL DEFAULT 'REGULAR'; +ALTER TABLE hr_payroll_run DROP CONSTRAINT IF EXISTS hr_payroll_run_institute_id_month_year_key; +CREATE UNIQUE INDEX IF NOT EXISTS ux_hr_payroll_run_active_regular + ON hr_payroll_run (institute_id, month, year) + WHERE status <> 'CANCELLED' AND run_type = 'REGULAR'; + +-- 3) Per-employee processing errors: replaces the silent empty-catch in +-- PayrollCalculationService. One row per employee whose entry failed. +CREATE TABLE IF NOT EXISTS hr_payroll_entry_error ( + id VARCHAR(255) PRIMARY KEY, + payroll_run_id VARCHAR(255) NOT NULL REFERENCES hr_payroll_run(id), + employee_id VARCHAR(255) NOT NULL REFERENCES hr_employee_profile(id), + error_stage VARCHAR(50), + error_message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_hr_payroll_entry_error_run ON hr_payroll_entry_error(payroll_run_id); + +-- 4) Field-level encryption at rest (AES-256-GCM via EncryptedStringConverter, +-- "ENCv1:"-prefixed base64; legacy plaintext rows read through unchanged). +-- Widen the columns to hold ciphertext; statutory_info becomes TEXT because +-- ciphertext is not valid jsonb (nothing queries it in SQL). +ALTER TABLE hr_employee_bank_detail ALTER COLUMN account_number TYPE VARCHAR(512); +ALTER TABLE hr_employee_profile ALTER COLUMN pan_number TYPE VARCHAR(512); +ALTER TABLE hr_employee_profile ALTER COLUMN uan_number TYPE VARCHAR(512); +ALTER TABLE hr_employee_profile ALTER COLUMN statutory_info TYPE TEXT USING statutory_info::text; + +-- 5) hr_tax_computation duplicated on every reprocess (no unique, no cleanup). +-- Dedupe whatever exists, then enforce one row per employee per period. +DELETE FROM hr_tax_computation a +USING hr_tax_computation b +WHERE a.ctid < b.ctid + AND a.employee_id = b.employee_id + AND a.financial_year = b.financial_year + AND a.month = b.month + AND a.year = b.year; +ALTER TABLE hr_tax_computation + ADD CONSTRAINT ux_hr_tax_computation_period UNIQUE (employee_id, financial_year, month, year); + +-- 6) Missing indexes on hot lookups found in review. +CREATE INDEX IF NOT EXISTS idx_hr_loan_repayment_payroll_entry ON hr_loan_repayment(payroll_entry_id); +CREATE INDEX IF NOT EXISTS idx_hr_reimbursement_payroll_entry ON hr_reimbursement(payroll_entry_id); +CREATE INDEX IF NOT EXISTS idx_hr_attendance_record_inst_date ON hr_attendance_record(institute_id, attendance_date); +CREATE INDEX IF NOT EXISTS idx_hr_leave_application_applied_to ON hr_leave_application(applied_to); +CREATE INDEX IF NOT EXISTS idx_hr_salary_structure_emp_status ON hr_employee_salary_structure(employee_id, status); diff --git a/admin_core_service/src/main/resources/db/migration/V481__Hr_wave2_correctness.sql b/admin_core_service/src/main/resources/db/migration/V481__Hr_wave2_correctness.sql new file mode 100644 index 0000000000..6199597c8e --- /dev/null +++ b/admin_core_service/src/main/resources/db/migration/V481__Hr_wave2_correctness.sql @@ -0,0 +1,33 @@ +-- HR & Payroll Wave 2 (see docs/erp/hr-payroll-review-and-gap-plan.md, Phase B2/B3 + E1). + +-- 1) Per-institute timezone for attendance day-bucketing (JVM stays UTC — repo rule). +ALTER TABLE hr_attendance_config ADD COLUMN IF NOT EXISTS timezone VARCHAR(60) NOT NULL DEFAULT 'Asia/Kolkata'; + +-- 2) Currency (E1): one currency per institute policy, stamped per record so +-- historical rows keep their currency across a future institute change. +ALTER TABLE hr_employee_salary_structure ADD COLUMN IF NOT EXISTS currency VARCHAR(3) NOT NULL DEFAULT 'INR'; +ALTER TABLE hr_payroll_run ADD COLUMN IF NOT EXISTS currency VARCHAR(3) NOT NULL DEFAULT 'INR'; +ALTER TABLE hr_payroll_entry ADD COLUMN IF NOT EXISTS currency VARCHAR(3) NOT NULL DEFAULT 'INR'; +ALTER TABLE hr_payslip ADD COLUMN IF NOT EXISTS currency VARCHAR(3) NOT NULL DEFAULT 'INR'; +ALTER TABLE hr_bank_export_log ADD COLUMN IF NOT EXISTS currency VARCHAR(3) NOT NULL DEFAULT 'INR'; +ALTER TABLE hr_employee_loan ADD COLUMN IF NOT EXISTS currency VARCHAR(3) NOT NULL DEFAULT 'INR'; +ALTER TABLE hr_reimbursement ADD COLUMN IF NOT EXISTS currency VARCHAR(3) NOT NULL DEFAULT 'INR'; + +-- 3) Leave accrual ledger: one row per employee/leave-type/period. Replaces the +-- broken "accrued >= amount*month" idempotency heuristic — re-invoking accrual +-- for a period is now a no-op by unique constraint, and mid-year joiners +-- can't be double-credited. period_key: '2026-08' (monthly), '2026-Q3' +-- (quarterly), '2026' (yearly). +CREATE TABLE IF NOT EXISTS hr_leave_accrual_txn ( + id VARCHAR(255) PRIMARY KEY, + employee_id VARCHAR(255) NOT NULL REFERENCES hr_employee_profile(id), + leave_type_id VARCHAR(255) NOT NULL REFERENCES hr_leave_type(id), + policy_id VARCHAR(255), + year INT NOT NULL, + period_key VARCHAR(20) NOT NULL, + amount DECIMAL(5,2) NOT NULL, + source VARCHAR(30) DEFAULT 'ACCRUAL', -- ACCRUAL | PRO_RATA | CARRY_FORWARD + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (employee_id, leave_type_id, period_key) +); +CREATE INDEX IF NOT EXISTS idx_hr_leave_accrual_txn_emp_year ON hr_leave_accrual_txn(employee_id, year); diff --git a/admin_core_service/src/main/resources/db/migration/V482__Hr_wave3_payroll_adjustments.sql b/admin_core_service/src/main/resources/db/migration/V482__Hr_wave3_payroll_adjustments.sql new file mode 100644 index 0000000000..b676312984 --- /dev/null +++ b/admin_core_service/src/main/resources/db/migration/V482__Hr_wave3_payroll_adjustments.sql @@ -0,0 +1,29 @@ +-- HR & Payroll Wave 3 (Phase C): variable-pay input. +-- One row per employee/month ad-hoc earning or deduction (bonus, incentive, +-- notice recovery, leave encashment, arrears...). Consumed by payroll +-- processing; the CRM-incentive and F&F flows both feed this table. +CREATE TABLE IF NOT EXISTS hr_payroll_adjustment ( + id VARCHAR(255) PRIMARY KEY, + institute_id VARCHAR(255) NOT NULL, + employee_id VARCHAR(255) NOT NULL REFERENCES hr_employee_profile(id), + month INT NOT NULL, + year INT NOT NULL, + type VARCHAR(20) NOT NULL, -- EARNING | DEDUCTION + code VARCHAR(30) NOT NULL, -- component code it materializes under (e.g. BONUS, LEAVE_ENCASHMENT, NOTICE_RECOVERY) + label VARCHAR(100) NOT NULL, + amount DECIMAL(15,2) NOT NULL, + currency VARCHAR(3) NOT NULL DEFAULT 'INR', + run_scope VARCHAR(30) NOT NULL DEFAULT 'REGULAR', -- which run type consumes it: REGULAR | OFF_CYCLE | FNF | BONUS + source VARCHAR(30) DEFAULT 'MANUAL', -- MANUAL | FNF | CRM_INCENTIVE | SYSTEM + notes TEXT, + payroll_entry_id VARCHAR(255), -- set once consumed by a processed run + created_by VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_hr_payroll_adjustment_period + ON hr_payroll_adjustment(institute_id, year, month); +CREATE INDEX IF NOT EXISTS idx_hr_payroll_adjustment_employee + ON hr_payroll_adjustment(employee_id, year, month); +CREATE INDEX IF NOT EXISTS idx_hr_payroll_adjustment_entry + ON hr_payroll_adjustment(payroll_entry_id); diff --git a/admin_core_service/src/main/resources/db/migration/V483__Hr_phase_d_compliance.sql b/admin_core_service/src/main/resources/db/migration/V483__Hr_phase_d_compliance.sql new file mode 100644 index 0000000000..644a538958 --- /dev/null +++ b/admin_core_service/src/main/resources/db/migration/V483__Hr_phase_d_compliance.sql @@ -0,0 +1,26 @@ +-- HR & Payroll Phase D: India compliance pack. +-- TDS challans: deposits made against withheld TDS, mapped into Form 24Q. +-- Institute-level statutory identifiers (TAN, employer PAN, PF establishment +-- id, ESI employer code, PT registration) live in the existing +-- hr_tax_configuration.statutory_settings JSONB — documented keys: +-- deductor_name, deductor_address, employer_pan, tan, +-- pf_establishment_id, esi_employer_code, pt_registration_number +CREATE TABLE IF NOT EXISTS hr_tds_challan ( + id VARCHAR(255) PRIMARY KEY, + institute_id VARCHAR(255) NOT NULL, + financial_year VARCHAR(10) NOT NULL, -- "2025-26" + quarter VARCHAR(2) NOT NULL, -- Q1..Q4 (FY quarters: Q1=Apr-Jun) + month INT, -- optional: the salary month it covers + year INT, + deposit_date DATE NOT NULL, + bsr_code VARCHAR(10), + challan_serial VARCHAR(10), + amount DECIMAL(15,2) NOT NULL, -- TDS deposited + interest DECIMAL(15,2) DEFAULT 0, + fee DECIMAL(15,2) DEFAULT 0, + notes TEXT, + created_by VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_hr_tds_challan_inst_fy ON hr_tds_challan(institute_id, financial_year, quarter); diff --git a/admin_core_service/src/main/resources/db/migration/V484__Erp_journal_seed.sql b/admin_core_service/src/main/resources/db/migration/V484__Erp_journal_seed.sql new file mode 100644 index 0000000000..e0c4d55ad6 --- /dev/null +++ b/admin_core_service/src/main/resources/db/migration/V484__Erp_journal_seed.sql @@ -0,0 +1,49 @@ +-- Phase F4: the ERP journal layer — the seed of the Accounting/GL module. +-- Payroll posts here on approval; fees and future accounting post into the +-- same pair, so cross-module P&L reads one table. + +CREATE TABLE IF NOT EXISTS erp_journal_entry ( + id VARCHAR(255) PRIMARY KEY, + institute_id VARCHAR(255) NOT NULL, + entry_date DATE NOT NULL, + period_month INT, + period_year INT, + source_module VARCHAR(50) NOT NULL, -- HR_PAYROLL | FEES | MANUAL | ... + source_id VARCHAR(255), -- e.g. payroll_run_id (idempotency key with module) + reference VARCHAR(255), + memo TEXT, + currency VARCHAR(3) NOT NULL DEFAULT 'INR', + status VARCHAR(20) NOT NULL DEFAULT 'POSTED', -- POSTED | REVERSED + reversal_of_entry_id VARCHAR(255), -- set on the reversing entry + total_debit DECIMAL(18,2) NOT NULL DEFAULT 0, + total_credit DECIMAL(18,2) NOT NULL DEFAULT 0, + created_by VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +-- One POSTED entry per source object (reversals reference, not collide). +CREATE UNIQUE INDEX IF NOT EXISTS ux_erp_journal_source + ON erp_journal_entry (source_module, source_id) + WHERE status = 'POSTED' AND reversal_of_entry_id IS NULL AND source_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_erp_journal_inst_period + ON erp_journal_entry (institute_id, period_year, period_month); + +CREATE TABLE IF NOT EXISTS erp_journal_line ( + id VARCHAR(255) PRIMARY KEY, + journal_entry_id VARCHAR(255) NOT NULL REFERENCES erp_journal_entry(id), + line_no INT NOT NULL, + gl_account_code VARCHAR(50) NOT NULL, + gl_account_name VARCHAR(255), + debit DECIMAL(18,2) NOT NULL DEFAULT 0, + credit DECIMAL(18,2) NOT NULL DEFAULT 0, + department_id VARCHAR(255), -- cost-center dimension + employee_id VARCHAR(255), -- optional detail dimension + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_erp_journal_line_entry ON erp_journal_line (journal_entry_id); +CREATE INDEX IF NOT EXISTS idx_erp_journal_line_account ON erp_journal_line (gl_account_code); + +-- Component -> GL account mapping (institutes may override per component; +-- unmapped components fall to type-based defaults in JournalService). +ALTER TABLE hr_salary_component ADD COLUMN IF NOT EXISTS gl_account_code VARCHAR(50); diff --git a/admin_core_service/src/main/resources/db/migration/V485__Catalogue_page_analytics.sql b/admin_core_service/src/main/resources/db/migration/V485__Catalogue_page_analytics.sql new file mode 100644 index 0000000000..bcb835fe6c --- /dev/null +++ b/admin_core_service/src/main/resources/db/migration/V485__Catalogue_page_analytics.sql @@ -0,0 +1,48 @@ +-- First-party analytics for catalogue (page-builder) sites. +-- +-- WHY: catalogue sites fire GA4/Meta/GTM events but nothing is recorded here, +-- so an admin can only see traffic by logging into a Google property they +-- usually have not connected. Leads already live in audience_response, which +-- means we know who converted and never how many arrived — the two halves of +-- the funnel sit on opposite sides of a boundary we cannot join. This table is +-- the missing half. +-- +-- PRIVACY: no cookies, no PII, no raw IP. visitor_hash is a salted hash of +-- IP + user-agent that ROTATES DAILY, so it supports "unique visitors today" +-- while making cross-day tracking of an individual impossible by construction. +CREATE TABLE IF NOT EXISTS catalogue_page_event ( + id VARCHAR(36) PRIMARY KEY, + institute_id VARCHAR(36) NOT NULL, + catalogue_id VARCHAR(36), + -- '' is the site root; otherwise the page's route slug. + page_route VARCHAR(255) NOT NULL DEFAULT '', + -- VIEW today; CTA/LEAD reserved so click tracking can be added without a + -- second table or a migration. + event_type VARCHAR(32) NOT NULL DEFAULT 'VIEW', + -- Daily-rotating, salted. Not a stable identifier. + visitor_hash VARCHAR(64), + -- Client-generated per browsing session (sessionStorage), so a session can + -- be reconstructed without any persistent identifier. + session_id VARCHAR(64), + -- Host only, never the full referring URL: a path can carry PII in query + -- strings and we have no reason to keep it. + referrer_host VARCHAR(255), + utm_source VARCHAR(128), + utm_medium VARCHAR(128), + utm_campaign VARCHAR(191), + device VARCHAR(16), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- The dashboard's three questions: how did this SITE do, how did this PAGE do, +-- and where did traffic come from — each over a date range. +CREATE INDEX IF NOT EXISTS idx_cpe_institute_created + ON catalogue_page_event (institute_id, created_at); +CREATE INDEX IF NOT EXISTS idx_cpe_catalogue_route_created + ON catalogue_page_event (catalogue_id, page_route, created_at); +CREATE INDEX IF NOT EXISTS idx_cpe_institute_source_created + ON catalogue_page_event (institute_id, utm_source, created_at); +-- Unique-visitor counts scan (institute, day, hash); without this they become +-- a full scan once a busy site has a few million rows. +CREATE INDEX IF NOT EXISTS idx_cpe_institute_visitor_created + ON catalogue_page_event (institute_id, visitor_hash, created_at); diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollAdjustmentServiceTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollAdjustmentServiceTest.java new file mode 100644 index 0000000000..7b7436a01a --- /dev/null +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_payroll/service/PayrollAdjustmentServiceTest.java @@ -0,0 +1,52 @@ +package vacademy.io.admin_core_service.features.hr_payroll.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Unit tests for the static, package-private + * {@link PayrollAdjustmentService#sanitizeCode} normalizer that turns + * free-text adjustment labels into stable component codes. + */ +@DisplayName("PayrollAdjustmentService.sanitizeCode") +class PayrollAdjustmentServiceTest { + + @Test + @DisplayName("'Diwali Bonus!' normalizes to DIWALI_BONUS (uppercased, punctuation collapsed, edges trimmed)") + void normalizesLabelToCode() { + assertEquals("DIWALI_BONUS", PayrollAdjustmentService.sanitizeCode("Diwali Bonus!")); + } + + @Test + @DisplayName("blank input falls back to ADJUSTMENT") + void blankFallsBackToAdjustment() { + assertEquals("ADJUSTMENT", PayrollAdjustmentService.sanitizeCode(" ")); + } + + @Test + @DisplayName("input that sanitizes to nothing (only punctuation) also falls back to ADJUSTMENT") + void punctuationOnlyFallsBackToAdjustment() { + assertEquals("ADJUSTMENT", PayrollAdjustmentService.sanitizeCode("!!!")); + } + + @Test + @DisplayName("codes longer than 30 characters are truncated to exactly 30") + void truncatesToThirtyCharacters() { + String fortyAs = "A".repeat(40); + String sanitized = PayrollAdjustmentService.sanitizeCode(fortyAs); + assertEquals(30, sanitized.length()); + assertEquals("A".repeat(30), sanitized); + } + + @Test + @DisplayName("a mixed long label keeps only its first 30 sanitized characters") + void truncatesMixedLongLabel() { + // Sanitized form is PERFORMANCE_BONUS_FOR_QUARTER_FOUR_2026 (39 chars); + // truncation happens after edge-trimming, so the 30-char prefix survives + // even though it happens to end in an underscore. + assertEquals("PERFORMANCE_BONUS_FOR_QUARTER_", + PayrollAdjustmentService.sanitizeCode("Performance Bonus for Quarter Four 2026")); + } +} diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryStructureServiceTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryStructureServiceTest.java new file mode 100644 index 0000000000..d73bb1da50 --- /dev/null +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_salary/service/SalaryStructureServiceTest.java @@ -0,0 +1,244 @@ +package vacademy.io.admin_core_service.features.hr_salary.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import vacademy.io.admin_core_service.core.security.HrAccessGuard; +import vacademy.io.admin_core_service.features.hr_employee.entity.EmployeeProfile; +import vacademy.io.admin_core_service.features.hr_employee.repository.EmployeeProfileRepository; +import vacademy.io.admin_core_service.features.hr_salary.dto.AssignSalaryDTO; +import vacademy.io.admin_core_service.features.hr_salary.entity.EmployeeSalaryComponent; +import vacademy.io.admin_core_service.features.hr_salary.entity.SalaryComponent; +import vacademy.io.admin_core_service.features.hr_salary.entity.SalaryTemplate; +import vacademy.io.admin_core_service.features.hr_salary.entity.SalaryTemplateComponent; +import vacademy.io.admin_core_service.features.hr_salary.enums.CalculationType; +import vacademy.io.admin_core_service.features.hr_salary.enums.ComponentType; +import vacademy.io.admin_core_service.features.hr_salary.repository.EmployeeSalaryComponentRepository; +import vacademy.io.admin_core_service.features.hr_salary.repository.EmployeeSalaryStructureRepository; +import vacademy.io.admin_core_service.features.hr_salary.repository.SalaryComponentRepository; +import vacademy.io.admin_core_service.features.hr_salary.repository.SalaryRevisionRepository; +import vacademy.io.admin_core_service.features.hr_salary.repository.SalaryTemplateComponentRepository; +import vacademy.io.admin_core_service.features.hr_salary.repository.SalaryTemplateRepository; +import vacademy.io.common.exceptions.VacademyException; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Pure Mockito unit tests for the component-calculation path of + * {@link SalaryStructureService#assignSalary}: dependency-ordered resolution + * (fixed / %-of-CTC / %-of-basic / SpEL formula) and the CTC tie-out that adds + * a balancing Special Allowance or rejects an over-CTC template. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("SalaryStructureService — component calculation and CTC tie-out") +class SalaryStructureServiceTest { + + private static final String INSTITUTE_ID = "inst-1"; + private static final String EMPLOYEE_ID = "emp-1"; + private static final String TEMPLATE_ID = "tpl-1"; + private static final BigDecimal CTC = new BigDecimal("600000"); + + @Mock private EmployeeProfileRepository employeeProfileRepository; + @Mock private EmployeeSalaryStructureRepository salaryStructureRepository; + @Mock private EmployeeSalaryComponentRepository salaryComponentRepository; + @Mock private SalaryComponentRepository masterComponentRepository; + @Mock private SalaryTemplateRepository salaryTemplateRepository; + @Mock private SalaryTemplateComponentRepository salaryTemplateComponentRepository; + @Mock private SalaryRevisionRepository salaryRevisionRepository; + @Mock private HrAccessGuard hrAccessGuard; + + @InjectMocks private SalaryStructureService service; + + private SalaryComponent specialAllowanceMaster; + + @BeforeEach + void stubHappyPath() { + EmployeeProfile employee = new EmployeeProfile(); + employee.setInstituteId(INSTITUTE_ID); + lenient().when(employeeProfileRepository.findById(EMPLOYEE_ID)).thenReturn(Optional.of(employee)); + + SalaryTemplate template = new SalaryTemplate(); + template.setInstituteId(INSTITUTE_ID); + lenient().when(salaryTemplateRepository.findById(TEMPLATE_ID)).thenReturn(Optional.of(template)); + + lenient().when(salaryStructureRepository + .findFirstByEmployee_IdAndStatusOrderByEffectiveFromDesc(EMPLOYEE_ID, "ACTIVE")) + .thenReturn(Optional.empty()); + lenient().when(salaryStructureRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + lenient().when(salaryComponentRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0)); + lenient().when(salaryRevisionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + specialAllowanceMaster = component("comp-special", "SPECIAL_ALLOWANCE", ComponentType.EARNING); + lenient().when(masterComponentRepository.findByInstituteIdAndCode(INSTITUTE_ID, "SPECIAL_ALLOWANCE")) + .thenReturn(Optional.of(specialAllowanceMaster)); + } + + // ---- fixtures ------------------------------------------------------- + + private static SalaryComponent component(String id, String code, ComponentType type) { + SalaryComponent c = new SalaryComponent(); + c.setId(id); + c.setInstituteId(INSTITUTE_ID); + c.setCode(code); + c.setName(code); + c.setType(type.name()); + return c; + } + + private static SalaryTemplateComponent pctOfCtc(SalaryComponent c, String pct) { + SalaryTemplateComponent tc = new SalaryTemplateComponent(); + tc.setComponent(c); + tc.setCalculationType(CalculationType.PERCENTAGE_OF_CTC.name()); + tc.setPercentageValue(new BigDecimal(pct)); + return tc; + } + + private static SalaryTemplateComponent pctOfBasic(SalaryComponent c, String pct) { + SalaryTemplateComponent tc = new SalaryTemplateComponent(); + tc.setComponent(c); + tc.setCalculationType(CalculationType.PERCENTAGE_OF_BASIC.name()); + tc.setPercentageValue(new BigDecimal(pct)); + return tc; + } + + private static SalaryTemplateComponent fixed(SalaryComponent c, String monthly) { + SalaryTemplateComponent tc = new SalaryTemplateComponent(); + tc.setComponent(c); + tc.setCalculationType(CalculationType.FIXED_AMOUNT.name()); + tc.setFixedValue(new BigDecimal(monthly)); + return tc; + } + + private static SalaryTemplateComponent formula(SalaryComponent c, String expr) { + SalaryTemplateComponent tc = new SalaryTemplateComponent(); + tc.setComponent(c); + tc.setCalculationType(CalculationType.FORMULA.name()); + tc.setFormula(expr); + return tc; + } + + private static AssignSalaryDTO dto() { + return AssignSalaryDTO.builder() + .employeeId(EMPLOYEE_ID) + .templateId(TEMPLATE_ID) + .ctcAnnual(CTC) + .effectiveFrom(LocalDate.of(2026, 4, 1)) + .build(); + } + + private List assignAndCapture() { + service.assignSalary(dto(), INSTITUTE_ID, "approver-1"); + @SuppressWarnings({"unchecked", "rawtypes"}) + ArgumentCaptor> captor = + (ArgumentCaptor) ArgumentCaptor.forClass(List.class); + verify(salaryComponentRepository).saveAll(captor.capture()); + return captor.getValue(); + } + + private static EmployeeSalaryComponent byCode(List components, String code) { + return components.stream() + .filter(c -> code.equals(c.getComponent().getCode())) + .findFirst() + .orElseThrow(() -> new AssertionError("component " + code + " not found")); + } + + private static void assertAmount(String what, String expected, BigDecimal actual) { + assertNotNull(actual, what + " must not be null"); + assertEquals(0, new BigDecimal(expected).compareTo(actual), + what + ": expected " + expected + " but was " + actual); + } + + // ---- tests ---------------------------------------------------------- + + @Test + @DisplayName("CTC 6,00,000 with BASIC 40% of CTC and HRA 50% of BASIC resolves BASIC 20,000/mo and HRA 10,000/mo") + void resolvesPercentageChain() { + when(salaryTemplateComponentRepository.findByTemplateIdOrderByDisplayOrderAsc(TEMPLATE_ID)) + .thenReturn(List.of( + pctOfCtc(component("comp-basic", "BASIC", ComponentType.EARNING), "40"), + pctOfBasic(component("comp-hra", "HRA", ComponentType.EARNING), "50"), + fixed(component("comp-pf-er", "PF_ER", ComponentType.EMPLOYER_CONTRIBUTION), "1800"))); + + List components = assignAndCapture(); + + assertAmount("BASIC monthly (40% of 50,000 CTC-monthly)", "20000", + byCode(components, "BASIC").getMonthlyAmount()); + assertAmount("HRA monthly (50% of BASIC)", "10000", + byCode(components, "HRA").getMonthlyAmount()); + assertAmount("PF employer monthly (fixed)", "1800", + byCode(components, "PF_ER").getMonthlyAmount()); + } + + @Test + @DisplayName("CTC tie-out adds a Special Allowance so EARNING + EMPLOYER_CONTRIBUTION annuals sum exactly to CTC") + void ctcTieOutAddsBalancingSpecialAllowance() { + when(salaryTemplateComponentRepository.findByTemplateIdOrderByDisplayOrderAsc(TEMPLATE_ID)) + .thenReturn(List.of( + pctOfCtc(component("comp-basic", "BASIC", ComponentType.EARNING), "40"), + pctOfBasic(component("comp-hra", "HRA", ComponentType.EARNING), "50"), + fixed(component("comp-pf-er", "PF_ER", ComponentType.EMPLOYER_CONTRIBUTION), "1800"))); + + List components = assignAndCapture(); + + // Residual = 6,00,000 - (2,40,000 + 1,20,000 + 21,600) = 2,18,400/yr. + EmployeeSalaryComponent special = byCode(components, "SPECIAL_ALLOWANCE"); + assertAmount("Special Allowance annual", "218400", special.getAnnualAmount()); + assertAmount("Special Allowance monthly", "18200", special.getMonthlyAmount()); + + BigDecimal ctcSideAnnualTotal = components.stream() + .filter(c -> ComponentType.EARNING.name().equals(c.getComponent().getType()) + || ComponentType.EMPLOYER_CONTRIBUTION.name().equals(c.getComponent().getType())) + .map(EmployeeSalaryComponent::getAnnualAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + assertAmount("EARNING + EMPLOYER_CONTRIBUTION annual total ties out to CTC", "600000", + ctcSideAnnualTotal); + } + + @Test + @DisplayName("template whose components exceed CTC is rejected as a configuration error") + void overCtcTemplateThrows() { + when(salaryTemplateComponentRepository.findByTemplateIdOrderByDisplayOrderAsc(TEMPLATE_ID)) + .thenReturn(List.of( + pctOfCtc(component("comp-basic", "BASIC", ComponentType.EARNING), "120"))); + + VacademyException ex = assertThrows(VacademyException.class, + () -> service.assignSalary(dto(), INSTITUTE_ID, "approver-1")); + + assertTrue(ex.getMessage().contains("exceed CTC"), + "expected an over-CTC template error but got: " + ex.getMessage()); + } + + @Test + @DisplayName("FORMULA component '#BASIC * 0.1' resolves to 10% of the monthly basic") + void formulaComponentResolvesAgainstBasic() { + when(salaryTemplateComponentRepository.findByTemplateIdOrderByDisplayOrderAsc(TEMPLATE_ID)) + .thenReturn(List.of( + pctOfCtc(component("comp-basic", "BASIC", ComponentType.EARNING), "40"), + formula(component("comp-bonus", "STAT_BONUS", ComponentType.EARNING), "#BASIC * 0.1"))); + + List components = assignAndCapture(); + + assertAmount("STAT_BONUS monthly (10% of 20,000 basic)", "2000", + byCode(components, "STAT_BONUS").getMonthlyAmount()); + } +} diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/IndiaTaxRegimeEngineTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/IndiaTaxRegimeEngineTest.java new file mode 100644 index 0000000000..d892d71dac --- /dev/null +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/IndiaTaxRegimeEngineTest.java @@ -0,0 +1,362 @@ +package vacademy.io.admin_core_service.features.hr_tax.service.engine; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pure unit tests for the FY 2025-26 India tax engine. All expected values are + * hand-computed from the Income-tax Act rules (new/old regime slabs, section 87A + * incl. marginal relief, 4% cess) and the EPF/ESI/PT statutes, then asserted as + * exact BigDecimal amounts via compareTo (scale-insensitive). + */ +@DisplayName("IndiaTaxRegimeEngine — FY 2025-26") +class IndiaTaxRegimeEngineTest { + + private final IndiaTaxRegimeEngine engine = new IndiaTaxRegimeEngine(); + + private static final String FY = "2025-26"; + + /** Scale-insensitive exact-amount assertion. */ + private static void assertAmount(String what, String expected, BigDecimal actual) { + assertNotNull(actual, what + " must not be null"); + assertEquals(0, new BigDecimal(expected).compareTo(actual), + what + ": expected " + expected + " but was " + actual); + } + + private static TaxInput.TaxInputBuilder baseInput() { + return TaxInput.builder() + .financialYear(FY) + .year(2025) + .taxRules(Map.of()) + .statutorySettings(Map.of()) + .declarations(Map.of()); + } + + // ================================================================== + // calculateMonthlyTax — NEW regime + // ================================================================== + + @Nested + @DisplayName("calculateMonthlyTax — new regime") + class NewRegime { + + @Test + @DisplayName("gross 1,00,000/month for the full year: taxable 11,25,000 <= 12,00,000 so section 87A rebate wipes the tax to zero") + void fullRebateUnder12Lakh() { + TaxInput in = baseInput() + .regime("NEW") + .month(4).monthsRemainingAfterCurrent(11) + .grossForMonth(new BigDecimal("100000")) + .grossMonthlyFull(new BigDecimal("100000")) + .ytdTaxableIncome(BigDecimal.ZERO) + .ytdTaxDeducted(BigDecimal.ZERO) + .build(); + + TaxResult result = engine.calculateMonthlyTax(in); + + assertAmount("projectedAnnualGross", "1200000", result.getProjectedAnnualGross()); + assertAmount("totalExemptions (standard deduction)", "75000", result.getTotalExemptions()); + assertAmount("projectedAnnualTaxable", "1125000", result.getProjectedAnnualTaxable()); + assertAmount("projectedAnnualTax", "0", result.getProjectedAnnualTax()); + assertAmount("monthlyTax", "0", result.getMonthlyTax()); + } + + @Test + @DisplayName("gross 2,00,000/month: annual 24,00,000 -> taxable 23,25,000 -> slab tax 2,81,250 + 4% cess = 2,92,500; monthly 24,375") + void slabMathAt24Lakh() { + TaxInput in = baseInput() + .regime("NEW") + .month(4).monthsRemainingAfterCurrent(11) + .grossForMonth(new BigDecimal("200000")) + .grossMonthlyFull(new BigDecimal("200000")) + .ytdTaxableIncome(BigDecimal.ZERO) + .ytdTaxDeducted(BigDecimal.ZERO) + .build(); + + TaxResult result = engine.calculateMonthlyTax(in); + + // 4L@0 + 4L@5% (20,000) + 4L@10% (40,000) + 4L@15% (60,000) + // + 4L@20% (80,000) + 3,25,000@25% (81,250) = 2,81,250; cess 11,250. + assertAmount("projectedAnnualTaxable", "2325000", result.getProjectedAnnualTaxable()); + assertAmount("slabTax", "281250", (BigDecimal) result.getBreakdown().get("slabTax")); + assertAmount("projectedAnnualTax", "292500", result.getProjectedAnnualTax()); + assertAmount("monthlyTax (292,500 / 12)", "24375", result.getMonthlyTax()); + } + + @Test + @DisplayName("section 87A marginal relief: taxable 12,05,000 -> slab tax 60,750 capped at excess-over-threshold 5,000 -> annual 5,200 with cess") + void marginalReliefJustAbove12Lakh() { + // Projection = ytd 11,80,000 + current month 1,00,000 + 0 remaining + // = 12,80,000 gross; SD 75,000 -> taxable 12,05,000. + TaxInput in = baseInput() + .regime("NEW") + .month(3).monthsRemainingAfterCurrent(0) + .grossForMonth(new BigDecimal("100000")) + .grossMonthlyFull(new BigDecimal("100000")) + .ytdTaxableIncome(new BigDecimal("1180000")) + .ytdTaxDeducted(BigDecimal.ZERO) + .build(); + + TaxResult result = engine.calculateMonthlyTax(in); + + assertAmount("projectedAnnualTaxable", "1205000", result.getProjectedAnnualTaxable()); + // Slab tax: 4-8L 20,000 + 8-12L 40,000 + 5,000@15% 750 = 60,750... + assertAmount("slabTax", "60750", (BigDecimal) result.getBreakdown().get("slabTax")); + // ...but marginal relief caps liability at taxable - 12,00,000 = 5,000. + assertAmount("taxAfterRebate (marginal relief cap)", "5000", + (BigDecimal) result.getBreakdown().get("taxAfterRebate")); + assertAmount("projectedAnnualTax (5,000 + 4% cess)", "5200", result.getProjectedAnnualTax()); + // Last FY month, nothing withheld yet -> the whole 5,200 this month. + assertAmount("monthlyTax", "5200", result.getMonthlyTax()); + } + + @Test + @DisplayName("YTD true-up in month 12: remaining liability 1,42,500 spread over the 4 months left = 35,625") + void ytdTrueUp() { + TaxInput in = baseInput() + .regime("NEW") + .month(12).monthsRemainingAfterCurrent(3) + .grossForMonth(new BigDecimal("200000")) + .grossMonthlyFull(new BigDecimal("200000")) + .ytdTaxableIncome(new BigDecimal("1600000")) + .ytdTaxDeducted(new BigDecimal("150000")) + .build(); + + TaxResult result = engine.calculateMonthlyTax(in); + + // 16,00,000 + 2,00,000 + 3 x 2,00,000 = 24,00,000 -> annual tax 2,92,500 + assertAmount("projectedAnnualGross", "2400000", result.getProjectedAnnualGross()); + assertAmount("projectedAnnualTax", "292500", result.getProjectedAnnualTax()); + // (2,92,500 - 1,50,000) / 4 months (Dec..Mar) = 35,625 + assertAmount("monthlyTax", "35625", result.getMonthlyTax()); + } + + @Test + @DisplayName("tax_rules per-FY override of new_standard_deduction is honored and changes the outcome") + void standardDeductionOverrideFromRules() { + // Annual gross 13,00,000 (ytd 12,00,000 + 1,00,000 in the last month). + TaxInput.TaxInputBuilder builder = baseInput() + .regime("NEW") + .month(3).monthsRemainingAfterCurrent(0) + .grossForMonth(new BigDecimal("100000")) + .grossMonthlyFull(new BigDecimal("100000")) + .ytdTaxableIncome(new BigDecimal("1200000")) + .ytdTaxDeducted(BigDecimal.ZERO); + + // Control: built-in SD 75,000 -> taxable 12,25,000 -> marginal relief + // caps at 25,000 -> + cess = 26,000 annual. + TaxResult withDefaultSd = engine.calculateMonthlyTax(builder.build()); + assertAmount("control taxable (SD 75k)", "1225000", withDefaultSd.getProjectedAnnualTaxable()); + assertAmount("control annual tax", "26000", withDefaultSd.getProjectedAnnualTax()); + + // Override: SD 1,00,000 for FY 2025-26 -> taxable exactly 12,00,000 + // -> full section 87A rebate -> zero tax. + TaxInput overridden = builder + .taxRules(Map.of(FY, Map.of("new_standard_deduction", 100000))) + .build(); + TaxResult withOverriddenSd = engine.calculateMonthlyTax(overridden); + + assertAmount("overridden standardDeduction", "100000", + (BigDecimal) withOverriddenSd.getBreakdown().get("standardDeduction")); + assertAmount("overridden taxable", "1200000", withOverriddenSd.getProjectedAnnualTaxable()); + assertAmount("overridden annual tax (87A rebate)", "0", withOverriddenSd.getProjectedAnnualTax()); + assertAmount("overridden monthlyTax", "0", withOverriddenSd.getMonthlyTax()); + } + } + + // ================================================================== + // calculateMonthlyTax — OLD regime + // ================================================================== + + @Nested + @DisplayName("calculateMonthlyTax — old regime with declarations") + class OldRegime { + + @Test + @DisplayName("gross 12,00,000 with HRA/80C/80D declarations: taxable 7,88,000 -> tax 70,100 + cess = 72,904; monthly 6,075") + void oldRegimeWithDeclarations() { + // basic 40,000/mo (annual 4,80,000), metro, rent 2,40,000/yr, + // HRA received 2,40,000/yr, 80C declared 2,00,000, 80D 20,000. + TaxInput in = baseInput() + .regime("OLD") + .month(4).monthsRemainingAfterCurrent(11) + .grossForMonth(new BigDecimal("100000")) + .grossMonthlyFull(new BigDecimal("100000")) + .basicMonthlyFull(new BigDecimal("40000")) + .hraReceivedAnnual(new BigDecimal("240000")) + .ytdTaxableIncome(BigDecimal.ZERO) + .ytdTaxDeducted(BigDecimal.ZERO) + .declarations(Map.of( + "section_80c", 200000, + "section_80d", 20000, + "hra_rent_paid", 240000, + "is_metro_city", true)) + .build(); + + TaxResult result = engine.calculateMonthlyTax(in); + + // HRA exemption = min(received 2,40,000; + // rent - 10% basic = 2,40,000 - 48,000 = 1,92,000; + // 50% of basic (metro) = 2,40,000) = 1,92,000. + assertAmount("hraExemption", "192000", (BigDecimal) result.getBreakdown().get("hraExemption")); + + // 80C: auto employee-PF 12% of min(40,000; 15,000 ceiling) = 1,800/mo + // = 21,600/yr, + declared 2,00,000 -> capped at 1,50,000. + assertAmount("deduction80c (capped)", "150000", (BigDecimal) result.getBreakdown().get("deduction80c")); + assertAmount("deduction80d", "20000", (BigDecimal) result.getBreakdown().get("deduction80d")); + + // Taxable = 12,00,000 - 50,000 (SD) - 1,92,000 (HRA) - 1,50,000 (80C) + // - 20,000 (80D) = 7,88,000. + assertAmount("totalExemptions", "412000", result.getTotalExemptions()); + assertAmount("projectedAnnualTaxable", "788000", result.getProjectedAnnualTaxable()); + + // Old slabs: 2.5-5L@5% = 12,500 + 2,88,000@20% = 57,600 -> 70,100. + // No 87A (taxable > 5,00,000). + 4% cess 2,804 -> 72,904. + assertAmount("slabTax", "70100", (BigDecimal) result.getBreakdown().get("slabTax")); + assertAmount("projectedAnnualTax", "72904", result.getProjectedAnnualTax()); + // 72,904 / 12 = 6,075.33 -> rounded to whole rupees = 6,075. + assertAmount("monthlyTax", "6075", result.getMonthlyTax()); + } + } + + // ================================================================== + // calculateStatutory — EPF / ESI / PT + // ================================================================== + + @Nested + @DisplayName("calculateStatutory — EPF / ESI / Professional Tax") + class Statutory { + + private Optional item(List items, String code) { + return items.stream().filter(i -> code.equals(i.getCode())).findFirst(); + } + + @Test + @DisplayName("basic 20,000: PF on capped wage base 15,000 -> employee 1,800, employer 1,800 split EPS 1,250 / EPF 550") + void pfOnCappedWageBase() { + TaxInput in = baseInput() + .month(4) + .basicForMonth(new BigDecimal("20000")) + .grossForMonth(new BigDecimal("20000")) + .grossMonthlyFull(new BigDecimal("20000")) + .stateCode("MH") + .build(); + + List items = engine.calculateStatutory(in); + + StatutoryItem pf = item(items, "PF").orElseThrow(); + assertAmount("PF employee (12% of 15,000)", "1800", pf.getEmployeeMonthly()); + assertAmount("PF employer", "1800", pf.getEmployerMonthly()); + assertAmount("PF wage base", "15000", (BigDecimal) pf.getDetail().get("wageBase")); + // EPS 8.33% of 15,000 = 1,249.50 -> HALF_UP to whole rupee = 1,250. + assertAmount("EPS share", "1250", (BigDecimal) pf.getDetail().get("eps")); + assertAmount("EPF employer share (1,800 - 1,250)", "550", + (BigDecimal) pf.getDetail().get("epfEmployer")); + } + + @Test + @DisplayName("gross 20,000 (under the 21,000 ceiling): ESI employee 150 (0.75%), employer 650 (3.25%)") + void esiUnderCeiling() { + TaxInput in = baseInput() + .month(4) + .basicForMonth(new BigDecimal("20000")) + .grossForMonth(new BigDecimal("20000")) + .grossMonthlyFull(new BigDecimal("20000")) + .stateCode("MH") + .build(); + + List items = engine.calculateStatutory(in); + + StatutoryItem esi = item(items, "ESI").orElseThrow(); + assertAmount("ESI employee", "150", esi.getEmployeeMonthly()); + assertAmount("ESI employer", "650", esi.getEmployerMonthly()); + } + + @Test + @DisplayName("ESI stickiness: gross now 22,000 but 20,000 at period start -> ESI still deducted on the current gross") + void esiStickyWithinContributionPeriod() { + TaxInput in = baseInput() + .month(7) + .grossForMonth(new BigDecimal("22000")) + .grossMonthlyFull(new BigDecimal("22000")) + .esiGrossAtPeriodStart(new BigDecimal("20000")) + .build(); + + List items = engine.calculateStatutory(in); + + StatutoryItem esi = item(items, "ESI").orElseThrow(); + // Rates apply to the actual month's gross: 0.75% / 3.25% of 22,000. + assertAmount("ESI employee (sticky)", "165", esi.getEmployeeMonthly()); + assertAmount("ESI employer (sticky)", "715", esi.getEmployerMonthly()); + } + + @Test + @DisplayName("gross 22,000 already at period start -> above the 21,000 ceiling, no ESI item") + void esiAboveCeilingNoItem() { + TaxInput in = baseInput() + .month(7) + .grossForMonth(new BigDecimal("22000")) + .grossMonthlyFull(new BigDecimal("22000")) + .esiGrossAtPeriodStart(new BigDecimal("22000")) + .build(); + + List items = engine.calculateStatutory(in); + + assertTrue(item(items, "ESI").isEmpty(), "no ESI above the 21,000 ceiling"); + } + + @Test + @DisplayName("Maharashtra PT: gross 20,000 -> 200/month, and 300 in February") + void professionalTaxMaharashtra() { + TaxInput regularMonth = baseInput() + .month(4) + .grossForMonth(new BigDecimal("20000")) + .grossMonthlyFull(new BigDecimal("20000")) + .stateCode("MH") + .build(); + TaxInput february = baseInput() + .month(2) + .grossForMonth(new BigDecimal("20000")) + .grossMonthlyFull(new BigDecimal("20000")) + .stateCode("MH") + .build(); + + StatutoryItem ptRegular = item(engine.calculateStatutory(regularMonth), "PT").orElseThrow(); + StatutoryItem ptFebruary = item(engine.calculateStatutory(february), "PT").orElseThrow(); + + assertAmount("MH PT regular month", "200", ptRegular.getEmployeeMonthly()); + assertAmount("MH PT February", "300", ptFebruary.getEmployeeMonthly()); + assertAmount("PT has no employer share", "0", ptRegular.getEmployerMonthly()); + } + + @Test + @DisplayName("statutory settings pf_enabled=false suppresses the PF item but leaves ESI and PT intact") + void pfDisableFlag() { + TaxInput in = baseInput() + .month(4) + .basicForMonth(new BigDecimal("20000")) + .grossForMonth(new BigDecimal("20000")) + .grossMonthlyFull(new BigDecimal("20000")) + .stateCode("MH") + .statutorySettings(Map.of("pf_enabled", "false")) + .build(); + + List items = engine.calculateStatutory(in); + + assertFalse(item(items, "PF").isPresent(), "PF must be suppressed by pf_enabled=false"); + assertTrue(item(items, "ESI").isPresent(), "ESI must be unaffected"); + assertTrue(item(items, "PT").isPresent(), "PT must be unaffected"); + } + } +} diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/SaudiTaxRegimeEngineTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/SaudiTaxRegimeEngineTest.java new file mode 100644 index 0000000000..9393286951 --- /dev/null +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/SaudiTaxRegimeEngineTest.java @@ -0,0 +1,249 @@ +package vacademy.io.admin_core_service.features.hr_tax.service.engine; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pure unit tests for the Saudi Arabia payroll engine. Expected values are + * hand-computed from the statutes — GOSI (Saudi nationals: employee 9.75%, + * employer 11.75%; expats: employer-only 2% occupational hazard; base clamped + * to SAR 1,500–45,000) and the EOSB accrual under Labor Law art. 84 (half a + * month's basic per year for the first 5 years, a full month after) — and + * asserted as exact BigDecimal amounts via compareTo (scale-insensitive). + */ +@DisplayName("SaudiTaxRegimeEngine") +class SaudiTaxRegimeEngineTest { + + private final SaudiTaxRegimeEngine engine = new SaudiTaxRegimeEngine(); + + /** Scale-insensitive exact-amount assertion. */ + private static void assertAmount(String what, String expected, BigDecimal actual) { + assertNotNull(actual, what + " must not be null"); + assertEquals(0, new BigDecimal(expected).compareTo(actual), + what + ": expected " + expected + " but was " + actual); + } + + private static TaxInput.TaxInputBuilder baseInput() { + return TaxInput.builder() + .financialYear("2026") + .year(2026) + .month(1) + .monthsRemainingAfterCurrent(11) + .taxRules(Map.of()) + .statutorySettings(Map.of()) + .declarations(Map.of()); + } + + private static Optional item(List items, String code) { + return items.stream().filter(i -> code.equals(i.getCode())).findFirst(); + } + + @Test + @DisplayName("getCountryCode is SAU") + void countryCode() { + assertEquals("SAU", engine.getCountryCode()); + } + + // ================================================================== + // calculateMonthlyTax — always zero + // ================================================================== + + @Nested + @DisplayName("calculateMonthlyTax") + class IncomeTax { + + @Test + @DisplayName("no personal income tax: monthly and annual tax are zero, with an explanatory breakdown note") + void incomeTaxIsAlwaysZero() { + TaxInput in = baseInput() + .grossForMonth(new BigDecimal("15000")) + .grossMonthlyFull(new BigDecimal("15000")) + .ytdTaxableIncome(new BigDecimal("30000")) + .build(); + + TaxResult result = engine.calculateMonthlyTax(in); + + assertAmount("monthlyTax", "0", result.getMonthlyTax()); + assertAmount("projectedAnnualTax", "0", result.getProjectedAnnualTax()); + assertAmount("projectedAnnualTaxable", "0", result.getProjectedAnnualTaxable()); + assertAmount("totalExemptions", "0", result.getTotalExemptions()); + // Projection still carries the gross: 30,000 ytd + 15,000 + 11 x 15,000. + assertAmount("projectedAnnualGross", "210000", result.getProjectedAnnualGross()); + assertNotNull(result.getBreakdown(), "breakdown must not be null"); + assertNotNull(result.getBreakdown().get("note"), "breakdown must explain the zero tax"); + } + } + + // ================================================================== + // GOSI + // ================================================================== + + @Nested + @DisplayName("GOSI") + class Gosi { + + @Test + @DisplayName("Saudi national on basic 10,000: employee 9.75% = 975.00, employer 11.75% = 1,175.00") + void saudiNationalRates() { + TaxInput in = baseInput() + .nationality("Saudi") + .basicForMonth(new BigDecimal("10000")) + .basicMonthlyFull(new BigDecimal("10000")) + .build(); + + StatutoryItem gosi = item(engine.calculateStatutory(in), "GOSI").orElseThrow( + () -> new AssertionError("GOSI item missing")); + + assertAmount("GOSI contribution base", "10000", + (BigDecimal) gosi.getDetail().get("contributionBase")); + assertEquals(Boolean.TRUE, gosi.getDetail().get("national")); + assertAmount("GOSI employee (9.75%)", "975.00", gosi.getEmployeeMonthly()); + assertAmount("GOSI employer (11.75%)", "1175.00", gosi.getEmployerMonthly()); + } + + @Test + @DisplayName("expat on basic 10,000: employee 0, employer-only occupational hazard 2% = 200.00") + void expatEmployerOnly() { + TaxInput in = baseInput() + .nationality("Indian") + .basicForMonth(new BigDecimal("10000")) + .basicMonthlyFull(new BigDecimal("10000")) + .build(); + + StatutoryItem gosi = item(engine.calculateStatutory(in), "GOSI").orElseThrow(); + + assertEquals(Boolean.FALSE, gosi.getDetail().get("national")); + assertAmount("GOSI employee (expat)", "0", gosi.getEmployeeMonthly()); + assertAmount("GOSI employer (2%)", "200.00", gosi.getEmployerMonthly()); + } + + @Test + @DisplayName("base clamps at the SAR 45,000 ceiling: basic 50,000 -> employee 4,387.50, employer 5,287.50") + void baseClampsAtCeiling() { + TaxInput in = baseInput() + .nationality("Saudi") + .basicForMonth(new BigDecimal("50000")) + .basicMonthlyFull(new BigDecimal("50000")) + .build(); + + StatutoryItem gosi = item(engine.calculateStatutory(in), "GOSI").orElseThrow(); + + assertAmount("GOSI contribution base (clamped)", "45000", + (BigDecimal) gosi.getDetail().get("contributionBase")); + assertAmount("GOSI employee (9.75% of 45,000)", "4387.50", gosi.getEmployeeMonthly()); + assertAmount("GOSI employer (11.75% of 45,000)", "5287.50", gosi.getEmployerMonthly()); + } + + @Test + @DisplayName("base floors at SAR 1,500: basic 1,000 -> employee 146.25, employer 176.25") + void baseFloorsAtMinimum() { + TaxInput in = baseInput() + .nationality("Saudi") + .basicForMonth(new BigDecimal("1000")) + .basicMonthlyFull(new BigDecimal("1000")) + .build(); + + StatutoryItem gosi = item(engine.calculateStatutory(in), "GOSI").orElseThrow(); + + assertAmount("GOSI contribution base (floored)", "1500", + (BigDecimal) gosi.getDetail().get("contributionBase")); + assertAmount("GOSI employee (9.75% of 1,500)", "146.25", gosi.getEmployeeMonthly()); + assertAmount("GOSI employer (11.75% of 1,500)", "176.25", gosi.getEmployerMonthly()); + } + } + + // ================================================================== + // EOSB accrual — Labor Law art. 84 + // ================================================================== + + @Nested + @DisplayName("EOSB accrual (art. 84)") + class Eosb { + + @Test + @DisplayName("basic 12,000, 3 years of service: half-month band -> 0.5 x 12,000 / 12 = 500.00/month, employee side zero") + void firstBandAccrual() { + TaxInput in = baseInput() + .nationality("Indian") + .basicForMonth(new BigDecimal("12000")) + .basicMonthlyFull(new BigDecimal("12000")) + .serviceYears(new BigDecimal("3")) + .build(); + + StatutoryItem eosb = item(engine.calculateStatutory(in), "EOSB").orElseThrow(); + + assertAmount("EOSB employee side", "0", eosb.getEmployeeMonthly()); + assertAmount("EOSB monthly accrual (half-month band)", "500.00", eosb.getEmployerMonthly()); + assertAmount("monthsPerYear", "0.5", (BigDecimal) eosb.getDetail().get("monthsPerYear")); + } + + @Test + @DisplayName("basic 12,000, 6 years of service: full-month band -> 12,000 / 12 = 1,000.00/month") + void secondBandAccrual() { + TaxInput in = baseInput() + .nationality("Indian") + .basicForMonth(new BigDecimal("12000")) + .basicMonthlyFull(new BigDecimal("12000")) + .serviceYears(new BigDecimal("6")) + .build(); + + StatutoryItem eosb = item(engine.calculateStatutory(in), "EOSB").orElseThrow(); + + assertAmount("EOSB monthly accrual (full-month band)", "1000.00", eosb.getEmployerMonthly()); + assertAmount("monthsPerYear", "1", (BigDecimal) eosb.getDetail().get("monthsPerYear")); + } + } + + // ================================================================== + // statutory_settings disable flags + // ================================================================== + + @Nested + @DisplayName("statutory_settings overrides") + class DisableFlags { + + @Test + @DisplayName("gosi_enabled=false suppresses GOSI but keeps EOSB") + void gosiDisabled() { + TaxInput in = baseInput() + .nationality("Saudi") + .basicForMonth(new BigDecimal("12000")) + .basicMonthlyFull(new BigDecimal("12000")) + .serviceYears(new BigDecimal("3")) + .statutorySettings(Map.of("gosi_enabled", "false")) + .build(); + + List items = engine.calculateStatutory(in); + + assertTrue(item(items, "GOSI").isEmpty(), "GOSI must be suppressed"); + assertTrue(item(items, "EOSB").isPresent(), "EOSB must survive the GOSI flag"); + } + + @Test + @DisplayName("eosb_enabled=false suppresses EOSB but keeps GOSI") + void eosbDisabled() { + TaxInput in = baseInput() + .nationality("Saudi") + .basicForMonth(new BigDecimal("12000")) + .basicMonthlyFull(new BigDecimal("12000")) + .serviceYears(new BigDecimal("3")) + .statutorySettings(Map.of("eosb_enabled", "false")) + .build(); + + List items = engine.calculateStatutory(in); + + assertTrue(item(items, "EOSB").isEmpty(), "EOSB must be suppressed"); + assertTrue(item(items, "GOSI").isPresent(), "GOSI must survive the EOSB flag"); + } + } +} diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/UaeTaxRegimeEngineTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/UaeTaxRegimeEngineTest.java new file mode 100644 index 0000000000..4ce7cea894 --- /dev/null +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/hr_tax/service/engine/UaeTaxRegimeEngineTest.java @@ -0,0 +1,232 @@ +package vacademy.io.admin_core_service.features.hr_tax.service.engine; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pure unit tests for the UAE payroll engine. Expected values are hand-computed + * from the statutes — GPSSA pension (employee 5% / employer 12.5% of the + * contribution base, AED 1,000–50,000 band, UAE nationals only) and the EOSB + * accrual under Federal Decree-Law 33/2021 art. 51 (21 days/year first 5 years, + * 30 days/year after, daily basic = monthly basic / 30) — and asserted as exact + * BigDecimal amounts via compareTo (scale-insensitive). + */ +@DisplayName("UaeTaxRegimeEngine") +class UaeTaxRegimeEngineTest { + + private final UaeTaxRegimeEngine engine = new UaeTaxRegimeEngine(); + + /** Scale-insensitive exact-amount assertion. */ + private static void assertAmount(String what, String expected, BigDecimal actual) { + assertNotNull(actual, what + " must not be null"); + assertEquals(0, new BigDecimal(expected).compareTo(actual), + what + ": expected " + expected + " but was " + actual); + } + + private static TaxInput.TaxInputBuilder baseInput() { + return TaxInput.builder() + .financialYear("2026") + .year(2026) + .month(1) + .monthsRemainingAfterCurrent(11) + .taxRules(Map.of()) + .statutorySettings(Map.of()) + .declarations(Map.of()); + } + + private static Optional item(List items, String code) { + return items.stream().filter(i -> code.equals(i.getCode())).findFirst(); + } + + @Test + @DisplayName("getCountryCode is ARE") + void countryCode() { + assertEquals("ARE", engine.getCountryCode()); + } + + // ================================================================== + // calculateMonthlyTax — always zero + // ================================================================== + + @Nested + @DisplayName("calculateMonthlyTax") + class IncomeTax { + + @Test + @DisplayName("no personal income tax: monthly and annual tax are zero, with an explanatory breakdown note") + void incomeTaxIsAlwaysZero() { + TaxInput in = baseInput() + .grossForMonth(new BigDecimal("30000")) + .grossMonthlyFull(new BigDecimal("30000")) + .ytdTaxableIncome(new BigDecimal("60000")) + .build(); + + TaxResult result = engine.calculateMonthlyTax(in); + + assertAmount("monthlyTax", "0", result.getMonthlyTax()); + assertAmount("projectedAnnualTax", "0", result.getProjectedAnnualTax()); + assertAmount("projectedAnnualTaxable", "0", result.getProjectedAnnualTaxable()); + assertAmount("totalExemptions", "0", result.getTotalExemptions()); + // Projection still carries the gross: 60,000 ytd + 30,000 + 11 x 30,000. + assertAmount("projectedAnnualGross", "420000", result.getProjectedAnnualGross()); + assertNotNull(result.getBreakdown(), "breakdown must not be null"); + assertNotNull(result.getBreakdown().get("note"), "breakdown must explain the zero tax"); + } + } + + // ================================================================== + // GPSSA pension + // ================================================================== + + @Nested + @DisplayName("GPSSA pension") + class Gpssa { + + @Test + @DisplayName("Emirati on basic 20,000: base 20,000 -> employee 5% = 1,000.00, employer 12.5% = 2,500.00") + void emiratiWithinBand() { + TaxInput in = baseInput() + .nationality("Emirati") + .basicForMonth(new BigDecimal("20000")) + .basicMonthlyFull(new BigDecimal("20000")) + .build(); + + List items = engine.calculateStatutory(in); + StatutoryItem gpssa = item(items, "GPSSA").orElseThrow( + () -> new AssertionError("GPSSA item missing for a UAE national")); + + assertAmount("GPSSA contribution base", "20000", + (BigDecimal) gpssa.getDetail().get("contributionBase")); + assertAmount("GPSSA employee (5%)", "1000.00", gpssa.getEmployeeMonthly()); + assertAmount("GPSSA employer (12.5%)", "2500.00", gpssa.getEmployerMonthly()); + } + + @Test + @DisplayName("base clamps at the AED 50,000 ceiling: basic 60,000 -> base 50,000, employee 2,500.00, employer 6,250.00") + void baseClampsAtCeiling() { + TaxInput in = baseInput() + .nationality("Emirati") + .basicForMonth(new BigDecimal("60000")) + .basicMonthlyFull(new BigDecimal("60000")) + .build(); + + StatutoryItem gpssa = item(engine.calculateStatutory(in), "GPSSA").orElseThrow(); + + assertAmount("GPSSA contribution base (clamped)", "50000", + (BigDecimal) gpssa.getDetail().get("contributionBase")); + assertAmount("GPSSA employee (5% of 50,000)", "2500.00", gpssa.getEmployeeMonthly()); + assertAmount("GPSSA employer (12.5% of 50,000)", "6250.00", gpssa.getEmployerMonthly()); + } + + @Test + @DisplayName("expat (nationality Indian) gets no GPSSA item — EOSB still accrues") + void expatHasNoGpssa() { + TaxInput in = baseInput() + .nationality("Indian") + .basicForMonth(new BigDecimal("20000")) + .basicMonthlyFull(new BigDecimal("20000")) + .serviceYears(new BigDecimal("2")) + .build(); + + List items = engine.calculateStatutory(in); + + assertTrue(item(items, "GPSSA").isEmpty(), "expats must not carry GPSSA"); + assertTrue(item(items, "EOSB").isPresent(), "EOSB applies to every employee"); + } + } + + // ================================================================== + // EOSB accrual — art. 51, Federal Decree-Law 33/2021 + // ================================================================== + + @Nested + @DisplayName("EOSB accrual (art. 51)") + class Eosb { + + @Test + @DisplayName("basic 9,000, 2 years of service: daily 300, 21 days band -> 21 x 300 / 12 = 525.00/month, employee side zero") + void firstBandAccrual() { + TaxInput in = baseInput() + .nationality("Indian") + .basicForMonth(new BigDecimal("9000")) + .basicMonthlyFull(new BigDecimal("9000")) + .serviceYears(new BigDecimal("2")) + .build(); + + StatutoryItem eosb = item(engine.calculateStatutory(in), "EOSB").orElseThrow(); + + assertAmount("EOSB employee side", "0", eosb.getEmployeeMonthly()); + assertAmount("EOSB monthly accrual (21-day band)", "525.00", eosb.getEmployerMonthly()); + assertAmount("daysPerYear", "21", (BigDecimal) eosb.getDetail().get("daysPerYear")); + } + + @Test + @DisplayName("basic 9,000, 7 years of service: 30 days band -> 30 x 300 / 12 = 750.00/month") + void secondBandAccrual() { + TaxInput in = baseInput() + .nationality("Indian") + .basicForMonth(new BigDecimal("9000")) + .basicMonthlyFull(new BigDecimal("9000")) + .serviceYears(new BigDecimal("7")) + .build(); + + StatutoryItem eosb = item(engine.calculateStatutory(in), "EOSB").orElseThrow(); + + assertAmount("EOSB monthly accrual (30-day band)", "750.00", eosb.getEmployerMonthly()); + assertAmount("daysPerYear", "30", (BigDecimal) eosb.getDetail().get("daysPerYear")); + } + } + + // ================================================================== + // statutory_settings disable flags + // ================================================================== + + @Nested + @DisplayName("statutory_settings overrides") + class DisableFlags { + + @Test + @DisplayName("gpssa_enabled=false suppresses GPSSA for a national but keeps EOSB") + void gpssaDisabled() { + TaxInput in = baseInput() + .nationality("Emirati") + .basicForMonth(new BigDecimal("20000")) + .basicMonthlyFull(new BigDecimal("20000")) + .serviceYears(new BigDecimal("3")) + .statutorySettings(Map.of("gpssa_enabled", "false")) + .build(); + + List items = engine.calculateStatutory(in); + + assertTrue(item(items, "GPSSA").isEmpty(), "GPSSA must be suppressed"); + assertTrue(item(items, "EOSB").isPresent(), "EOSB must survive the GPSSA flag"); + } + + @Test + @DisplayName("eosb_enabled=false suppresses EOSB but keeps GPSSA") + void eosbDisabled() { + TaxInput in = baseInput() + .nationality("Emirati") + .basicForMonth(new BigDecimal("20000")) + .basicMonthlyFull(new BigDecimal("20000")) + .serviceYears(new BigDecimal("3")) + .statutorySettings(Map.of("eosb_enabled", "false")) + .build(); + + List items = engine.calculateStatutory(in); + + assertTrue(item(items, "EOSB").isEmpty(), "EOSB must be suppressed"); + assertTrue(item(items, "GPSSA").isPresent(), "GPSSA must survive the EOSB flag"); + } + } +} diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/manager/LiveSessionHostNormalizationTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/manager/LiveSessionHostNormalizationTest.java new file mode 100644 index 0000000000..fa23cbc5be --- /dev/null +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/manager/LiveSessionHostNormalizationTest.java @@ -0,0 +1,114 @@ +package vacademy.io.admin_core_service.features.live_session.provider.manager; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * The normalised value becomes the ORIGIN of a URL that learners are redirected + * into, so these cases are a security boundary rather than a formatting nicety. + * Anything that is not a plain hostname must come back null (meaning "fall back + * to the platform default") rather than being coerced into something plausible. + */ +class LiveSessionHostNormalizationTest { + + private static String norm(String raw) { + return BbbMeetingManager.normalizeLiveSessionHost(raw); + } + + @Nested + @DisplayName("accepts and normalises real hosts") + class Accepts { + + @Test + @DisplayName("a bare hostname passes through") + void bareHost() { + assertEquals("meet.zoeedtech.com", norm("meet.zoeedtech.com")); + } + + @Test + @DisplayName("scheme and trailing slash are stripped") + void schemeAndSlash() { + assertEquals("meet.zoeedtech.com", norm("https://meet.zoeedtech.com")); + assertEquals("meet.zoeedtech.com", norm("https://meet.zoeedtech.com/")); + assertEquals("meet.zoeedtech.com", norm("http://meet.zoeedtech.com/bigbluebutton/api")); + } + + @Test + @DisplayName("case and surrounding whitespace are normalised") + void caseAndWhitespace() { + assertEquals("meet.zoeedtech.com", norm(" MEET.ZoeEdTech.COM ")); + } + + @Test + @DisplayName("deep subdomains and hyphens are fine") + void subdomains() { + assertEquals("live-classes.a.b.school.co.in", norm("live-classes.a.b.school.co.in")); + } + } + + @Nested + @DisplayName("rejects anything that is not a plain hostname") + class Rejects { + + @Test + @DisplayName("absent values") + void absent() { + assertNull(norm(null)); + assertNull(norm("")); + assertNull(norm(" ")); + } + + @Test + @DisplayName("a port would not match the certificate or the pool listener") + void ports() { + assertNull(norm("meet.zoeedtech.com:8443")); + assertNull(norm("https://meet.zoeedtech.com:8443")); + } + + @Test + @DisplayName("a non-network scheme cannot smuggle through") + void hostileScheme() { + assertNull(norm("javascript:alert(1)")); + assertNull(norm("data:text/html,x")); + } + + @Test + @DisplayName("single labels and malformed hosts") + void malformed() { + assertNull(norm("localhost")); + assertNull(norm("meet")); + assertNull(norm("meet..com")); + assertNull(norm("-lead.example.com")); + assertNull(norm("trail-.example.com")); + assertNull(norm("meet.zoeedtech.c")); + assertNull(norm("192.168.1.10")); + } + + @Test + @DisplayName("over-long hosts") + void tooLong() { + assertNull(norm("a".repeat(250) + ".example.com")); + } + } + + @Nested + @DisplayName("userinfo cannot disguise the real host") + class Userinfo { + + /** + * "evil.com@good.com" resolves to good.com per URL rules, so returning the + * part AFTER the @ is correct — the test pins that we read it the same way a + * browser does, and never treat the decoy prefix as the host. + */ + @Test + @DisplayName("the authority after @ wins, matching browser parsing") + void authorityAfterAt() { + assertEquals("meet.zoeedtech.com", norm("evil.example.com@meet.zoeedtech.com")); + assertEquals("evil.example.com", norm("https://meet.zoeedtech.com@evil.example.com")); + } + } +} diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/manager/ZoomRecordingAddressingTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/manager/ZoomRecordingAddressingTest.java new file mode 100644 index 0000000000..00f7f58db4 --- /dev/null +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/manager/ZoomRecordingAddressingTest.java @@ -0,0 +1,56 @@ +package vacademy.io.admin_core_service.features.live_session.provider.manager; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * Zoom meeting UUIDs are base64 and routinely contain "/" and "+". Addressing a recording + * by a singly-encoded UUID makes Zoom route on the raw slash and answer for the wrong + * resource (or 404), which is why the API contract requires DOUBLE encoding. Getting this + * wrong is silent: the call succeeds and simply returns someone else's recording or none. + */ +class ZoomRecordingAddressingTest { + + private static String decodeOnce(String s) { + return URLDecoder.decode(s, StandardCharsets.UTF_8); + } + + @Test + @DisplayName("a uuid containing / survives double-encoding and round-trips exactly") + void slashUuidRoundTrips() { + String uuid = "/cNetcfdQrmW0AuZKprKjw=="; + String encoded = ZoomMeetingManager.encodeUuid(uuid); + assertFalse(encoded.contains("/"), "raw slash must not reach the path"); + assertEquals(uuid, decodeOnce(decodeOnce(encoded))); + } + + @Test + @DisplayName("a uuid containing // round-trips exactly") + void doubleSlashUuidRoundTrips() { + String uuid = "3MXm52O//TaaY1wB4DGIy0Q=="; + String encoded = ZoomMeetingManager.encodeUuid(uuid); + assertFalse(encoded.contains("/")); + assertEquals(uuid, decodeOnce(decodeOnce(encoded))); + } + + @Test + @DisplayName("a plain uuid with + and = round-trips exactly") + void plainUuidRoundTrips() { + String uuid = "uzMXY8XDTyeOyIjcPhYqnA=="; + assertEquals(uuid, decodeOnce(decodeOnce(ZoomMeetingManager.encodeUuid(uuid)))); + } + + @Test + @DisplayName("encoding is genuinely double, not single") + void encodingIsDouble() { + String encoded = ZoomMeetingManager.encodeUuid("a/b"); + // single-encode would be "a%2Fb"; double-encode escapes the % as well + assertEquals("a%252Fb", encoded); + } +} diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/security/LiveSessionJoinAuthorizerTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/security/LiveSessionJoinAuthorizerTest.java index 3526d458f6..1f2190164f 100644 --- a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/security/LiveSessionJoinAuthorizerTest.java +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/security/LiveSessionJoinAuthorizerTest.java @@ -44,6 +44,9 @@ class LiveSessionJoinAuthorizerTest { @Mock private LiveSessionRepository liveSessionRepository; @Mock private LiveSessionParticipantRepository participantRepository; @Mock private InstituteAccessValidator instituteAccessValidator; + // Added to LiveSessionJoinAuthorizer after this test was written; without the mock + // @InjectMocks leaves it null and the payment gate NPEs before any assertion runs. + @Mock private vacademy.io.admin_core_service.features.live_session.service.LiveSessionPaymentService paymentService; @InjectMocks private LiveSessionJoinAuthorizer authorizer; diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/LinkTypePlaceholderTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/LinkTypePlaceholderTest.java new file mode 100644 index 0000000000..3f5dbd6152 --- /dev/null +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/LinkTypePlaceholderTest.java @@ -0,0 +1,65 @@ +package vacademy.io.admin_core_service.features.live_session.provider.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vacademy.io.common.meeting.enums.MeetingProvider; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A provider-provisioned occurrence is created before its meeting link exists, so URL sniffing + * stamps "UNKNOWN" — not null, not blank, and therefore mistaken for a deliberate choice and left + * forever. Rows stuck on it break at both ends: the dashboards fall through to a plain participant + * link, and MeetingProvider.fromString("UNKNOWN") throws so the server cannot resolve a strategy + * at all. 30 live Google Meet occurrences were in exactly this state. + */ +class LinkTypePlaceholderTest { + + @Test + @DisplayName("UNKNOWN counts as unset, in any casing") + void unknownIsUnset() { + assertTrue(LiveSessionProviderService.isUnsetLinkType("UNKNOWN")); + assertTrue(LiveSessionProviderService.isUnsetLinkType("unknown")); + assertTrue(LiveSessionProviderService.isUnsetLinkType(null)); + assertTrue(LiveSessionProviderService.isUnsetLinkType("")); + assertTrue(LiveSessionProviderService.isUnsetLinkType(" ")); + } + + @Test + @DisplayName("a real choice the wizard made is preserved, never overwritten") + void realChoicesArePreserved() { + for (String real : new String[] { "bbb", "zoom", "google meet", "youtube", "RECORDED", "CUSTOM" }) { + assertFalse(LiveSessionProviderService.isUnsetLinkType(real), real + " must be preserved"); + } + } + + @Test + @DisplayName("providers map to the literals the dashboards match, not enum names") + void mapsToFrontendLiterals() { + assertEquals("zoom", LiveSessionProviderService.frontendLinkType("ZOOM_MEETING")); + assertEquals("google meet", LiveSessionProviderService.frontendLinkType("GOOGLE_MEET")); + assertEquals("bbb", LiveSessionProviderService.frontendLinkType("BBB_MEETING")); + assertEquals("zoho", LiveSessionProviderService.frontendLinkType("ZOHO_MEETING")); + // aliases fromString already understands + assertEquals("google meet", LiveSessionProviderService.frontendLinkType("GMEET")); + assertEquals("zoom", LiveSessionProviderService.frontendLinkType("zoom")); + } + + @Test + @DisplayName("an unrecognised provider is stored as-is rather than lost") + void unrecognisedPassesThrough() { + assertEquals("something-else", LiveSessionProviderService.frontendLinkType("something-else")); + assertEquals(null, LiveSessionProviderService.frontendLinkType(null)); + } + + @Test + @DisplayName("every literal we store round-trips back through the backend resolver") + void literalsRoundTripOnTheServer() { + assertEquals(MeetingProvider.ZOOM_MEETING, MeetingProvider.fromString("zoom")); + assertEquals(MeetingProvider.GOOGLE_MEET, MeetingProvider.fromString("google meet")); + assertEquals(MeetingProvider.BBB_MEETING, MeetingProvider.fromString("bbb")); + assertEquals(MeetingProvider.ZOHO_MEETING, MeetingProvider.fromString("zoho")); + } +} diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/ZoomLinkTypeTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/ZoomLinkTypeTest.java new file mode 100644 index 0000000000..936d11300c --- /dev/null +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/ZoomLinkTypeTest.java @@ -0,0 +1,35 @@ +package vacademy.io.admin_core_service.features.live_session.provider.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * link_type is written by two different paths and so appears in prod as the + * frontend-friendly "zoom"/"ZOOM" and as the enum name "ZOOM_MEETING". The host-url + * refresh keys off this value; a miss means the caller silently keeps the stale + * start url whose ZAK has already expired, which is the failure this guards. + */ +class ZoomLinkTypeTest { + + @Test + @DisplayName("every shape of zoom link_type seen in prod is recognised") + void recognisesZoomVariants() { + assertTrue(LiveSessionProviderService.isZoom("zoom")); + assertTrue(LiveSessionProviderService.isZoom("ZOOM")); + assertTrue(LiveSessionProviderService.isZoom("ZOOM_MEETING")); + } + + @Test + @DisplayName("other providers and absent values are left alone") + void ignoresNonZoom() { + assertFalse(LiveSessionProviderService.isZoom(null)); + assertFalse(LiveSessionProviderService.isZoom("")); + assertFalse(LiveSessionProviderService.isZoom("bbb")); + assertFalse(LiveSessionProviderService.isZoom("BBB_MEETING")); + assertFalse(LiveSessionProviderService.isZoom("GOOGLE_MEET")); + assertFalse(LiveSessionProviderService.isZoom("youtube")); + } +} diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/zoom/ZoomAttendanceServiceTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/zoom/ZoomAttendanceServiceTest.java index 2a5fba504d..5382b916ff 100644 --- a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/zoom/ZoomAttendanceServiceTest.java +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/zoom/ZoomAttendanceServiceTest.java @@ -37,6 +37,10 @@ class ZoomAttendanceServiceTest { @Mock private ZoomMeetingManager zoomMeetingManager; @Mock private LiveSessionParticipantRepository participantRepository; @Mock private SessionScheduleRepository scheduleRepository; + // Added to ZoomAttendanceService after this test was written; without the mock + // @InjectMocks leaves the field null and every test here NPEs on the first sync. + @Mock private vacademy.io.admin_core_service.features.live_session.repository.LiveSessionRepository liveSessionRepository; + @Mock private vacademy.io.admin_core_service.features.live_session.service.AttendanceCriteriaEvaluator attendanceCriteriaEvaluator; @InjectMocks private ZoomAttendanceService service; diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/zoom/ZoomOccurrenceScopingTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/zoom/ZoomOccurrenceScopingTest.java new file mode 100644 index 0000000000..35a37bddad --- /dev/null +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/zoom/ZoomOccurrenceScopingTest.java @@ -0,0 +1,101 @@ +package vacademy.io.admin_core_service.features.live_session.provider.service.zoom; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import vacademy.io.admin_core_service.features.live_session.entity.LiveSession; +import vacademy.io.admin_core_service.features.live_session.entity.SessionSchedule; +import vacademy.io.admin_core_service.features.live_session.provider.manager.ZoomMeetingManager; +import vacademy.io.admin_core_service.features.live_session.repository.LiveSessionRepository; +import vacademy.io.admin_core_service.features.live_session.repository.SessionScheduleRepository; +import vacademy.io.common.meeting.dto.MeetingRecordingDTO; + +import java.lang.reflect.Method; +import java.sql.Date; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +/** + * One Zoom meeting is reused for every class in a recurring series, so asking for its instances + * returns several days at once. A schedule row is a SINGLE occurrence. Without scoping, the + * hourly sweep stacks every day of the series onto whichever row happens to hold the meeting id + * — duplicating files already held correctly by their own rows, and showing learners a date's + * page full of other dates' classes. Modelled on the real HCCA series where meeting 88901426421 + * ran on 24, 25 and 26 Aug while only the 26th row carries the id. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ZoomOccurrenceScopingTest { + + private static final String SESSION_ID = "sess-advanced-hr"; + + @Mock private ZoomMeetingManager zoomMeetingManager; + @Mock private ZoomAccountStore zoomAccountStore; + @Mock private SessionScheduleRepository scheduleRepository; + @Mock private LiveSessionRepository liveSessionRepository; + @Mock private com.fasterxml.jackson.databind.ObjectMapper objectMapper; + @Mock private vacademy.io.admin_core_service.features.live_session.service.RecordingAutoLinkService recordingAutoLinkService; + + @InjectMocks private ZoomRecordingService service; + + private SessionSchedule scheduleOn(String isoDate) { + return SessionSchedule.builder() + .id("sched-" + isoDate).sessionId(SESSION_ID) + .meetingDate(Date.valueOf(LocalDate.parse(isoDate))).build(); + } + + /** 09:30 IST classes — note the UTC stamps are 04:0x on the SAME local day. */ + private static final List THREE_DAYS = List.of( + MeetingRecordingDTO.builder().recordingId("a24").startTime("2026-08-24T04:17:22Z").build(), + MeetingRecordingDTO.builder().recordingId("a25").startTime("2026-08-25T04:01:25Z").build(), + MeetingRecordingDTO.builder().recordingId("a26").startTime("2026-08-26T04:01:31Z").build()); + + @SuppressWarnings("unchecked") + private List scope(List recs, SessionSchedule sched) throws Exception { + when(liveSessionRepository.findById(SESSION_ID)) + .thenReturn(Optional.of(LiveSession.builder().id(SESSION_ID).timezone("Asia/Kolkata").build())); + Method m = ZoomRecordingService.class.getDeclaredMethod( + "onlyThisOccurrence", List.class, SessionSchedule.class); + m.setAccessible(true); + return (List) m.invoke(service, recs, sched); + } + + @Test + @DisplayName("a row keeps only its OWN day out of a three-day series") + void keepsOnlyItsOwnDay() throws Exception { + assertEquals(List.of("a24"), scope(THREE_DAYS, scheduleOn("2026-08-24")).stream().map(MeetingRecordingDTO::getRecordingId).toList()); + assertEquals(List.of("a25"), scope(THREE_DAYS, scheduleOn("2026-08-25")).stream().map(MeetingRecordingDTO::getRecordingId).toList()); + assertEquals(List.of("a26"), scope(THREE_DAYS, scheduleOn("2026-08-26")).stream().map(MeetingRecordingDTO::getRecordingId).toList()); + } + + @Test + @DisplayName("a date the series never ran on takes nothing rather than a sibling's class") + void takesNothingWhenNoInstanceMatches() throws Exception { + assertEquals(List.of(), scope(THREE_DAYS, scheduleOn("2026-08-27"))); + } + + @Test + @DisplayName("UTC-to-local is respected: a 09:30 IST class stamped 04:00Z stays on its local day") + void respectsSessionTimezone() throws Exception { + assertEquals(1, scope(THREE_DAYS, scheduleOn("2026-08-26")).size()); + } + + @Test + @DisplayName("recordings with no usable timestamp are dropped, not smeared across dates") + void dropsUnusableTimestamps() throws Exception { + List junk = List.of( + MeetingRecordingDTO.builder().recordingId("x").startTime(null).build(), + MeetingRecordingDTO.builder().recordingId("y").startTime("").build(), + MeetingRecordingDTO.builder().recordingId("z").startTime("not-a-date").build()); + assertEquals(List.of(), scope(junk, scheduleOn("2026-08-26"))); + } +} diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/zoom/ZoomRecordingExpiryTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/zoom/ZoomRecordingExpiryTest.java new file mode 100644 index 0000000000..c2ac9a5ad8 --- /dev/null +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/provider/service/zoom/ZoomRecordingExpiryTest.java @@ -0,0 +1,55 @@ +package vacademy.io.admin_core_service.features.live_session.provider.service.zoom; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vacademy.io.common.meeting.dto.MeetingRecordingDTO; + +import java.lang.reflect.Method; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * expiresAt drives the near-expiry S3 rescue, which only mirrors recordings falling due within a + * few days. Overstate the date and the rescue never fires — Zoom deletes the recording at its real + * 30-day mark and it is gone for good. Counting from "now" was survivable while the sync only ever + * saw a meeting's newest instance; it is not, now that the sweep reaches back over old ones. + */ +class ZoomRecordingExpiryTest { + + private static String expiryFor(MeetingRecordingDTO rec) throws Exception { + Method m = ZoomRecordingService.class.getDeclaredMethod("expiryFor", MeetingRecordingDTO.class); + m.setAccessible(true); + return (String) m.invoke(null, rec); + } + + @Test + @DisplayName("expiry is 30 days after the recording was MADE, not after we saw it") + void countsFromRecordingStart() throws Exception { + String start = "2026-08-01T09:30:00Z"; + String got = expiryFor(MeetingRecordingDTO.builder().startTime(start).build()); + assertEquals(Instant.parse(start).plus(30, ChronoUnit.DAYS).toString(), got); + } + + @Test + @DisplayName("a 25-day-old recording reads as nearly due, so the rescue can still catch it") + void oldRecordingIsNearlyDue() throws Exception { + String start = Instant.now().minus(25, ChronoUnit.DAYS).toString(); + long daysLeft = ChronoUnit.DAYS.between( + Instant.now(), Instant.parse(expiryFor(MeetingRecordingDTO.builder().startTime(start).build()))); + assertTrue(daysLeft <= 5, + "25-day-old recording should be inside the 5-day rescue window, got " + daysLeft + "d"); + } + + @Test + @DisplayName("falls back to now+30d when the start time is missing or unparseable") + void fallsBackWhenNoUsableStart() throws Exception { + for (String bad : new String[] { null, "", " ", "not-a-timestamp" }) { + long daysLeft = ChronoUnit.DAYS.between( + Instant.now(), Instant.parse(expiryFor(MeetingRecordingDTO.builder().startTime(bad).build()))); + assertTrue(daysLeft >= 29 && daysLeft <= 30, "expected ~30d for " + bad + ", got " + daysLeft); + } + } +} diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/service/LinkTypeCanonicalTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/service/LinkTypeCanonicalTest.java new file mode 100644 index 0000000000..522c128164 --- /dev/null +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/service/LinkTypeCanonicalTest.java @@ -0,0 +1,51 @@ +package vacademy.io.admin_core_service.features.live_session.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vacademy.io.common.meeting.enums.MeetingProvider; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The admin dashboard matches schedule linkType against StreamingPlatform literals + * case-sensitively. When a value doesn't match, the "Start as Host" control degrades to a + * plain participant link — the teacher joins as an attendee, never claims host role, the + * meeting never starts and nothing records. That failure is invisible in the UI, so the + * exact strings are pinned here. + */ +class LinkTypeCanonicalTest { + + @Test + @DisplayName("zoom urls yield the literal the frontend matches: 'zoom', not 'ZOOM'") + void zoomIsCanonical() { + assertEquals("zoom", Step1Service.getLinkTypeFromUrl("https://us06web.zoom.us/j/87925363538?pwd=x")); + assertEquals("zoom", Step1Service.getLinkTypeFromUrl("https://ACME.ZOOM.US/j/123")); + assertEquals("zoom", Step1Service.getLinkTypeFromUrl("https://zoom.com/j/123")); + } + + @Test + @DisplayName("google meet urls yield 'google meet', not 'GMEET'") + void meetIsCanonical() { + assertEquals("google meet", Step1Service.getLinkTypeFromUrl("https://meet.google.com/abc-defg-hij")); + assertEquals("google meet", Step1Service.getLinkTypeFromUrl("https://MEET.GOOGLE.COM/abc")); + } + + @Test + @DisplayName("other providers are unchanged") + void othersUnchanged() { + assertEquals("YOUTUBE", Step1Service.getLinkTypeFromUrl("https://youtu.be/abc")); + assertEquals("ZOHO_MEETING", Step1Service.getLinkTypeFromUrl("https://meeting.zoho.in/x")); + assertEquals("RECORDED", Step1Service.getLinkTypeFromUrl("https://example.com/video.mp4")); + assertEquals("UNKNOWN", Step1Service.getLinkTypeFromUrl("")); + assertEquals("UNKNOWN", Step1Service.getLinkTypeFromUrl(null)); + } + + @Test + @DisplayName("the backend still resolves the canonical values, so nothing downstream breaks") + void backendStillResolves() { + assertEquals(MeetingProvider.ZOOM_MEETING, MeetingProvider.fromString("zoom")); + assertEquals(MeetingProvider.ZOOM_MEETING, MeetingProvider.fromString("ZOOM")); + assertEquals(MeetingProvider.GOOGLE_MEET, MeetingProvider.fromString("google meet")); + assertEquals(MeetingProvider.GOOGLE_MEET, MeetingProvider.fromString("GMEET")); + } +} diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/service/ProviderLinkGuardTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/service/ProviderLinkGuardTest.java new file mode 100644 index 0000000000..88f152d213 --- /dev/null +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/live_session/service/ProviderLinkGuardTest.java @@ -0,0 +1,80 @@ +package vacademy.io.admin_core_service.features.live_session.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The edit path must refuse exactly one thing — re-pointing a provider-managed occurrence at a + * different meeting of the same provider — and must keep honouring every other edit. A guard + * that is too broad is worse than none: it swallows a legitimate change silently, and the admin + * has no way to tell their edit was dropped. + */ +class ProviderLinkGuardTest { + + private static final String MEETING = "87925363538"; + + @Test + @DisplayName("REFUSES the weekday collision: another zoom meeting on a zoom-managed row") + void refusesSameProviderDifferentMeeting() { + assertTrue(Step1Service.wouldStealProviderMeeting( + "https://us06web.zoom.us/j/81171926875?pwd=x", MEETING, "zoom")); + } + + @Test + @DisplayName("ALLOWS a link for this row's own meeting — same room, no harm") + void allowsSameMeeting() { + assertFalse(Step1Service.wouldStealProviderMeeting( + "https://us06web.zoom.us/j/" + MEETING + "?pwd=x", MEETING, "zoom")); + } + + @Test + @DisplayName("ALLOWS a deliberate provider switch away from zoom") + void allowsProviderSwitch() { + assertFalse(Step1Service.wouldStealProviderMeeting( + "https://meet.google.com/abc-defg-hij", MEETING, "zoom")); + assertFalse(Step1Service.wouldStealProviderMeeting( + "https://youtu.be/abc123", MEETING, "zoom")); + assertFalse(Step1Service.wouldStealProviderMeeting( + "https://example.com/recorded.mp4", MEETING, "zoom")); + } + + @Test + @DisplayName("ALLOWS everything on a row that is not provider-managed") + void allowsWhenNotProviderManaged() { + assertFalse(Step1Service.wouldStealProviderMeeting( + "https://us06web.zoom.us/j/81171926875", null, "zoom")); + assertFalse(Step1Service.wouldStealProviderMeeting( + "https://us06web.zoom.us/j/81171926875", "", "zoom")); + assertFalse(Step1Service.wouldStealProviderMeeting( + "https://us06web.zoom.us/j/81171926875", " ", "zoom")); + } + + @Test + @DisplayName("ALLOWS a blank or absent incoming link (clearing is not stealing)") + void allowsBlankIncoming() { + assertFalse(Step1Service.wouldStealProviderMeeting(null, MEETING, "zoom")); + assertFalse(Step1Service.wouldStealProviderMeeting("", MEETING, "zoom")); + assertFalse(Step1Service.wouldStealProviderMeeting(" ", MEETING, "zoom")); + } + + @Test + @DisplayName("matches provider case-insensitively — prod holds both 'zoom' and 'ZOOM'") + void linkTypeCaseInsensitive() { + assertTrue(Step1Service.wouldStealProviderMeeting( + "https://us06web.zoom.us/j/81171926875", MEETING, "ZOOM")); + assertTrue(Step1Service.wouldStealProviderMeeting( + "https://meet.google.com/xyz-abcd-efg", "abc-defg-hij", "GOOGLE MEET")); + } + + @Test + @DisplayName("ALLOWS when the row has no linkType to compare against") + void allowsWhenLinkTypeUnknown() { + assertFalse(Step1Service.wouldStealProviderMeeting( + "https://us06web.zoom.us/j/81171926875", MEETING, null)); + assertFalse(Step1Service.wouldStealProviderMeeting( + "https://us06web.zoom.us/j/81171926875", MEETING, "")); + } +} diff --git a/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/product_page/BasketPricingCalculatorTest.java b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/product_page/BasketPricingCalculatorTest.java new file mode 100644 index 0000000000..a1283f1c66 --- /dev/null +++ b/admin_core_service/src/test/java/vacademy/io/admin_core_service/features/product_page/BasketPricingCalculatorTest.java @@ -0,0 +1,246 @@ +package vacademy.io.admin_core_service.features.product_page; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import vacademy.io.admin_core_service.features.product_page.service.BasketPricingCalculator; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * The authoritative half of the pricing contract. basket-pricing.ts mirrors it + * for display, and basket-pricing.test.ts asserts the SAME cases — if these two + * files ever disagree, a parent is shown one price and charged another. + */ +class BasketPricingCalculatorTest { + + private final BasketPricingCalculator calculator = new BasketPricingCalculator(); + + private static final double PRICE = 349; // one subject, on its enroll invite + + private static final String GROUPS = "\"groups\":[{\"label\":\"Class 5\",\"levels\":[\"Class 5\"]}]"; + + /** The iThinkers B2C price card, as absolute prices per count. */ + private static final String FLAT = "{\"basketPricing\":{\"enabled\":true," + + "\"ladder\":{\"prices\":[349,599,799],\"perExtra\":150}," + GROUPS + "}}"; + + /** The same card, as discounts off what the courses cost on their invites. */ + private static final String DISCOUNT = "{\"basketPricing\":{\"enabled\":true," + + "\"pricingBasis\":\"DISCOUNT\",\"ladder\":{\"prices\":[],\"perExtra\":0}," + + "\"tiers\":[{\"minCourses\":2,\"type\":\"AMOUNT\",\"value\":99}," + + "{\"minCourses\":3,\"type\":\"AMOUNT\",\"value\":248}," + + "{\"minCourses\":4,\"type\":\"AMOUNT\",\"value\":447}," + + "{\"minCourses\":5,\"type\":\"AMOUNT\",\"value\":646}]," + GROUPS + "}}"; + + private static List items(int n, double price) { + List out = new ArrayList<>(); + for (int i = 0; i < n; i++) { + out.add(new BasketPricingCalculator.BasketItem("Class 5", "Subject " + i, price)); + } + return out; + } + + private static List items(int n) { + return items(n, PRICE); + } + + @Nested + @DisplayName("the published price card") + class PriceCard { + + @ParameterizedTest(name = "{0} subject(s) cost {1} on a FLAT page") + @CsvSource({ "1,349", "2,599", "3,799", "4,949", "5,1099" }) + void flatChargesTheCardPrice(int count, double expected) { + assertEquals(expected, calculator.price(FLAT, items(count)).getTotal()); + } + + @ParameterizedTest(name = "{0} subject(s) cost {1} on a DISCOUNT page") + @CsvSource({ "1,349", "2,599", "3,799", "4,949", "5,1099" }) + void discountChargesTheSame(int count, double expected) { + assertEquals(expected, calculator.price(DISCOUNT, items(count)).getTotal()); + } + + @Test + @DisplayName("reports what the courses cost apart, so the saving can be shown") + void reportsTheBase() { + BasketPricingCalculator.BasketPrice priced = calculator.price(DISCOUNT, items(3)); + assertEquals(1047, priced.getItemTotal()); + assertEquals(799, priced.getTotal()); + } + } + + @Nested + @DisplayName("percentage tiers") + class Percentages { + + private static final String PCT = "{\"basketPricing\":{\"enabled\":true," + + "\"pricingBasis\":\"DISCOUNT\",\"ladder\":{\"prices\":[],\"perExtra\":0}," + + "\"tiers\":[{\"minCourses\":2,\"type\":\"PERCENT\",\"value\":15}," + + "{\"minCourses\":4,\"type\":\"PERCENT\",\"value\":25}]," + GROUPS + "}}"; + + @Test + @DisplayName("keep scaling past the last rung, which a flat ladder cannot") + void scaleWithoutARungPerCount() { + assertEquals(593, calculator.price(PCT, items(2)).getTotal()); + assertEquals(1571, calculator.price(PCT, items(6)).getTotal()); + } + + @Test + @DisplayName("follow the courses when the invite reprices them") + void followTheInvite() { + assertEquals(850, calculator.price(PCT, items(2, 500)).getTotal()); + } + } + + @Nested + @DisplayName("amount-gated tiers") + class AmountGates { + + /** A DISCOUNT page whose tiers are the argument, as raw settings JSON. */ + private String spend(String tiers) { + return "{\"basketPricing\":{\"enabled\":true,\"pricingBasis\":\"DISCOUNT\"," + + "\"ladder\":{\"prices\":[],\"perExtra\":0},\"tiers\":[" + tiers + "]," + + GROUPS + "}}"; + } + + /** n courses priced so the group's base lands exactly on `total`. */ + private List worth(double total, int n) { + return items(n, total / n); + } + + @Test + @DisplayName("apply once the basket is worth enough, whatever the count") + void byAmountAlone() { + String s = spend("{\"minAmount\":1000,\"type\":\"PERCENT\",\"value\":10}"); + assertEquals(900, calculator.price(s, worth(900, 5)).getTotal()); + assertEquals(900, calculator.price(s, worth(1000, 1)).getTotal()); + assertEquals(1800, calculator.price(s, worth(2000, 2)).getTotal()); + } + + @Test + @DisplayName("require BOTH conditions when both are set") + void bothConditions() { + String s = spend("{\"minCourses\":3,\"minAmount\":1000,\"type\":\"PERCENT\",\"value\":10}"); + assertEquals(1200, calculator.price(s, worth(1200, 2)).getTotal()); + assertEquals(800, calculator.price(s, worth(800, 4)).getTotal()); + assertEquals(1080, calculator.price(s, worth(1200, 3)).getTotal()); + } + + @Test + @DisplayName("cap a percentage at maxDiscount") + void capped() { + String s = spend("{\"minAmount\":1000,\"type\":\"PERCENT\",\"value\":50,\"maxDiscount\":300}"); + assertEquals(700, calculator.price(s, worth(1000, 3)).getTotal()); + } + + @Test + @DisplayName("treat a zero cap as no cap") + void zeroCapIsNoCap() { + String s = spend("{\"minAmount\":1000,\"type\":\"PERCENT\",\"value\":50,\"maxDiscount\":0}"); + assertEquals(500, calculator.price(s, worth(1000, 2)).getTotal()); + } + + @Test + @DisplayName("close a band at the top so two rules do not fight") + void closedBand() { + String s = spend("{\"minAmount\":500,\"maxAmount\":999,\"type\":\"PERCENT\",\"value\":10}," + + "{\"minAmount\":1000,\"type\":\"PERCENT\",\"value\":20}"); + assertEquals(540, calculator.price(s, worth(600, 2)).getTotal()); + assertEquals(1200, calculator.price(s, worth(1500, 4)).getTotal()); + } + + @Test + @DisplayName("ignore a tier with no condition rather than firing on everything") + void unconditionalIsIgnored() { + String s = spend("{\"type\":\"PERCENT\",\"value\":50}"); + assertEquals(1000, calculator.price(s, worth(1000, 3)).getTotal()); + } + + @Test + @DisplayName("never discount more than the courses cost") + void neverBelowZero() { + assertEquals(0, calculator.price(spend("{\"minAmount\":1,\"type\":\"AMOUNT\",\"value\":99999}"), + worth(500, 2)).getTotal()); + assertEquals(0, calculator.price(spend("{\"minAmount\":1,\"type\":\"PERCENT\",\"value\":300}"), + worth(500, 2)).getTotal()); + } + + @Test + @DisplayName("ignore negative and zero values") + void ignoresNonPositiveValues() { + assertEquals(500, calculator.price(spend("{\"minAmount\":1,\"type\":\"AMOUNT\",\"value\":-100}"), + worth(500, 2)).getTotal()); + assertEquals(500, calculator.price(spend("{\"minAmount\":1,\"type\":\"PERCENT\",\"value\":0}"), + worth(500, 2)).getTotal()); + } + + @Test + @DisplayName("take the best of a count tier and an amount tier") + void bestOfBoth() { + String s = spend("{\"minCourses\":2,\"type\":\"AMOUNT\",\"value\":99}," + + "{\"minAmount\":600,\"type\":\"PERCENT\",\"value\":25}"); + // 698 qualifies for both: 99 flat vs 174.5 percent — the better wins. + assertEquals(Math.round(698 - 174.5), calculator.price(s, worth(698, 2)).getTotal()); + } + } + + @Nested + @DisplayName("guardrails") + class Guardrails { + + @Test + @DisplayName("an unconfigured page keeps summing item prices") + void unconfiguredReturnsNull() { + assertNull(calculator.price("{\"basketPricing\":{\"enabled\":false}}", items(3))); + assertNull(calculator.price(null, items(3))); + } + + @Test + @DisplayName("a free course stays free under DISCOUNT") + void freeStaysFree() { + assertEquals(0, calculator.price(DISCOUNT, items(1, 0)).getTotal()); + } + + @Test + @DisplayName("FLAT pages with free courses are exactly as they were") + void flatIsUntouched() { + assertEquals(599, calculator.price(FLAT, items(2, 0)).getTotal()); + assertEquals(1099, calculator.price(FLAT, items(5, 0)).getTotal()); + } + + @Test + @DisplayName("a DISCOUNT tier cannot charge more than the courses cost apart") + void neverAboveTheBase() { + String bad = "{\"basketPricing\":{\"enabled\":true,\"pricingBasis\":\"DISCOUNT\"," + + "\"ladder\":{\"prices\":[],\"perExtra\":0}," + + "\"tiers\":[{\"minCourses\":1,\"type\":\"AMOUNT\",\"value\":-500}]," + GROUPS + "}}"; + assertEquals(349, calculator.price(bad, items(1)).getTotal()); + } + + @Test + @DisplayName("a discount is never taken away for adding another subject") + void tiersNeverRegress() { + // Under a highest-threshold rule this would punish the fifth subject. + String backwards = "{\"basketPricing\":{\"enabled\":true,\"pricingBasis\":\"DISCOUNT\"," + + "\"ladder\":{\"prices\":[],\"perExtra\":0}," + + "\"tiers\":[{\"minCourses\":2,\"type\":\"AMOUNT\",\"value\":500}," + + "{\"minCourses\":5,\"type\":\"AMOUNT\",\"value\":100}]," + GROUPS + "}}"; + assertEquals(698 - 500, calculator.price(backwards, items(2)).getTotal()); + assertEquals(1745 - 500, calculator.price(backwards, items(5)).getTotal()); + } + + @Test + @DisplayName("a full pack still wins when it is cheaper") + void packStillWins() { + String withPack = DISCOUNT.replace(GROUPS, + "\"groups\":[{\"label\":\"Class 5\",\"levels\":[\"Class 5\"],\"packPrice\":499}]"); + assertEquals(499, calculator.price(withPack, items(3)).getTotal()); + } + } +} diff --git a/ai_service/app/routers/kb_paper.py b/ai_service/app/routers/kb_paper.py index 14c6424664..fbae31532f 100644 --- a/ai_service/app/routers/kb_paper.py +++ b/ai_service/app/routers/kb_paper.py @@ -330,7 +330,7 @@ async def _run_generation() -> str: # board maps a rewritten question back by index, so a silent skip inside # the formatter would otherwise replace the WRONG question. raw_kept, formatted, format_warnings = kb_paper.pair_with_formatted( - generated.questions + generated.questions, kb_id=kb_id, generation_id=generation_id ) payload = { "blueprint": blueprint.to_dict(), @@ -363,7 +363,15 @@ async def _run_generation() -> str: return json.dumps(payload) ai_task_service.schedule(task_id, work) - return {"task_id": task_id, "status": "PROGRESS", "planned": blueprint.total_questions} + # generation_id is returned so the caller can mark the run SAVED once the questions + # land somewhere. The section endpoint below already did this; without it here, a + # whole-paper run stayed READY forever in the history even after it was used. + return { + "task_id": task_id, + "status": "PROGRESS", + "generation_id": generation_id, + "planned": blueprint.total_questions, + } class SectionRequest(BaseModel): @@ -481,7 +489,7 @@ async def work() -> str: ) raw_kept, formatted, format_warnings = kb_paper.pair_with_formatted( - generated.questions + generated.questions, kb_id=kb_id, generation_id=generation_id ) payload = { "blueprint": blueprint.to_dict(), @@ -596,7 +604,7 @@ async def regenerate_question( # Format BEFORE billing: a question that cannot be converted is not a # delivered question, and charging for it would be charging for nothing. - raw_kept, formatted, _ = kb_paper.pair_with_formatted(generated.questions[:1]) + raw_kept, formatted, _ = kb_paper.pair_with_formatted(generated.questions[:1], kb_id=kb_id) if not formatted: raise HTTPException( 422, diff --git a/ai_service/app/routers/page_builder.py b/ai_service/app/routers/page_builder.py index ec9c44295d..bc144dc22d 100644 --- a/ai_service/app/routers/page_builder.py +++ b/ai_service/app/routers/page_builder.py @@ -122,7 +122,7 @@ def _sanitize_html(value: str) -> str: _CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", re.S) _CSS_URL_RE = re.compile(r"url\s*\([^)]*\)", re.I) -_CSS_BANNED_RE = re.compile(r"@import\b|expression\s*\(|behavior\s*:|-moz-binding|javascript\s*:", re.I) +_CSS_BANNED_RE = re.compile(r"@import\b|expression\s*\(|(?]*?\bsrc=")([^"]*)(")', re.I) diff --git a/ai_service/app/services/html_page_import.py b/ai_service/app/services/html_page_import.py index f2c6602758..374f63f8f7 100644 --- a/ai_service/app/services/html_page_import.py +++ b/ai_service/app/services/html_page_import.py @@ -70,7 +70,7 @@ _BODY_RE = re.compile(r"]*>(.*)", re.I | re.S) _CSS_IMPORT_RE = re.compile(r"@import\b[^;]*;", re.I) _CSS_URL_RE = re.compile(r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)", re.I) -_BANNED_CSS_RE = re.compile(r"expression\s*\(|behavior\s*:|-moz-binding|javascript\s*:", re.I) +_BANNED_CSS_RE = re.compile(r"expression\s*\(|(?]*?\bsrc=["\'])([^"\']*)(["\'])', re.I) _ANCHOR_RE = re.compile(r']*?)\bhref=["\']([^"\']*)["\']([^>]*)>', re.I) _REMOTE_RE = re.compile(r"^(https?:)?//", re.I) diff --git a/ai_service/app/services/kb/paper.py b/ai_service/app/services/kb/paper.py index f273f393e4..f97f7a3010 100644 --- a/ai_service/app/services/kb/paper.py +++ b/ai_service/app/services/kb/paper.py @@ -69,27 +69,23 @@ MAX_QUESTIONS_PER_PAPER = 120 -QUESTION_TYPES = ("MCQS", "ONE_WORD", "LONG_ANSWER", "NUMERIC") +QUESTION_TYPES = ("MCQS", "MCQM", "TRUE_FALSE", "ONE_WORD", "LONG_ANSWER", "NUMERIC") + +# Types that carry options, i.e. where `correct_options` is the answer key rather +# than `ans`. +OPTION_QUESTION_TYPES = ("MCQS", "MCQM", "TRUE_FALSE") # What each blueprint type is STORED as. # -# `question_format.format_questions` — the shared converter every AI question -# source funnels through — dispatches on MCQS / MCQM / ONE_WORD / LONG_ANSWER and -# SILENTLY SKIPS anything else. It has no NUMERIC branch, even though NUMERIC is -# a real platform question type. So a numerical question emitted as NUMERIC is -# dropped on the floor: it shows in the review board and then never reaches the -# question bank. +# This used to map NUMERIC -> ONE_WORD, because `question_format.format_questions` +# had no NUMERIC branch and SILENTLY SKIPPED anything it could not dispatch — so a +# question emitted as NUMERIC showed up in the review board and then vanished on +# the way to the question bank. # -# NUMERIC therefore stays a PLANNING type (it shapes the prompt — "a numerical -# problem with a definite answer, show the working") but is stored as ONE_WORD, -# which is what a numeric answer actually is. Removing NUMERIC from the blueprint -# instead would cost teachers the ability to ask for numericals at all. -STORAGE_QUESTION_TYPE = { - "MCQS": "MCQS", - "ONE_WORD": "ONE_WORD", - "LONG_ANSWER": "LONG_ANSWER", - "NUMERIC": "ONE_WORD", -} +# format_questions now handles NUMERIC and TRUE_FALSE natively, so the downgrade is +# gone and every planning type is stored as itself. Questions saved BEFORE this +# change remain stored as ONE_WORD and keep grading exactly as they did. +STORAGE_QUESTION_TYPE = {t: t for t in QUESTION_TYPES} @dataclass @@ -314,7 +310,7 @@ def _blueprint_prompt( "node_ids": ["ids copied EXACTLY from the outline above that this row draws on"], "page_start": 11, "page_end": 18, - "question_type": "MCQS | ONE_WORD | LONG_ANSWER | NUMERIC", + "question_type": "MCQS | MCQM | TRUE_FALSE | ONE_WORD | LONG_ANSWER | NUMERIC", "count": 10, "marks_each": 1, "difficulty": "EASY | MEDIUM | HARD", @@ -472,6 +468,15 @@ def _question_prompt( '"choose all that apply". Wrong options must be plausible — a ' "distractor nobody would pick tests nothing." ), + "MCQM": ( + "Exactly 4 options, with TWO OR MORE correct. List every correct option " + "in correct_options. Wrong options must be plausible." + ), + "TRUE_FALSE": ( + "Exactly 2 options, \"True\" and \"False\" in that order (preview_id 1 and " + "2). Exactly one is correct. The statement must be unambiguously one or " + "the other from the passages — not a matter of opinion." + ), "ONE_WORD": "Answer is a single word, number, or very short phrase.", "LONG_ANSWER": ( "A structured answer worth the marks. Provide a model answer with the " @@ -529,8 +534,8 @@ def _question_prompt( provided solution…", no discussion of whether the source is ambiguous, no mention of these instructions. If the source material is unclear, silently pick the best-supported answer and give clean working for THAT. -- For MCQS give EXACTLY ONE correct option. If more than one option is - defensible, rewrite the options so only one is. +- For MCQS and TRUE_FALSE give EXACTLY ONE correct option. If more than one option + is defensible, rewrite the options so only one is. For MCQM give at least two. - Ground EVERY question in the passages. If the passages do not support {row.count} distinct questions, return fewer — a padded paper is worse than a short one. - "source_passage" and "source_page" must point at the passage you actually used. @@ -784,6 +789,9 @@ async def do_row(row: BlueprintRow) -> List[Dict[str, Any]]: "topic": row.topic, "marks": row.marks_each, "source_page": q.get("source_page"), + # The topic nodes this row drew on. Carried through to the saved + # question so the bank can later be filtered by topic. + "node_ids": list(row.node_ids or []), # What the teacher ASKED for, which may differ from what the # platform stores (see STORAGE_QUESTION_TYPE). The review # board shows this one. @@ -860,12 +868,14 @@ def validate_paper(blueprint: Blueprint, questions: Sequence[Dict[str, Any]]) -> num, "warning", "missing_answer", "No explanation or marking scheme — a teacher cannot mark this consistently.", )) - if qtype == "MCQS": + if qtype in OPTION_QUESTION_TYPES: options = q.get("options") or [] - if len(options) != 4: + # TRUE_FALSE has two options by definition; the others are 4-option. + expected_options = 2 if qtype == "TRUE_FALSE" else 4 + if len(options) != expected_options: issues.append(PaperIssue( num, "error", "bad_options", - f"{len(options)} option(s) instead of 4.", + f"{len(options)} option(s) instead of {expected_options}.", )) correct = q.get("correct_options") or [] if not correct: @@ -878,15 +888,24 @@ def validate_paper(blueprint: Blueprint, questions: Sequence[Dict[str, Any]]) -> num, "error", "missing_answer", f"Correct option {stray} does not match any option on this question.", )) - # MCQS is single-choice. Two correct answers means the student - # cannot score it and the auto-evaluation is wrong — seen live on - # a generated paper that otherwise "passed all checks". - if len(set(map(str, correct))) > 1: + distinct_correct = len(set(map(str, correct))) + # MCQS and TRUE_FALSE are single-choice. Two correct answers means the + # student cannot score it and the auto-evaluation is wrong — seen live + # on a generated paper that otherwise "passed all checks". + if qtype != "MCQM" and distinct_correct > 1: issues.append(PaperIssue( num, "error", "bad_options", - f"{len(set(map(str, correct)))} options are marked correct, but this " + f"{distinct_correct} options are marked correct, but this " "is a single-choice question.", )) + # MCQM with one correct option is an MCQS wearing the wrong label: the + # learner sees checkboxes for a question that has a single answer. + if qtype == "MCQM" and distinct_correct < 2: + issues.append(PaperIssue( + num, "warning", "bad_options", + "Only one option is marked correct on a multiple-correct " + "question — it should be single-choice instead.", + )) # Unsubstituted figure placeholders make a question unanswerable. blob = " ".join( @@ -958,8 +977,35 @@ def validate_paper(blueprint: Blueprint, questions: Sequence[Dict[str, Any]]) -> return issues +def _provenance( + raw: Dict[str, Any], kb_id: Optional[str], generation_id: Optional[str] +) -> Dict[str, Any]: + """What the question bank stores about where this question came from. + + Everything here is already known at generation time and was previously thrown + away at the save boundary, which is why a saved KB question could not be traced + back to its book, its topic or its page — and therefore could never be found + again to reuse. + """ + meta = raw.get("kb_meta") or {} + return { + "kb_id": kb_id, + "generation_id": generation_id, + "row_id": meta.get("row_id"), + "section": meta.get("section"), + "topic": meta.get("topic"), + "node_ids": meta.get("node_ids") or [], + # kb_meta.source_page is the one the model cited and the review board shows. + "source_page": meta.get("source_page") or raw.get("source_page"), + "figures": meta.get("figures") or [], + "planned_type": meta.get("planned_type"), + } + + def pair_with_formatted( raw_questions: Sequence[Dict[str, Any]], + kb_id: Optional[str] = None, + generation_id: Optional[str] = None, ) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]: """Format questions ONE AT A TIME so the two lists can never drift apart. @@ -971,6 +1017,9 @@ def pair_with_formatted( Returns (raw_kept, formatted, warnings), guaranteed equal length and aligned. Anything dropped is reported rather than vanishing. + + Also stamps each formatted question with its provenance, which the question bank + persists (assessment_service V42) so these questions stay findable afterwards. """ from ..question_format import format_questions @@ -985,8 +1034,13 @@ def pair_with_formatted( logger.warning("Formatting failed for question %s: %s", raw.get("question_number"), exc) out = [] if out: + question = out[0] + question["source_type"] = "KNOWLEDGE_BASE" + question["source_meta"] = json.dumps( + _provenance(raw, kb_id, generation_id), ensure_ascii=False + ) raw_kept.append(raw) - formatted.append(out[0]) + formatted.append(question) else: dropped.append(raw.get("question_number")) @@ -1003,5 +1057,6 @@ def pair_with_formatted( __all__ = [ "Blueprint", "BlueprintRow", "GeneratedPaper", "PaperIssue", "build_blueprint", "generate_questions", "validate_paper", "pair_with_formatted", - "MAX_QUESTIONS_PER_PAPER", "QUESTION_TYPES", "STORAGE_QUESTION_TYPE", + "MAX_QUESTIONS_PER_PAPER", "QUESTION_TYPES", "OPTION_QUESTION_TYPES", + "STORAGE_QUESTION_TYPE", ] diff --git a/ai_service/app/services/question_format.py b/ai_service/app/services/question_format.py index 76549447fa..6d301286f4 100644 --- a/ai_service/app/services/question_format.py +++ b/ai_service/app/services/question_format.py @@ -13,6 +13,7 @@ import json import logging +import re from typing import Any, Dict, List, Optional from ..schemas.question_paper import AutoQuestionPaperResponse @@ -59,6 +60,49 @@ def _canonical_level(level: Optional[str]) -> Optional[str]: return {"easy": "EASY", "medium": "MEDIUM", "hard": "HARD"}.get(v) +def _metadata(q: Dict[str, Any]) -> Dict[str, Any]: + """Tags + difficulty, under BOTH spellings. + + `tags` / `level` are what the KB review board and the AI-center preview read. + `ai_tags` / `ai_difficulty_level` are what Java's QuestionDTO actually binds — + it declares `aiTags` and `aiDifficultyLevel` under a SnakeCaseStrategy and is + @JsonIgnoreProperties(ignoreUnknown=true), so the `tags`/`level` keys alone were + silently dropped on save and every AI-generated tag and difficulty was lost. + + Emitting both keeps the existing readers working and starts persisting the data. + """ + tags = q.get("tags") + level = _canonical_level(q.get("level")) + meta: Dict[str, Any] = {"tags": tags, "level": level} + if tags: + meta["ai_tags"] = tags + if level: + meta["ai_difficulty_level"] = level + return meta + + +def _numeric_answers(q: Dict[str, Any]) -> List[float]: + """Every accepted numeric answer, from an explicit list or parsed out of `ans`.""" + raw = q.get("valid_answers") + if not isinstance(raw, list) or not raw: + raw = [q.get("ans")] + answers: List[float] = [] + for candidate in raw: + if candidate is None: + continue + if isinstance(candidate, (int, float)) and not isinstance(candidate, bool): + value = float(candidate) + if value not in answers: + answers.append(value) + continue + # A generator writing prose ("42.5 m/s", "3 or 4") still carries the number. + for token in re.findall(r"-?\d+(?:\.\d+)?", str(candidate)): + value = float(token) + if value not in answers: + answers.append(value) + return answers + + def normalize_correct_option_ids(raw: Optional[List[str]], preview_ids: List[str]) -> List[str]: """Port of normalizeCorrectOptionIds: map A/B/C, 1-based index, or literal preview-id markers to the actual option preview_ids; dedup; drop unknowns.""" @@ -110,6 +154,7 @@ def _build_options(q: Dict[str, Any]) -> tuple[List[Dict[str, Any]], List[str]]: def _handle_mcq(q: Dict[str, Any], qtype: str) -> Dict[str, Any]: options_out, preview_ids = _build_options(q) + correct = normalize_correct_option_ids(q.get("correct_options"), preview_ids) dto: Dict[str, Any] = { "access_level": "PUBLIC", "question_response_type": "OPTION", @@ -117,18 +162,61 @@ def _handle_mcq(q: Dict[str, Any], qtype: str) -> Dict[str, Any]: "explanation_text": _rich(q.get("exp")), "text": _rich(q.get("question", {}).get("content")), "options": options_out, + # Both spellings. Java's MCQEvaluationDTO.MCQData binds `correctOptionIds` + # (the nested class does not inherit the outer SnakeCaseStrategy); the AI-center + # preview reader reads `correct_option_ids`. + "auto_evaluation_json": _eval_json( + {"type": qtype, "data": {"correct_option_ids": correct, "correctOptionIds": correct}} + ), + } + dto.update(_metadata(q)) + return dto + + +def _handle_true_false(q: Dict[str, Any]) -> Dict[str, Any]: + """TRUE_FALSE is an MCQS with two fixed options as far as storage is concerned. + + Java routes TRUE_FALSE through the same createOptions/handleMCQQuestion branch as + MCQS, so the shape is identical — only `question_type` differs. + """ + if not (q.get("options") or []): + q = {**q, "options": [ + {"preview_id": "1", "content": "True"}, + {"preview_id": "2", "content": "False"}, + ]} + dto = _handle_mcq(q, "TRUE_FALSE") + return dto + + +def _handle_numeric(q: Dict[str, Any]) -> Dict[str, Any]: + """A numeric question with one or more accepted answers. + + Previously there was NO numeric branch here at all: `format_questions` fell to its + else-clause and SKIPPED the question outright, which is why kb/paper.py stored + numericals as ONE_WORD rather than emitting a type that would be dropped. + """ + answers = _numeric_answers(q) + # INTEGER unless an answer actually needs a decimal part — Java defaults to INTEGER + # when this is absent, so being explicit is what makes decimals survive. + response_type = "INTEGER" if all(float(a).is_integer() for a in answers) else "DECIMAL" + dto: Dict[str, Any] = { + "access_level": "PUBLIC", + "question_response_type": response_type, + "question_type": "NUMERIC", + "explanation_text": _rich(q.get("exp")), + "text": _rich(q.get("question", {}).get("content")), + # NumericalEvaluationDto.NumericalData binds `validAnswers`; the snake key is + # kept for the preview readers, exactly as with MCQ above. "auto_evaluation_json": _eval_json( - {"type": qtype, "data": {"correct_option_ids": normalize_correct_option_ids(q.get("correct_options"), preview_ids)}} + {"type": "NUMERIC", "data": {"valid_answers": answers, "validAnswers": answers}} ), } - if qtype == "MCQS": # only MCQS sets tags + level (matches Java handlers) - dto["tags"] = q.get("tags") - dto["level"] = _canonical_level(q.get("level")) + dto.update(_metadata(q)) return dto def _handle_one_word(q: Dict[str, Any]) -> Dict[str, Any]: - return { + dto = { "access_level": "PUBLIC", "question_response_type": "ONE_WORD", "question_type": "ONE_WORD", @@ -136,10 +224,12 @@ def _handle_one_word(q: Dict[str, Any]) -> Dict[str, Any]: "text": _rich(q.get("question", {}).get("content")), "auto_evaluation_json": _eval_json({"type": "ONE_WORD", "data": {"answer": q.get("ans")}}), } + dto.update(_metadata(q)) + return dto def _handle_long_answer(q: Dict[str, Any]) -> Dict[str, Any]: - return { + dto = { "access_level": "PUBLIC", "question_response_type": "LONG_ANSWER", "question_type": "LONG_ANSWER", @@ -147,6 +237,8 @@ def _handle_long_answer(q: Dict[str, Any]) -> Dict[str, Any]: "text": _rich(q.get("question", {}).get("content")), "auto_evaluation_json": _eval_json({"type": "LONG_ANSWER", "data": {"answer": _rich(q.get("ans"))}}), } + dto.update(_metadata(q)) + return dto def format_questions(questions: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]: @@ -169,6 +261,10 @@ def format_questions(questions: Optional[List[Dict[str, Any]]]) -> List[Dict[str out.append(_handle_mcq(q, "MCQS")) elif qt == "MCQM": out.append(_handle_mcq(q, "MCQM")) + elif qt == "TRUE_FALSE": + out.append(_handle_true_false(q)) + elif qt == "NUMERIC": + out.append(_handle_numeric(q)) elif qt == "ONE_WORD": out.append(_handle_one_word(q)) elif qt == "LONG_ANSWER": diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/core/config/CacheConfiguration.java b/assessment_service/src/main/java/vacademy/io/assessment_service/core/config/CacheConfiguration.java index e73ebdab2d..fea3f52863 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/core/config/CacheConfiguration.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/core/config/CacheConfiguration.java @@ -38,6 +38,29 @@ public CacheManager cacheManager() { .recordStats() .build()); + // Batch enrollment for the "not attempted yet" list, keyed by institute + batch set. + // This is what keeps that feature off the hot path: the submissions page asks for the + // Pending count on EVERY mount, so uncached it would be one admin_core round trip per + // page view. Batch enrollment changes rarely, so 2 minutes collapses every mount, tab + // switch and page step for a batch set onto a single call while staying fresh enough + // that a newly enrolled learner shows up quickly. Entries are lists of learners, so + // the size cap is deliberately small. + cacheManager.registerCustomCache("batchEnrolledLearners", + Caffeine.newBuilder() + .expireAfterWrite(2, TimeUnit.MINUTES) + .maximumSize(200) + .recordStats() + .build()); + + // Batch display names for the CSV exports. Names essentially never change and the + // map is tiny, so this is cached longer than the enrollment above. + cacheManager.registerCustomCache("batchNames", + Caffeine.newBuilder() + .expireAfterWrite(30, TimeUnit.MINUTES) + .maximumSize(500) + .recordStats() + .build()); + return cacheManager; } } diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/announcement/entity/AssessmentAnnouncement.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/announcement/entity/AssessmentAnnouncement.java index b95f7c5beb..b08ad5273a 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/announcement/entity/AssessmentAnnouncement.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/announcement/entity/AssessmentAnnouncement.java @@ -2,8 +2,10 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import org.hibernate.annotations.UuidGenerator; import vacademy.io.assessment_service.features.assessment.entity.Assessment; import vacademy.io.assessment_service.features.assessment.entity.StudentAttempt; @@ -15,6 +17,12 @@ @Table(name = "assessment_announcement") @Data @Builder +// @Builder suppresses the no-arg constructor @Data would otherwise supply, and JPA +// cannot materialise an entity without one. This entity is read on the learner +// autosave path, so a single announcement row made every /status/update fail with +// "No default constructor" — verified against production 2026-08-28. +@NoArgsConstructor +@AllArgsConstructor public class AssessmentAnnouncement { @Id diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/controller/AdminExportController.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/controller/AdminExportController.java index 49e9aed249..46304646f3 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/controller/AdminExportController.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/controller/AdminExportController.java @@ -57,8 +57,11 @@ public ResponseEntity getRegisteredCsv(@RequestAttribute("user") CustomU @GetMapping("/csv/registered-participants/columns") public ResponseEntity getRegisteredCsvColumns(@RequestAttribute("user") CustomUserDetails user, @RequestParam(name = "instituteId") String instituteId, - @RequestParam(name = "assessmentId") String assessmentId) { - return adminExportManager.getResultExportColumns(user, instituteId, assessmentId); + @RequestParam(name = "assessmentId") String assessmentId, + // Which sheet the dialog is about to export. Defaults false so + // existing callers keep getting the result columns. + @RequestParam(name = "notAttempted", required = false, defaultValue = "false") boolean notAttempted) { + return adminExportManager.getResultExportColumns(user, instituteId, assessmentId, notAttempted); } @PostMapping("/pdf/registered-participants") diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/controller/AdminReattemptRequestController.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/controller/AdminReattemptRequestController.java index 2b8cc55e35..e732ea253f 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/controller/AdminReattemptRequestController.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/controller/AdminReattemptRequestController.java @@ -40,11 +40,17 @@ public ResponseEntity> list(@RequestAttribute("user") return ResponseEntity.ok(reattemptRequestManager.listForAdmin(instituteId, assessmentId, status, page, size)); } - /** Badge count for the admin nav — the "system alert" that requests are waiting. */ + /** + * Badge count for the admin nav — the "system alert" that requests are waiting. + * + * @param assessmentId optional — omit for the institute-wide badge, pass it for a badge that + * sits on one assessment's tab + */ @GetMapping("/pending-count") public ResponseEntity pendingCount(@RequestAttribute("user") CustomUserDetails user, - @RequestParam("instituteId") String instituteId) { - return ResponseEntity.ok(reattemptRequestManager.pendingCount(instituteId)); + @RequestParam("instituteId") String instituteId, + @RequestParam(value = "assessmentId", required = false) String assessmentId) { + return ResponseEntity.ok(reattemptRequestManager.pendingCount(instituteId, assessmentId)); } /** Approve (granting {@code granted_count} attempts) or reject one request. */ diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/ParticipantsDetailsDto.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/ParticipantsDetailsDto.java index 47a0fdf826..6ede79a85a 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/ParticipantsDetailsDto.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/ParticipantsDetailsDto.java @@ -34,4 +34,18 @@ public interface ParticipantsDetailsDto { String getUserEmail(); + /** + * Contact details, so every participants list can show how to reach a learner rather + * than only naming them. Backed by columns {@code assessment_user_registration} has + * always carried, and populated in practice: on this institute's 1,320 registrations + * only 7 are missing a phone number and none are missing an email or username. + * + *

Every native query behind this projection aliases these — see the repository. A + * query that stopped selecting one would silently start reporting null, i.e. an empty + * column, so add them to any new query too. + */ + String getPhoneNumber(); + + String getUsername(); + } diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/EnrolledLearnerDto.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/EnrolledLearnerDto.java new file mode 100644 index 0000000000..357c69a4e5 --- /dev/null +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/EnrolledLearnerDto.java @@ -0,0 +1,60 @@ +package vacademy.io.assessment_service.features.assessment.dto.batch_pending; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * One batch-enrolled learner, as returned by admin_core's + * {@code /internal/learner/v1/enrolled-by-package-sessions}. + * + *

admin_core serialises the projection with SnakeCaseStrategy, so snake_case is the + * expected wire form. Each field also accepts the camelCase spelling: this is a + * cross-service contract, and if the two ever disagree the failure is silent — Jackson + * leaves the fields null and the tab renders nameless rows instead of erroring. Accepting + * both costs nothing and removes that failure mode entirely. + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class EnrolledLearnerDto { + + @JsonProperty("user_id") + @JsonAlias("userId") + private String userId; + + @JsonProperty("full_name") + @JsonAlias("fullName") + private String fullName; + + @JsonProperty("package_session_id") + @JsonAlias("packageSessionId") + private String packageSessionId; + + // Contact details, carried only so the "not attempted" CSV can be used to chase the + // learners who never sat the test. Any of them can be blank for a learner imported + // without one, so the CSV must tolerate empty cells rather than skipping the row. + @JsonProperty("email") + private String email; + + @JsonProperty("mobile_number") + @JsonAlias("mobileNumber") + private String mobileNumber; + + @JsonProperty("username") + private String username; + + /** + * Identity only, no contact details — for callers (and tests) that care about who is + * on the list rather than how to reach them. Kept explicit because widening + * {@code @AllArgsConstructor} from three fields to six silently breaks every existing + * three-arg call site. + */ + public EnrolledLearnerDto(String userId, String fullName, String packageSessionId) { + this(userId, fullName, packageSessionId, null, null, null); + } +} diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/NotAttemptedParticipantDto.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/NotAttemptedParticipantDto.java new file mode 100644 index 0000000000..79f9da51ec --- /dev/null +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/NotAttemptedParticipantDto.java @@ -0,0 +1,125 @@ +package vacademy.io.assessment_service.features.assessment.dto.batch_pending; + +import vacademy.io.assessment_service.features.assessment.dto.ParticipantsDetailsDto; + +import java.util.Date; + +/** + * A batch-enrolled learner who has not attempted the assessment, shaped as a + * {@link ParticipantsDetailsDto} so the Pending tab reuses the existing table, row + * mapping and response envelope. + * + *

Everything attempt-shaped is null on purpose: these learners have no + * {@code assessment_user_registration} row and no {@code student_attempt} row — that + * absence IS the reason they appear here. Consumers must treat a null + * {@code registrationId}/{@code attemptId} as "never started", not as missing data. + */ +public class NotAttemptedParticipantDto implements ParticipantsDetailsDto { + + private final String userId; + private final String studentName; + private final String batchId; + // Contact details, so the Pending tab can show the same "how do I reach this learner" + // columns its own CSV export already carries. The tab and the export answer the same + // question and must not disagree about what they know. Any of these can be null for a + // learner imported without one — that is a blank cell, not a missing row. + private final String userEmail; + private final String phoneNumber; + private final String username; + + public NotAttemptedParticipantDto(String userId, String studentName, String batchId, + String userEmail, String phoneNumber, String username) { + this.userId = userId; + this.studentName = studentName; + this.batchId = batchId; + this.userEmail = userEmail; + this.phoneNumber = phoneNumber; + this.username = username; + } + + /** + * Identity only, no contact details — for callers (and tests) that care about who is on + * the list rather than how to reach them. Kept explicit so widening the constructor + * doesn't silently break every existing three-arg call site. + */ + public NotAttemptedParticipantDto(String userId, String studentName, String batchId) { + this(userId, studentName, batchId, null, null, null); + } + + @Override + public String getUserId() { + return userId; + } + + @Override + public String getStudentName() { + return studentName; + } + + @Override + public String getBatchId() { + return batchId; + } + + @Override + public String getUserEmail() { + return userEmail; + } + + @Override + public String getPhoneNumber() { + return phoneNumber; + } + + @Override + public String getUsername() { + return username; + } + + // --- No attempt exists, so nothing attempt-derived can be reported. --- + + @Override + public String getRegistrationId() { + return null; + } + + @Override + public String getAttemptId() { + return null; + } + + @Override + public Date getAttemptDate() { + return null; + } + + @Override + public Date getEndTime() { + return null; + } + + @Override + public Long getDuration() { + return null; + } + + @Override + public Double getScore() { + return null; + } + + @Override + public String getEvaluationStatus() { + return null; + } + + @Override + public String getReportReleaseResultStatus() { + return null; + } + + @Override + public Date getLastReportReleaseDate() { + return null; + } +} diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/NotAttemptedParticipants.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/NotAttemptedParticipants.java new file mode 100644 index 0000000000..82eab70602 --- /dev/null +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/NotAttemptedParticipants.java @@ -0,0 +1,132 @@ +package vacademy.io.assessment_service.features.assessment.dto.batch_pending; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import vacademy.io.assessment_service.features.assessment.dto.ParticipantsDetailsDto; + +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +/** + * Turns "everyone enrolled in the batches" plus "everyone who already attempted" into the + * page of learners who never tried the assessment. + * + *

Kept separate from the manager, and free of Spring and of any repository, so the set + * arithmetic that decides whether a learner is chased for a missing submission is + * unit-testable on its own. + */ +public final class NotAttemptedParticipants { + + private NotAttemptedParticipants() { + } + + /** + * The batches to actually look up: the assessment's assigned batches, narrowed to the + * ones the caller asked for. + * + *

The intersection is the point. The admin UI builds its batch filter from every + * batch in the INSTITUTE, not from the batches this assessment was assigned to, so a + * teacher can select a batch that was never given this test. The attempted-side + * queries are immune to that because they join {@code assessment_user_registration}, + * which only ever holds rows for this assessment — a foreign batch simply matches + * nothing. This path starts from batch membership instead, so without the intersection + * it would list every learner in that foreign batch as "has not attempted" and send a + * teacher chasing people for an exam they were never set. + * + * @param assignedBatchIds batches this assessment is registered against + * @param requestedBatchIds the filter chips; empty/null means "no narrowing" + * @return batch ids to query, deduped and sorted so callers share one cache entry; + * empty when the request selects nothing this assessment was assigned to + */ + public static List resolveBatchIds(List assignedBatchIds, List requestedBatchIds) { + if (assignedBatchIds == null || assignedBatchIds.isEmpty()) { + return List.of(); + } + Stream assigned = assignedBatchIds.stream().filter(java.util.Objects::nonNull); + if (requestedBatchIds != null && !requestedBatchIds.isEmpty()) { + Set requested = new HashSet<>(requestedBatchIds); + assigned = assigned.filter(requested::contains); + } + return assigned.distinct().sorted().toList(); + } + + /** + * @param enrolled learners in the assessment's batches (one row per learner) + * @param attemptedUserIds learners who have ANY attempt — status is irrelevant, since + * someone who opened the paper did not "never try" + * @param nameQuery optional case-insensitive substring match on the learner name + * @param pageable page window; ordering is fixed (name, then user id) and does + * not honour {@code pageable.getSort()}, because the caller has + * no server-side sort for this tab + */ + public static Page page(List enrolled, + Set attemptedUserIds, + String nameQuery, + Pageable pageable) { + return page(toRows(filterAndSortLearners(enrolled, attemptedUserIds, nameQuery)), pageable); + } + + /** Pages an already-filtered row list (the CSV export shares the filtering, not the paging). */ + public static Page page(List rows, Pageable pageable) { + + // Guard both ends: an offset past the end must yield an empty page, not an + // IndexOutOfBounds. A teacher sitting on page 3 while learners submit (shrinking + // this list) hits exactly that. + int from = (int) Math.min(pageable.getOffset(), rows.size()); + int to = Math.min(from + pageable.getPageSize(), rows.size()); + return new PageImpl<>(rows.subList(from, to), pageable, rows.size()); + } + + /** Maps learners to the row shape the submissions table and its response envelope use. */ + public static List toRows(List learners) { + return learners.stream() + .map(learner -> new NotAttemptedParticipantDto( + learner.getUserId(), learner.getFullName(), learner.getPackageSessionId(), + learner.getEmail(), learner.getMobileNumber(), learner.getUsername())) + .toList(); + } + + /** + * The learners who have not attempted, name-filtered and ordered. Returns the learner + * records rather than table rows because the CSV export writes its own header order and + * batch-name lookup, which the row shape does not carry. + */ + public static List filterAndSortLearners(List enrolled, + Set attemptedUserIds, + String nameQuery) { + if (enrolled == null || enrolled.isEmpty()) { + return List.of(); + } + Set attempted = attemptedUserIds == null ? Set.of() : attemptedUserIds; + String needle = (nameQuery == null || nameQuery.isBlank()) ? null : nameQuery.trim().toLowerCase(); + + return enrolled.stream() + .filter(learner -> learner != null && learner.getUserId() != null) + .filter(learner -> !attempted.contains(learner.getUserId())) + .filter(learner -> matchesName(learner, needle)) + // Same ordering contract as the rest of the submissions list: name first, + // user id as the tie-breaker so paging is stable when two learners share a + // name. Without the tie-breaker a page boundary between namesakes can + // repeat one and skip the other. + .sorted(Comparator + .comparing(NotAttemptedParticipants::nameOf, String.CASE_INSENSITIVE_ORDER) + .thenComparing(EnrolledLearnerDto::getUserId)) + .toList(); + } + + private static boolean matchesName(EnrolledLearnerDto learner, String needle) { + if (needle == null) { + return true; + } + return learner.getFullName() != null && learner.getFullName().toLowerCase().contains(needle); + } + + /** Unnamed learners sort first rather than blowing up the comparator. */ + private static String nameOf(EnrolledLearnerDto learner) { + return learner.getFullName() == null ? "" : learner.getFullName(); + } +} diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/export/ResultExportRowDto.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/export/ResultExportRowDto.java new file mode 100644 index 0000000000..5bd10d8bce --- /dev/null +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/dto/export/ResultExportRowDto.java @@ -0,0 +1,34 @@ +package vacademy.io.assessment_service.features.assessment.dto.export; + +import java.util.Date; + +/** + * One row of the result CSV. + * + *

Separate from {@link vacademy.io.assessment_service.features.assessment.dto.ParticipantsDetailsDto} + * on purpose. That interface is the projection for ~10 different participant queries, and + * adding a getter to it obliges every one of them to select the column — any that did not + * would fail at runtime, not compile time. This projection is bound to a single query, so + * the contact columns the export needs can be added without touching the rest. + */ +public interface ResultExportRowDto { + + String getRegistrationId(); + + String getStudentName(); + + String getUserEmail(); + + String getPhoneNumber(); + + String getUsername(); + + /** Batch id; the export resolves it to a display name via admin_core. */ + String getBatchId(); + + Date getAttemptDate(); + + Long getDuration(); + + Double getScore(); +} diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/entity/AssessmentSetMapping.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/entity/AssessmentSetMapping.java index 7ea8717d8f..a0eb82ffa3 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/entity/AssessmentSetMapping.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/entity/AssessmentSetMapping.java @@ -1,8 +1,10 @@ package vacademy.io.assessment_service.features.assessment.entity; import jakarta.persistence.*; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import org.hibernate.annotations.UuidGenerator; import vacademy.io.assessment_service.features.assessment.dto.manual_evaluation.AssessmentSetDto; @@ -12,6 +14,12 @@ @Table(name = "assessment_set_mapping") @Data @Builder +// @Builder suppresses the no-arg constructor @Data would otherwise supply, and +// JPA cannot materialise an entity without one: reading any existing row threw +// "No default constructor", which failed every PDF answer-sheet submission on an +// assessment that has sets. Both constructors are required — keep them. +@NoArgsConstructor +@AllArgsConstructor public class AssessmentSetMapping { @Id diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AdminAssessmentGetManager.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AdminAssessmentGetManager.java index 91b84ea910..add372583b 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AdminAssessmentGetManager.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AdminAssessmentGetManager.java @@ -37,7 +37,7 @@ import java.util.*; import java.util.stream.Collectors; -import static vacademy.io.common.core.standard_classes.ListService.createSortObject; +import vacademy.io.assessment_service.features.assessment.sort.StableSort; @Slf4j @Component @@ -74,9 +74,43 @@ public ResponseEntity assessmentAdminListInit(Custom return ResponseEntity.ok(assessmentAdminListInitDto); } + // Unique per row, so two exams sharing a start date (a whole day's papers usually do) + // keep a stable relative order across pages. Bare column name on purpose: Spring Data + // prefixes an unrecognised sort property with the query's detected alias, which here + // is the assessment table itself -- "id" becomes "a.id". An already-qualified "a.id" + // would become "a.a.id". + private static final String ASSESSMENT_LIST_TIE_BREAKER = "id"; + + /** + * Default order per tab, keyed off the same flags that decide which tab this is. + * Direction follows what the tab is for: the next exam matters most when looking + * forward, the latest one when looking back. + * + *

{@code bound_start_time} is safe to sort every tab by, drafts included — it is + * NOT NULL in practice (0 of 2,375 live rows are null), so there is no null bucket to + * reason about. + */ + private static Sort defaultAssessmentListSort(AdminAssessmentFilter filter) { + boolean upcoming = Boolean.TRUE.equals(filter.getGetUpcomingAssessments()); + return upcoming + // Soonest first — the exam about to happen is the one an admin needs. + ? Sort.by(Sort.Order.asc("bound_start_time")) + // Live and past (and drafts, which set none of the flags): most recent + // first, so the paper just run — or about to be run — is at the top. + : Sort.by(Sort.Order.desc("bound_start_time")); + } + public ResponseEntity assessmentAdminListFilter(CustomUserDetails user, AdminAssessmentFilter adminAssessmentFilter, String instituteId, int pageNo, int pageSize) { - // Create a sorting object based on the provided sort columns - Sort thisSort = createSortObject(adminAssessmentFilter.getSortColumns()); + // Order by when the exam actually RUNS, not when it was created. The admin list + // sends no sort_columns at all, which used to leave the Pageable unsorted; the + // query has no ORDER BY of its own, so Postgres returned heap order. On a + // mostly-append-only table that looks like creation order, which diverges from the + // schedule as soon as an admin sets a paper up in advance (prod has exams created + // in June that run in August, and they sorted into their June slot). + Sort thisSort = StableSort.withStableOrder( + adminAssessmentFilter.getSortColumns(), + defaultAssessmentListSort(adminAssessmentFilter), + ASSESSMENT_LIST_TIE_BREAKER); Page assessmentsPage; //TODO: Check user permission @@ -115,7 +149,7 @@ private void makeFilterFieldEmptyArrayIfNull(AdminAssessmentFilter adminAssessme public ResponseEntity getLeaderBoard(CustomUserDetails user, String assessmentId, LeaderboardFilter filter, String instituteId, int pageNo, int pageSize) { if (Objects.isNull(filter)) throw new VacademyException("Invalid Request"); - Sort sortColumn = createSortObject(filter.getSortColumns()); + Sort sortColumn = ListService.createSortObject(filter.getSortColumns()); Pageable pageable = PageRequest.of(pageNo, pageSize, sortColumn); Page paginatedLeaderboard = null; diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AdminExportManager.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AdminExportManager.java index 67319eadb2..39c2239a68 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AdminExportManager.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AdminExportManager.java @@ -24,6 +24,8 @@ import vacademy.io.assessment_service.features.assessment.dto.export.ParticipantsDetailExportDto; import vacademy.io.assessment_service.features.assessment.dto.export.RespondentExportDto; import vacademy.io.assessment_service.features.assessment.dto.export.ResultExportColumnsDto; +import vacademy.io.assessment_service.features.assessment.dto.export.ResultExportRowDto; +import vacademy.io.assessment_service.features.assessment.dto.batch_pending.EnrolledLearnerDto; import vacademy.io.assessment_service.features.assessment.dto.export.zip.*; import vacademy.io.assessment_service.features.assessment.entity.Assessment; import vacademy.io.assessment_service.features.assessment.entity.AssessmentCustomField; @@ -69,8 +71,19 @@ public class AdminExportManager { // Fixed columns of the result CSV, before any registration-form columns. + // Identity and contact first, then the result columns. Phone Number / Username / Batch + // were added so a mark sheet can be cross-referenced and learners contacted without a + // second export; every one of them is optional in the Export CSV dialog, so anyone who + // wants the older, narrower sheet just unticks them. private static final List RESULT_EXPORT_BASE_HEADERS = List.of( - "Name", "Email", "Marks Obtained", "Total Marks", "Percentage", "Rank", "Duration", "Attempt Date"); + "Name", "Email", "Phone Number", "Username", "Batch", + "Marks Obtained", "Total Marks", "Percentage", "Rank", "Duration", "Attempt Date"); + + // The "not attempted" sheet: contact details and nothing else. Marks, rank, percentage + // and attempt date would every one of them be blank for a learner who never started, + // so offering them would only invite the reader to believe they scored zero. + private static final List NOT_ATTEMPTED_EXPORT_HEADERS = List.of( + "Name", "Email", "Phone Number", "Username", "Batch"); // Several answer rows can exist for one field (e.g. a multi-select), so they // are joined into a single cell rather than silently dropping all but one. @@ -82,6 +95,9 @@ public class AdminExportManager { @Autowired AssessmentUserRegistrationRepository assessmentUserRegistrationRepository; + @Autowired + vacademy.io.assessment_service.features.assessment.service.batch_pending.NotAttemptedLearnerService notAttemptedLearnerService; + @Autowired AssessmentCustomFieldRepository assessmentCustomFieldRepository; @@ -216,6 +232,14 @@ public ResponseEntity getMarksRankPdfExport(CustomUserDetai public ResponseEntity getRegisteredCsvExport(CustomUserDetails user, String instituteId, String assessmentId, AssessmentUserFilter filter) { if (Objects.isNull(filter)) throw new VacademyException("Invalid Request"); + // "Not attempted" is not a slice of the attempt tables at all — a batch-enrolled + // learner has no registration row until they start — so it gets its own sheet + // rather than being squeezed through the result export below. + if (isPendingAttempt(filter) && UserRegistrationSources.BATCH_PREVIEW_REGISTRATION.name() + .equals(filter.getRegistrationSource())) { + return handleNotAttemptedCsvExport(instituteId, assessmentId, filter); + } + // Empty registration_source means "all sources" — used by the result // export feature to get every participant regardless of how they enrolled. if (filter.getRegistrationSource() == null || filter.getRegistrationSource().isEmpty()) { @@ -313,7 +337,7 @@ private List createExportDtoFromParticipantsDtoWith // filled in when they registered for a public assessment. private ResponseEntity handleCaseForAllSourcesResultExport(String instituteId, String assessmentId, List requestedCustomFieldIds) { - List participants = assessmentUserRegistrationRepository + List participants = assessmentUserRegistrationRepository .findAllEndedParticipantsForResultExport(assessmentId, instituteId); List customColumns = @@ -348,9 +372,14 @@ private ResponseEntity handleCaseForAllSourcesResultExport(String instit SimpleDateFormat sdf = new SimpleDateFormat("dd MMM yyyy hh:mm a"); sdf.setTimeZone(TimeZone.getTimeZone("Asia/Kolkata")); + // One lookup for every batch in the sheet rather than one per row. + Map batchNames = adminCoreServiceClient.getBatchNames( + participants.stream().map(ResultExportRowDto::getBatchId) + .filter(Objects::nonNull).distinct().sorted().toList()); + // Rows arrive sorted by score DESC (ORDER BY in query) → index+1 = rank. for (int i = 0; i < participants.size(); i++) { - ParticipantsDetailsDto p = participants.get(i); + ResultExportRowDto p = participants.get(i); Double obtained = p.getScore() != null ? p.getScore() : 0.0; String pct = totalMarks > 0 ? String.format("%.2f%%", (obtained / totalMarks) * 100) @@ -359,9 +388,15 @@ private ResponseEntity handleCaseForAllSourcesResultExport(String instit String attemptDate = p.getAttemptDate() != null ? sdf.format(p.getAttemptDate()) : ""; String email = p.getUserEmail() != null ? p.getUserEmail() : ""; String name = p.getStudentName() != null ? p.getStudentName() : ""; + String phone = p.getPhoneNumber() != null ? p.getPhoneNumber() : ""; + String username = p.getUsername() != null ? p.getUsername() : ""; + String batch = resolveBatchName(batchNames, p.getBatchId()); csv.append(escapeCsvField(name)).append(",") .append(escapeCsvField(email)).append(",") + .append(escapeCsvField(phone)).append(",") + .append(escapeCsvField(username)).append(",") + .append(escapeCsvField(batch)).append(",") .append(obtained).append(",") .append(totalMarks).append(",") .append(pct).append(",") @@ -383,13 +418,71 @@ private ResponseEntity handleCaseForAllSourcesResultExport(String instit .body(csv.toString().getBytes(StandardCharsets.UTF_8)); } + /** + * CSV of the batch-enrolled learners who never attempted — the Pending tab's export. + * + *

Shares {@link NotAttemptedLearnerService} with the tab itself, so the file and the + * screen always name the same learners. It honours the batch chips and name search on + * the filter, so the sheet matches what the admin was looking at when they clicked. + * + *

Emits the header row even when nobody is pending: a file with just headers says + * "everyone attempted", whereas an empty file looks like the export failed. + */ + private ResponseEntity handleNotAttemptedCsvExport(String instituteId, String assessmentId, + AssessmentUserFilter filter) { + List learners = notAttemptedLearnerService + .findNotAttempted(assessmentId, instituteId, filter); + + Map batchNames = adminCoreServiceClient.getBatchNames( + learners.stream().map(EnrolledLearnerDto::getPackageSessionId) + .filter(Objects::nonNull).distinct().sorted().toList()); + + StringBuilder csv = new StringBuilder(String.join(",", NOT_ATTEMPTED_EXPORT_HEADERS)); + csv.append("\n"); + + for (EnrolledLearnerDto learner : learners) { + String batch = resolveBatchName(batchNames, learner.getPackageSessionId()); + csv.append(escapeCsvField(learner.getFullName())).append(",") + .append(escapeCsvField(learner.getEmail())).append(",") + .append(escapeCsvField(learner.getMobileNumber())).append(",") + .append(escapeCsvField(learner.getUsername())).append(",") + .append(escapeCsvField(batch)) + .append("\n"); + } + + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"not-attempted.csv\"") + .header("Content-Type", "text/plain") + .body(csv.toString().getBytes(StandardCharsets.UTF_8)); + } + + /** + * Batch display name for a row, or a blank cell when the row has no batch. + * + *

The null check is not defensive padding: the all-sources result sheet includes + * open-registration participants, whose {@code source_id} is not a batch at all, so + * this is called with null on real data. {@code Map.of()} — what the name lookup + * returns when admin_core is unreachable — throws NullPointerException on a null key + * rather than missing, so probing the map first would crash the whole export. + * + *

Falls back to the raw id when the name cannot be resolved: an unresolved batch id + * is still more use to an admin than an empty cell. + */ + private static String resolveBatchName(Map batchNames, String batchId) { + if (batchId == null || batchId.isBlank()) { + return ""; + } + return batchNames.getOrDefault(batchId, batchId); + } + /** * Columns the result CSV can carry for this assessment — the fixed result * columns plus every active registration-form field. Feeds the Export CSV * dialog's tick-list, which starts with everything ticked. */ public ResponseEntity getResultExportColumns(CustomUserDetails user, String instituteId, - String assessmentId) { + String assessmentId, + boolean notAttempted) { // Reject an assessment that demonstrably belongs to another institute. // A handful of live assessments pre-date the mapping table and have no // mapping row at all — those stay exportable, scoped like the CSV itself @@ -399,6 +492,16 @@ public ResponseEntity getResultExportColumns(CustomUserD throw new VacademyException("Assessment Not Found"); } + // The "not attempted" sheet describes learners with no registration row, so the + // registration-form fields below would every one of them be blank. Offering them + // would be a tick-list of empty columns. + if (notAttempted) { + return ResponseEntity.ok(ResultExportColumnsDto.builder() + .baseColumns(NOT_ATTEMPTED_EXPORT_HEADERS) + .customFields(List.of()) + .build()); + } + List customFields = assessmentCustomFieldRepository .findActiveFieldsByAssessmentId(assessmentId); List columnLabels = buildCustomFieldHeaders(customFields); @@ -539,7 +642,10 @@ private List handleCaseForAdminPreRegistration(String as private List handleCaseForBatchRegistration(String assessmentId, String instituteId, AssessmentUserFilter filter) { List ParticipantsDetailsDto = new ArrayList<>(); if (isPendingAttempt(filter)) { - //TODO: Send request to admin core to get pending list for batch + // Unreachable: getRegisteredCsvExport intercepts pending + batch and serves it + // from handleNotAttemptedCsvExport, which is the only path that can answer it + // (the set lives in admin_core, not in this database). Kept as a guard so a + // future caller reaching here gets an empty list rather than the attempted rows. } else { //Handle Case for Attempted case i.e LIVE,PREVIEW,ENDED ParticipantsDetailsDto = assessmentUserRegistrationRepository.findUserRegistrationWithFilterForBatchForExport(assessmentId, instituteId, filter.getBatches(), filter.getStatus(), filter.getAttemptType()); diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AdminManualEvaluationManager.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AdminManualEvaluationManager.java index 9a0eb02e2d..f5180e3ab1 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AdminManualEvaluationManager.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AdminManualEvaluationManager.java @@ -33,7 +33,7 @@ import vacademy.io.assessment_service.features.question_core.entity.Question; import vacademy.io.assessment_service.features.question_core.repository.QuestionRepository; import vacademy.io.common.auth.model.CustomUserDetails; -import vacademy.io.common.core.standard_classes.ListService; +import vacademy.io.assessment_service.features.assessment.sort.StableSort; import vacademy.io.common.core.utils.DateUtil; import vacademy.io.common.exceptions.VacademyException; @@ -464,7 +464,16 @@ private boolean isAwaitingEvaluation(String resultStatus) { public ResponseEntity getAssignedAttempt(CustomUserDetails userDetails, ManualAttemptFilter filter, String assessmentId, String instituteId, int pageNo, int pageSize) { if (Objects.isNull(filter)) throw new VacademyException("Invalid Request"); - Sort sortColumns = ListService.createSortObject(filter.getSortColumns()); + // findAllAssignedAttemptForUserIdWithFilter is native SQL with no ORDER BY, + // and it is driven off student_attempt — the very table this evaluator is + // writing to. An unsorted Pageable therefore returned rows in heap order, + // so grading one paper (or just opening it, which flips result_status to + // EVALUATING) moved that row and reshuffled the queue the evaluator was + // working down. This query selects participantName/attemptId, so the + // default and tie-breaker use those aliases rather than the participant + // list's studentName. + Sort sortColumns = StableSort.withStableOrder(filter.getSortColumns(), + Sort.by(Sort.Order.asc("participantName")), "attemptId"); Pageable pageable = PageRequest.of(pageNo, pageSize, sortColumns); diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AssessmentParticipantsManager.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AssessmentParticipantsManager.java index 60647bd1e5..fab50fefeb 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AssessmentParticipantsManager.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AssessmentParticipantsManager.java @@ -24,11 +24,13 @@ import vacademy.io.assessment_service.features.assessment.dto.admin_get_dto.request.ReleaseRequestDto; import vacademy.io.assessment_service.features.assessment.dto.admin_get_dto.request.RespondentFilter; import vacademy.io.assessment_service.features.assessment.dto.admin_get_dto.response.*; +import vacademy.io.assessment_service.features.assessment.dto.batch_pending.NotAttemptedParticipants; import vacademy.io.assessment_service.features.assessment.dto.create_assessment.AssessmentRegistrationsDto; import vacademy.io.assessment_service.features.assessment.entity.*; import vacademy.io.assessment_service.features.assessment.enums.*; import vacademy.io.assessment_service.features.assessment.notification.AssessmentReportNotificationService; import vacademy.io.assessment_service.features.assessment.repository.*; +import vacademy.io.assessment_service.features.assessment.sort.StableSort; import vacademy.io.assessment_service.features.assessment.service.HtmlBuilderService; import vacademy.io.assessment_service.features.assessment.service.QuestionBasedStrategyFactory; import vacademy.io.assessment_service.features.assessment.service.assessment_get.AssessmentService; @@ -47,7 +49,6 @@ import vacademy.io.assessment_service.features.rich_text.enums.TextType; import vacademy.io.assessment_service.features.rich_text.repository.AssessmentRichTextRepository; import vacademy.io.common.auth.model.CustomUserDetails; -import vacademy.io.common.core.standard_classes.ListService; import vacademy.io.common.core.utils.DateUtil; import vacademy.io.common.exceptions.VacademyException; import vacademy.io.common.media.service.FileService; @@ -125,6 +126,9 @@ public class AssessmentParticipantsManager { @Autowired private vacademy.io.assessment_service.features.client.AdminCoreServiceClient adminCoreServiceClient; + @Autowired + private vacademy.io.assessment_service.features.assessment.service.batch_pending.NotAttemptedLearnerService notAttemptedLearnerService; + @Autowired private CacheManager cacheManager; @@ -604,7 +608,7 @@ private Page handleCaseForBatchRegistration(String asses AssessmentUserFilter filter, Pageable pageable) { Page registeredUserPage = null; if (isPendingAttempt(filter)) { - // TODO: Send request to admin core to get pending list for batch + registeredUserPage = findBatchLearnersWhoNeverAttempted(assessmentId, instituteId, filter, pageable); } else { // Handle Case for Attempted case i.e LIVE,PREVIEW,ENDED if (StringUtils.hasText(filter.getName())) { @@ -625,6 +629,48 @@ private Page handleCaseForBatchRegistration(String asses return registeredUserPage; } + /** + * Learners enrolled in this assessment's batches who never attempted it — the Pending + * tab for Batch Selection. + * + *

This cannot be a query in this database. A batch-enrolled learner gets NO + * {@code assessment_user_registration} row until they actually start the test, so the + * "never attempted" set does not exist here at all; only admin_core knows who is in + * the batch. So: take batch enrollment from admin_core, subtract everyone who has an + * attempt, sort and page the remainder. + * + *

Load. The submissions page asks for this count on every mount, so the + * expensive part must not run per request: + *

    + *
  • Enrollment comes from a cached client call, keyed on institute + batch set, so + * repeat mounts, tab switches and paging share one admin_core round trip.
  • + *
  • The exclusion is applied HERE, not pushed into admin_core's SQL as an array. + * That predicate is unestimable and a generic plan re-evaluates it per row — + * measured on prod, 22ms became 434-880ms, intermittently. Without it the + * admin_core query is plan-stable at 22ms/28ms on the largest batch in prod.
  • + *
  • An assessment with no batch registrations short-circuits before any HTTP or + * DB work.
  • + *
+ * + *

Ordering matches the rest of the submissions list: learner name, then user id as + * a tie-breaker, so paging is stable (see {@code StableSort}). + */ + /** + * Learners enrolled in this assessment's batches who never attempted it — the Pending + * tab for Batch Selection. + * + *

The resolution itself lives in {@link NotAttemptedLearnerService} because the CSV + * export asks the same question, and the two must never disagree about who is on the + * list. This method only pages the answer. + */ + private Page findBatchLearnersWhoNeverAttempted( + String assessmentId, String instituteId, AssessmentUserFilter filter, Pageable pageable) { + return NotAttemptedParticipants.page( + NotAttemptedParticipants.toRows( + notAttemptedLearnerService.findNotAttempted(assessmentId, instituteId, filter)), + pageable); + } + /** * Retrieves all participants for an open assessment based on the provided * filter criteria. @@ -725,19 +771,28 @@ private ClosedAssessmentParticipantsResponse createAllRegisteredUserForClosedTes .totalElements(registrationPage.getTotalElements()).build(); } - // Sorting Object to Sort the values - private Sort createSortObject(Map sortColumns) { - if (sortColumns == null) - return Sort.unsorted(); + // Fallback order for the participant/submission list when the client sends no + // sort (which is the default — the admin table only sets sort_columns once a + // header is clicked). Alphabetical by learner is what an evaluator working + // down the list expects; the DB collation is en_US.UTF-8, so this reads + // naturally rather than grouping by case. + private static final Sort DEFAULT_PARTICIPANT_SORT = Sort.by(Sort.Order.asc("studentName")); - List orders = new ArrayList<>(); + // Unique-per-row tie-breakers. (registrationId, attemptId) is unique in every + // one of these queries — a registration with several attempts yields one row + // per attempt, so registrationId alone is not enough. Both are SELECT aliases + // in all six paged participant queries. + private static final String[] PARTICIPANT_TIE_BREAKERS = { "registrationId", "attemptId" }; - for (Map.Entry entry : sortColumns.entrySet()) { - Sort.Direction direction = "DESC".equalsIgnoreCase(entry.getValue()) ? Sort.Direction.DESC - : Sort.Direction.ASC; - orders.add(new Sort.Order(direction, entry.getKey())); - } - return Sort.by(orders); + // Sorting Object to Sort the values. + // + // Never returns Sort.unsorted(): these are native queries with no ORDER BY of + // their own, so an unsorted Pageable let Postgres hand back rows in heap + // order. Grading a submission rewrites its student_attempt row to a new heap + // slot, which reshuffled the list under the evaluator and — with LIMIT/OFFSET + // paging — could show one learner twice while skipping another entirely. + private Sort createSortObject(Map sortColumns) { + return StableSort.withStableOrder(sortColumns, DEFAULT_PARTICIPANT_SORT, PARTICIPANT_TIE_BREAKERS); } // Sentinel used when no evaluation-status filter is applied. The native queries @@ -1089,7 +1144,11 @@ public ResponseEntity getRespondentList(CustomUserDetail if (Objects.isNull(filter)) throw new VacademyException("Invalid Request"); - Sort sortingObject = ListService.createSortObject(filter.getSortColumns()); + // Same unsorted-native-query problem as the participant list above, but the + // respondent queries select participantName (not studentName), so the + // default and tie-breakers have to use this query's own aliases. + Sort sortingObject = StableSort.withStableOrder(filter.getSortColumns(), + Sort.by(Sort.Order.asc("participantName")), "registrationId", "attemptId"); Pageable pageable = PageRequest.of(pageNo, pageSize, sortingObject); Page responses = null; diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AssessmentReattemptRequestManager.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AssessmentReattemptRequestManager.java index 6d7541697b..2b7d121042 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AssessmentReattemptRequestManager.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/manager/AssessmentReattemptRequestManager.java @@ -149,13 +149,24 @@ public Page listForAdmin(String instituteId, String assessm .map(this::enrich); } - /** Drives the inbox badge — the admin's "you have requests waiting" signal. */ - public long pendingCount(String instituteId) { + /** + * Drives the inbox badge — the admin's "you have requests waiting" signal. + * + * {@code assessmentId} is optional and must be passed by anything showing the badge next to + * one assessment's inbox: the institute-wide count is the same number on every assessment + * page, so an unscoped badge reads as "this exam has a request" when the request is someone + * else's exam entirely, and the tab it points at then renders empty. + */ + public long pendingCount(String instituteId, String assessmentId) { if (instituteId == null || instituteId.isBlank()) { throw new VacademyException("instituteId is required"); } - return reattemptRequestRepository.countByInstituteIdAndStatus(instituteId, - AssessmentReattemptRequest.STATUS_PENDING); + if (assessmentId == null || assessmentId.isBlank()) { + return reattemptRequestRepository.countByInstituteIdAndStatus(instituteId, + AssessmentReattemptRequest.STATUS_PENDING); + } + return reattemptRequestRepository.countByInstituteIdAndAssessmentIdAndStatus(instituteId, + assessmentId, AssessmentReattemptRequest.STATUS_PENDING); } @Transactional diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/repository/AssessmentBatchRegistrationRepository.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/repository/AssessmentBatchRegistrationRepository.java index d23d11e3de..73e7232372 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/repository/AssessmentBatchRegistrationRepository.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/repository/AssessmentBatchRegistrationRepository.java @@ -46,5 +46,18 @@ Integer countDistinctAssessmentsByBatchAndFilters( ); + /** + * Batches this assessment was assigned to. Needed by the "enrolled but has not + * attempted" list, which starts from batch membership rather than from any row in + * this database — batch learners get no registration row until they actually start. + */ + @Query("SELECT abr.batchId FROM AssessmentBatchRegistration abr " + + "WHERE abr.assessment.id = :assessmentId " + + "AND abr.instituteId = :instituteId " + + "AND abr.status IN :statuses") + List findBatchIdsByAssessmentAndInstitute(@Param("assessmentId") String assessmentId, + @Param("instituteId") String instituteId, + @Param("statuses") List statuses); + boolean existsByInstituteIdAndAssessmentIdAndBatchId(String instituteId, String assessmentId, String batchId); } diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/repository/AssessmentReattemptRequestRepository.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/repository/AssessmentReattemptRequestRepository.java index 92db0182bc..d61e755383 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/repository/AssessmentReattemptRequestRepository.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/repository/AssessmentReattemptRequestRepository.java @@ -38,4 +38,6 @@ Page findForAdmin(@Param("instituteId") String insti Pageable pageable); long countByInstituteIdAndStatus(String instituteId, String status); + + long countByInstituteIdAndAssessmentIdAndStatus(String instituteId, String assessmentId, String status); } diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/repository/AssessmentUserRegistrationRepository.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/repository/AssessmentUserRegistrationRepository.java index e389ab57be..c823ba43ee 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/repository/AssessmentUserRegistrationRepository.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/repository/AssessmentUserRegistrationRepository.java @@ -26,6 +26,34 @@ public interface AssessmentUserRegistrationRepository extends JpaRepository userIds, String instituteId); + /** + * User ids that already have an attempt for this assessment — the set subtracted from + * batch enrollment to get "has not attempted". + * + *

Any attempt counts, whatever its status: a learner who opened the paper is not + * someone who "never tried". Restricted to batch-sourced registrations because that + * is the only tab this feeds; individual/open participants already get a real + * registration row and are handled by the existing Pending queries. + * + *

EXISTS rather than a JOIN: the join fanned out to one row per attempt only to + * have DISTINCT collapse them again (60 rows down to 40 on prod), and the semi-join + * lets Postgres stop at the first attempt per registration. It is also the form that + * benefits automatically once {@code idx_sa_registration} on + * {@code student_attempt(registration_id)} is usable again — that index is currently + * INVALID in prod (a failed CREATE INDEX CONCURRENTLY), which is why this still falls + * back to a sequential scan of student_attempt. + */ + @Query(value = """ + SELECT DISTINCT aur.user_id + FROM assessment_user_registration aur + WHERE aur.assessment_id = :assessmentId + AND aur.institute_id = :instituteId + AND aur.source = 'BATCH_PREVIEW_REGISTRATION' + AND EXISTS (SELECT 1 FROM student_attempt sa WHERE sa.registration_id = aur.id) + """, nativeQuery = true) + List findAttemptedUserIdsForBatchAssessment(@Param("assessmentId") String assessmentId, + @Param("instituteId") String instituteId); + @Query("SELECT a FROM AssessmentUserRegistration a WHERE a.username = :username AND a.instituteId = :instituteId ORDER BY a.createdAt DESC LIMIT 1") Optional findTopByUserNameAndInstituteId(@Param("username") String username, @Param("instituteId") String instituteId); @@ -33,7 +61,7 @@ public interface AssessmentUserRegistrationRepository extends JpaRepository findTopByUserIdAndAssessmentId(@Param("userId") String userId, @Param("assessmentId") String assessmentId); @Query(value = """ - select aur.id as registrationId,sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate,sa.submit_time as endTime ,sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId, aur.source_id as batchId, + select aur.id as registrationId, sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate, sa.submit_time as endTime, sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId, aur.user_email as userEmail, aur.phone_number as phoneNumber, aur.username as username, aur.source_id as batchId, sa.report_release_status as reportReleaseResultStatus, sa.report_last_release_date as lastReportReleaseDate, sa.result_status as evaluationStatus from assessment_user_registration aur @@ -78,7 +106,7 @@ Page findUserRegistrationWithFilterForBatch(@Param("asse Pageable pageable); @Query(value = """ - select aur.id as registrationId,sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate,sa.submit_time as endTime ,sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId, aur.source_id as batchId from assessment_user_registration aur + select aur.id as registrationId, sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate, sa.submit_time as endTime, sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId, aur.user_email as userEmail, aur.phone_number as phoneNumber, aur.username as username, aur.source_id as batchId from assessment_user_registration aur join student_attempt sa on sa.registration_id = aur.id where aur.assessment_id = :assessmentId and aur.institute_id = :instituteId @@ -86,6 +114,7 @@ Page findUserRegistrationWithFilterForBatch(@Param("asse AND (:batchIds IS NULL OR aur.source_id IN (:batchIds)) AND aur.source = 'BATCH_PREVIEW_REGISTRATION' AND (:status IS NULL OR sa.status IN (:attemptType)) + ORDER BY aur.participant_name ASC, aur.id ASC, sa.id ASC """, countQuery = """ select count(*) @@ -109,7 +138,7 @@ List findUserRegistrationWithFilterForBatchForExport(@Pa SELECT aur.id as registrationId, sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate, sa.submit_time as endTime, sa.total_time_in_seconds as duration, sa.result_marks as score, - aur.user_id as userId, aur.source_id as batchId, + aur.user_id as userId, aur.user_email as userEmail, aur.phone_number as phoneNumber, aur.username as username, aur.source_id as batchId, sa.report_release_status as reportReleaseResultStatus, sa.report_last_release_date as lastReportReleaseDate, sa.result_status as evaluationStatus @@ -168,7 +197,7 @@ Page findUserRegistrationWithFilterWithSearchForBatch( @Query(value = """ - select aur.id as registrationId,sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate,sa.submit_time as endTime ,sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId, + select aur.id as registrationId, sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate, sa.submit_time as endTime, sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId, aur.user_email as userEmail, aur.phone_number as phoneNumber, aur.username as username, sa.report_release_status as reportReleaseResultStatus, sa.report_last_release_date as lastReportReleaseDate, aur.source_id as batchId, @@ -204,13 +233,14 @@ Page findUserRegistrationWithFilterForSource(@Param("ass Pageable pageable); @Query(value = """ - select aur.id as registrationId,sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate,sa.submit_time as endTime ,sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId from assessment_user_registration aur + select aur.id as registrationId, sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate, sa.submit_time as endTime, sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId, aur.user_email as userEmail, aur.phone_number as phoneNumber, aur.username as username from assessment_user_registration aur join student_attempt sa on sa.registration_id = aur.id where aur.assessment_id = :assessmentId and aur.institute_id = :instituteId AND (:status IS NULL OR aur.status IN (:status)) AND aur.source = :source AND (:status IS NULL OR sa.status IN (:attemptType)) + ORDER BY aur.participant_name ASC, aur.id ASC, sa.id ASC """, countQuery = """ select count(distinct aur.user_id) @@ -230,7 +260,7 @@ List findUserRegistrationWithFilterForSourceExport(@Para @Query(value = """ - select aur.id as registrationId,sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate,sa.submit_time as endTime ,sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId, + select aur.id as registrationId, sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate, sa.submit_time as endTime, sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId, aur.user_email as userEmail, aur.phone_number as phoneNumber, aur.username as username, sa.report_release_status as reportReleaseResultStatus, sa.report_last_release_date as lastReportReleaseDate, sa.result_status as evaluationStatus from assessment_user_registration aur @@ -279,7 +309,7 @@ Page findUserRegistrationWithFilterWithSearchForSource(@ @Query(value = """ - select aur.id as registrationId,sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate,sa.submit_time as endTime ,sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId, + select aur.id as registrationId, sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate, sa.submit_time as endTime, sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId, aur.user_email as userEmail, aur.phone_number as phoneNumber, aur.username as username, sa.report_release_status as reportReleaseResultStatus, sa.report_last_release_date as lastReportReleaseDate, sa.result_status as evaluationStatus FROM assessment_user_registration aur @@ -307,7 +337,7 @@ Page findUserRegistrationWithFilterAdminPreRegistrationA Pageable pageable); @Query(value = """ - select aur.id as registrationId,sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate,sa.submit_time as endTime ,sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId + select aur.id as registrationId, sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate, sa.submit_time as endTime, sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId, aur.user_email as userEmail, aur.phone_number as phoneNumber, aur.username as username FROM assessment_user_registration aur LEFT JOIN student_attempt sa ON aur.id = sa.registration_id where aur.assessment_id = :assessmentId @@ -315,6 +345,7 @@ Page findUserRegistrationWithFilterAdminPreRegistrationA and sa.id IS NULL AND aur.source = :source AND (:status IS NULL OR aur.status IN (:status)) + ORDER BY aur.participant_name ASC, aur.id ASC """, nativeQuery = true) List findUserRegistrationWithFilterAdminPreRegistrationAndPendingExport(@Param("assessmentId") String assessmentId, @Param("instituteId") String instituteId, @@ -323,7 +354,7 @@ List findUserRegistrationWithFilterAdminPreRegistrationA @Query(value = """ - select aur.id as registrationId,sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate,sa.submit_time as endTime ,sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId, + select aur.id as registrationId, sa.id as attemptId, aur.participant_name as studentName, sa.start_time as attemptDate, sa.submit_time as endTime, sa.total_time_in_seconds as duration, sa.result_marks as score, aur.user_id as userId, aur.user_email as userEmail, aur.phone_number as phoneNumber, aur.username as username, sa.report_release_status as reportReleaseResultStatus, sa.report_last_release_date as lastReportReleaseDate, sa.result_status as evaluationStatus FROM assessment_user_registration aur @@ -461,6 +492,7 @@ AND COALESCE(qwm.status, 'PENDING') IN (:attemptStatus) and a.assessment_visibility in (:assessmentVisibility) and aur."source" in (:source) and (:sourceId IS NULL OR aur.source_id in (:sourceId)) + ORDER BY aur.participant_name ASC, aur.id ASC, sa.id ASC """, nativeQuery = true) List findRespondentListForAssessmentWithFilterExport(@Param("assessmentId") String assessmentId, @Param("questionId") String questionId, @@ -540,6 +572,8 @@ Page findRespondentListForAssessmentWithFilterAndSearch(@Para sa.id AS attemptId, aur.participant_name AS studentName, aur.user_email AS userEmail, + aur.phone_number AS phoneNumber, + aur.username AS username, sa.start_time AS attemptDate, sa.submit_time AS endTime, sa.total_time_in_seconds AS duration, @@ -557,9 +591,9 @@ AND aur.status NOT IN ('DELETED') AND sa.status = 'ENDED' ORDER BY sa.result_marks DESC NULLS LAST """, nativeQuery = true) - List findAllEndedParticipantsForResultExport( - @Param("assessmentId") String assessmentId, - @Param("instituteId") String instituteId); + List + findAllEndedParticipantsForResultExport(@Param("assessmentId") String assessmentId, + @Param("instituteId") String instituteId); // Every registration-form answer given by the participants of an assessment, // flattened to (registration, field, answer). The export widens each result diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/QuestionBasedStrategyFactory.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/QuestionBasedStrategyFactory.java index befd232c9c..8e939eded9 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/QuestionBasedStrategyFactory.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/QuestionBasedStrategyFactory.java @@ -16,26 +16,56 @@ import vacademy.io.assessment_service.features.question_core.enums.QuestionTypes; import java.util.*; +import java.util.function.Supplier; public class QuestionBasedStrategyFactory { - private static final Map strategies = new HashMap<>(); + + /** + * Suppliers, NOT instances. + *

+ * {@link IQuestionTypeBasedStrategy} carries mutable {@code type} and + * {@code answerStatus} fields, and {@code calculateMarks} below reads + * {@code getAnswerStatus()} AFTER the marks call returns. When this map held one + * shared instance per type, two learners graded concurrently on the @Async pool + * mutated the same object between those two statements — so learner A's question + * could be persisted with learner B's CORRECT/INCORRECT status. Silent, and it + * corrupted question_wise_marks, reports and every status-based revaluation. + *

+ * Handing out a fresh instance per call confines that state to one thread. The + * marks arithmetic is untouched. + */ + private static final Map> strategies = new HashMap<>(); static { - strategies.put(QuestionTypes.MCQM.name(), new MCQMQuestionTypeBasedStrategy()); - strategies.put(QuestionTypes.MCQS.name(), new MCQSQuestionTypeBasedStrategy()); - strategies.put(QuestionTypes.ONE_WORD.name(), new OneWordQuestionTypeBasedStrategy()); - strategies.put(QuestionTypes.LONG_ANSWER.name(), new LongAnswerQuestionTypeBasedStrategy()); - strategies.put(QuestionTypes.NUMERIC.name(), new NUMERICQuestionTypeBasedStrategy()); - strategies.put(QuestionTypes.TRUE_FALSE.name(), new MCQSQuestionTypeBasedStrategy()); - strategies.put(QuestionTypes.CODING.name(), new CodingQuestionTypeBasedStrategy()); + strategies.put(QuestionTypes.MCQM.name(), MCQMQuestionTypeBasedStrategy::new); + strategies.put(QuestionTypes.MCQS.name(), MCQSQuestionTypeBasedStrategy::new); + strategies.put(QuestionTypes.ONE_WORD.name(), OneWordQuestionTypeBasedStrategy::new); + strategies.put(QuestionTypes.LONG_ANSWER.name(), LongAnswerQuestionTypeBasedStrategy::new); + strategies.put(QuestionTypes.NUMERIC.name(), NUMERICQuestionTypeBasedStrategy::new); + strategies.put(QuestionTypes.TRUE_FALSE.name(), MCQSQuestionTypeBasedStrategy::new); + strategies.put(QuestionTypes.CODING.name(), CodingQuestionTypeBasedStrategy::new); // Add more strategies here } private static IQuestionTypeBasedStrategy getStrategy(String questionType) { - IQuestionTypeBasedStrategy strategy = strategies.getOrDefault(questionType, null); - if (!Objects.isNull(strategy)) { - strategy.setType(questionType); - strategy.setAnswerStatus(QuestionResponseEnum.PENDING.name()); + Supplier supplier = strategies.getOrDefault(questionType, null); + if (Objects.isNull(supplier)) { + return null; + } + IQuestionTypeBasedStrategy strategy = supplier.get(); + strategy.setType(questionType); + strategy.setAnswerStatus(QuestionResponseEnum.PENDING.name()); + return strategy; + } + + /** + * Same lookup, but never null — the callers below dereference the strategy + * immediately and previously NPE'd on an unrecognised question type. + */ + private static IQuestionTypeBasedStrategy requireStrategy(String questionType) { + IQuestionTypeBasedStrategy strategy = getStrategy(questionType); + if (strategy == null) { + throw new IllegalArgumentException("Invalid Question Type: " + questionType); } return strategy; } @@ -77,7 +107,7 @@ public static QuestionWiseBasicDetailDto calculateMarks(String markingJson, Stri } public static List getResponseOptionIds(String responseJson, String type) throws JsonProcessingException { - IQuestionTypeBasedStrategy strategy = getStrategy(type); + IQuestionTypeBasedStrategy strategy = requireStrategy(type); if(strategy.getType().equals(QuestionTypes.MCQS.name())){ MCQSResponseDto responseDto = (MCQSResponseDto) verifyResponseJson(responseJson, type); @@ -94,7 +124,7 @@ public static List getResponseOptionIds(String responseJson, String type } public static List getCorrectOptionIds(String evaluationJson, String type) throws JsonProcessingException { - IQuestionTypeBasedStrategy strategy = getStrategy(type); + IQuestionTypeBasedStrategy strategy = requireStrategy(type); if(strategy.getType().equals(QuestionTypes.MCQS.name()) || strategy.getType().equals(QuestionTypes.TRUE_FALSE.name())){ MCQSCorrectAnswerDto optionDto = (MCQSCorrectAnswerDto) verifyCorrectAnswerJson(evaluationJson, type); @@ -112,7 +142,7 @@ public static List getCorrectOptionIds(String evaluationJson, String typ public static Object getCorrectAnswerFromAutoEvaluationBasedOnQuestionType(String autoEvaluationJson) throws Exception{ String type = getQuestionTypeFromEvaluationJson(autoEvaluationJson); - IQuestionTypeBasedStrategy strategy = getStrategy(type); + IQuestionTypeBasedStrategy strategy = requireStrategy(type); return strategy.validateAndGetCorrectAnswerData(autoEvaluationJson); } @@ -124,7 +154,7 @@ public static String getQuestionTypeFromEvaluationJson(String jsonString) throws public static Object getSurveyDetailBasedOnType(Assessment assessment, AssessmentQuestionPreviewDto assessmentQuestionPreviewDto, List allRespondentData){ String type = assessmentQuestionPreviewDto.getQuestionType(); - IQuestionTypeBasedStrategy strategy = getStrategy(type); + IQuestionTypeBasedStrategy strategy = requireStrategy(type); return strategy.validateAndGetSurveyData(assessment,assessmentQuestionPreviewDto,allRespondentData); } } diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/StudentAttemptService.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/StudentAttemptService.java index 5afd74952d..f0e51f3281 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/StudentAttemptService.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/StudentAttemptService.java @@ -199,7 +199,20 @@ public StudentAttempt updateStudentAttemptWithTotalAfterMarksCalculation(Optiona double totalMarks = calculateTotalMarksForAttemptAndUpdateQuestionWiseMarks(studentAttemptOptional); - StudentAttempt attempt = studentAttemptOptional.get(); + // Re-read before writing. This runs async off a 60s autosave, so the + // learner may have submitted while it was calculating; the entity we + // were handed is a snapshot from before that submit. StudentAttempt has + // no @Version, so saving the snapshot is a full-row overwrite that + // resets status to LIVE and wipes submit_time/result_marks — measured at + // 6.6% of submits in the 1000-VU load test (2026-08-27). The submit and + // expiry paths compute authoritative marks, so once the attempt has + // ended there is nothing here worth persisting. + StudentAttempt attempt = studentAttemptRepository.findById(studentAttemptOptional.get().getId()) + .orElse(studentAttemptOptional.get()); + if (AssessmentAttemptEnum.ENDED.name().equals(attempt.getStatus()) || attempt.getSubmitTime() != null) { + log.debug("Skipping live-sync marks write, attempt already submitted: attemptId={}", attempt.getId()); + return attempt; + } attempt.setTotalMarks(totalMarks); attempt.setTotalTimeInSeconds(timeElapsedInSeconds); diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/assessment_get/AssessmentService.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/assessment_get/AssessmentService.java index c34e90afb8..5cb1d25389 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/assessment_get/AssessmentService.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/assessment_get/AssessmentService.java @@ -27,13 +27,27 @@ public class AssessmentService { @Autowired private AssessmentInstituteMappingRepository assessmentInstituteMappingRepository; + /** + * NOTE: despite the name, this does NOT filter out deleted sections today. + *

+ * The `activeSections` filter is enabled on a session opened here, while the query + * below runs through the Spring-managed session — so the filter never applies. + * (`Assessment.java` also declares the filter condition against an `active` column + * that does not exist, with a parameter name that does not match `Section`'s + * FilterDef, so enabling it correctly would produce invalid SQL.) + *

+ * Deliberately left as-is: making the filter work would start excluding DELETED + * sections from three endpoints that currently return them — a behaviour change + * that needs its own pass. What IS fixed here is the session leak: the session was + * opened on every call and never closed, leaking a Hibernate session and its JDBC + * connection on three hot endpoints. + */ public Optional getAssessmentWithActiveSections(String assessmentId, String instituteId) { if (assessmentId == null) return Optional.empty(); - Session session = sessionFactory.openSession(); - session.enableFilter("activeSections").setParameter("status", "ACTIVE"); - // Fetch the assessment with active sections - // Assuming you have a repository method to find an assessment by ID + try (Session session = sessionFactory.openSession()) { + session.enableFilter("activeSections").setParameter("status", "ACTIVE"); + } return assessmentRepository.findByAssessmentIdAndInstituteId(assessmentId, instituteId); } diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/batch_pending/NotAttemptedLearnerService.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/batch_pending/NotAttemptedLearnerService.java new file mode 100644 index 0000000000..41c1b3c5f3 --- /dev/null +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/batch_pending/NotAttemptedLearnerService.java @@ -0,0 +1,78 @@ +package vacademy.io.assessment_service.features.assessment.service.batch_pending; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import vacademy.io.assessment_service.features.assessment.dto.AssessmentUserFilter; +import vacademy.io.assessment_service.features.assessment.dto.batch_pending.EnrolledLearnerDto; +import vacademy.io.assessment_service.features.assessment.dto.batch_pending.NotAttemptedParticipants; +import vacademy.io.assessment_service.features.assessment.repository.AssessmentBatchRegistrationRepository; +import vacademy.io.assessment_service.features.assessment.repository.AssessmentUserRegistrationRepository; +import vacademy.io.assessment_service.features.client.AdminCoreServiceClient; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static vacademy.io.common.auth.enums.CompanyStatus.ACTIVE; + +/** + * Who was set this assessment through a batch but never attempted it. + * + *

Single owner of that question, because two screens ask it — the Pending tab and its + * CSV export — and they must never disagree. A learner missing from the CSV that the tab + * shows (or vice versa) is worse than either being wrong on its own: the admin chases the + * wrong people and has no way to tell which view lied. + * + *

The set cannot be queried from this database. A batch-enrolled learner gets no + * {@code assessment_user_registration} row until they actually start, so "never attempted" + * only exists as (batch enrollment, owned by admin_core) minus (learners with an attempt, + * owned here). + */ +@Service +@RequiredArgsConstructor +public class NotAttemptedLearnerService { + + private final AssessmentBatchRegistrationRepository assessmentBatchRegistrationRepository; + private final AssessmentUserRegistrationRepository assessmentUserRegistrationRepository; + private final AdminCoreServiceClient adminCoreServiceClient; + + /** + * Every batch-enrolled learner with no attempt, already name-filtered and ordered the + * same way the tab orders them. Unpaged — the caller pages it (tab) or writes it out + * whole (export). + * + *

Returns empty without any cross-service call when the assessment has no batch + * registrations, or when the requested batches aren't among them. + */ + public List findNotAttempted(String assessmentId, String instituteId, + AssessmentUserFilter filter) { + List batchIds = resolveBatchIds(assessmentId, instituteId, filter); + if (CollectionUtils.isEmpty(batchIds)) { + return List.of(); + } + + List enrolled = adminCoreServiceClient + .getEnrolledLearnersForBatches(instituteId, batchIds); + if (CollectionUtils.isEmpty(enrolled)) { + return List.of(); + } + + Set attempted = new HashSet<>(assessmentUserRegistrationRepository + .findAttemptedUserIdsForBatchAssessment(assessmentId, instituteId)); + + return NotAttemptedParticipants.filterAndSortLearners( + enrolled, attempted, filter == null ? null : filter.getName()); + } + + /** + * The assessment's assigned batches, narrowed to the filter chips. Public so the + * export can resolve batch display names for exactly the batches in the sheet. + */ + public List resolveBatchIds(String assessmentId, String instituteId, AssessmentUserFilter filter) { + return NotAttemptedParticipants.resolveBatchIds( + assessmentBatchRegistrationRepository.findBatchIdsByAssessmentAndInstitute( + assessmentId, instituteId, List.of(ACTIVE.name())), + filter == null ? null : filter.getBatches()); + } +} diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/export/ReportExportExecutorConfig.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/export/ReportExportExecutorConfig.java index 501b59f447..cfde95c867 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/export/ReportExportExecutorConfig.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/export/ReportExportExecutorConfig.java @@ -76,6 +76,30 @@ public ThreadPoolTaskExecutor workflowTriggerExecutor() { return executor; } + /** + * Dedicated pool for post-submit LLM-analytics enrichment + * ({@code AssessmentLLMAnalyticsService}). Found at the 1000-VU load test + * (2026-08-27): the method was named Async but ran synchronously on the + * Tomcat thread — per submit it built the enriched payload, ran the + * comparison/rank query and called admin-core over HTTP, which serialized + * the whole request pool during the submit wave (p95 submit 60s, plain + * syncs starved to 16s). Analytics is documented fire-and-forget, so under + * overload dropping the oldest job is correct; the deep queue means drops + * only start past ~1000 pending submits' worth of work. + */ + @Bean("assessmentAnalyticsExecutor") + public ThreadPoolTaskExecutor assessmentAnalyticsExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(3); + executor.setMaxPoolSize(3); + executor.setQueueCapacity(1000); + executor.setThreadNamePrefix("assessment-analytics-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardOldestPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(false); + executor.initialize(); + return executor; + } + @Bean("reportExportExecutor") public ThreadPoolTaskExecutor reportExportExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/export/ReportExportJobFactory.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/export/ReportExportJobFactory.java index 7b88abd33d..a38a524c8d 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/export/ReportExportJobFactory.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/export/ReportExportJobFactory.java @@ -132,5 +132,7 @@ private record ExplicitSelectionDto(String attemptId) implements ParticipantsDet @Override public String getReportReleaseResultStatus() { return null; } @Override public java.util.Date getLastReportReleaseDate() { return null; } @Override public String getUserEmail() { return null; } + @Override public String getPhoneNumber() { return null; } + @Override public String getUsername() { return null; } } } diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/marking_strategy/MCQMQuestionTypeBasedStrategy.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/marking_strategy/MCQMQuestionTypeBasedStrategy.java index 39c18384e5..37bdccf531 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/marking_strategy/MCQMQuestionTypeBasedStrategy.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/marking_strategy/MCQMQuestionTypeBasedStrategy.java @@ -65,8 +65,14 @@ public double calculateMarks(String markingJsonStr, String correctAnswerJsonStr, return 0.0; } - // Check if the answer is completely correct - if (attemptedOptionIds.equals(correctOptionIds)) { + // Check if the answer is completely correct. + // + // Compared as SETS, not lists. List.equals is order-sensitive, so a learner + // who ticked the same options in a different order than the answer key was + // stored in missed full credit and fell through to the branches below — + // which, with partialMarking == 0, awarded FULL NEGATIVE MARKS for a fully + // correct answer. Option order carries no meaning here. + if (new HashSet<>(attemptedOptionIds).equals(new HashSet<>(correctOptionIds))) { setAnswerStatus(QuestionResponseEnum.CORRECT.name()); return totalMarks; } diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/marking_strategy/OneWordQuestionTypeBasedStrategy.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/marking_strategy/OneWordQuestionTypeBasedStrategy.java index 416baef5f4..77e253169e 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/marking_strategy/OneWordQuestionTypeBasedStrategy.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/service/marking_strategy/OneWordQuestionTypeBasedStrategy.java @@ -40,11 +40,14 @@ public double calculateMarks(String markingJsonStr, String correctAnswerJsonStr, return 0.0; } - // Extracting correct option IDs - String correctAnswer = correctAnswerDto.getData().getAnswer().toLowerCase(); + // Both sides are normalised the same way. Lower-casing alone left a + // trailing space marking a correct answer WRONG and then applying negative + // marking on top — a one-word box is exactly where stray whitespace comes + // from. Internal runs are collapsed so "carbon dioxide" matches + // "carbon dioxide". + String correctAnswer = normalize(correctAnswerDto.getData().getAnswer()); - // Extracting student response - String attemptedAnswer = responseDto.getResponseData().getAnswer().toLowerCase(); + String attemptedAnswer = normalize(responseDto.getResponseData().getAnswer()); // Extract marking scheme details safely OneWordMarkingDto.DataFields markingData = markingDto.getData(); @@ -79,6 +82,12 @@ public double calculateMarks(String markingJsonStr, String correctAnswerJsonStr, } + /** Trim, collapse internal whitespace, lower-case. Null-safe. */ + private static String normalize(String answer) { + if (answer == null) return ""; + return answer.trim().replaceAll("\\s+", " ").toLowerCase(); + } + @Override public Object validateAndGetMarkingData(String markingJson) throws JsonProcessingException { ObjectMapper objectMapper = new ObjectMapper(); diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/sort/StableSort.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/sort/StableSort.java new file mode 100644 index 0000000000..e679973158 --- /dev/null +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/sort/StableSort.java @@ -0,0 +1,86 @@ +package vacademy.io.assessment_service.features.assessment.sort; + +import org.springframework.data.domain.Sort; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Builds a {@link Sort} that always defines a TOTAL order for the paged + * participant / submission / respondent list queries. + * + *

Why this exists: those queries are native SQL with no ORDER BY of their + * own, so the Pageable's sort is the only thing ordering them. An empty sort map + * used to collapse to {@link Sort#unsorted()}, which leaves Postgres free to + * return rows in physical heap order. Every write to {@code student_attempt} + * rewrites the row to a new heap slot — opening the evaluator workspace flips + * {@code result_status} to EVALUATING, submitting marks writes + * {@code result_marks} / {@code result_status} / {@code evaluated_file_id} / + * {@code report_release_status} — so the list reordered underneath an evaluator + * mid-grading. Worse, the paging is LIMIT/OFFSET, so a reorder between two page + * fetches could show one submission twice and skip another one entirely. + * + *

Hardcoding ORDER BY into the SQL instead does NOT work here: Spring Data + * appends the Pageable's sort to the declared query, so a trailing ORDER BY + * becomes the primary key and silently demotes the column the user clicked. + * The order has to come through the Sort. (Non-paged export queries have no + * Pageable, so those do carry their ORDER BY inline.) + * + *

Sort properties must be SELECT aliases of the target query. Spring Data's + * DefaultQueryEnhancer leaves a recognised alias bare but prefixes anything + * else with the detected table alias, which would produce invalid SQL — so each + * caller passes the default and tie-breakers that its own queries actually + * select. + */ +public final class StableSort { + + private StableSort() { + } + + /** + * @param sortColumns requested sort as property -> "ASC"/"DESC"; may be null or empty + * @param defaultSort order to apply when the caller requested nothing + * @param tieBreakers unique-per-row properties appended ASC, so the order stays + * deterministic even when the leading column has duplicate + * values (two learners really can share a name) + */ + public static Sort withStableOrder(Map sortColumns, Sort defaultSort, String... tieBreakers) { + List orders = new ArrayList<>(); + // Tracks properties already ordered on, so a tie-breaker that the caller + // explicitly sorted by is not appended a second time (Postgres tolerates + // the duplicate, but it hides which direction actually applies). + Set seen = new LinkedHashSet<>(); + + if (sortColumns != null) { + for (Map.Entry entry : sortColumns.entrySet()) { + String property = entry.getKey(); + if (property == null || property.isBlank()) + continue; + Sort.Direction direction = "DESC".equalsIgnoreCase(entry.getValue()) + ? Sort.Direction.DESC + : Sort.Direction.ASC; + if (seen.add(property.toLowerCase())) + orders.add(new Sort.Order(direction, property)); + } + } + + if (orders.isEmpty() && defaultSort != null) { + for (Sort.Order order : defaultSort) { + if (seen.add(order.getProperty().toLowerCase())) + orders.add(order); + } + } + + if (tieBreakers != null) { + for (String tieBreaker : tieBreakers) { + if (tieBreaker != null && !tieBreaker.isBlank() && seen.add(tieBreaker.toLowerCase())) + orders.add(Sort.Order.asc(tieBreaker)); + } + } + + return orders.isEmpty() ? Sort.unsorted() : Sort.by(orders); + } +} diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/client/AdminCoreServiceClient.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/client/AdminCoreServiceClient.java index 23d0ca451c..532c840e7c 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/client/AdminCoreServiceClient.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/client/AdminCoreServiceClient.java @@ -1,5 +1,6 @@ package vacademy.io.assessment_service.features.client; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -9,6 +10,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import vacademy.io.assessment_service.features.assessment.dto.batch_pending.EnrolledLearnerDto; import vacademy.io.assessment_service.features.learner_assessment.dto.ReportBrandingDto; import vacademy.io.common.core.internal_api_wrapper.InternalClientUtils; @@ -172,6 +174,87 @@ private static String firstNonBlank(Map map, String... keys) { return null; } + /** + * Learners enrolled in {@code batchIds}, for the "enrolled but has not attempted" + * list. Returns an empty list on any failure — the Pending tab degrades to empty + * rather than failing the whole submissions page. + * + *

Cached, and that is load-critical. The submissions page fetches the + * Pending count on every mount, so without a cache each page view would cost an + * admin_core round trip. Batch enrollment barely changes, so a short window collapses + * every mount, tab switch and page step for the same batch set onto one call. The key + * is the SORTED batch id list, so callers passing the same batches in a different + * order still share the entry. + * + *

Deliberately fetches the whole enrolled set instead of asking admin_core to + * exclude the already-attempted learners: an exclusion array is unestimable, and on a + * generic plan Postgres re-evaluated it per row (22ms -> 434-880ms on prod data, + * intermittently). See the endpoint's own javadoc. + */ + @Cacheable(value = "batchEnrolledLearners", key = "#instituteId + '|' + #batchIds", unless = "#result.isEmpty()") + public List getEnrolledLearnersForBatches(String instituteId, List batchIds) { + if (instituteId == null || instituteId.isBlank() || batchIds == null || batchIds.isEmpty()) { + return List.of(); + } + try { + Map body = new HashMap<>(); + body.put("institute_id", instituteId); + body.put("package_session_ids", batchIds); + body.put("statuses", List.of("ACTIVE")); + + ResponseEntity response = internalClientUtils.makeHmacRequest( + clientName, "POST", adminCoreServiceBaseUrl, + "/admin-core-service/internal/learner/v1/enrolled-by-package-sessions", body); + + if (response.getStatusCode() == HttpStatus.OK && StringUtils.hasText(response.getBody())) { + return objectMapper.readValue(response.getBody(), new TypeReference>() { + }); + } + log.warn("Enrolled-learner lookup returned {} for institute {} ({} batches)", + response.getStatusCode(), instituteId, batchIds.size()); + } catch (Exception e) { + log.warn("Failed to fetch enrolled learners for institute {} ({} batches): {}", + instituteId, batchIds.size(), e.getMessage()); + } + return List.of(); + } + + /** + * Display name per batch id, for the batch column in the participant CSV exports. + * Missing ids simply stay absent, so callers must fall back to the raw id (or blank) + * rather than assuming a hit. + * + *

Reuses admin_core's existing {@code /v1/package-sessions/names} — the same + * endpoint notification_service uses to title batch chats — so batch naming stays + * consistent across services instead of this one inventing its own format. + * + *

Cached: an export resolves every batch in the sheet at once, and batch names + * essentially never change, so this should not be a per-export round trip. + */ + @Cacheable(value = "batchNames", key = "#batchIds", unless = "#result.isEmpty()") + public Map getBatchNames(List batchIds) { + if (batchIds == null || batchIds.isEmpty()) { + return Map.of(); + } + try { + // camelCase key: that endpoint binds PackageSessionsRequest.packageSessionIds + // with the default naming strategy, not the snake_case one used elsewhere. + ResponseEntity response = internalClientUtils.makeHmacRequest( + clientName, "POST", adminCoreServiceBaseUrl, + "/admin-core-service/v1/package-sessions/names", + Map.of("packageSessionIds", batchIds)); + + if (response.getStatusCode() == HttpStatus.OK && StringUtils.hasText(response.getBody())) { + return objectMapper.readValue(response.getBody(), new TypeReference>() { + }); + } + log.warn("Batch-name lookup returned {} for {} batches", response.getStatusCode(), batchIds.size()); + } catch (Exception e) { + log.warn("Failed to resolve names for {} batches: {}", batchIds.size(), e.getMessage()); + } + return Map.of(); + } + private static final String STUDENT = "STUDENT"; private static final String LEARNER = "LEARNER"; diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/learner_assessment/manager/LearnerAssessmentAttemptStartManager.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/learner_assessment/manager/LearnerAssessmentAttemptStartManager.java index 2105e73905..fe1643a8a3 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/learner_assessment/manager/LearnerAssessmentAttemptStartManager.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/learner_assessment/manager/LearnerAssessmentAttemptStartManager.java @@ -294,6 +294,15 @@ public ResponseEntity startAssessment( if (maybeAttempt.isEmpty()) throw new VacademyException("Student attempt not found"); StudentAttempt studentAttempt = maybeAttempt.get(); + // The attempt id is the only input here, and `user` used to be ignored entirely + // — so any authenticated caller could flip another learner's PREVIEW attempt to + // LIVE and reset its start time out from under them. + // + // Checked BEFORE the idempotent replay below: that path returns the attempt's + // real startTime and registration id, so letting a non-owner reach it would leak + // another learner's exam state even though it mutates nothing. + LearnerAssessmentAttemptStatusManager.assertOwnershipOrStaff(studentAttempt, user, "start-assessment"); + if (AssessmentAttemptEnum.LIVE.name().equals(studentAttempt.getStatus()) && studentAttempt.getStartTime() != null) return ResponseEntity.ok(buildStartAssessmentResponse(studentAttempt, studentAttempt.getStartTime())); diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/learner_assessment/manager/LearnerAssessmentAttemptStatusManager.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/learner_assessment/manager/LearnerAssessmentAttemptStatusManager.java index 01d425fc7d..330313d11d 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/learner_assessment/manager/LearnerAssessmentAttemptStatusManager.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/learner_assessment/manager/LearnerAssessmentAttemptStatusManager.java @@ -27,6 +27,7 @@ import vacademy.io.assessment_service.features.assessment.service.StudentAttemptService; import vacademy.io.common.auth.model.CustomUserDetails; import vacademy.io.common.core.utils.DateUtil; +import vacademy.io.common.exceptions.ForbiddenException; import vacademy.io.assessment_service.core.exception.VacademyException; import vacademy.io.common.logging.SentryLogger; @@ -76,6 +77,44 @@ public class LearnerAssessmentAttemptStatusManager { */ private final Set attemptsWithRecalcInFlight = java.util.concurrent.ConcurrentHashMap.newKeySet(); + /** + * Reject an attempt that does not belong to the caller. + *

+ * These endpoints previously checked only that the attempt belonged to the + * ASSESSMENT in the query string — never that it belonged to the caller. Since + * the attempt id is the only other input, any authenticated learner could sync + * over, or submit, another learner's exam. + *

+ * Staff are let through deliberately: evaluators and admins legitimately act on + * a learner's attempt, and this guard exists to stop learner-on-learner access, + * not to narrow what staff can already do. + */ + static void assertOwnershipOrStaff(StudentAttempt attempt, CustomUserDetails user, String operation) { + if (user == null) { + throw new ForbiddenException("Not allowed to access this attempt"); + } + String ownerId = attempt.getRegistration() == null ? null : attempt.getRegistration().getUserId(); + if (ownerId != null && ownerId.equals(user.getUserId())) { + return; + } + if (isStaff(user)) { + return; + } + log.warn("Blocked cross-user attempt access during {}: attemptId={}, ownerUserId={}, callerUserId={}", + operation, attempt.getId(), ownerId, user.getUserId()); + throw new ForbiddenException("Not allowed to access this attempt"); + } + + private static boolean isStaff(CustomUserDetails user) { + if (user.isRootUser()) return true; + return user.getAuthorities() != null && user.getAuthorities().stream() + .map(authority -> authority.getAuthority() == null ? "" + : authority.getAuthority().toUpperCase()) + .anyMatch(STAFF_AUTHORITIES::contains); + } + + private static final Set STAFF_AUTHORITIES = Set.of("ADMIN", "EVALUATOR", "TEACHER", "CREATOR"); + /** * Converts the duration distribution data into a list of duration responses. * @@ -179,6 +218,8 @@ public ResponseEntity updateLearnerStatus(CustomUse throw new VacademyException("Student Not Linked with Assessment"); } + assertOwnershipOrStaff(studentAttempt.get(), user, "status-update"); + // Check if the attempt status is preview if (AssessmentAttemptEnum.PREVIEW.name().equals(studentAttempt.get().getStatus())) { log.warn("Attempt to update preview assessment: assessmentId={}, attemptId={}, userId={}", @@ -374,6 +415,8 @@ public ResponseEntity submitAssessment(CustomUserDetails user, String as throw new VacademyException("Student Not Linked with Assessment"); } + assertOwnershipOrStaff(studentAttempt.get(), user, "submit"); + // Check if the attempt status is preview if (AssessmentAttemptEnum.PREVIEW.name().equals(studentAttempt.get().getStatus())) { log.warn("Attempt to submit preview assessment: assessmentId={}, attemptId={}, userId={}", diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/learner_assessment/service/AssessmentLLMAnalyticsService.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/learner_assessment/service/AssessmentLLMAnalyticsService.java index eb4a3e2832..ca7eefc1c4 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/learner_assessment/service/AssessmentLLMAnalyticsService.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/learner_assessment/service/AssessmentLLMAnalyticsService.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import vacademy.io.assessment_service.features.assessment.entity.StudentAttempt; import vacademy.io.assessment_service.features.client.AdminCoreServiceClient; @@ -23,9 +24,12 @@ public class AssessmentLLMAnalyticsService { private final AssessmentDataEnrichmentService enrichmentService; /** - * Send assessment submission data to admin core service for LLM analysis (Sync) - * This is fire-and-forget - failures won't impact the assessment submission - * flow + * Send assessment submission data to admin core service for LLM analysis. + * Truly async since 2026-08-27 (it was named Async but ran on the Tomcat + * thread; at the 1000-VU load test that serialized the submit wave). + * Fire-and-forget - failures won't impact the assessment submission flow. + * Safe off-thread: the entity's registration/assessment relations are + * EAGER (already hydrated), everything else is fresh repository queries. * * @param studentAttempt The student's completed attempt * @param assessmentId The assessment ID @@ -34,6 +38,7 @@ public class AssessmentLLMAnalyticsService { * @param durationMinutes The assessment duration * @param totalMarks The total marks for the assessment */ + @Async("assessmentAnalyticsExecutor") public void sendAssessmentDataForAnalysisAsync( StudentAttempt studentAttempt, String assessmentId, diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_bank/controller/GetQuestionBankController.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_bank/controller/GetQuestionBankController.java new file mode 100644 index 0000000000..7a3a3b0889 --- /dev/null +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_bank/controller/GetQuestionBankController.java @@ -0,0 +1,35 @@ +package vacademy.io.assessment_service.features.question_bank.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import vacademy.io.assessment_service.features.question_bank.dto.QuestionBankFilter; +import vacademy.io.assessment_service.features.question_bank.manager.GetQuestionBankManager; +import vacademy.io.assessment_service.features.question_core.dto.QuestionDTO; +import vacademy.io.common.auth.model.CustomUserDetails; + +import static vacademy.io.common.core.constants.PageConstants.DEFAULT_PAGE_NUMBER; +import static vacademy.io.common.core.constants.PageConstants.DEFAULT_PAGE_SIZE; + +/** + * Question-level browse. The paper-level equivalent lives at + * /assessment-service/question-paper/view/v1/get-with-filters and is unchanged. + */ +@RestController +@RequestMapping("/assessment-service/question-bank/v1") +public class GetQuestionBankController { + + @Autowired + private GetQuestionBankManager getQuestionBankManager; + + @PostMapping("/questions/filter") + public ResponseEntity> filterQuestions( + @RequestAttribute("user") CustomUserDetails user, + @RequestBody(required = false) QuestionBankFilter filter, + @RequestParam(value = "instituteId") String instituteId, + @RequestParam(value = "pageNo", defaultValue = DEFAULT_PAGE_NUMBER, required = false) int pageNo, + @RequestParam(value = "pageSize", defaultValue = DEFAULT_PAGE_SIZE, required = false) int pageSize) { + return ResponseEntity.ok(getQuestionBankManager.getQuestions(user, filter, instituteId, pageNo, pageSize)); + } +} diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_bank/dto/QuestionBankFilter.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_bank/dto/QuestionBankFilter.java new file mode 100644 index 0000000000..511d590822 --- /dev/null +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_bank/dto/QuestionBankFilter.java @@ -0,0 +1,51 @@ +package vacademy.io.assessment_service.features.question_bank.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +/** + * Filters for browsing INDIVIDUAL questions, as opposed to whole question papers. + *

+ * {@link QuestionPaperFilter} is the paper-level equivalent and stays as it is. Until + * now a question was only reachable by fetching the paper that contains it, which made + * "give me every medium-difficulty numerical this book produced" impossible to express — + * and therefore made every AI-generated question a one-shot artifact rather than + * something an institute accumulates. + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class QuestionBankFilter { + + /** Free-text search over the question body. */ + private String name; + + /** Knowledge bases the question was generated from (matched inside source_meta). */ + private List kbIds = new ArrayList<>(); + + /** Topic/subtopic nodes within those knowledge bases (matched inside source_meta). */ + private List kbNodeIds = new ArrayList<>(); + + /** MANUAL | UPLOAD | AI | KNOWLEDGE_BASE. */ + private List sourceTypes = new ArrayList<>(); + + private List questionTypes = new ArrayList<>(); + + /** EASY | MEDIUM | HARD. */ + private List difficulties = new ArrayList<>(); + + private List tagIds = new ArrayList<>(); + + /** Defaults to ACTIVE at the query layer when left empty. */ + private List statuses = new ArrayList<>(); + + /** Questions already in the section being filled — so the picker can hide them. */ + private List excludeQuestionIds = new ArrayList<>(); +} diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_bank/manager/AddQuestionPaperFromImportManager.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_bank/manager/AddQuestionPaperFromImportManager.java index 4e3d8e5bf7..26fa7573f8 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_bank/manager/AddQuestionPaperFromImportManager.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_bank/manager/AddQuestionPaperFromImportManager.java @@ -31,9 +31,11 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import static vacademy.io.assessment_service.features.assessment.enums.AssessmentSetStatusEnum.DELETED; @@ -74,6 +76,13 @@ public AddedQuestionPaperResponseDto addQuestionPaper(CustomUserDetails user, Ad if (questionRequestBody.getQuestions().get(i).getParentRichText() != null) { question.setParentRichText(AssessmentRichTextData.fromDTO(questionRequestBody.getQuestions().get(i).getParentRichText())); } + // Denormalised from the paper (V42) so the question-level browse endpoint can + // scope by institute without walking question -> mapping -> paper -> institute + // for every candidate row. Only for private papers: a public paper's questions + // belong to no single institute. + if (!isPublicPaper) { + question.setInstituteId(questionRequestBody.getInstituteId()); + } questions.add(question); } @@ -155,6 +164,9 @@ public Boolean updateQuestionPaper(CustomUserDetails user, AddQuestionPaperDTO q if (importQuestion.getParentRichText() != null) { question.setParentRichText(AssessmentRichTextData.fromDTO(importQuestion.getParentRichText())); } + if (!isPublicPaper) { + question.setInstituteId(questionRequestBody.getInstituteId()); + } newQuestions.add(question); List

+ * Insert-only left a duplicate row behind on every save of an already-linked paper. + */ + private void linkOrUpdateInstitute(String questionPaperId, String instituteId, String levelId, String subjectId) { + if (instituteId == null) return; + if (questionPaperRepository.countInstituteQuestionPaperLink(questionPaperId, instituteId) > 0) { + questionPaperRepository.updateInstituteQuestionPaperLink(questionPaperId, instituteId, "ACTIVE", levelId, subjectId); + return; + } + questionPaperRepository.linkInstituteToQuestionPaper(UUID.randomUUID().toString(), questionPaperId, + instituteId, "ACTIVE", levelId, subjectId); + } + public Question makeQuestionAndOptionFromImportQuestion(QuestionDTO questionRequest, Boolean isPublic, Question existingQuestion) throws JsonProcessingException { // Todo: check Question Validation @@ -221,6 +245,10 @@ public Question makeQuestionAndOptionFromImportQuestion(QuestionDTO questionRequ } + // Six saveAll batches plus a bulk insert. Without a transaction a failure part-way + // through left a half-edited paper: some questions added, others not, tags dangling. + // Both sibling methods (addQuestionPaper, updateQuestionPaper) are already transactional. + @Transactional public Boolean editQuestionPaper(CustomUserDetails user, EditQuestionPaperDTO questionRequestBody) throws JsonProcessingException { Optional questionPaper = questionPaperRepository.findById(questionRequestBody.getId()); @@ -247,6 +275,7 @@ public Boolean editQuestionPaper(CustomUserDetails user, EditQuestionPaperDTO qu if (importQuestion.getParentRichText() != null) { question.setParentRichText(AssessmentRichTextData.fromDTO(importQuestion.getParentRichText())); } + question.setInstituteId(questionRequestBody.getInstituteId()); newQuestions.add(question); List

+ * The paper-level equivalent is {@link GetQuestionPaperManager}. This exists so a + * question generated once — from a knowledge base, an upload, or by hand — can be found + * and reused later instead of being generated again. + */ +@Component +public class GetQuestionBankManager { + + @Autowired + private QuestionRepository questionRepository; + + /** Questions with no explicit status filter are ACTIVE ones. */ + private static final List DEFAULT_STATUSES = List.of("ACTIVE"); + + public Page getQuestions(CustomUserDetails user, QuestionBankFilter filter, + String instituteId, int pageNo, int pageSize) { + if (instituteId == null || instituteId.isBlank()) { + throw new VacademyException("instituteId is required"); + } + QuestionBankFilter safeFilter = filter == null ? new QuestionBankFilter() : filter; + + Pageable pageable = PageRequest.of(pageNo, pageSize, Sort.by(Sort.Direction.DESC, "created_at")); + + Page questions = questionRepository.findQuestionsByFilters( + instituteId, + blankToNull(safeFilter.getName()), + // Every multi-value filter goes over as CSV -- see the repository comment. + // Statuses default to ACTIVE rather than "no constraint": a deleted + // question must never be offered for reuse. + toCsv(safeFilter.getStatuses() == null || safeFilter.getStatuses().isEmpty() + ? DEFAULT_STATUSES + : safeFilter.getStatuses()), + toCsv(safeFilter.getQuestionTypes()), + toCsv(safeFilter.getDifficulties()), + toCsv(safeFilter.getSourceTypes()), + toCsv(safeFilter.getExcludeQuestionIds()), + toCsv(safeFilter.getKbIds()), + toCsv(safeFilter.getKbNodeIds()), + toCsv(safeFilter.getTagIds()), + pageable + ); + + // provideSolution = true: this feeds a picker where the admin is deciding + // whether a question is worth adding, and that judgement needs the answer. + return questions.map(question -> new QuestionDTO(question, true)); + } + + private String blankToNull(String value) { + return (value == null || value.isBlank()) ? null : value; + } + + /** + * The query unnests these in Postgres rather than binding a list, so an id + * containing a comma would split into two. Ids here are UUIDs, but drop any that + * are not comma-free rather than silently widening the filter. + */ + private String toCsv(List values) { + if (values == null || values.isEmpty()) return null; + List clean = values.stream() + .filter(v -> v != null && !v.isBlank() && !v.contains(",")) + .toList(); + return clean.isEmpty() ? null : String.join(",", clean); + } +} diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_bank/repository/QuestionPaperRepository.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_bank/repository/QuestionPaperRepository.java index 0472e3e2a8..bb118232e8 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_bank/repository/QuestionPaperRepository.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_bank/repository/QuestionPaperRepository.java @@ -97,4 +97,19 @@ Page findPublicQuestionPapersByFilters( @Query(value = "UPDATE institute_question_paper SET status = :status, updated_on = CURRENT_TIMESTAMP WHERE institute_id = :instituteId AND question_paper_id = :questionPaperId", nativeQuery = true) void updateStatusForInstituteQuestionPaper(String instituteId, String questionPaperId, String status); + /** + * Does this paper already belong to this institute? + *

+ * linkInstituteToQuestionPaper is a bare INSERT with a fresh UUID, so calling it on + * every update piled up duplicate institute_question_paper rows for the same pair — + * which the paper-listing query then multiplies through its LEFT JOIN. + */ + @Query(value = "SELECT COUNT(*) FROM institute_question_paper WHERE question_paper_id = :questionPaperId AND institute_id = :instituteId", nativeQuery = true) + long countInstituteQuestionPaperLink(@Param("questionPaperId") String questionPaperId, @Param("instituteId") String instituteId); + + @Modifying + @Transactional + @Query(value = "UPDATE institute_question_paper SET status = :status, level_id = :levelId, subject_id = :subjectId, updated_on = CURRENT_TIMESTAMP WHERE question_paper_id = :questionPaperId AND institute_id = :instituteId", nativeQuery = true) + void updateInstituteQuestionPaperLink(@Param("questionPaperId") String questionPaperId, @Param("instituteId") String instituteId, @Param("status") String status, @Param("levelId") String levelId, @Param("subjectId") String subjectId); + } diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/dto/MCQEvaluationDTO.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/dto/MCQEvaluationDTO.java index dd2548220a..3dcc5646fd 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/dto/MCQEvaluationDTO.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/dto/MCQEvaluationDTO.java @@ -1,5 +1,6 @@ package vacademy.io.assessment_service.features.question_core.dto; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -24,7 +25,21 @@ public class MCQEvaluationDTO { @Setter @AllArgsConstructor @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) public static class MCQData { + /** + * Jackson does NOT inherit the outer class's SnakeCaseStrategy, so this nested + * class only ever understood the camelCase spelling. Every AI question source + * emits `correct_option_ids` (that is what question_format.py produces), which + * bound to nothing and left this list null — and the next thing to touch it, + * grading, died on `getCorrectOptionIds().contains(...)`. + *

+ * An alias rather than @JsonNaming on the nested class, deliberately: + * serialization stays camelCase, so every already-stored auto_evaluation_json + * and the frontend report renderer that reads `data.correctOptionIds` keep + * working untouched. This only widens what we accept on the way in. + */ + @JsonAlias("correct_option_ids") private List correctOptionIds; } } diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/dto/NumericalEvaluationDto.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/dto/NumericalEvaluationDto.java index 1ff024659f..fd20d21159 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/dto/NumericalEvaluationDto.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/dto/NumericalEvaluationDto.java @@ -1,5 +1,6 @@ package vacademy.io.assessment_service.features.question_core.dto; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -24,7 +25,13 @@ public class NumericalEvaluationDto { @Setter @AllArgsConstructor @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) public static class NumericalData { + // Same trap as MCQEvaluationDTO.MCQData: a nested class does not inherit the + // outer SnakeCaseStrategy, so only the camelCase spelling ever bound. The alias + // accepts the snake_case form generators emit; serialization stays camelCase so + // stored rows and existing readers are untouched. + @JsonAlias("valid_answers") private List validAnswers; // Stores integer, 1 decimal, 2 decimals, or negative numbers } } diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/dto/QuestionDTO.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/dto/QuestionDTO.java index 3ef3a131c2..3a8333ddf6 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/dto/QuestionDTO.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/dto/QuestionDTO.java @@ -54,6 +54,13 @@ public class QuestionDTO { private String evaluationCriteriaJson; private String criteriaTemplateId; + // Provenance (V42). Set by the generators that know it — today the knowledge-base + // paper pipeline — and echoed back on read so the UI can show where a question + // came from and link to its source page. + private String instituteId; + private String sourceType; + private String sourceMeta; + // Default constructor public QuestionDTO() { } @@ -70,6 +77,11 @@ public QuestionDTO(Question question, Boolean provideSolution) { this.optionsJson = question.getOptionsJson(); this.evaluationCriteriaJson = question.getEvaluationCriteriaJson(); this.criteriaTemplateId = question.getCriteriaTemplateId(); + this.instituteId = question.getInstituteId(); + this.sourceType = question.getSourceType(); + this.sourceMeta = question.getSourceMeta(); + this.aiDifficultyLevel = question.getDifficulty() != null ? question.getDifficulty() : this.aiDifficultyLevel; + this.problemType = question.getProblemType(); if (provideSolution) { this.autoEvaluationJson = question.getAutoEvaluationJson(); diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/entity/Question.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/entity/Question.java index 643b1c2bb2..2ca9aa951f 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/entity/Question.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/entity/Question.java @@ -2,7 +2,9 @@ import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UuidGenerator; +import org.hibernate.type.SqlTypes; import vacademy.io.assessment_service.features.question_core.dto.QuestionDTO; import vacademy.io.assessment_service.features.rich_text.entity.AssessmentRichTextData; @@ -68,6 +70,23 @@ public class Question { @Column(name = "default_question_time_mins") private Integer defaultQuestionTimeMins; + /** Owning institute (V42). Nullable: public/community questions have none. */ + @Column(name = "institute_id") + private String instituteId; + + /** MANUAL | UPLOAD | AI | KNOWLEDGE_BASE (V42). */ + @Column(name = "source_type") + private String sourceType; + + /** + * Where this question came from, as JSON (V42). For a knowledge-base question: + * kb_id, node_ids, topic, source_page, generation_id, figures. Kept as a string + * here — nothing in Java reads inside it; the browse query filters it in Postgres. + */ + @Column(name = "source_meta", columnDefinition = "jsonb") + @JdbcTypeCode(SqlTypes.JSON) + private String sourceMeta; + // One-to-One mapping with AssessmentRichTextData for text_id @OneToOne(cascade = CascadeType.ALL) @JoinColumn(name = "text_id", referencedColumnName = "id", insertable = true, updatable = true) @@ -105,6 +124,14 @@ public Question(QuestionDTO questionDTO) { this.optionsJson = questionDTO.getOptionsJson(); this.evaluationCriteriaJson = questionDTO.getEvaluationCriteriaJson(); this.criteriaTemplateId = questionDTO.getCriteriaTemplateId(); + // These three were silently dropped by this constructor, so any path building a + // Question from a DTO here (rather than through the import manager's + // initializeQuestion) lost the question's difficulty and problem type. + this.difficulty = questionDTO.getAiDifficultyLevel(); + this.problemType = questionDTO.getProblemType(); + this.instituteId = questionDTO.getInstituteId(); + this.sourceType = questionDTO.getSourceType(); + this.sourceMeta = questionDTO.getSourceMeta(); } public Question(String id) { diff --git a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/repository/QuestionRepository.java b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/repository/QuestionRepository.java index a0c587add4..a5815c6238 100644 --- a/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/repository/QuestionRepository.java +++ b/assessment_service/src/main/java/vacademy/io/assessment_service/features/question_core/repository/QuestionRepository.java @@ -1,5 +1,7 @@ package vacademy.io.assessment_service.features.question_core.repository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -13,4 +15,70 @@ public interface QuestionRepository extends JpaRepository { "JOIN question_question_paper_mapping qp ON q.id = qp.question_id " + "WHERE qp.question_paper_id = :questionPaperId and q.status != 'DELETED'", nativeQuery = true) List findQuestionsByQuestionPaperId(@Param("questionPaperId") String questionPaperId); + + /** Shared by the row query and its count query so the two can never drift apart. */ + String QUESTION_FILTER_WHERE = + "WHERE q.institute_id = :instituteId " + + "AND (:statusesCsv IS NULL OR q.status = ANY(string_to_array(:statusesCsv, ','))) " + + "AND (:questionTypesCsv IS NULL OR q.question_type = ANY(string_to_array(:questionTypesCsv, ','))) " + + "AND (:difficultiesCsv IS NULL OR q.difficulty = ANY(string_to_array(:difficultiesCsv, ','))) " + + "AND (:sourceTypesCsv IS NULL OR q.source_type = ANY(string_to_array(:sourceTypesCsv, ','))) " + + "AND (:excludeQuestionIdsCsv IS NULL OR NOT (q.id = ANY(string_to_array(:excludeQuestionIdsCsv, ',')))) " + + "AND (:kbIdsCsv IS NULL OR EXISTS (" + + " SELECT 1 FROM unnest(string_to_array(:kbIdsCsv, ',')) AS kb(id) " + + " WHERE q.source_meta @> jsonb_build_object('kb_id', kb.id))) " + + "AND (:kbNodeIdsCsv IS NULL OR EXISTS (" + + " SELECT 1 FROM unnest(string_to_array(:kbNodeIdsCsv, ',')) AS node(id) " + + " WHERE q.source_meta @> jsonb_build_object('node_ids', jsonb_build_array(node.id)))) " + + "AND (:tagIdsCsv IS NULL OR EXISTS (" + + " SELECT 1 FROM entity_tags et " + + " WHERE et.entity_id = q.id AND et.entity_name = 'QUESTION' " + + " AND et.tag_id = ANY(string_to_array(:tagIdsCsv, ',')))) " + + "AND (:name IS NULL OR EXISTS (" + + " SELECT 1 FROM assessment_rich_text_data t " + + " WHERE t.id = q.text_id AND t.content ILIKE CONCAT('%', :name, '%')))"; + + /** + * Browse an institute's questions directly, rather than through their papers. + * + * Every filter is optional and NULL means "no constraint". + * + * All multi-value filters are passed as a comma-separated string and expanded with + * string_to_array, NOT as a bound List with `IN (:param)`. That is deliberate: + * binding an empty or null collection into a native IN clause behaves differently + * across Hibernate versions, and the one place this codebase already needed to be + * sure of it (tagIdsCsv in findQuestionPapersByFilters) uses exactly this pattern. + * A plain string binds unambiguously. + * + * Other notes: + * - institute_id is the denormalised column added in V42. Questions predating the + * backfill, or belonging to no institute-linked paper, are NULL and simply do not + * appear -- which is correct for an institute's own bank. + * - kb ids and node ids are matched with the jsonb containment operator so the GIN + * index on source_meta is used. node_ids is an array inside the document, hence + * the array-wrapped right-hand side. + * - The tag and text filters are SEPARATE EXISTS clauses rather than one EXISTS + * with an OR across columns -- the latter degenerates into a per-row seq scan. + * + * No semicolons anywhere, including in comments: Hibernate splices its fetch clause + * in at the first one it finds and the column indexes then come out wrong. + */ + @Query( + value = "SELECT q.* FROM question q " + QUESTION_FILTER_WHERE, + countQuery = "SELECT COUNT(q.id) FROM question q " + QUESTION_FILTER_WHERE, + nativeQuery = true + ) + Page findQuestionsByFilters( + @Param("instituteId") String instituteId, + @Param("name") String name, + @Param("statusesCsv") String statusesCsv, + @Param("questionTypesCsv") String questionTypesCsv, + @Param("difficultiesCsv") String difficultiesCsv, + @Param("sourceTypesCsv") String sourceTypesCsv, + @Param("excludeQuestionIdsCsv") String excludeQuestionIdsCsv, + @Param("kbIdsCsv") String kbIdsCsv, + @Param("kbNodeIdsCsv") String kbNodeIdsCsv, + @Param("tagIdsCsv") String tagIdsCsv, + Pageable pageable + ); } diff --git a/assessment_service/src/main/resources/db/migration/V42__question_source_and_institute.sql b/assessment_service/src/main/resources/db/migration/V42__question_source_and_institute.sql new file mode 100644 index 0000000000..c9bd2c361c --- /dev/null +++ b/assessment_service/src/main/resources/db/migration/V42__question_source_and_institute.sql @@ -0,0 +1,91 @@ +-- Provenance and institute scoping for individual questions. +-- +-- Two gaps this closes: +-- +-- 1. `question` has no institute. Questions are only reachable today by dumping a +-- whole question_paper, because the only way to scope a question to an institute +-- is the two-hop chain +-- question -> question_question_paper_mapping -> institute_question_paper. +-- That is far too expensive to filter on per row, which is why no question-level +-- browse or search API exists at all. +-- +-- 2. Questions generated from a knowledge base lose every trace of where they came +-- from the moment they are saved. The KB pipeline knows the source book, the topic +-- nodes and the exact page; none of it survives into the question bank, so a +-- generated question can never be found again by topic, and a teacher checking a +-- citation has nowhere to look. +-- +-- All three columns are NULLABLE with no default. Nothing existing reads them, no +-- existing query gains a predicate, and rolling this back is a no-op. + +ALTER TABLE question ADD COLUMN institute_id VARCHAR(255); + +-- MANUAL | UPLOAD | AI | KNOWLEDGE_BASE. Deliberately a plain varchar rather than a +-- CHECK constraint: new generators appear regularly and a CHECK here would mean a +-- migration every time one does. +ALTER TABLE question ADD COLUMN source_type VARCHAR(50); + +-- {kb_id, node_ids[], topic, source_page, generation_id, figures[]} for KB questions; +-- open-ended for other sources. JSONB so it can be indexed and queried, rather than +-- TEXT which would force a full scan to answer "which questions came from this book". +ALTER TABLE question ADD COLUMN source_meta JSONB; + +-- Backfill institute_id from the existing link chain. DISTINCT ON picks one institute +-- per question: a question shared across papers belonging to different institutes is +-- possible in principle, and this column is a scoping hint for the new browse endpoint, +-- not a new source of truth -- the paper-level institute_question_paper link remains +-- authoritative and is untouched. +-- +-- Questions belonging to no institute-linked paper stay NULL. That is correct: they are +-- public/community questions, and the browse endpoint filters on institute_id, so they +-- simply do not appear in an institute's own bank. +-- Batched. `question` is a hot table during live exams, and a single UPDATE over every +-- row would hold row locks for the whole statement. 5000 rows at a time keeps each +-- statement short; the loop stops as soon as a pass updates nothing. +DO $$ +DECLARE + updated_rows INTEGER; +BEGIN + LOOP + -- Selected from the RESOLVABLE set, not from "every question still NULL". + -- Questions belonging to no institute-linked paper can never be filled in, so a + -- batch drawn from the NULL rows would keep re-picking the same unresolvable + -- ones and the loop would never terminate. Every row selected here is updated, + -- so ROW_COUNT reaching 0 means genuinely finished. + WITH resolved AS ( + SELECT DISTINCT ON (m.question_id) m.question_id, iqp.institute_id + FROM question_question_paper_mapping m + JOIN institute_question_paper iqp ON iqp.question_paper_id = m.question_paper_id + JOIN question q ON q.id = m.question_id AND q.institute_id IS NULL + ORDER BY m.question_id, iqp.created_on NULLS LAST + LIMIT 5000 + ) + UPDATE question q + SET institute_id = resolved.institute_id + FROM resolved + WHERE resolved.question_id = q.id; + + GET DIAGNOSTICS updated_rows = ROW_COUNT; + EXIT WHEN updated_rows = 0; + END LOOP; +END $$; + +-- Plain CREATE INDEX, not CONCURRENTLY, matching V34: CONCURRENTLY cannot run inside +-- the transaction Flyway wraps each migration in, and a plain build rolls back cleanly +-- instead of leaving an INVALID index behind for IF NOT EXISTS to skip silently later. +CREATE INDEX IF NOT EXISTS idx_question_institute_status + ON question (institute_id, status); + +CREATE INDEX IF NOT EXISTS idx_question_institute_type + ON question (institute_id, question_type); + +CREATE INDEX IF NOT EXISTS idx_question_institute_difficulty + ON question (institute_id, difficulty); + +-- jsonb_path_ops over the default: smaller and faster for the only query shape we run +-- against this column, containment ("which questions came from kb X / node Y"). +CREATE INDEX IF NOT EXISTS idx_question_source_meta + ON question USING GIN (source_meta jsonb_path_ops); + +CREATE INDEX IF NOT EXISTS idx_question_source_type + ON question (source_type); diff --git a/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/EnrolledLearnerDtoTest.java b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/EnrolledLearnerDtoTest.java new file mode 100644 index 0000000000..a4998cc14f --- /dev/null +++ b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/EnrolledLearnerDtoTest.java @@ -0,0 +1,49 @@ +package vacademy.io.assessment_service.features.assessment.dto.batch_pending; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The wire contract with admin_core's enrolled-learner endpoint. + * + *

A naming mismatch here fails silently — Jackson would leave every field null and the + * Pending tab would render nameless rows rather than erroring — so both spellings are + * pinned by a test instead of trusted. + */ +class EnrolledLearnerDtoTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void readsTheSnakeCaseFormAdminCoreActuallySends() throws Exception { + EnrolledLearnerDto dto = mapper.readValue( + "{\"user_id\":\"u1\",\"full_name\":\"Aadya Saxena\",\"package_session_id\":\"b1\"}", + EnrolledLearnerDto.class); + + assertThat(dto.getUserId()).isEqualTo("u1"); + assertThat(dto.getFullName()).isEqualTo("Aadya Saxena"); + assertThat(dto.getPackageSessionId()).isEqualTo("b1"); + } + + @Test + void alsoReadsCamelCaseSoANamingStrategyChangeCannotSilentlyBlankTheTab() throws Exception { + EnrolledLearnerDto dto = mapper.readValue( + "{\"userId\":\"u1\",\"fullName\":\"Aadya Saxena\",\"packageSessionId\":\"b1\"}", + EnrolledLearnerDto.class); + + assertThat(dto.getUserId()).isEqualTo("u1"); + assertThat(dto.getFullName()).isEqualTo("Aadya Saxena"); + assertThat(dto.getPackageSessionId()).isEqualTo("b1"); + } + + @Test + void ignoresUnknownFieldsSoAdminCoreCanAddColumnsWithoutBreakingThis() throws Exception { + EnrolledLearnerDto dto = mapper.readValue( + "{\"user_id\":\"u1\",\"full_name\":\"A\",\"package_session_id\":\"b1\",\"email\":\"a@b.c\"}", + EnrolledLearnerDto.class); + + assertThat(dto.getUserId()).isEqualTo("u1"); + } +} diff --git a/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/NotAttemptedParticipantsTest.java b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/NotAttemptedParticipantsTest.java new file mode 100644 index 0000000000..698b2eb805 --- /dev/null +++ b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/dto/batch_pending/NotAttemptedParticipantsTest.java @@ -0,0 +1,244 @@ +package vacademy.io.assessment_service.features.assessment.dto.batch_pending; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import vacademy.io.assessment_service.features.assessment.dto.ParticipantsDetailsDto; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The set arithmetic behind the Pending tab for batch-enrolled learners. + * + *

This decides who a teacher chases for a missing submission, so the two ways it can be + * wrong both matter: showing a learner who did submit (teacher chases someone who already + * sat the test) and hiding one who did not (submission silently never gets collected). + */ +class NotAttemptedParticipantsTest { + + private static final String BATCH = "batch-1"; + private static final Pageable FIRST_PAGE = PageRequest.of(0, 10); + + private static EnrolledLearnerDto learner(String userId, String name) { + return new EnrolledLearnerDto(userId, name, BATCH); + } + + private static List namesOf(Page page) { + return page.getContent().stream().map(ParticipantsDetailsDto::getStudentName).toList(); + } + + @Test + void keepsOnlyTheEnrolledLearnersWithNoAttempt() { + List enrolled = List.of( + learner("u1", "Aadya Saxena"), + learner("u2", "Akshat Sharma"), + learner("u3", "Amaan Saifi")); + + Page page = + NotAttemptedParticipants.page(enrolled, Set.of("u2"), null, FIRST_PAGE); + + assertThat(namesOf(page)).containsExactly("Aadya Saxena", "Amaan Saifi"); + assertThat(page.getTotalElements()).isEqualTo(2); + } + + @Test + void anAttemptedLearnerWhoIsNoLongerEnrolledDoesNotResurface() { + // Subtraction is one-directional on purpose: the enrolled set is the universe, so + // an attempted user id that is not enrolled any more simply has nothing to remove. + Page page = NotAttemptedParticipants.page( + List.of(learner("u1", "Aadya Saxena")), Set.of("ghost-user"), null, FIRST_PAGE); + + assertThat(namesOf(page)).containsExactly("Aadya Saxena"); + } + + @Test + void ordersByNameThenUserIdSoPagingIsStableAcrossNamesakes() { + // Two learners really can share a name; without the id tie-breaker a page boundary + // between them can repeat one and skip the other. + List enrolled = List.of( + learner("u9", "Amar"), + learner("u3", "Amar"), + learner("u1", "Aadya Saxena")); + + Page page = + NotAttemptedParticipants.page(enrolled, Set.of(), null, FIRST_PAGE); + + assertThat(page.getContent().stream().map(ParticipantsDetailsDto::getUserId)) + .containsExactly("u1", "u3", "u9"); + } + + @Test + void sortsCaseInsensitivelyToMatchThePostgresCollation() { + // The DB is en_US.UTF-8, so the attempted list reads "archa, MIDHUN, Rahana". + // A case-sensitive sort here would make this tab disagree with every other tab. + List enrolled = List.of( + learner("u1", "MIDHUN TK"), + learner("u2", "archa d p"), + learner("u3", "Rahana")); + + Page page = + NotAttemptedParticipants.page(enrolled, Set.of(), null, FIRST_PAGE); + + assertThat(namesOf(page)).containsExactly("archa d p", "MIDHUN TK", "Rahana"); + } + + @Test + void nameSearchIsACaseInsensitiveSubstringMatch() { + List enrolled = List.of( + learner("u1", "Aadya Saxena"), + learner("u2", "Akshat Sharma"), + learner("u3", "Amaan Saifi")); + + assertThat(namesOf(NotAttemptedParticipants.page(enrolled, Set.of(), "sha", FIRST_PAGE))) + .containsExactly("Akshat Sharma"); + assertThat(namesOf(NotAttemptedParticipants.page(enrolled, Set.of(), " AADYA ", FIRST_PAGE))) + .containsExactly("Aadya Saxena"); + assertThat(namesOf(NotAttemptedParticipants.page(enrolled, Set.of(), " ", FIRST_PAGE))) + .hasSize(3); + } + + @Test + void pagesWithoutRunningOffTheEndOfTheList() { + List enrolled = List.of( + learner("u1", "A"), learner("u2", "B"), learner("u3", "C")); + + Page second = + NotAttemptedParticipants.page(enrolled, Set.of(), null, PageRequest.of(1, 2)); + assertThat(namesOf(second)).containsExactly("C"); + assertThat(second.getTotalElements()).isEqualTo(3); + assertThat(second.getTotalPages()).isEqualTo(2); + + // A teacher sitting on a page that no longer exists (learners submitted while they + // were looking) must get an empty page, not an IndexOutOfBounds. + Page past = + NotAttemptedParticipants.page(enrolled, Set.of(), null, PageRequest.of(9, 10)); + assertThat(past.getContent()).isEmpty(); + assertThat(past.getTotalElements()).isEqualTo(3); + } + + @Test + void toleratesMissingNamesAndNullRowsRatherThanFailingThePage() { + List enrolled = java.util.Arrays.asList( + learner("u1", null), + null, + new EnrolledLearnerDto(null, "No user id", BATCH), + learner("u2", "Zoya")); + + Page page = + NotAttemptedParticipants.page(enrolled, Set.of(), null, FIRST_PAGE); + + assertThat(page.getContent()).hasSize(2); + assertThat(page.getContent().get(0).getUserId()).isEqualTo("u1"); + assertThat(page.getContent().get(1).getStudentName()).isEqualTo("Zoya"); + } + + @Test + void everyAttemptDerivedFieldIsNullBecauseTheseLearnersNeverStarted() { + // The Pending row mapping and both sidebars must read these as "never started", + // so the contract is explicit rather than incidental. + ParticipantsDetailsDto row = NotAttemptedParticipants + .page(List.of(learner("u1", "Aadya Saxena")), Set.of(), null, FIRST_PAGE) + .getContent() + .get(0); + + assertThat(row.getUserId()).isEqualTo("u1"); + assertThat(row.getStudentName()).isEqualTo("Aadya Saxena"); + assertThat(row.getBatchId()).isEqualTo(BATCH); + assertThat(row.getRegistrationId()).isNull(); + assertThat(row.getAttemptId()).isNull(); + assertThat(row.getScore()).isNull(); + assertThat(row.getAttemptDate()).isNull(); + assertThat(row.getEndTime()).isNull(); + assertThat(row.getDuration()).isNull(); + assertThat(row.getEvaluationStatus()).isNull(); + assertThat(row.getReportReleaseResultStatus()).isNull(); + assertThat(row.getLastReportReleaseDate()).isNull(); + } + + // --- contact details: what the tab needs to actually chase these learners --- + + @Test + void contactDetailsReachTheRowSoTheTabCanShowWhatItsOwnCsvShows() { + // The Pending tab and its "not attempted" export answer the same question; a + // learner the tab can only name, while the CSV gives an email and a phone number, + // sends the teacher to the export for every follow-up. + EnrolledLearnerDto learner = new EnrolledLearnerDto( + "u1", "Aadya Saxena", BATCH, "aadya@example.com", "9876543210", "aadya123"); + + ParticipantsDetailsDto row = NotAttemptedParticipants + .page(List.of(learner), Set.of(), null, FIRST_PAGE) + .getContent() + .get(0); + + assertThat(row.getUserEmail()).isEqualTo("aadya@example.com"); + assertThat(row.getPhoneNumber()).isEqualTo("9876543210"); + assertThat(row.getUsername()).isEqualTo("aadya123"); + } + + @Test + void aLearnerImportedWithoutContactDetailsStillAppearsWithBlanksRatherThanBeingDropped() { + ParticipantsDetailsDto row = NotAttemptedParticipants + .page(List.of(learner("u1", "Aadya Saxena")), Set.of(), null, FIRST_PAGE) + .getContent() + .get(0); + + assertThat(row.getStudentName()).isEqualTo("Aadya Saxena"); + assertThat(row.getUserEmail()).isNull(); + assertThat(row.getPhoneNumber()).isNull(); + assertThat(row.getUsername()).isNull(); + } + + // --- resolveBatchIds: which batches we are even allowed to look at --- + + @Test + void aFilterChipForABatchThisAssessmentWasNeverAssignedToIsIgnored() { + // The admin batch filter is built from every batch in the INSTITUTE, so a teacher + // can select one this assessment was never given to. Without the intersection its + // learners would all be listed as "has not attempted" and chased for an exam they + // were never set. + List assigned = List.of("assigned-a", "assigned-b"); + + assertThat(NotAttemptedParticipants.resolveBatchIds(assigned, List.of("foreign-batch"))) + .isEmpty(); + assertThat(NotAttemptedParticipants.resolveBatchIds(assigned, List.of("assigned-b", "foreign-batch"))) + .containsExactly("assigned-b"); + } + + @Test + void noFilterChipsMeansEveryAssignedBatch() { + List assigned = List.of("assigned-b", "assigned-a"); + + assertThat(NotAttemptedParticipants.resolveBatchIds(assigned, null)) + .containsExactly("assigned-a", "assigned-b"); + assertThat(NotAttemptedParticipants.resolveBatchIds(assigned, List.of())) + .containsExactly("assigned-a", "assigned-b"); + } + + @Test + void anAssessmentWithNoAssignedBatchesResolvesToNothing() { + // No batch registrations means nobody was set this test, so there is nobody to + // chase — and the caller must not make a cross-service call. + assertThat(NotAttemptedParticipants.resolveBatchIds(List.of(), List.of("anything"))).isEmpty(); + assertThat(NotAttemptedParticipants.resolveBatchIds(null, null)).isEmpty(); + } + + @Test + void resolvedBatchIdsAreDedupedAndSortedSoCallersShareACacheEntry() { + assertThat(NotAttemptedParticipants.resolveBatchIds( + java.util.Arrays.asList("b", "a", "b", null), null)) + .containsExactly("a", "b"); + // Same set, different chip order -> identical key. + assertThat(NotAttemptedParticipants.resolveBatchIds(List.of("a", "b"), List.of("b", "a"))) + .isEqualTo(NotAttemptedParticipants.resolveBatchIds(List.of("a", "b"), List.of("a", "b"))); + } + + @Test + void emptyEnrollmentYieldsAnEmptyPageNotAnError() { + assertThat(NotAttemptedParticipants.page(List.of(), Set.of("u1"), null, FIRST_PAGE).getContent()).isEmpty(); + assertThat(NotAttemptedParticipants.page(null, null, null, FIRST_PAGE).getContent()).isEmpty(); + } +} diff --git a/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/manager/AdminExportManagerNotAttemptedCsvTest.java b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/manager/AdminExportManagerNotAttemptedCsvTest.java new file mode 100644 index 0000000000..0ac79ceb06 --- /dev/null +++ b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/manager/AdminExportManagerNotAttemptedCsvTest.java @@ -0,0 +1,154 @@ +package vacademy.io.assessment_service.features.assessment.manager; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import vacademy.io.assessment_service.features.assessment.dto.AssessmentUserFilter; +import vacademy.io.assessment_service.features.assessment.dto.batch_pending.EnrolledLearnerDto; +import vacademy.io.assessment_service.features.assessment.dto.export.ResultExportColumnsDto; +import vacademy.io.assessment_service.features.assessment.enums.UserRegistrationSources; +import vacademy.io.assessment_service.features.assessment.repository.AssessmentInstituteMappingRepository; +import vacademy.io.assessment_service.features.assessment.service.batch_pending.NotAttemptedLearnerService; +import vacademy.io.assessment_service.features.client.AdminCoreServiceClient; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * The "not attempted" CSV — the sheet an admin uses to chase learners who never sat the + * test, so the contact columns are the whole point of it. + */ +class AdminExportManagerNotAttemptedCsvTest { + + private static final String ASSESSMENT_ID = "assessment-1"; + private static final String INSTITUTE_ID = "institute-1"; + private static final String HEADER = "Name,Email,Phone Number,Username,Batch"; + + private AdminExportManager manager; + private NotAttemptedLearnerService notAttemptedLearnerService; + private AdminCoreServiceClient adminCoreServiceClient; + + @BeforeEach + void setUp() { + manager = new AdminExportManager(); + notAttemptedLearnerService = mock(NotAttemptedLearnerService.class); + adminCoreServiceClient = mock(AdminCoreServiceClient.class); + AssessmentInstituteMappingRepository instituteMappingRepository = + mock(AssessmentInstituteMappingRepository.class); + + manager.notAttemptedLearnerService = notAttemptedLearnerService; + manager.adminCoreServiceClient = adminCoreServiceClient; + manager.assessmentInstituteMappingRepository = instituteMappingRepository; + } + + /** The filter the Pending tab sends: batch source, PENDING attempt type. */ + private static AssessmentUserFilter pendingBatchFilter() { + AssessmentUserFilter filter = new AssessmentUserFilter(); + filter.setAttemptType(List.of("PENDING")); + filter.setRegistrationSource(UserRegistrationSources.BATCH_PREVIEW_REGISTRATION.name()); + return filter; + } + + private String[] exportLines(AssessmentUserFilter filter) { + ResponseEntity response = + manager.getRegisteredCsvExport(null, INSTITUTE_ID, ASSESSMENT_ID, filter); + assertThat(response.getBody()).isNotNull(); + return new String(response.getBody(), StandardCharsets.UTF_8).split("\n"); + } + + @Test + void carriesTheContactDetailsNeededToChaseALearner() { + when(notAttemptedLearnerService.findNotAttempted(eq(ASSESSMENT_ID), eq(INSTITUTE_ID), any())) + .thenReturn(List.of(new EnrolledLearnerDto( + "u1", "Aadya Saxena", "batch-1", "aadya@example.com", "+919999999999", "aadya01"))); + when(adminCoreServiceClient.getBatchNames(anyList())).thenReturn(Map.of("batch-1", "Class 10 A")); + + String[] lines = exportLines(pendingBatchFilter()); + + assertThat(lines[0]).isEqualTo(HEADER); + assertThat(lines[1]).isEqualTo("Aadya Saxena,aadya@example.com,+919999999999,aadya01,Class 10 A"); + } + + @Test + void omitsMarksAndRankBecauseTheseLearnersNeverStarted() { + when(notAttemptedLearnerService.findNotAttempted(eq(ASSESSMENT_ID), eq(INSTITUTE_ID), any())) + .thenReturn(List.of()); + when(adminCoreServiceClient.getBatchNames(anyList())).thenReturn(Map.of()); + + // A zero in a Marks column would read as "sat the test and scored nothing". + assertThat(exportLines(pendingBatchFilter())[0]) + .doesNotContain("Marks Obtained") + .doesNotContain("Rank") + .doesNotContain("Percentage"); + } + + @Test + void stillReturnsTheHeaderWhenEveryoneAttempted() { + when(notAttemptedLearnerService.findNotAttempted(eq(ASSESSMENT_ID), eq(INSTITUTE_ID), any())) + .thenReturn(List.of()); + when(adminCoreServiceClient.getBatchNames(anyList())).thenReturn(Map.of()); + + // Headers alone say "nobody is pending"; an empty file looks like a failed export. + String[] lines = exportLines(pendingBatchFilter()); + + assertThat(lines).hasSize(1); + assertThat(lines[0]).isEqualTo(HEADER); + } + + @Test + void rendersMissingContactDetailsAsEmptyCellsRatherThanDroppingTheLearner() { + // A learner imported without an email or phone still has to appear — they are + // precisely the one the admin needs to notice they cannot reach. + when(notAttemptedLearnerService.findNotAttempted(eq(ASSESSMENT_ID), eq(INSTITUTE_ID), any())) + .thenReturn(List.of(new EnrolledLearnerDto("u1", "Aadya Saxena", null))); + when(adminCoreServiceClient.getBatchNames(anyList())).thenReturn(Map.of()); + + String[] lines = exportLines(pendingBatchFilter()); + + assertThat(lines).hasSize(2); + assertThat(lines[1]).isEqualTo("Aadya Saxena,,,,"); + } + + @Test + void fallsBackToTheBatchIdWhenItsNameCannotBeResolved() { + // admin_core unreachable returns an empty map; an id beats a blank cell. + when(notAttemptedLearnerService.findNotAttempted(eq(ASSESSMENT_ID), eq(INSTITUTE_ID), any())) + .thenReturn(List.of(new EnrolledLearnerDto("u1", "Aadya Saxena", "batch-1"))); + when(adminCoreServiceClient.getBatchNames(anyList())).thenReturn(Map.of()); + + assertThat(exportLines(pendingBatchFilter())[1]).endsWith(",batch-1"); + } + + @Test + void escapesACommaInALearnerNameSoTheRowKeepsItsColumns() { + when(notAttemptedLearnerService.findNotAttempted(eq(ASSESSMENT_ID), eq(INSTITUTE_ID), any())) + .thenReturn(List.of(new EnrolledLearnerDto( + "u1", "Saxena, Aadya", "batch-1", "a@b.c", "+91", "aadya01"))); + when(adminCoreServiceClient.getBatchNames(anyList())).thenReturn(Map.of("batch-1", "Class 10, A")); + + String[] lines = exportLines(pendingBatchFilter()); + + assertThat(lines[1]).isEqualTo("\"Saxena, Aadya\",a@b.c,+91,aadya01,\"Class 10, A\""); + } + + @Test + void theColumnPickerOffersTheContactColumnsAndNoRegistrationFields() { + ResultExportColumnsDto columns = + manager.getResultExportColumns(null, INSTITUTE_ID, ASSESSMENT_ID, true).getBody(); + + assertThat(columns).isNotNull(); + assertThat(columns.getBaseColumns()) + .containsExactly("Name", "Email", "Phone Number", "Username", "Batch"); + // A never-attempted learner has no registration row, so every form answer would be + // blank — offering them would be a tick-list of empty columns. + assertThat(columns.getCustomFields()).isEmpty(); + } +} diff --git a/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/manager/AdminExportManagerResultCsvTest.java b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/manager/AdminExportManagerResultCsvTest.java index 36acd13ce6..27f59d9113 100644 --- a/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/manager/AdminExportManagerResultCsvTest.java +++ b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/manager/AdminExportManagerResultCsvTest.java @@ -4,7 +4,7 @@ import org.junit.jupiter.api.Test; import org.springframework.http.ResponseEntity; import vacademy.io.assessment_service.features.assessment.dto.AssessmentUserFilter; -import vacademy.io.assessment_service.features.assessment.dto.ParticipantsDetailsDto; +import vacademy.io.assessment_service.features.assessment.dto.export.ResultExportRowDto; import vacademy.io.assessment_service.features.assessment.dto.RegistrationCustomFieldAnswerDto; import vacademy.io.assessment_service.features.assessment.dto.export.ResultExportColumnsDto; import vacademy.io.assessment_service.features.assessment.entity.AssessmentCustomField; @@ -57,7 +57,14 @@ void setUp() { SectionRepository sectionRepository = mock(SectionRepository.class); instituteMappingRepository = mock(AssessmentInstituteMappingRepository.class); - + // Batch names come from admin_core. Stubbed empty so these tests stay about the + // sheet's shape; the Batch cell then falls back to the (unstubbed, null) batch id + // and must render as empty rather than "null". + var adminCoreServiceClient = + mock(vacademy.io.assessment_service.features.client.AdminCoreServiceClient.class); + when(adminCoreServiceClient.getBatchNames(anyList())).thenReturn(java.util.Map.of()); + + manager.adminCoreServiceClient = adminCoreServiceClient; manager.assessmentUserRegistrationRepository = registrationRepository; manager.assessmentCustomFieldRepository = customFieldRepository; manager.sectionRepository = sectionRepository; @@ -79,7 +86,7 @@ void setUp() { // Built before the stubbing calls — these are mocks themselves, and // Mockito rejects a mock being stubbed inside an unfinished when(...). - List participants = List.of( + List participants = List.of( participant("reg-1", "Anand M", "anand@example.com", 80.0, 600L), participant("reg-2", "Adithya M", "adithya@example.com", 60.0, 700L)); List answers = List.of( @@ -98,10 +105,12 @@ void setUp() { void exportsEveryRegistrationFieldWhenNoSelectionIsSent() { String[] lines = exportLines(filterWithCustomFieldIds(null)); - // "Email" clashes with the result column, so its form twin is disambiguated. + // "Email" and "Phone Number" both clash with a result column, so their form twins + // are disambiguated. A CSV cannot carry two identically named headers, and the + // suffix is what tells the reader which value came off the registration form. assertThat(lines[0]).isEqualTo( - "Name,Email,Marks Obtained,Total Marks,Percentage,Rank,Duration,Attempt Date," - + "Phone Number,Email (Form),College Name"); + "Name,Email,Phone Number,Username,Batch,Marks Obtained,Total Marks,Percentage,Rank,Duration,Attempt Date," + + "Phone Number (Form),Email (Form),College Name"); assertThat(lines[1]).contains("+919999999999,anand.form@example.com,\"GEC, Kozhikode\""); } @@ -127,7 +136,7 @@ void untickingEveryFieldLeavesTheResultColumnsAlone() { String[] lines = exportLines(filterWithCustomFieldIds(List.of())); assertThat(lines[0]).isEqualTo( - "Name,Email,Marks Obtained,Total Marks,Percentage,Rank,Duration,Attempt Date"); + "Name,Email,Phone Number,Username,Batch,Marks Obtained,Total Marks,Percentage,Rank,Duration,Attempt Date"); } @Test @@ -138,7 +147,7 @@ void emptyAssessmentStillReturnsTheFullHeaderRow() { String[] lines = exportLines(filterWithCustomFieldIds(null)); assertThat(lines).hasSize(1); - assertThat(lines[0]).endsWith("Phone Number,Email (Form),College Name"); + assertThat(lines[0]).endsWith("Phone Number (Form),Email (Form),College Name"); } @Test @@ -168,7 +177,7 @@ void exportsLegacyAssessmentsThatHaveNoInstituteMappingRow() { .thenReturn(Optional.empty()); ResultExportColumnsDto columns = - manager.getResultExportColumns(null, INSTITUTE_ID, ASSESSMENT_ID).getBody(); + manager.getResultExportColumns(null, INSTITUTE_ID, ASSESSMENT_ID, false).getBody(); assertThat(columns).isNotNull(); assertThat(columns.getCustomFields()).hasSize(3); @@ -181,20 +190,21 @@ void rejectsAnAssessmentMappedToAnotherInstitute() { when(instituteMappingRepository.findTopByAssessmentId(ASSESSMENT_ID)) .thenReturn(Optional.of(new AssessmentInstituteMapping())); - assertThatThrownBy(() -> manager.getResultExportColumns(null, INSTITUTE_ID, ASSESSMENT_ID)) + assertThatThrownBy(() -> manager.getResultExportColumns(null, INSTITUTE_ID, ASSESSMENT_ID, false)) .isInstanceOf(VacademyException.class); } @Test void columnListMatchesTheHeadersTheCsvWillProduce() { ResultExportColumnsDto columns = - manager.getResultExportColumns(null, INSTITUTE_ID, ASSESSMENT_ID).getBody(); + manager.getResultExportColumns(null, INSTITUTE_ID, ASSESSMENT_ID, false).getBody(); assertThat(columns).isNotNull(); - assertThat(columns.getBaseColumns()).startsWith("Name", "Email"); + assertThat(columns.getBaseColumns()) + .startsWith("Name", "Email", "Phone Number", "Username", "Batch"); assertThat(columns.getCustomFields()) .extracting(ResultExportColumnsDto.CustomFieldColumn::getColumnLabel) - .containsExactly("Phone Number", "Email (Form)", "College Name"); + .containsExactly("Phone Number (Form)", "Email (Form)", "College Name"); } private String[] exportLines(AssessmentUserFilter filter) { @@ -227,9 +237,11 @@ private AssessmentCustomField customField(String id, String name, int order) { .build(); } - private ParticipantsDetailsDto participant(String registrationId, String name, String email, - Double score, Long duration) { - ParticipantsDetailsDto dto = mock(ParticipantsDetailsDto.class); + private ResultExportRowDto participant(String registrationId, String name, String email, + Double score, Long duration) { + // Phone / username / batch are left unstubbed (null) here on purpose: the sheet must + // render an empty cell for a learner missing them, not drop the row or print "null". + ResultExportRowDto dto = mock(ResultExportRowDto.class); when(dto.getRegistrationId()).thenReturn(registrationId); when(dto.getStudentName()).thenReturn(name); when(dto.getUserEmail()).thenReturn(email); diff --git a/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/manager/AssessmentListOrderingTest.java b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/manager/AssessmentListOrderingTest.java new file mode 100644 index 0000000000..afa52abe94 --- /dev/null +++ b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/manager/AssessmentListOrderingTest.java @@ -0,0 +1,103 @@ +package vacademy.io.assessment_service.features.assessment.manager; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.query.QueryUtils; +import vacademy.io.assessment_service.features.assessment.dto.admin_get_dto.AdminAssessmentFilter; +import vacademy.io.assessment_service.features.assessment.repository.AssessmentRepository; +import vacademy.io.assessment_service.features.assessment.sort.StableSort; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The Live / Upcoming / Past / Draft list must order by when the exam RUNS + * ({@code bound_start_time}), not by when it was created. + * + *

The admin list sends no sort_columns, which used to leave the Pageable unsorted; the + * query carries no ORDER BY of its own, so Postgres returned heap order — indistinguishable + * from creation order on this table, and wrong the moment a paper is set up in advance. + */ +class AssessmentListOrderingTest { + + private static final String TIE_BREAKER = "id"; + + private static Sort defaultSortFor(boolean upcoming) { + AdminAssessmentFilter filter = new AdminAssessmentFilter(); + filter.setGetUpcomingAssessments(upcoming); + return invokeDefaultSort(filter); + } + + /** Mirrors AdminAssessmentGetManager.defaultAssessmentListSort (private). */ + private static Sort invokeDefaultSort(AdminAssessmentFilter filter) { + return Boolean.TRUE.equals(filter.getGetUpcomingAssessments()) + ? Sort.by(Sort.Order.asc("bound_start_time")) + : Sort.by(Sort.Order.desc("bound_start_time")); + } + + private static String declaredQuery(String methodName) { + List matches = Arrays.stream(AssessmentRepository.class.getMethods()) + .filter(m -> m.getName().equals(methodName)) + .filter(m -> m.getAnnotation(Query.class) != null) + .toList(); + assertThat(matches).as("exactly one @Query named %s", methodName).hasSize(1); + return matches.get(0).getAnnotation(Query.class).value(); + } + + private static String orderByClause(Sort sort) { + String query = declaredQuery("filterAssessments"); + String sorted = QueryUtils.applySorting(query, sort, QueryUtils.detectAlias(query)); + int at = sorted.toLowerCase().lastIndexOf("order by"); + assertThat(at).as("an ORDER BY must be appended").isNotNegative(); + return sorted.substring(at).replaceAll("\\s+", " ").trim(); + } + + @Test + void upcomingShowsTheSoonestExamFirst() { + Sort sort = StableSort.withStableOrder(Map.of(), defaultSortFor(true), TIE_BREAKER); + + assertThat(orderByClause(sort)).isEqualTo("order by a.bound_start_time asc, a.id asc"); + } + + @Test + void liveAndPastAndDraftShowTheMostRecentExamFirst() { + Sort sort = StableSort.withStableOrder(Map.of(), defaultSortFor(false), TIE_BREAKER); + + assertThat(orderByClause(sort)).isEqualTo("order by a.bound_start_time desc, a.id asc"); + } + + @Test + void neverOrdersByCreatedAt() { + // The whole point of the change: creation time must not decide list position. + for (boolean upcoming : new boolean[] { true, false }) { + String clause = orderByClause( + StableSort.withStableOrder(Map.of(), defaultSortFor(upcoming), TIE_BREAKER)); + assertThat(clause).doesNotContain("created_at"); + } + } + + @Test + void sortPropertiesResolveToTheAssessmentTableAndNotADoubledAlias() { + // Spring Data prefixes an unrecognised sort property with the detected alias. These + // are bare column names so they become "a.

"; passing "a.id" would have + // produced the invalid "a.a.id". + assertThat(QueryUtils.detectAlias(declaredQuery("filterAssessments"))).isEqualTo("a"); + + String clause = orderByClause( + StableSort.withStableOrder(Map.of(), defaultSortFor(false), TIE_BREAKER)); + assertThat(clause).doesNotContain("a.a."); + } + + @Test + void anExplicitSortFromTheClientStillWinsAndKeepsTheTieBreaker() { + Sort sort = StableSort.withStableOrder( + Map.of("name", "ASC"), defaultSortFor(false), TIE_BREAKER); + + assertThat(orderByClause(sort)).isEqualTo("order by a.name asc, a.id asc"); + } +} diff --git a/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/repository/ParticipantListOrderingTest.java b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/repository/ParticipantListOrderingTest.java new file mode 100644 index 0000000000..03237be524 --- /dev/null +++ b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/repository/ParticipantListOrderingTest.java @@ -0,0 +1,148 @@ +package vacademy.io.assessment_service.features.assessment.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.query.QueryUtils; +import vacademy.io.assessment_service.features.assessment.sort.StableSort; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Guards the ORDER BY that Spring Data appends to the paged participant / submission / + * respondent queries. + * + * These are native queries with no ORDER BY of their own, so the sort has to arrive + * through the Pageable — a hardcoded trailing ORDER BY would become the primary key and + * silently demote whichever column the teacher clicked. + * + * The catch is that Spring Data appends the sort by string manipulation: a property it + * recognises as a SELECT alias is emitted bare, and anything else is prefixed with the + * detected table alias ("aur."). Its alias scanner only spots an alias that follows a + * comma AND whitespace, so writing "select aur.id as registrationId,sa.id as attemptId" + * instead of ", sa.id as attemptId" hides attemptId, produces "order by aur.attemptId", + * and 500s the submissions list. This test fails on that reformat, instead of prod. + */ +class ParticipantListOrderingTest { + + // Mirrors AssessmentParticipantsManager.createSortObject. + private static final Sort PARTICIPANT_DEFAULT = Sort.by(Sort.Order.asc("studentName")); + // Mirrors AssessmentParticipantsManager.getRespondentList. + private static final Sort RESPONDENT_DEFAULT = Sort.by(Sort.Order.asc("participantName")); + private static final String[] TIE_BREAKERS = { "registrationId", "attemptId" }; + + private static final List PARTICIPANT_QUERIES = List.of( + "findUserRegistrationWithFilterForBatch", + "findUserRegistrationWithFilterWithSearchForBatch", + "findUserRegistrationWithFilterForSource", + "findUserRegistrationWithFilterWithSearchForSource", + "findUserRegistrationWithFilterAdminPreRegistrationAndPending", + "findUserRegistrationWithFilterWithSearchForPreRegistrationAndPending"); + + private static final List RESPONDENT_QUERIES = List.of( + "findRespondentListForAssessmentWithFilter", + "findRespondentListForAssessmentWithFilterAndSearch"); + + private static String declaredQuery(String methodName) { + return declaredQuery(AssessmentUserRegistrationRepository.class, methodName); + } + + private static String declaredQuery(Class repository, String methodName) { + List matches = Arrays.stream(repository.getMethods()) + .filter(m -> m.getName().equals(methodName)) + .filter(m -> m.getAnnotation(Query.class) != null) + .toList(); + assertThat(matches).as("exactly one @Query method named %s", methodName).hasSize(1); + return matches.get(0).getAnnotation(Query.class).value(); + } + + /** The ORDER BY that Spring Data will actually append to this query. */ + private static String orderByClause(String methodName, Sort sort) { + return orderByClause(AssessmentUserRegistrationRepository.class, methodName, sort); + } + + private static String orderByClause(Class repository, String methodName, Sort sort) { + String query = declaredQuery(repository, methodName); + String sorted = QueryUtils.applySorting(query, sort, QueryUtils.detectAlias(query)); + int at = sorted.toLowerCase().lastIndexOf("order by"); + assertThat(at).as("%s must get an ORDER BY appended", methodName).isNotNegative(); + return sorted.substring(at).replaceAll("\\s+", " ").trim(); + } + + @Test + void everyPagedListQueryGetsATotallyOrderedOrderByWithResolvableAliases() { + Sort participant = StableSort.withStableOrder(Map.of(), PARTICIPANT_DEFAULT, TIE_BREAKERS); + Sort respondent = StableSort.withStableOrder(Map.of(), RESPONDENT_DEFAULT, TIE_BREAKERS); + + // A table-qualified property here means Spring Data did NOT recognise the SELECT + // alias, so the generated SQL references a column that does not exist. + List unresolved = new ArrayList<>(); + + for (String method : PARTICIPANT_QUERIES) { + String clause = orderByClause(method, participant); + if (clause.contains("aur.")) + unresolved.add(method + " -> " + clause); + assertThat(clause).as("%s default order", method) + .isEqualTo("order by studentName asc, registrationId asc, attemptId asc"); + } + for (String method : RESPONDENT_QUERIES) { + String clause = orderByClause(method, respondent); + if (clause.contains("aur.")) + unresolved.add(method + " -> " + clause); + assertThat(clause).as("%s default order", method) + .isEqualTo("order by participantName asc, registrationId asc, attemptId asc"); + } + + assertThat(unresolved) + .as("sort properties that failed to resolve to a SELECT alias — " + + "check for a missing space after a comma in the SELECT list") + .isEmpty(); + } + + @Test + void aTeacherClickedSortStaysPrimaryAndKeepsItsTieBreakers() { + Sort clicked = StableSort.withStableOrder(Map.of("score", "DESC"), PARTICIPANT_DEFAULT, TIE_BREAKERS); + + for (String method : PARTICIPANT_QUERIES) { + assertThat(orderByClause(method, clicked)).as("%s clicked order", method) + .isEqualTo("order by score desc, registrationId asc, attemptId asc"); + } + } + + @Test + void theEvaluatorsAssignedAttemptQueueAlsoResolvesItsAliases() { + // Lives in StudentAttemptRepository, not the participant repository, and selects + // participantName/attemptId rather than studentName/registrationId — so it needs + // its own check. If either alias went unrecognised Spring Data would emit + // "order by sa.participantName" and 500 the evaluator's queue. + Sort sort = StableSort.withStableOrder(Map.of(), + Sort.by(Sort.Order.asc("participantName")), "attemptId"); + + String clause = orderByClause( + vacademy.io.assessment_service.features.assessment.repository.StudentAttemptRepository.class, + "findAllAssignedAttemptForUserIdWithFilter", sort); + + assertThat(clause).isEqualTo("order by participantName asc, attemptId asc"); + assertThat(clause).doesNotContain("sa."); + } + + @Test + void nonPagedExportQueriesCarryTheirOwnOrderBy() { + // These take no Pageable, so nothing appends an order for them — theirs is inline. + for (String method : List.of( + "findUserRegistrationWithFilterForBatchForExport", + "findUserRegistrationWithFilterForSourceExport", + "findUserRegistrationWithFilterAdminPreRegistrationAndPendingExport", + "findRespondentListForAssessmentWithFilterExport")) { + assertThat(declaredQuery(method).toLowerCase()) + .as("%s must be deterministically ordered", method) + .contains("order by aur.participant_name asc, aur.id asc"); + } + } +} diff --git a/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/service/QuestionBasedStrategyFactoryTest.java b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/service/QuestionBasedStrategyFactoryTest.java new file mode 100644 index 0000000000..3786f92812 --- /dev/null +++ b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/service/QuestionBasedStrategyFactoryTest.java @@ -0,0 +1,127 @@ +package vacademy.io.assessment_service.features.assessment.service; + +import org.junit.jupiter.api.Test; +import vacademy.io.assessment_service.features.assessment.dto.QuestionWiseBasicDetailDto; +import vacademy.io.assessment_service.features.assessment.enums.QuestionResponseEnum; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The factory hands out marking strategies, and grading runs on an @Async pool with many + * learners in flight at once. + * + * It used to hold ONE shared instance per question type in a static map. + * {@link IQuestionTypeBasedStrategy} keeps `type` and `answerStatus` as mutable instance + * fields, and {@code calculateMarks} reads {@code getAnswerStatus()} AFTER the marks call + * returns -- so between those two statements another thread could overwrite the status. + * The result was learner A's question being persisted with learner B's CORRECT/INCORRECT + * status in question_wise_marks: silent, non-deterministic, and corrupting to every + * report and status-based revaluation downstream. + * + * The fix hands out a fresh instance per call. These tests pin both halves of that: + * the arithmetic is unchanged, and concurrent grading no longer crosses answers. + */ +class QuestionBasedStrategyFactoryTest { + + private static final String MCQS_MARKING = + "{\"type\":\"MCQS\",\"data\":{\"totalMark\":4,\"negativeMark\":1,\"negativeMarkingPercentage\":100}}"; + + private static final String MCQS_CORRECT_A = + "{\"type\":\"MCQS\",\"data\":{\"correctOptionIds\":[\"a\"]}}"; + + private static String mcqsResponse(String optionId) { + return "{\"responseData\":{\"type\":\"MCQS\",\"optionIds\":[\"" + optionId + "\"]}}"; + } + + @Test + void gradesACorrectAnswer() { + QuestionWiseBasicDetailDto result = + QuestionBasedStrategyFactory.calculateMarks( + MCQS_MARKING, MCQS_CORRECT_A, mcqsResponse("a"), "MCQS"); + + assertThat(result.getMarks()).isEqualTo(4.0); + assertThat(result.getAnswerStatus()).isEqualTo(QuestionResponseEnum.CORRECT.name()); + } + + @Test + void gradesAnIncorrectAnswer() { + QuestionWiseBasicDetailDto result = + QuestionBasedStrategyFactory.calculateMarks( + MCQS_MARKING, MCQS_CORRECT_A, mcqsResponse("b"), "MCQS"); + + assertThat(result.getMarks()).isEqualTo(-1.0); + assertThat(result.getAnswerStatus()).isEqualTo(QuestionResponseEnum.INCORRECT.name()); + } + + @Test + void eachCallGetsItsOwnStrategyInstance() throws Exception { + // The structural guarantee behind the concurrency fix: if two lookups returned + // the same object, one thread's status could overwrite another's. + assertThat(QuestionBasedStrategyFactory.verifyMarkingJson(MCQS_MARKING, "MCQS")) + .isNotSameAs(QuestionBasedStrategyFactory.verifyMarkingJson(MCQS_MARKING, "MCQS")); + } + + @Test + void concurrentGradingNeverCrossesAnswerStatuses() throws Exception { + // Half the tasks grade a correct answer, half an incorrect one, interleaved on a + // pool. Every result must match the answer THAT task submitted. + final int taskCount = 400; + ExecutorService pool = Executors.newFixedThreadPool(16); + try { + List> tasks = new ArrayList<>(); + for (int i = 0; i < taskCount; i++) { + final boolean shouldBeCorrect = (i % 2 == 0); + tasks.add(() -> { + QuestionWiseBasicDetailDto result = + QuestionBasedStrategyFactory.calculateMarks( + MCQS_MARKING, + MCQS_CORRECT_A, + mcqsResponse(shouldBeCorrect ? "a" : "b"), + "MCQS"); + + String expectedStatus = shouldBeCorrect + ? QuestionResponseEnum.CORRECT.name() + : QuestionResponseEnum.INCORRECT.name(); + double expectedMarks = shouldBeCorrect ? 4.0 : -1.0; + + return expectedStatus.equals(result.getAnswerStatus()) + && expectedMarks == result.getMarks(); + }); + } + + List> futures = pool.invokeAll(tasks, 60, TimeUnit.SECONDS); + + int mismatches = 0; + for (Future future : futures) { + if (!future.get()) mismatches++; + } + assertThat(mismatches) + .as("gradings whose marks/status did not match their own submitted answer") + .isZero(); + } finally { + pool.shutdownNow(); + } + } + + @Test + void unknownQuestionType_failsLoudlyRatherThanNullPointer() { + // getStrategy returned null for an unrecognised type and several call sites + // dereferenced it straight away. + try { + QuestionBasedStrategyFactory.getResponseOptionIds(mcqsResponse("a"), "NOT_A_TYPE"); + assertThat(false).as("expected an IllegalArgumentException").isTrue(); + } catch (IllegalArgumentException expected) { + assertThat(expected).hasMessageContaining("NOT_A_TYPE"); + } catch (Exception other) { + assertThat(other).as("expected IllegalArgumentException, got %s", other).isNull(); + } + } +} diff --git a/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/service/marking_strategy/MCQMQuestionTypeBasedStrategyTest.java b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/service/marking_strategy/MCQMQuestionTypeBasedStrategyTest.java new file mode 100644 index 0000000000..959ce0b57b --- /dev/null +++ b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/service/marking_strategy/MCQMQuestionTypeBasedStrategyTest.java @@ -0,0 +1,104 @@ +package vacademy.io.assessment_service.features.assessment.service.marking_strategy; + +import org.junit.jupiter.api.Test; +import vacademy.io.assessment_service.features.assessment.enums.QuestionResponseEnum; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Marking rules for multiple-correct MCQs. + * + * The case that matters most here is the FIRST one. The full-credit check used to be + * `attemptedOptionIds.equals(correctOptionIds)` -- List.equals, which is order-sensitive. + * A learner who ticked exactly the right options but in a different order than the answer + * key happened to be stored in missed full credit, fell through to the partial branch, + * and (with partialMarking == 0) was then given FULL NEGATIVE MARKS for a completely + * correct answer. These tests pin the set-comparison behaviour so it cannot regress. + */ +class MCQMQuestionTypeBasedStrategyTest { + + private final MCQMQuestionTypeBasedStrategy strategy = new MCQMQuestionTypeBasedStrategy(); + + /** 4 marks, 1 negative, no partial credit. */ + private static final String MARKING_NO_PARTIAL = + "{\"type\":\"MCQM\",\"data\":{\"totalMark\":4,\"negativeMark\":1," + + "\"negativeMarkingPercentage\":100,\"partialMarking\":0,\"partialMarkingPercentage\":0}}"; + + /** 4 marks, 1 negative, partial credit enabled at 100% of the pro-rata value. */ + private static final String MARKING_WITH_PARTIAL = + "{\"type\":\"MCQM\",\"data\":{\"totalMark\":4,\"negativeMark\":1," + + "\"negativeMarkingPercentage\":100,\"partialMarking\":1,\"partialMarkingPercentage\":100}}"; + + private static final String CORRECT_A_B = + "{\"type\":\"MCQM\",\"data\":{\"correctOptionIds\":[\"a\",\"b\"]}}"; + + private String response(String... optionIds) { + StringBuilder ids = new StringBuilder(); + for (int i = 0; i < optionIds.length; i++) { + if (i > 0) ids.append(","); + ids.append("\"").append(optionIds[i]).append("\""); + } + return "{\"responseData\":{\"type\":\"MCQM\",\"optionIds\":[" + ids + "]}}"; + } + + @Test + void correctOptionsInADifferentOrder_stillAwardsFullMarks() { + // The regression this whole test class exists for. + double marks = strategy.calculateMarks(MARKING_NO_PARTIAL, CORRECT_A_B, response("b", "a")); + + assertThat(marks).isEqualTo(4.0); + assertThat(strategy.getAnswerStatus()).isEqualTo(QuestionResponseEnum.CORRECT.name()); + } + + @Test + void correctOptionsInKeyOrder_awardsFullMarks() { + double marks = strategy.calculateMarks(MARKING_NO_PARTIAL, CORRECT_A_B, response("a", "b")); + + assertThat(marks).isEqualTo(4.0); + assertThat(strategy.getAnswerStatus()).isEqualTo(QuestionResponseEnum.CORRECT.name()); + } + + @Test + void duplicateSelectionOfTheSameCorrectOption_isStillFullMarks() { + // Set comparison also makes the scorer robust to a client that repeats an id. + double marks = strategy.calculateMarks(MARKING_NO_PARTIAL, CORRECT_A_B, response("b", "a", "b")); + + assertThat(marks).isEqualTo(4.0); + assertThat(strategy.getAnswerStatus()).isEqualTo(QuestionResponseEnum.CORRECT.name()); + } + + @Test + void unattempted_scoresZeroAndStaysPending() { + double marks = strategy.calculateMarks(MARKING_NO_PARTIAL, CORRECT_A_B, response()); + + assertThat(marks).isEqualTo(0.0); + assertThat(strategy.getAnswerStatus()).isEqualTo(QuestionResponseEnum.PENDING.name()); + } + + @Test + void wrongOptionSelected_appliesNegativeMarking() { + double marks = strategy.calculateMarks(MARKING_NO_PARTIAL, CORRECT_A_B, response("a", "c")); + + assertThat(marks).isEqualTo(-1.0); + assertThat(strategy.getAnswerStatus()).isEqualTo(QuestionResponseEnum.INCORRECT.name()); + } + + @Test + void subsetOfCorrectOptions_withPartialEnabled_awardsProRata() { + // 1 of 2 correct options, no wrong ones -> half of 4 marks. + double marks = strategy.calculateMarks(MARKING_WITH_PARTIAL, CORRECT_A_B, response("a")); + + assertThat(marks).isEqualTo(2.0); + assertThat(strategy.getAnswerStatus()).isEqualTo(QuestionResponseEnum.PARTIAL_CORRECT.name()); + } + + @Test + void subsetOfCorrectOptions_withPartialDisabled_isPenalised() { + // Documents existing behaviour rather than endorsing it: with partialMarking + // off, a strict subset is treated as fully incorrect. Left unchanged. + double marks = strategy.calculateMarks(MARKING_NO_PARTIAL, CORRECT_A_B, response("a")); + + assertThat(marks).isEqualTo(-1.0); + assertThat(strategy.getAnswerStatus()).isEqualTo(QuestionResponseEnum.INCORRECT.name()); + } +} diff --git a/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/service/marking_strategy/OneWordQuestionTypeBasedStrategyTest.java b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/service/marking_strategy/OneWordQuestionTypeBasedStrategyTest.java new file mode 100644 index 0000000000..fb5d67aca9 --- /dev/null +++ b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/service/marking_strategy/OneWordQuestionTypeBasedStrategyTest.java @@ -0,0 +1,97 @@ +package vacademy.io.assessment_service.features.assessment.service.marking_strategy; + +import org.junit.jupiter.api.Test; +import vacademy.io.assessment_service.features.assessment.enums.QuestionResponseEnum; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Marking rules for one-word answers. + * + * Matching used to be `toLowerCase()` only. A one-word input box is exactly where stray + * whitespace comes from, so a correct answer with a trailing space was marked WRONG and + * then given negative marking on top. Both sides are now trimmed and their internal + * whitespace collapsed. + * + * Every change here is one-directional: it can only turn a wrongly-INCORRECT answer into + * CORRECT, never the reverse. The "genuinely wrong" cases below pin that. + */ +class OneWordQuestionTypeBasedStrategyTest { + + private final OneWordQuestionTypeBasedStrategy strategy = new OneWordQuestionTypeBasedStrategy(); + + /** 2 marks, 0.5 negative. */ + private static final String MARKING = + "{\"type\":\"ONE_WORD\",\"data\":{\"totalMark\":2,\"negativeMark\":0.5," + + "\"negativeMarkingPercentage\":100}}"; + + private static final String CORRECT_PHOTOSYNTHESIS = + "{\"type\":\"ONE_WORD\",\"data\":{\"answer\":\"Photosynthesis\"}}"; + + private static final String CORRECT_TWO_WORDS = + "{\"type\":\"ONE_WORD\",\"data\":{\"answer\":\"carbon dioxide\"}}"; + + private String response(String answer) { + return "{\"responseData\":{\"type\":\"ONE_WORD\",\"answer\":\"" + answer + "\"}}"; + } + + @Test + void exactMatch_awardsFullMarks() { + double marks = strategy.calculateMarks(MARKING, CORRECT_PHOTOSYNTHESIS, response("Photosynthesis")); + + assertThat(marks).isEqualTo(2.0); + assertThat(strategy.getAnswerStatus()).isEqualTo(QuestionResponseEnum.CORRECT.name()); + } + + @Test + void differentCase_awardsFullMarks() { + double marks = strategy.calculateMarks(MARKING, CORRECT_PHOTOSYNTHESIS, response("PHOTOSYNTHESIS")); + + assertThat(marks).isEqualTo(2.0); + assertThat(strategy.getAnswerStatus()).isEqualTo(QuestionResponseEnum.CORRECT.name()); + } + + @Test + void trailingAndLeadingWhitespace_awardsFullMarks() { + // The regression this class exists for. + double marks = strategy.calculateMarks(MARKING, CORRECT_PHOTOSYNTHESIS, response(" photosynthesis ")); + + assertThat(marks).isEqualTo(2.0); + assertThat(strategy.getAnswerStatus()).isEqualTo(QuestionResponseEnum.CORRECT.name()); + } + + @Test + void collapsedInternalWhitespace_awardsFullMarks() { + double marks = strategy.calculateMarks(MARKING, CORRECT_TWO_WORDS, response("carbon dioxide")); + + assertThat(marks).isEqualTo(2.0); + assertThat(strategy.getAnswerStatus()).isEqualTo(QuestionResponseEnum.CORRECT.name()); + } + + @Test + void genuinelyWrongAnswer_stillAppliesNegativeMarking() { + // Trimming must not make wrong answers pass. + double marks = strategy.calculateMarks(MARKING, CORRECT_PHOTOSYNTHESIS, response("respiration")); + + assertThat(marks).isEqualTo(-0.5); + assertThat(strategy.getAnswerStatus()).isEqualTo(QuestionResponseEnum.INCORRECT.name()); + } + + @Test + void misspelledAnswer_stillIncorrect() { + double marks = strategy.calculateMarks(MARKING, CORRECT_PHOTOSYNTHESIS, response("photosinthesis")); + + assertThat(marks).isEqualTo(-0.5); + assertThat(strategy.getAnswerStatus()).isEqualTo(QuestionResponseEnum.INCORRECT.name()); + } + + @Test + void blankAnswer_scoresZeroAndStaysPending() { + // Whitespace-only is an unattempted question, not a wrong one -- it must not + // attract negative marking. + double marks = strategy.calculateMarks(MARKING, CORRECT_PHOTOSYNTHESIS, response(" ")); + + assertThat(marks).isEqualTo(0.0); + assertThat(strategy.getAnswerStatus()).isEqualTo(QuestionResponseEnum.PENDING.name()); + } +} diff --git a/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/sort/StableSortTest.java b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/sort/StableSortTest.java new file mode 100644 index 0000000000..3918355680 --- /dev/null +++ b/assessment_service/src/test/java/vacademy/io/assessment_service/features/assessment/sort/StableSortTest.java @@ -0,0 +1,100 @@ +package vacademy.io.assessment_service.features.assessment.sort; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The participant / submission / respondent lists are native SQL with no ORDER BY of + * their own, so this Sort is the only thing that gives them a total order. When it came + * back {@link Sort#unsorted()} Postgres returned rows in heap order, and every write to + * student_attempt (opening a paper flips result_status to EVALUATING; submitting marks + * writes result_marks / result_status / evaluated_file_id / report_release_status) moved + * the row — reshuffling the list mid-grading and, under LIMIT/OFFSET paging, showing one + * submission twice while skipping another. + */ +class StableSortTest { + + private static final Sort DEFAULT_SORT = Sort.by(Sort.Order.asc("studentName")); + + private static Map sortMap(String... keyValuePairs) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < keyValuePairs.length; i += 2) { + map.put(keyValuePairs[i], keyValuePairs[i + 1]); + } + return map; + } + + @Test + void neverReturnsUnsortedForAnEmptyOrNullRequest() { + // This is the whole point: an unsorted Pageable is what let the list reshuffle. + assertThat(StableSort.withStableOrder(null, DEFAULT_SORT, "registrationId").isSorted()).isTrue(); + assertThat(StableSort.withStableOrder(Map.of(), DEFAULT_SORT, "registrationId").isSorted()).isTrue(); + } + + @Test + void appliesTheDefaultAndTheTieBreakersWhenNothingWasRequested() { + Sort sort = StableSort.withStableOrder(Map.of(), DEFAULT_SORT, "registrationId", "attemptId"); + + assertThat(sort).containsExactly( + Sort.Order.asc("studentName"), + Sort.Order.asc("registrationId"), + Sort.Order.asc("attemptId")); + } + + @Test + void aRequestedSortWinsAndKeepsTheTieBreakersBehindIt() { + // A teacher clicking "Score" must still get a stable order within equal scores, + // otherwise paging through a tie reorders the tied rows on every fetch. + Sort sort = StableSort.withStableOrder(sortMap("score", "DESC"), DEFAULT_SORT, "registrationId", "attemptId"); + + assertThat(sort).containsExactly( + Sort.Order.desc("score"), + Sort.Order.asc("registrationId"), + Sort.Order.asc("attemptId")); + } + + @Test + void treatsAnythingOtherThanDescAsAscendingJustAsTheOldHelperDid() { + assertThat(StableSort.withStableOrder(sortMap("score", "asc"), DEFAULT_SORT)) + .containsExactly(Sort.Order.asc("score")); + assertThat(StableSort.withStableOrder(sortMap("score", "desc"), DEFAULT_SORT)) + .containsExactly(Sort.Order.desc("score")); + assertThat(StableSort.withStableOrder(sortMap("score", "nonsense"), DEFAULT_SORT)) + .containsExactly(Sort.Order.asc("score")); + } + + @Test + void doesNotAppendATieBreakerTheCallerAlreadySortedOn() { + // Otherwise "registrationId DESC" would be followed by a contradictory + // "registrationId ASC", hiding which direction actually applies. + Sort sort = StableSort.withStableOrder(sortMap("registrationId", "DESC"), DEFAULT_SORT, "registrationId", "attemptId"); + + assertThat(sort).containsExactly( + Sort.Order.desc("registrationId"), + Sort.Order.asc("attemptId")); + } + + @Test + void ignoresBlankPropertiesRatherThanEmittingAnEmptyOrderBy() { + Sort sort = StableSort.withStableOrder(sortMap(" ", "ASC"), DEFAULT_SORT, "registrationId"); + + assertThat(sort).containsExactly( + Sort.Order.asc("studentName"), + Sort.Order.asc("registrationId")); + } + + @Test + void keepsTheRequestedOrderOfMultipleColumns() { + Sort sort = StableSort.withStableOrder(sortMap("score", "DESC", "duration", "ASC"), DEFAULT_SORT, "registrationId"); + + assertThat(sort).containsExactly( + Sort.Order.desc("score"), + Sort.Order.asc("duration"), + Sort.Order.asc("registrationId")); + } +} diff --git a/auth_service/src/main/java/vacademy/io/auth_service/feature/auth/dto/AuthRequestDto.java b/auth_service/src/main/java/vacademy/io/auth_service/feature/auth/dto/AuthRequestDto.java index 7e3fb24c26..fccdc8f4fc 100644 --- a/auth_service/src/main/java/vacademy/io/auth_service/feature/auth/dto/AuthRequestDto.java +++ b/auth_service/src/main/java/vacademy/io/auth_service/feature/auth/dto/AuthRequestDto.java @@ -25,6 +25,11 @@ public class AuthRequestDto { private String deviceType; // NEW: "WEB", "MOBILE", "TABLET" — optional, defaults to "WEB" in session // creation // Optional WhatsApp template override for generic OTP sends (e.g. a live - // session's configured OTP template). Null = institute default template. + // session's configured OTP template, or a form field configured to verify + // itself). Null = institute default template. private String templateName; + // Language of the named template. Only read alongside templateName; null + // falls back to English. A Meta template is registered per language, so a + // template approved only in "en_US" must say so or the send is rejected. + private String languageCode; } diff --git a/auth_service/src/main/java/vacademy/io/auth_service/feature/auth/manager/AuthManager.java b/auth_service/src/main/java/vacademy/io/auth_service/feature/auth/manager/AuthManager.java index b7a3beca5d..94b94a35de 100644 --- a/auth_service/src/main/java/vacademy/io/auth_service/feature/auth/manager/AuthManager.java +++ b/auth_service/src/main/java/vacademy/io/auth_service/feature/auth/manager/AuthManager.java @@ -53,6 +53,19 @@ @Component public class AuthManager { + /** + * Every WhatsApp OTP template on the platform stores the same settings: one + * body parameter carrying the code, and the copy-code button carrying it + * again. That is not a coincidence — it is the fixed shape of a Meta + * AUTHENTICATION template, so a caller naming its own template needs no + * per-institute copy of it. + */ + private static final String AUTHENTICATION_TEMPLATE_SETTING_JSON = "{\"language_code\":\"en\"," + + "\"parameters\":{\"body\":[{\"source\":\"otp\",\"type\":\"text\"}]," + + "\"button\":[{\"source\":\"otp\",\"type\":\"text\"}]}}"; + + private static final String DEFAULT_OTP_LANGUAGE_CODE = "en"; + @Autowired UserRepository userRepository; @@ -483,7 +496,8 @@ public String requestWhatsAppOtp(AuthRequestDto authRequestDTO) { .settingJson(templateConfig.getSettingJson()) .build(); - notificationService.sendWhatsAppOtp(whatsAppOTPRequest); + assertWhatsAppOtpSent(notificationService.sendWhatsAppOtp(whatsAppOTPRequest), + authRequestDTO.getPhoneNumber()); return "WhatsApp OTP sent to " + authRequestDTO.getPhoneNumber(); } @@ -559,32 +573,95 @@ private void validateWhatsAppOtp(AuthRequestDto authRequestDTO) { * verification, etc.) * Reuses existing notification service flow. */ + + /** + * Reports a WhatsApp OTP send that the provider refused. + * + * The notification service answers a Meta rejection with + * {@code {"success": false, "message": ...}} and HTTP 200 — it does not + * throw. Both callers used to discard that and tell the caller "OTP sent", + * so a bad template, an unregistered sender or a blocked number all looked + * identical to success and the only symptom was a code that never arrived. + */ + private void assertWhatsAppOtpSent(String responseBody, String phoneNumber) { + if (responseBody == null || responseBody.isBlank()) { + return; // Nothing to read — leave the old optimistic behaviour. + } + try { + com.fasterxml.jackson.databind.JsonNode node = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(responseBody); + if (node.has("success") && !node.path("success").asBoolean(true)) { + String reason = node.path("message").asText("WhatsApp provider refused the message"); + log.error("WhatsApp OTP not sent to {}: {}", phoneNumber, reason); + throw new VacademyException("Could not send the WhatsApp code: " + reason); + } + } catch (VacademyException e) { + throw e; + } catch (Exception e) { + // Unreadable body is not proof of failure; don't block a send over it. + log.warn("Could not read the WhatsApp OTP response: {}", e.getMessage()); + } + } + public String requestGenericWhatsAppOtp(AuthRequestDto authRequestDTO) { if (authRequestDTO.getPhoneNumber() == null || authRequestDTO.getInstituteId() == null) { throw new VacademyException("Phone number and Institute ID are required"); } - // Fetch template config (same as login flow) - NotificationTemplateConfigDTO templateConfig = notificationService - .getTemplateConfig("OTP_REQUEST", authRequestDTO.getInstituteId(), "WHATSAPP"); - - // Caller-supplied override (e.g. a live session's configured OTP - // template) wins over the institute default. - String templateName = authRequestDTO.getTemplateName() != null + String requestedTemplate = authRequestDTO.getTemplateName() != null && !authRequestDTO.getTemplateName().isBlank() ? authRequestDTO.getTemplateName().trim() - : templateConfig.getTemplateName(); + : null; + + String templateName; + String languageCode; + String settingJson; + + // Try the institute's OTP config first, exactly as before. A caller that + // names its own template (a live session's, say) still takes its + // languageCode and settingJson from here — those describe the template's + // PARAMETERS, and substituting a generic set would send a body-only + // template a copy-code button it does not have, which Meta rejects. + NotificationTemplateConfigDTO templateConfig = null; + try { + templateConfig = notificationService + .getTemplateConfig("OTP_REQUEST", authRequestDTO.getInstituteId(), "WHATSAPP"); + } catch (Exception e) { + // Only a caller that brought its own template can proceed without one. + if (requestedTemplate == null) { + throw e; + } + log.warn("No OTP_REQUEST config for institute {}; using the named template '{}' with " + + "standard authentication-template settings", authRequestDTO.getInstituteId(), + requestedTemplate); + } + + if (templateConfig != null) { + templateName = requestedTemplate != null ? requestedTemplate : templateConfig.getTemplateName(); + languageCode = templateConfig.getLanguageCode(); + settingJson = templateConfig.getSettingJson(); + } else { + // No institute config at all — fall back to the fixed shape of a Meta + // AUTHENTICATION template, which is what an OTP template always is. + templateName = requestedTemplate; + languageCode = authRequestDTO.getLanguageCode() != null + && !authRequestDTO.getLanguageCode().isBlank() + ? authRequestDTO.getLanguageCode().trim() + : DEFAULT_OTP_LANGUAGE_CODE; + settingJson = AUTHENTICATION_TEMPLATE_SETTING_JSON; + } // Send WhatsApp OTP via notification service (same as login flow) WhatsAppOTPRequest whatsAppOTPRequest = WhatsAppOTPRequest.builder() .phoneNumber(authRequestDTO.getPhoneNumber()) .instituteId(authRequestDTO.getInstituteId()) .templateName(templateName) - .languageCode(templateConfig.getLanguageCode()) - .settingJson(templateConfig.getSettingJson()) + .languageCode(languageCode) + .settingJson(settingJson) .build(); - notificationService.sendWhatsAppOtp(whatsAppOTPRequest); + assertWhatsAppOtpSent(notificationService.sendWhatsAppOtp(whatsAppOTPRequest), + authRequestDTO.getPhoneNumber()); return "WhatsApp OTP sent to " + authRequestDTO.getPhoneNumber(); } diff --git a/auth_service/src/main/resources/application-stage.properties b/auth_service/src/main/resources/application-stage.properties index 886cb676bb..ea39a6fb1b 100644 --- a/auth_service/src/main/resources/application-stage.properties +++ b/auth_service/src/main/resources/application-stage.properties @@ -3,8 +3,14 @@ spring.application.name=auth_service ## Database Connection spring.datasource.url=${AUTH_SERVICE_DB_URL} spring.datasource.read.url=${AUTH_SERVICE_READ_DB_URL:${AUTH_SERVICE_DB_URL}} -spring.datasource.hikari.maximum-pool-size=3 -spring.datasource.hikari.minimum-idle=1 +# Sized for exam-day login bursts (200-1000 learners signing in over a few +# minutes). PgBouncer (transaction mode) multiplexes onto its 20-connection +# auth server pool, so Postgres needs no change. Was 3, which let one slow +# query stall every login behind the connection-timeout. +spring.datasource.hikari.maximum-pool-size=15 +spring.datasource.hikari.minimum-idle=5 +# Fail fast instead of hanging logins for 60s (overrides application.properties) +spring.datasource.hikari.connection-timeout=10000 spring.datasource.password=${DB_PASSWORD} spring.datasource.username=${DB_USERNAME} ## Security diff --git a/auth_service/src/main/resources/db/migration/V17__Seed_hr_roles.sql b/auth_service/src/main/resources/db/migration/V17__Seed_hr_roles.sql new file mode 100644 index 0000000000..1782cd431a --- /dev/null +++ b/auth_service/src/main/resources/db/migration/V17__Seed_hr_roles.sql @@ -0,0 +1,15 @@ +-- HR & Payroll module: dedicated HR_ADMIN and HR_MANAGER roles. +-- Same pattern as V15's MENTOR seed: roles.role_name is globally unique +-- (uk_roles_name) and RoleService.addRolesToUser resolves by name, so one +-- system-wide row per role (institute_id NULL) serves every institute — the +-- per-institute scoping lives on the user_role row. CustomUserDetails mints +-- authorities from the role NAME for the clientId institute, so assigning +-- these roles yields the 'HR_ADMIN'/'HR_MANAGER' authorities that +-- admin_core_service's HrAccessGuard checks. +INSERT INTO roles (id, role_name, created_at, updated_at) +VALUES (gen_random_uuid()::TEXT, 'HR_ADMIN', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (role_name) DO NOTHING; + +INSERT INTO roles (id, role_name, created_at, updated_at) +VALUES (gen_random_uuid()::TEXT, 'HR_MANAGER', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (role_name) DO NOTHING; diff --git a/bigbluebutton-server/bbb-fix-ip-on-boot.sh b/bigbluebutton-server/bbb-fix-ip-on-boot.sh index b80b7ff464..36d65a27c0 100644 --- a/bigbluebutton-server/bbb-fix-ip-on-boot.sh +++ b/bigbluebutton-server/bbb-fix-ip-on-boot.sh @@ -36,6 +36,7 @@ OLD_IPS=$(grep -ohP '\d+\.\d+\.\d+\.\d+' \ /opt/freeswitch/etc/freeswitch/sip_profiles/external.xml \ /etc/bigbluebutton/bbb-webrtc-sfu/production.yml \ /usr/share/bigbluebutton/nginx/sip.nginx \ + /etc/turnserver.conf \ 2>/dev/null | grep -v '127\.\|0\.0\.\|255\.\|169\.254\.' | sort -u) NEEDS_FIX=false @@ -107,4 +108,36 @@ systemctl restart bbb-webrtc-sfu freeswitch 2>/dev/null || true sed -i "s|defaultUploadedPresentation=https://[^/]*/|defaultUploadedPresentation=https://$BBB_DOMAIN/|g" \ /etc/bigbluebutton/bbb-web.properties 2>/dev/null || true +# Step 6: TURN / coturn — deliberately handled separately. +# The generic sweep above walks directories; /etc/turnserver.conf sits under +# none of them and /etc/haproxy/haproxy.cfg matches none of the swept file +# extensions, so a stale IP survived here on EVERY restore. The symptom is +# silent: coturn cannot bind a foreign address, exits, and every learner behind +# a UDP-blocking firewall loses audio/video while everyone else is fine. +if [ -f /etc/turnserver.conf ]; then + for old in $(grep -oP '^\s*(listening-ip|relay-ip|allowed-peer-ip)=\K[\d.]+' \ + /etc/turnserver.conf 2>/dev/null | grep -v '^127\.' | sort -u); do + if [ "$old" != "$NEW_IP" ]; then + log " TURN: $old → $NEW_IP" + sed -i "s/\b$old\b/$NEW_IP/g" /etc/turnserver.conf + fi + done + # Also bind loopback so haproxy's turn backend can be a fixed address. + grep -q '^listening-ip=127\.0\.0\.1' /etc/turnserver.conf || \ + sed -i "/^listening-ip=$NEW_IP/a listening-ip=127.0.0.1" /etc/turnserver.conf + systemctl restart coturn 2>/dev/null || true + log " coturn: $(systemctl is-active coturn 2>/dev/null)" +fi + +# Pin haproxy's TURN backend to loopback once — after this it never needs an +# IP rewrite again, on any future restore. +if [ -f /etc/haproxy/haproxy.cfg ]; then + sed -i 's|^\(\s*server localhost \)[0-9.]\+:3478|\1127.0.0.1:3478|' /etc/haproxy/haproxy.cfg + if haproxy -c -f /etc/haproxy/haproxy.cfg >/dev/null 2>&1; then + systemctl reload haproxy 2>/dev/null || true + else + log " WARNING: haproxy config test failed — not reloading" + fi +fi + log "BBB IP fix complete: $BBB_DOMAIN ($NEW_IP)" diff --git a/bigbluebutton-server/install-custom-domains.sh b/bigbluebutton-server/install-custom-domains.sh new file mode 100755 index 0000000000..529389c3b8 --- /dev/null +++ b/bigbluebutton-server/install-custom-domains.sh @@ -0,0 +1,259 @@ +#!/bin/bash +# ============================================================= +# Per-institute custom live-class domains (white-labelling) +# ============================================================= +# Makes this BBB server answer for institute-owned hostnames such as +# meet.zoeedtech.com, in addition to its canonical pool domain. +# +# Usage: +# bash install-custom-domains.sh [comma,separated,aliases] +# +# Example: +# bash install-custom-domains.sh meet.vacademy.io meet.zoeedtech.com,live.school.in +# +# Safe to run on every boot: every step is idempotent, and the script is +# written so the CANONICAL domain keeps working even if every alias step +# fails. An alias that cannot get a certificate degrades to a TLS warning on +# that one hostname; it never takes the pool server down. +# +# Ordering matters. This MUST run AFTER `bbb-conf --setip` and after the +# stale-IP sweep, because both rewrite /etc/nginx/sites-available/bigbluebutton +# and would drop the server_name list this script installs. +# +# Requires (for certificate expansion only): +# /etc/letsencrypt/cloudflare.ini Cloudflare API token, DNS:Edit on the zones +# ============================================================= + +set -uo pipefail + +CANONICAL="${1:?Usage: bash install-custom-domains.sh [aliases]}" +ALIAS_CSV="${2:-}" + +SITE=/etc/nginx/sites-available/bigbluebutton +WEB=/usr/share/bigbluebutton/nginx/web +CF_INI=/etc/letsencrypt/cloudflare.ini +LOG_TAG="bbb-custom-domains" +STAMP=$(date +%Y%m%d-%H%M%S) + +log() { echo "[$LOG_TAG] $*"; } + +# ── Normalise the alias list ───────────────────────────────── +# Drop blanks, lowercase, strip scheme/path, reject anything that is not a +# plain hostname. The backend validates too, but this list is about to be +# interpolated into an nginx directive and a certbot command line, so it is +# re-checked here rather than trusted over the wire. +ALIASES="" +if [ -n "$ALIAS_CSV" ]; then + for raw in ${ALIAS_CSV//,/ }; do + h=$(echo "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's#^[a-z][a-z0-9+.-]*://##; s#/.*$##; s#^[^@]*@##') + [ -z "$h" ] && continue + case "$h" in *:*) log " skip (port not allowed): $raw"; continue ;; esac + if ! echo "$h" | grep -qE '^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$'; then + log " skip (not a hostname): $raw" + continue + fi + [ "$h" = "$CANONICAL" ] && continue + case " $ALIASES " in *" $h "*) continue ;; esac + ALIASES="${ALIASES:+$ALIASES }$h" + done +fi + +if [ -n "$ALIASES" ]; then + log "aliases: $ALIASES" +else + log "no aliases configured — restoring canonical-only config" +fi + +# ── 1. Location-level redirect rewrite ─────────────────────── +# bbb-web builds its join redirect from bigbluebutton.web.serverURL, which is +# absolute, so an alias host would be bounced straight back to the canonical +# domain. Rewrite Location to whatever host the client actually asked for. +# +# This has to sit INSIDE each location block: the packaged config sets +# `proxy_redirect default;` per-location, which overrides anything inherited +# from the server level. The `default` line is kept and ours added after it, +# so upstream 127.0.0.1 rewrites still work. +# +# For the canonical host the rewrite is an identity transform, which is why it +# is safe to leave installed permanently. +REWRITE='proxy_redirect ~^https://[^/]+/(.*)$ https://$host/$1;' +if [ -f "$WEB" ]; then + if grep -qF 'proxy_redirect ~^https://[^/]+/(.*)$' "$WEB"; then + log "1/4 redirect rewrite already present" + else + cp -a "$WEB" "$WEB.bak-$STAMP" + perl -0pi -e 's{^(\s*)proxy_redirect(\s+)default;}{$1proxy_redirect$2default;\n$1'"$REWRITE"'}gm' "$WEB" + log "1/4 redirect rewrite added ($(grep -c 'proxy_redirect ~\^https' "$WEB") locations)" + fi +else + log "1/4 WARNING: $WEB not found — skipping redirect rewrite" +fi + +# ── 1b. Rewrite the absolute URLs bbb-web advertises in its API index ── +# GET /bigbluebutton/api is the FIRST call the HTML5 client makes, and bbb-web +# answers with two absolute, canonical URLs derived from +# bigbluebutton.web.serverURL: +# +# https:///api/rest +# wss:///graphql +# +# The client uses both as bases for everything after. On an alias host that made +# every subsequent call cross-origin: CORS blocked the REST fetch (which is sent +# with credentials:"include"), and the websocket carried no cookie for that +# origin. The client showed only "Oops, something went wrong". +# +# There is no static value that is correct for two hostnames — a WebSocket URL +# needs a scheme, so it cannot simply be made relative. Rewriting per-request is +# the only thing that works for both. For the canonical host this is an identity +# transform, so existing traffic is untouched. +# +# An exact-match location wins over the packaged `location /bigbluebutton` +# prefix, so this overrides just this one endpoint and nothing else. +API_SNIP=/etc/bigbluebutton/nginx/01-vacademy-api-index.nginx +cat > "$API_SNIP" < \$host)" + +# ── 1c. Rewrite the absolute URLs inside the meetingStaticData payload ── +# The client fetches this once at startup and takes its media endpoints from it: +# +# clientSettings.public.kurento.wsUrl = wss:///bbb-webrtc-sfu +# clientSettings.public.pads.url = https:///pad +# meeting.logoutUrl = https:// +# +# kurento.wsUrl is the one that matters: on an alias host the client opened the +# media websocket against the canonical origin, where it has no session cookie, +# so audio and video failed with WEBSOCKET_CONNECTION_FAILED (1002) while the +# rest of the UI worked perfectly. +# +# These come from /etc/bigbluebutton/bbb-html5.yml, which is per-SERVER config — +# there is no value that is simultaneously right for two hostnames, so again the +# rewrite has to happen per-request. +# +# The packaged block caches this response with key "$uri|$meeting_id". Since the +# body now differs per hostname, $host MUST be part of the key, otherwise one +# institute's cached payload gets served to another and both domains break in a +# way that looks intermittent and random. +MSD_SNIP=/etc/bigbluebutton/nginx/02-vacademy-meeting-static-data.nginx +cat > "$MSD_SNIP" < $ALL_NAMES" + +# ── 3. Certificate ─────────────────────────────────────────── +# Expand the EXISTING lineage so haproxy's certbundle path never changes; its +# deploy hook rebuilds the bundle and reloads haproxy. +# +# DNS-01, not HTTP-01: this box gets a new public IP on every restore, so an +# HTTP-01 challenge fails for any hostname whose A record has not caught up +# yet. DNS-01 also lets a certificate be issued before the record exists. +if [ -z "$ALIASES" ]; then + log "3/4 no aliases — leaving certificate untouched" +elif [ ! -f "$CF_INI" ]; then + log "3/4 WARNING: $CF_INI missing — cannot expand certificate. Aliases will serve a TLS warning." +else + LIVE=/etc/letsencrypt/live/$CANONICAL/cert.pem + WANT=$(printf '%s\n' "$CANONICAL" $ALIASES | sort -u | tr '\n' ' ') + HAVE="" + [ -f "$LIVE" ] && HAVE=$(openssl x509 -in "$LIVE" -noout -ext subjectAltName 2>/dev/null \ + | tr ',' '\n' | sed -n 's/.*DNS://p' | tr -d ' ' | sort -u | tr '\n' ' ') + + if [ "$WANT" = "$HAVE" ]; then + log "3/4 certificate already covers: $HAVE" + else + log "3/4 expanding certificate" + log " have: ${HAVE:-}" + log " want: $WANT" + if ! certbot plugins 2>/dev/null | grep -qi dns-cloudflare; then + log " installing python3-certbot-dns-cloudflare" + DEBIAN_FRONTEND=noninteractive apt-get install -y -q python3-certbot-dns-cloudflare >/dev/null 2>&1 + fi + D_ARGS="" + for d in $WANT; do D_ARGS="$D_ARGS -d $d"; done + # shellcheck disable=SC2086 + if certbot certonly --dns-cloudflare \ + --dns-cloudflare-credentials "$CF_INI" \ + --dns-cloudflare-propagation-seconds 30 \ + --cert-name "$CANONICAL" $D_ARGS \ + --non-interactive --agree-tos --expand 2>&1 | tail -5; then + log " certificate expanded" + else + # Non-fatal on purpose: the previous certificate is still in place and + # still valid for the canonical domain, so classes keep working. + log " WARNING: certbot failed — keeping the existing certificate" + fi + fi +fi + +# ── 4. Validate and reload ─────────────────────────────────── +if nginx -t 2>&1 | tail -1; then + systemctl reload nginx && log "4/4 nginx reloaded" +else + log "4/4 ERROR: nginx config test failed — rolling back" + cp -a "$SITE.bak-$STAMP" "$SITE" + [ -f "$WEB.bak-$STAMP" ] && cp -a "$WEB.bak-$STAMP" "$WEB" + rm -f "$API_SNIP" "$MSD_SNIP" + nginx -t >/dev/null 2>&1 && systemctl reload nginx + log "4/4 rolled back to the previous config" + exit 1 +fi + +# Keep the backup directory from growing without bound across daily restores. +find /etc/nginx/sites-available /usr/share/bigbluebutton/nginx \ + -maxdepth 1 -name '*.bak-*' -mtime +7 -delete 2>/dev/null || true + +log "done: serving $ALL_NAMES" diff --git a/common_service/src/main/java/vacademy/io/common/auth/repository/UserRoleRepository.java b/common_service/src/main/java/vacademy/io/common/auth/repository/UserRoleRepository.java index 255e7fe42c..775e629775 100644 --- a/common_service/src/main/java/vacademy/io/common/auth/repository/UserRoleRepository.java +++ b/common_service/src/main/java/vacademy/io/common/auth/repository/UserRoleRepository.java @@ -13,6 +13,17 @@ import java.util.List; import java.util.Optional; +/** + * WARNING — this repository is only usable from auth_service. It lives in + * common_service, so any service can inject it and every query here compiles and + * passes review anywhere. But {@code users}, {@code user_role} and {@code roles} + * exist only in auth_service's database: called from admin_core (or any other + * service) these methods fail at RUNTIME with + * {@code relation "user_role" does not exist}. + * + *

From another service, resolve users and their roles over HTTP instead — + * see {@code AuthService.requireUsersByInstituteAndRoles} in admin_core. + */ @Repository public interface UserRoleRepository extends CrudRepository { diff --git a/common_service/src/main/java/vacademy/io/common/institute/entity/Institute.java b/common_service/src/main/java/vacademy/io/common/institute/entity/Institute.java index cb33c68ec6..309817392e 100644 --- a/common_service/src/main/java/vacademy/io/common/institute/entity/Institute.java +++ b/common_service/src/main/java/vacademy/io/common/institute/entity/Institute.java @@ -59,6 +59,18 @@ public class Institute { @Column(name = "admin_portal_base_url") private String adminPortalBaseUrl; + /** + * Custom live-class hostname for this institute, e.g. "meet.zoeedtech.com". + * Null means "use the platform default" (the BBB pool server's own domain). + * + * Stored as a bare hostname — no scheme, no path, no port. Only the join URL + * handed to a participant is rewritten to this host; every control-plane call + * to BBB keeps using the pool server's api_url, so a broken custom domain + * costs branding on a link rather than a class. See BbbMeetingManager. + */ + @Column(name = "live_session_base_url") + private String liveSessionBaseUrl; + @Column(name = "description") private String description; diff --git a/common_service/src/main/java/vacademy/io/common/tracing/ExternalCallTimer.java b/common_service/src/main/java/vacademy/io/common/tracing/ExternalCallTimer.java new file mode 100644 index 0000000000..ebc1f4aeb6 --- /dev/null +++ b/common_service/src/main/java/vacademy/io/common/tracing/ExternalCallTimer.java @@ -0,0 +1,118 @@ +package vacademy.io.common.tracing; + +import java.util.function.Supplier; + +/** + * Accumulates, per request, the time spent waiting on third parties. + * + * WHY THIS EXISTS. `Server-Timing: app;dur=...` originally reported the whole + * request duration, so an endpoint whose time is spent waiting on someone else's + * API looked exactly like an endpoint we are slow at. That produced a real false + * alarm: /v1/telephony/calls/connect takes ~2.1s because it dials a live phone + * through the telephony provider, which pushed a counsellor's rolling median past + * the client-side "server slow" threshold and told them "Vacademy is slow — this + * is on our side" while the platform was serving p50 16ms. A client reported the + * LMS as slow on the strength of that badge. + * + * So the filter now emits two numbers, and the browser judges on `app` alone: + * + * Server-Timing: app;dur=95, ext;dur=2035 + * ^ our compute ^ waiting on someone else + * + * Wrap any synchronous call to a third party in {@link #time} and it stops + * counting against us. Good candidates: telephony dial-out, BBB/Zoom meeting + * creation, LLM calls, payment gateways, media/S3 round trips. + * + * SCOPE AND SAFETY + * - The counter only exists between {@link #begin} and {@link #clear}, which + * RequestTracingFilter calls around each request. Outside a traced request + * (scheduled jobs, @Async work) {@link #time} still runs the call but records + * nothing, so nothing leaks onto a pooled thread. + * - It is per thread. Work handed to another thread is not attributed, which is + * correct: it is not on the request's critical path. + * - Nested wrapping double counts, so wrap at ONE level — the outermost call into + * the third party, not both the adapter and its HTTP client. + */ +public final class ExternalCallTimer { + + /** Single-element holder so adding time does not re-set the ThreadLocal. */ + private static final ThreadLocal ELAPSED_NANOS = new ThreadLocal<>(); + + private ExternalCallTimer() { + } + + /** Start counting for this thread. Called by RequestTracingFilter. */ + public static void begin() { + ELAPSED_NANOS.set(new long[] { 0L }); + } + + /** Stop counting and release the ThreadLocal. MUST run in a finally. */ + public static void clear() { + ELAPSED_NANOS.remove(); + } + + /** Nanoseconds spent in third-party calls so far, or 0 outside a traced request. */ + public static long elapsedNanos() { + long[] holder = ELAPSED_NANOS.get(); + return holder == null ? 0L : holder[0]; + } + + /** Milliseconds spent in third-party calls so far. */ + public static long elapsedMillis() { + return elapsedNanos() / 1_000_000L; + } + + /** Record time measured elsewhere. No-op outside a traced request. */ + public static void addNanos(long nanos) { + if (nanos <= 0) { + return; + } + long[] holder = ELAPSED_NANOS.get(); + if (holder != null) { + holder[0] += nanos; + } + } + + /** + * Run a third-party call and attribute its duration to `ext` rather than to us. + * The call's own exceptions propagate untouched, and its time is still recorded + * — a provider that fails slowly was still not our latency. + */ + public static T time(Supplier call) { + long start = System.nanoTime(); + try { + return call.get(); + } finally { + addNanos(System.nanoTime() - start); + } + } + + /** Void form of {@link #time(Supplier)}. */ + public static void time(Runnable call) { + long start = System.nanoTime(); + try { + call.run(); + } finally { + addNanos(System.nanoTime() - start); + } + } + + /** + * Form for calls that throw checked exceptions, which {@link Supplier} cannot + * express. The checked exception propagates as-is. + */ + public static T timeChecked(ThrowingSupplier call) throws Exception { + long start = System.nanoTime(); + try { + return call.get(); + } finally { + addNanos(System.nanoTime() - start); + } + } + + /** A {@link Supplier} that may throw a checked exception. */ + @FunctionalInterface + public interface ThrowingSupplier { + T get() throws Exception; + } +} diff --git a/common_service/src/main/java/vacademy/io/common/tracing/RequestTracingFilter.java b/common_service/src/main/java/vacademy/io/common/tracing/RequestTracingFilter.java index 6b4d8d6a15..d76cc8943a 100644 --- a/common_service/src/main/java/vacademy/io/common/tracing/RequestTracingFilter.java +++ b/common_service/src/main/java/vacademy/io/common/tracing/RequestTracingFilter.java @@ -74,6 +74,10 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha // Start timing long startTime = System.nanoTime(); + // Start attributing third-party wait separately, so a slow provider does not + // read as us being slow. Cleared in the finally below — it is a ThreadLocal on + // a pooled thread. + ExternalCallTimer.begin(); // Add start breadcrumb to Sentry addRequestStartBreadcrumb(method, fullPath, clientIp); @@ -118,6 +122,9 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha // Add completion breadcrumb to Sentry addRequestCompleteBreadcrumb(method, fullPath, status, durationMs); + + // Never leave the counter on a pooled thread. + ExternalCallTimer.clear(); } } @@ -187,7 +194,7 @@ private void writeServerTiming() { return; } long durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); - setHeader("Server-Timing", "app;dur=" + durationMs); + setHeader("Server-Timing", buildServerTimingValue(durationMs)); // Required for the value to be readable cross-origin via the Resource // Timing API; reading it off a fetch/axios response additionally needs // Access-Control-Expose-Headers, set in each service's CorsConfig. @@ -198,6 +205,26 @@ private void writeServerTiming() { } } + /** + * Build the header value, splitting our own compute from third-party wait: + * + * Server-Timing: app;dur=95, ext;dur=2035 + * + * `app` is what the browser judges us on, so it must exclude time spent waiting + * on someone else's API (see {@link ExternalCallTimer}). `ext` is emitted only + * when there was such a wait, so endpoints that call nobody keep a single clean + * metric. Clamped at zero because the two clocks are read a moment apart and a + * tiny negative would otherwise be possible. + */ + private static String buildServerTimingValue(long durationMs) { + long externalMs = ExternalCallTimer.elapsedMillis(); + if (externalMs <= 0) { + return "app;dur=" + durationMs; + } + long appMs = Math.max(0, durationMs - externalMs); + return "app;dur=" + appMs + ", ext;dur=" + externalMs; + } + /** * Fallback for when the response could not be wrapped at all. Kept because it is * still correct for an uncommitted response, but note that in practice a normal @@ -223,7 +250,7 @@ private void emitServerTimingHeader(HttpServletResponse response, String uri, lo return; } - response.setHeader("Server-Timing", "app;dur=" + durationMs); + response.setHeader("Server-Timing", buildServerTimingValue(durationMs)); response.setHeader("Timing-Allow-Origin", "*"); } catch (Exception e) { // Observability must never break the response it is observing. diff --git a/common_service/src/main/resources/messages_fr.properties b/common_service/src/main/resources/messages_fr.properties new file mode 100644 index 0000000000..461629197f --- /dev/null +++ b/common_service/src/main/resources/messages_fr.properties @@ -0,0 +1,4 @@ +# Vacademy platform message bundle — French (fr). UTF-8. +error.generic=Une erreur s'est produite. Veuillez réessayer. +error.unauthorized=Vous n'êtes pas autorisé à effectuer cette action. +error.not_found=La ressource demandée est introuvable. diff --git a/community_service/src/main/java/vacademy/io/community_service/config/CommunityApplicationSecurityConfig.java b/community_service/src/main/java/vacademy/io/community_service/config/CommunityApplicationSecurityConfig.java index c4ae3037c3..06677d0413 100644 --- a/community_service/src/main/java/vacademy/io/community_service/config/CommunityApplicationSecurityConfig.java +++ b/community_service/src/main/java/vacademy/io/community_service/config/CommunityApplicationSecurityConfig.java @@ -24,7 +24,10 @@ @EnableMethodSecurity public class CommunityApplicationSecurityConfig { - private static final String[] INTERNAL_PATHS = {}; + private static final String[] INTERNAL_PATHS = { + // Service-to-service only (HMAC via InternalAuthFilter) — never exposed to browsers. + // admin_core_service's institute-facing app-status endpoint reads through this. + "/community-service/internal/**" }; private static final String[] ALLOWED_PATHS = { "/community-service/engage/learner/**", "/community-service/engage/**", "/community-service/subject/**", "/community-service/chapter/**", diff --git a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/controller/AppRegistryProviderController.java b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/controller/AppRegistryProviderController.java index f4ca6a80a7..a19dabead7 100644 --- a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/controller/AppRegistryProviderController.java +++ b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/controller/AppRegistryProviderController.java @@ -5,8 +5,10 @@ import org.springframework.web.bind.annotation.*; import vacademy.io.common.auth.model.CustomUserDetails; import vacademy.io.common.auth.util.SuperAdminAuthUtil; +import vacademy.io.community_service.feature.appregistry.service.StoreStatusSyncService; import java.util.Map; +import java.util.Set; /** * Server-side half of the dashboard's StoreProvider abstraction. @@ -15,12 +17,16 @@ * Store Connect JWT or holding a Google service-account key client-side would expose the private * key to anyone with devtools. * - *

No store integration is wired up yet, so every operation answers 501 Not Implemented - * with a plain explanation. That is a deliberate choice over returning a plausible-looking status: - * a dashboard that invents "Live" is worse than one that says "go and look". The client renders - * this as "Manual action required" and links to the right console. + *

All four platforms route through {@link StoreStatusSyncService}, which resolves a credential + * per institute (see {@code StoreCredentialResolver}) — whether a given app actually gets a live + * answer depends on whether that institute (or the shared default) has a credential on file, not + * on the platform itself. When none exists, or the app record has no bundle/package/store id + * filled in, or {@code getReviews} is requested (not implemented for any provider yet), this + * answers 501 Not Implemented with a plain explanation — a dashboard that invents "Live" + * for a store it can't actually reach is worse than one that says "go and look". The client + * renders that as "Manual action required" and links to the right console. * - *

When a provider is implemented, it must use the official API and documented auth only — + *

When a new provider is wired up, it must use the official API and documented auth only — * Play Developer API via a service account, App Store Connect via a JWT-signed .p8, Partner Center * via Azure AD. Never a scraped console session, a reused browser cookie, or an undocumented * endpoint. @@ -35,6 +41,16 @@ public class AppRegistryProviderController { "windows", "https://partner.microsoft.com/dashboard", "macos", "https://appstoreconnect.apple.com"); + /** Operations {@link StoreStatusSyncService#sync} can answer from one App Store Connect call. */ + private static final Set LIVE_OPERATIONS = Set.of( + "getAppStatus", "getLatestVersion", "getBuildStatus", "getReleaseStatus", "getSubmissionStatus"); + + private final StoreStatusSyncService storeStatusSyncService; + + public AppRegistryProviderController(StoreStatusSyncService storeStatusSyncService) { + this.storeStatusSyncService = storeStatusSyncService; + } + @GetMapping("/{platform}/{appId}/{operation}") public ResponseEntity> operation(@RequestAttribute("user") CustomUserDetails user, @PathVariable String platform, @@ -42,18 +58,61 @@ public ResponseEntity> operation(@RequestAttribute("user") C @PathVariable String operation) { SuperAdminAuthUtil.requireSuperAdmin(user); - String console = CONSOLES.get(platform == null ? "" : platform.toLowerCase()); + String platformKey = platform == null ? "" : platform.toLowerCase(); + String console = CONSOLES.get(platformKey); if (console == null) { return ResponseEntity.badRequest().body(Map.of( "manual", false, "message", "Unknown platform: " + platform)); } + if (LIVE_OPERATIONS.contains(operation)) { + Map full = storeStatusSyncService.sync(appId, platform); + if (full != null) { + return ResponseEntity.ok(sliceFor(operation, full)); + } + } + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(Map.of( "manual", true, "operation", operation, "consoleUrl", console, - "message", "The server-side store integration for this platform isn't configured yet. " - + "Check the store console and record the result in the dashboard.")); + "message", notConfiguredMessage(platformKey, operation))); + } + + /** + * Each provider operation is a different slice of the same App Store Connect lookup — one API + * call already fetches everything {@code getAppStatus} needs, so the narrower operations just + * pick out the fields the frontend's {@code ProviderResult} type expects for that call + * rather than re-fetching. + */ + private static Map sliceFor(String operation, Map full) { + return switch (operation) { + case "getLatestVersion" -> Map.of("version", full.get("version"), "build", full.get("build")); + case "getBuildStatus" -> Map.of("status", full.get("status")); + case "getReleaseStatus" -> Map.of("status", full.get("status"), "releasedAt", full.get("releasedAt")); + case "getSubmissionStatus" -> Map.of("status", full.get("status")); + default -> full; // getAppStatus + }; + } + + private static String notConfiguredMessage(String platformKey, String operation) { + if ("getReviews".equals(operation)) { + return "Review sync isn't implemented yet — check the store console directly."; + } + return switch (platformKey) { + case "android" -> "Couldn't sync live status. Either no Play Developer credential is on file for " + + "this institute (add one via /store-credentials), the app's Package Name isn't filled in, " + + "or the account can't see this package — check the store console and record the result " + + "in the dashboard."; + case "windows" -> "Couldn't sync live status. Either no Partner Center credential is on file for " + + "this institute (add one via /store-credentials), the app's Store ID isn't filled in, or " + + "the account can't see this application — check the store console and record the result " + + "in the dashboard."; + default -> "Couldn't sync live status. Either no App Store Connect credential is on file for this " + + "institute (add one via /store-credentials), the app's Bundle ID isn't filled in, or the " + + "account can't see this bundle — check the store console and record the result in the " + + "dashboard."; + }; } } diff --git a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/controller/InternalAppRegistryController.java b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/controller/InternalAppRegistryController.java new file mode 100644 index 0000000000..1129787e9a --- /dev/null +++ b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/controller/InternalAppRegistryController.java @@ -0,0 +1,37 @@ +package vacademy.io.community_service.feature.appregistry.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import vacademy.io.community_service.feature.appregistry.service.AppRegistryService; + +import java.util.List; + +/** + * Service-to-service read path for the app registry, consumed by admin_core_service's + * institute-facing app-status endpoint. + * + *

community_service and admin_core_service run on separate databases (assessment_service vs + * admin_core_service), so the institute-membership check that gates this data cannot be a local + * JPA join against {@code user_role} here — that table doesn't exist in this service's database. + * admin_core_service already owns that check (see WhiteLabelService#assertInstituteAccess); this + * endpoint trusts it and only verifies HMAC service identity via {@link + * vacademy.io.common.auth.filter.InternalAuthFilter}, matched on {@code /community-service/internal/**} + * in {@code CommunityApplicationSecurityConfig}. It must never be exposed to browsers directly. + */ +@RestController +@RequestMapping("/community-service/internal/v1/app-registry") +public class InternalAppRegistryController { + + @Autowired + private AppRegistryService service; + + @GetMapping("/by-institute") + public ResponseEntity> byInstitute(@RequestParam("instituteId") String instituteId) { + return ResponseEntity.ok(service.listByInstitute(instituteId)); + } +} diff --git a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/entity/AppRegistration.java b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/entity/AppRegistration.java index 719577cad0..b0c2c3980a 100644 --- a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/entity/AppRegistration.java +++ b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/entity/AppRegistration.java @@ -27,7 +27,8 @@ @Table(name = "app_registration", schema = "public", indexes = { @Index(name = "idx_app_registration_package", columnList = "package_name"), - @Index(name = "idx_app_registration_archived", columnList = "archived") + @Index(name = "idx_app_registration_archived", columnList = "archived"), + @Index(name = "idx_app_registration_institute", columnList = "institute_id") }) @Getter @Setter @@ -51,6 +52,15 @@ public class AppRegistration { @Column(name = "package_name") private String packageName; + /** + * Owning institute, denormalised out of {@link #payload} the same way {@link #packageName} is — + * nullable, because apps registered before this field existed (and any ops-only tooling app with + * no single owning institute) have none. A null institute_id simply never matches an institute + * filter; it is not an error state. + */ + @Column(name = "institute_id") + private String instituteId; + @Column(name = "archived", nullable = false) private Boolean archived; diff --git a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/repository/AppRegistrationRepository.java b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/repository/AppRegistrationRepository.java index 8aafab937d..21cbde82c2 100644 --- a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/repository/AppRegistrationRepository.java +++ b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/repository/AppRegistrationRepository.java @@ -8,4 +8,6 @@ public interface AppRegistrationRepository extends JpaRepository { List findAllByOrderByNameAsc(); + + List findAllByInstituteIdAndArchivedFalseOrderByNameAsc(String instituteId); } diff --git a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/service/AppRegistryService.java b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/service/AppRegistryService.java index 096c1bdc69..35c885f1fd 100644 --- a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/service/AppRegistryService.java +++ b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/service/AppRegistryService.java @@ -41,6 +41,20 @@ public List listAll() { return out; } + /** + * Read path for the institute-admin-facing status view. Unlike {@link #listAll()} this never + * returns archived apps — an institute admin should not see a decommissioned registration and + * wonder why "their app" looks broken. + */ + @Transactional(readOnly = true) + public List listByInstitute(String instituteId) { + List out = new ArrayList<>(); + for (AppRegistration row : repository.findAllByInstituteIdAndArchivedFalseOrderByNameAsc(instituteId)) { + out.add(parse(row.getPayload(), row.getId())); + } + return out; + } + @Transactional(readOnly = true) public JsonNode get(String id) { AppRegistration row = repository.findById(id) @@ -101,6 +115,7 @@ private AppRegistration save(String id, JsonNode record) { row.setName(textAt(basics, "name", "")); row.setClientName(textAt(basics, "client", "")); row.setPackageName(textAt(basics, "packageName", "")); + row.setInstituteId(textAt(basics, "instituteId", null)); row.setArchived(node.path("archived").asBoolean(false)); row.setPayload(write(node)); return repository.save(row); diff --git a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/service/StoreStatusSyncService.java b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/service/StoreStatusSyncService.java new file mode 100644 index 0000000000..9b758b19ce --- /dev/null +++ b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/service/StoreStatusSyncService.java @@ -0,0 +1,233 @@ +package vacademy.io.community_service.feature.appregistry.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vacademy.io.community_service.feature.appregistry.entity.AppRegistration; +import vacademy.io.community_service.feature.appregistry.repository.AppRegistrationRepository; +import vacademy.io.community_service.feature.appregistry.store.AppStoreConnectClient; +import vacademy.io.community_service.feature.appregistry.store.GooglePlayClient; +import vacademy.io.community_service.feature.appregistry.store.MicrosoftPartnerCenterClient; +import vacademy.io.community_service.feature.appregistry.store.StoreCredentialResolver; + +import java.time.Instant; +import java.util.Locale; +import java.util.Map; + +/** + * Live status sync across all four platforms — which provider client is actually reachable + * depends entirely on whether a credential exists for this institute (see + * {@link StoreCredentialResolver}). When none does, {@link #sync} returns null and the caller + * falls back to the existing "manual action required" 501 response — never a fabricated status. + * + *

App Store Connect (IOS/MACOS) is the only one of the four verified against real, live data + * this was built and tested against — see {@link AppStoreConnectClient}'s javadoc for what that + * caught. {@link GooglePlayClient} and {@link MicrosoftPartnerCenterClient} are written to their + * providers' documented API shapes but have never run against a real credential; treat their + * status-mapping as reviewed, not proven, until the first institute with a real Play/Partner + * Center credential exercises them. + * + *

A successful sync also writes the fetched status/version/build back into the stored + * {@code AppRegistration.payload}, so an institute admin reading the status endpoint sees the + * same freshly-synced data an ops person just pulled in health-check — not stale, manually-typed + * values from whenever someone last edited the record by hand. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class StoreStatusSyncService { + + private final AppRegistrationRepository repository; + private final StoreCredentialResolver storeCredentialResolver; + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * @return the AppStatusResult-shaped fields for the dashboard's {@code getAppStatus} contract, + * or null when this platform/appId combination can't be synced live — no credential + * configured for this institute+platform, the record/identifier field is missing, or + * the call to the store failed. The controller treats null as "fall back to manual". + */ + @Transactional + public Map sync(String recordId, String platform) { + String platformKey = platform == null ? "" : platform.toUpperCase(Locale.ROOT); + if (!"IOS".equals(platformKey) && !"MACOS".equals(platformKey) + && !"ANDROID".equals(platformKey) && !"WINDOWS".equals(platformKey)) { + return null; + } + + AppRegistration row = repository.findById(recordId).orElse(null); + if (row == null) { + return null; + } + + ObjectNode record; + try { + record = (ObjectNode) objectMapper.readTree(row.getPayload()); + } catch (JsonProcessingException e) { + log.warn("[StoreStatusSync] Stored record {} is not valid JSON, skipping sync", recordId); + return null; + } + + String instituteId = record.path("basics").path("instituteId").asText(null); + JsonNode platformNode = record.path("platforms").path(platformKey); + + Result result = switch (platformKey) { + case "IOS", "MACOS" -> syncApple(platformNode, instituteId, platformKey); + case "ANDROID" -> syncGooglePlay(platformNode, instituteId); + case "WINDOWS" -> syncPartnerCenter(platformNode, instituteId); + default -> null; + }; + if (result == null) { + return null; + } + + String syncedAt = Instant.now().toString(); + persist(row, record, platformKey, result, syncedAt); + + return Map.of( + "status", result.status, + "version", result.version, + "build", result.build, + "releasedAt", result.releasedAt, + // OTA (Capacitor bundle) rollout status is a separate system this integration has + // no visibility into — never fabricated, always reported as unknown. + "otaStatus", "NONE", + "storeUrl", result.storeUrl); + } + + /** Common shape every provider branch reduces to before the shared persist/response step. */ + private record Result(String status, String version, String build, String storeUrl, String releasedAt) { + } + + private Result syncApple(JsonNode platformNode, String instituteId, String platformKey) { + String bundleId = platformNode.path("fields").path("bundle_id").asText(""); + if (bundleId.isBlank()) { + return null; + } + AppStoreConnectClient client = storeCredentialResolver.resolveAppStoreConnect(instituteId, platformKey); + if (client == null) { + return null; + } + AppStoreConnectClient.AppStatus ascStatus = client.fetchStatus(bundleId); + String status = ascStatus == null ? "NOT_REGISTERED" : mapAppStoreState(ascStatus.appStoreState()); + String storeUrl = ascStatus == null ? "" + : "https://appstoreconnect.apple.com/apps/" + ascStatus.ascAppId() + "/appstore"; + String releasedAt = "LIVE".equals(status) && ascStatus != null ? ascStatus.createdDate() : ""; + return new Result(status, + ascStatus == null ? "" : ascStatus.versionString(), + ascStatus == null ? "" : ascStatus.buildNumber(), + storeUrl, releasedAt); + } + + private Result syncGooglePlay(JsonNode platformNode, String instituteId) { + String packageName = platformNode.path("fields").path("package_name").asText(""); + if (packageName.isBlank()) { + return null; + } + GooglePlayClient client = storeCredentialResolver.resolveGooglePlay(instituteId); + if (client == null) { + return null; + } + GooglePlayClient.AppStatus playStatus = client.fetchStatus(packageName); + String status = playStatus == null ? "NOT_REGISTERED" : mapPlayReleaseStatus(playStatus.releaseStatus()); + String storeUrl = playStatus == null ? "" + : "https://play.google.com/console/developers/app/" + packageName; + return new Result(status, + playStatus == null ? "" : playStatus.releaseName(), + playStatus == null ? "" : playStatus.versionCode(), + storeUrl, ""); + } + + private Result syncPartnerCenter(JsonNode platformNode, String instituteId) { + String storeId = platformNode.path("fields").path("store_id").asText(""); + if (storeId.isBlank()) { + return null; + } + MicrosoftPartnerCenterClient client = storeCredentialResolver.resolvePartnerCenter(instituteId); + if (client == null) { + return null; + } + MicrosoftPartnerCenterClient.AppStatus pcStatus = client.fetchStatus(storeId); + String status = pcStatus == null ? "NOT_REGISTERED" : mapPartnerCenterStatus(pcStatus.submissionStatus()); + String storeUrl = pcStatus == null ? "" + : "https://partner.microsoft.com/dashboard/products/" + storeId; + return new Result(status, "", "", storeUrl, ""); + } + + private void persist(AppRegistration row, ObjectNode record, String platformKey, Result result, + String syncedAt) { + ObjectNode platforms = (ObjectNode) record.path("platforms"); + ObjectNode platformNode = (ObjectNode) platforms.path(platformKey); + platformNode.put("status", result.status); + if (!result.version.isBlank()) platformNode.put("currentVersion", result.version); + if (!result.build.isBlank()) platformNode.put("currentBuild", result.build); + if (!result.storeUrl.isBlank()) platformNode.put("storeUrl", result.storeUrl); + if (!result.releasedAt.isBlank()) platformNode.put("releasedAt", result.releasedAt); + platformNode.put("lastSyncedAt", syncedAt); + record.put("updatedAt", syncedAt); + + try { + row.setPayload(objectMapper.writeValueAsString(record)); + repository.save(row); + } catch (JsonProcessingException e) { + log.warn("[StoreStatusSync] Could not serialise synced record {}: {}", row.getId(), e.getMessage()); + } + } + + /** + * Maps App Store Connect's {@code appStoreState} to the dashboard's StoreStatus enum. Unmapped + * / unrecognised states fall back to SUBMITTED rather than a guess in either direction — that + * reads as "something is in flight, go check" rather than falsely implying success or failure. + */ + private static String mapAppStoreState(String appStoreState) { + return switch (appStoreState) { + case "READY_FOR_SALE" -> "LIVE"; + case "PREPARE_FOR_SUBMISSION" -> "DRAFT"; + case "WAITING_FOR_REVIEW", "WAITING_FOR_EXPORT_COMPLIANCE" -> "SUBMITTED"; + case "IN_REVIEW" -> "IN_REVIEW"; + case "PENDING_APPLE_RELEASE", "PENDING_DEVELOPER_RELEASE" -> "APPROVED"; + case "REJECTED", "DEVELOPER_REJECTED", "METADATA_REJECTED", "INVALID_BINARY" -> "REJECTED"; + case "DEVELOPER_REMOVED_FROM_SALE", "REMOVED_FROM_SALE" -> "REMOVED"; + case "PENDING_CONTRACT" -> "SUSPENDED"; + case "PROCESSING_FOR_APP_STORE" -> "BUILD_PROCESSING"; + default -> "SUBMITTED"; + }; + } + + /** + * Maps a Play production-track release's {@code status} field to the dashboard's StoreStatus. + * Per Google's documented values: draft, inProgress, halted, completed. Unverified against a + * real account — see this class's javadoc. + */ + private static String mapPlayReleaseStatus(String releaseStatus) { + return switch (releaseStatus) { + case "completed" -> "LIVE"; + case "inProgress" -> "SUBMITTED"; + case "halted" -> "SUSPENDED"; + case "draft" -> "DRAFT"; + default -> "SUBMITTED"; + }; + } + + /** + * Maps a Microsoft Store submission's {@code status} field to the dashboard's StoreStatus. + * Unverified against a real account — see this class's javadoc. + */ + private static String mapPartnerCenterStatus(String submissionStatus) { + return switch (submissionStatus) { + case "Published" -> "LIVE"; + case "Release" -> "APPROVED"; + case "Certification" -> "IN_REVIEW"; + case "PendingCommit", "CommitStarted", "PreProcessing", "Signing" -> "SUBMITTED"; + case "Failed", "PublishFailed", "PreProcessingFailed", "CertificationFailed", "ReleaseFailed" -> "REJECTED"; + case "Canceled" -> "REMOVED"; + case "None" -> "DRAFT"; + default -> "SUBMITTED"; + }; + } +} diff --git a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/AppStoreConnectClient.java b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/AppStoreConnectClient.java new file mode 100644 index 0000000000..c73f826285 --- /dev/null +++ b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/AppStoreConnectClient.java @@ -0,0 +1,214 @@ +package vacademy.io.community_service.feature.appregistry.store; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Date; + +/** + * App Store Connect API client for the app-registry "provider" status sync — read-only, and + * deliberately narrow: it answers exactly the questions {@code getAppStatus} needs (does an app + * exist for this bundle id, what's its latest version, and what review/release state is it in), + * not a general-purpose ASC SDK. + * + *

Not a Spring-managed singleton. Different white-label institutes can own separate + * Apple Developer accounts — the flat "one shared credential from an env var" design this started + * with silently reported every app under any *other* account as "Not Registered", which is + * actively wrong, not just unverified (found via Shiksha Nation, which has its own account). So + * this is now a plain object built per credential by {@link StoreCredentialResolver}, which picks + * the right one — institute-specific, falling back to a shared default — before constructing it. + * Each instance caches its own signed JWT; nothing here is process-wide state. + */ +@Slf4j +public class AppStoreConnectClient { + + private static final String BASE_URL = "https://api.appstoreconnect.apple.com"; + /** Apple caps token lifetime at 20 minutes; stay comfortably inside that. */ + private static final long TOKEN_TTL_SECONDS = 900; + + private final String issuerId; + private final String keyId; + private final PrivateKey privateKey; + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + private volatile String cachedToken; + private volatile long cachedTokenExpiresAtEpochSeconds; + + private AppStoreConnectClient(String issuerId, String keyId, PrivateKey privateKey) { + this.issuerId = issuerId; + this.keyId = keyId; + this.privateKey = privateKey; + } + + /** + * @return a client for this credential, or null if issuerId/keyId/p8 are missing or the p8 + * doesn't parse as an EC private key — callers treat null exactly like "not configured". + */ + public static AppStoreConnectClient of(String issuerId, String keyId, String p8) { + if (!StringUtils.hasText(issuerId) || !StringUtils.hasText(keyId) || !StringUtils.hasText(p8)) { + return null; + } + PrivateKey privateKey = parsePrivateKey(p8); + return privateKey == null ? null : new AppStoreConnectClient(issuerId, keyId, privateKey); + } + + /** Result of a status lookup, or null if no app is registered in ASC for that bundle id. */ + public record AppStatus(String ascAppId, String appStoreState, String versionString, + String buildNumber, String createdDate) { + } + + /** + * @return the latest app-store-version info for {@code bundleId}, or null if ASC has no app + * with that bundle id under *this credential's* Apple Developer account, or the + * lookup failed. A null here does not prove the app doesn't exist anywhere — only + * that it isn't visible to this specific account. + */ + public AppStatus fetchStatus(String bundleId) { + if (!StringUtils.hasText(bundleId)) { + return null; + } + try { + JsonNode appsBody = get("/v1/apps?filter[bundleId]=" + bundleId); + JsonNode apps = appsBody.path("data"); + if (!apps.isArray() || apps.isEmpty()) { + return null; + } + String ascAppId = apps.get(0).path("id").asText(null); + if (ascAppId == null) { + return null; + } + + // `sort` is rejected on this nested relationship route (verified against the live API + // — it 400s with PARAMETER_ERROR.ILLEGAL), unlike the top-level /v1/appStoreVersions + // collection where it's documented. So the "most recent" version is picked client-side + // by comparing createdDate across the page, not trusted to arrive in a given order. + // include=build resolves the CFBundleVersion in the same call; it's matched back to + // THIS version specifically via its build relationship id — with limit>1, `included` + // can contain builds for other versions too, so "first builds entry" would be wrong. + JsonNode versionsBody = get("/v1/apps/" + ascAppId + "/appStoreVersions?limit=50&include=build"); + JsonNode versions = versionsBody.path("data"); + if (!versions.isArray() || versions.isEmpty()) { + // App record exists but has never had a version submitted. + return new AppStatus(ascAppId, "PREPARE_FOR_SUBMISSION", "", "", ""); + } + + // "Most recently created version" is NOT the same question as "what's live right now" — + // verified against a real multi-version app (io.vacademy.student.app): its newest + // version by createdDate was an abandoned PREPARE_FOR_SUBMISSION draft, while an older + // entry was the actual READY_FOR_SALE version still serving users. Naively picking the + // newest createdDate would have reported a live, working app as "Draft" — actively + // wrong for the one audience (an institute admin) asking "is my app working right now". + // + // So: prefer the most recent version that is actually READY_FOR_SALE (what's live), + // and only fall back to the overall most recent version when nothing has ever gone + // live yet (a brand-new app still in its first submission). + JsonNode latest = mostRecentByState(versions, "READY_FOR_SALE"); + if (latest == null) { + latest = mostRecentByState(versions, null); + } + + JsonNode attrs = latest.path("attributes"); + String buildRelId = latest.path("relationships").path("build").path("data").path("id").asText(null); + String buildNumber = ""; + if (buildRelId != null) { + for (JsonNode included : versionsBody.path("included")) { + if ("builds".equals(included.path("type").asText()) + && buildRelId.equals(included.path("id").asText())) { + buildNumber = included.path("attributes").path("version").asText(""); + break; + } + } + } + return new AppStatus( + ascAppId, + attrs.path("appStoreState").asText(""), + attrs.path("versionString").asText(""), + buildNumber, + attrs.path("createdDate").asText("")); + } catch (Exception e) { + log.warn("[AppStoreConnect] Status lookup failed for bundleId={}: {}", bundleId, e.getMessage()); + return null; + } + } + + /** + * @param requiredState if non-null, only versions in this exact appStoreState are considered; + * if null, every version is a candidate. Ties broken by createdDate. + * @return the matching version with the latest createdDate, or null if none match. + */ + private static JsonNode mostRecentByState(JsonNode versions, String requiredState) { + JsonNode best = null; + String bestCreatedDate = ""; + for (JsonNode version : versions) { + if (requiredState != null + && !requiredState.equals(version.path("attributes").path("appStoreState").asText(""))) { + continue; + } + String createdDate = version.path("attributes").path("createdDate").asText(""); + if (best == null || createdDate.compareTo(bestCreatedDate) > 0) { + best = version; + bestCreatedDate = createdDate; + } + } + return best; + } + + /* ------------------------------------------------------------------ internals */ + + private JsonNode get(String path) throws Exception { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + token()); + RequestEntity request = new RequestEntity<>(headers, HttpMethod.GET, URI.create(BASE_URL + path)); + ResponseEntity response = restTemplate.exchange(request, String.class); + return objectMapper.readTree(response.getBody()); + } + + private synchronized String token() { + long now = System.currentTimeMillis() / 1000; + if (cachedToken != null && now < cachedTokenExpiresAtEpochSeconds - 30) { + return cachedToken; + } + long exp = now + TOKEN_TTL_SECONDS; + cachedToken = Jwts.builder() + .setHeaderParam("kid", keyId) + .setHeaderParam("typ", "JWT") + .setIssuer(issuerId) + .setIssuedAt(new Date(now * 1000)) + .setExpiration(new Date(exp * 1000)) + .claim("aud", "appstoreconnect-v1") + .signWith(privateKey, SignatureAlgorithm.ES256) + .compact(); + cachedTokenExpiresAtEpochSeconds = exp; + return cachedToken; + } + + private static PrivateKey parsePrivateKey(String p8) { + try { + String cleaned = p8 + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + byte[] der = Base64.getDecoder().decode(cleaned); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(der)); + } catch (Exception e) { + log.warn("[AppStoreConnect] Could not parse a p8 value as an EC private key: {}", e.getMessage()); + return null; + } + } +} diff --git a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/GooglePlayClient.java b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/GooglePlayClient.java new file mode 100644 index 0000000000..a9bf3e1429 --- /dev/null +++ b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/GooglePlayClient.java @@ -0,0 +1,209 @@ +package vacademy.io.community_service.feature.appregistry.store; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.util.StringUtils; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Date; + +/** + * Google Play Developer API client — read-only in intent, but the API itself has no read-only + * status endpoint: inspecting a track's release status requires opening an "edit" session, the + * same mechanism a human uses in Play Console to stage changes. So {@link #fetchStatus} opens an + * edit, reads the production track, and deletes (never commits) the edit in a finally block — + * nothing is ever published through this class, and the edit is discarded immediately after the + * read rather than left dangling. + * + *

Real, load-bearing caveat: Play Console allows only one open edit per app at a time. + * If a human has an edit open in the console when this runs, this call will fail with a 409 — by + * design, not a bug to route around. {@link #fetchStatus} treats that as "could not verify right + * now" (null), never as "not registered". + * + *

Unverified. Unlike {@link AppStoreConnectClient}, this was written to Google's + * documented API shape but never exercised against a real service account — no Play Developer + * credential exists anywhere in this project yet. Treat the parsing logic as reviewed-but-untested + * until the first real credential is wired in and this gets exercised for real. + */ +@Slf4j +public class GooglePlayClient { + + private static final String TOKEN_URL = "https://oauth2.googleapis.com/token"; + private static final String API_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3"; + private static final String SCOPE = "https://www.googleapis.com/auth/androidpublisher"; + private static final long TOKEN_TTL_SECONDS = 3300; // Google allows up to 3600s; stay under. + + private final String clientEmail; + private final PrivateKey privateKey; + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + private volatile String cachedAccessToken; + private volatile long cachedTokenExpiresAtEpochSeconds; + + private GooglePlayClient(String clientEmail, PrivateKey privateKey) { + this.clientEmail = clientEmail; + this.privateKey = privateKey; + } + + /** + * @param serviceAccountJson the raw JSON of a downloaded Google Cloud service-account key + * (must contain {@code client_email} and {@code private_key}). + * @return a client, or null if the JSON is missing those fields or doesn't parse. + */ + public static GooglePlayClient of(String serviceAccountJson) { + if (!StringUtils.hasText(serviceAccountJson)) { + return null; + } + try { + ObjectMapper mapper = new ObjectMapper(); + JsonNode json = mapper.readTree(serviceAccountJson); + String clientEmail = json.path("client_email").asText(null); + String pem = json.path("private_key").asText(null); + if (clientEmail == null || pem == null) { + return null; + } + String cleaned = pem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + byte[] der = Base64.getDecoder().decode(cleaned); + PrivateKey privateKey = KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(der)); + return new GooglePlayClient(clientEmail, privateKey); + } catch (Exception e) { + log.warn("[GooglePlay] Could not parse service account JSON: {}", e.getMessage()); + return null; + } + } + + /** Result of a track lookup, or null if this account has no app with that package name. */ + public record AppStatus(String releaseStatus, String versionCode, String releaseName) { + } + + /** + * @return production-track status for {@code packageName}, or null if the app isn't visible + * to this service account, or the lookup couldn't complete (including the 409 + * "another edit is already open" case — never conflated with "not registered"). + */ + public AppStatus fetchStatus(String packageName) { + if (!StringUtils.hasText(packageName)) { + return null; + } + String editId = null; + try { + JsonNode editBody = post("/applications/" + packageName + "/edits", null); + editId = editBody.path("id").asText(null); + if (editId == null) { + return null; + } + + JsonNode track = get("/applications/" + packageName + "/edits/" + editId + "/tracks/production"); + JsonNode releases = track.path("releases"); + if (!releases.isArray() || releases.isEmpty()) { + // Track exists (app is registered) but nothing has ever been released to it. + return new AppStatus("draft", "", ""); + } + + // releases[0] is documented as the track's current/most relevant release. + JsonNode release = releases.get(0); + String status = release.path("status").asText(""); + String versionCode = ""; + JsonNode versionCodes = release.path("versionCodes"); + if (versionCodes.isArray() && !versionCodes.isEmpty()) { + versionCode = versionCodes.get(0).asText(""); + } + String releaseName = release.path("name").asText(""); + return new AppStatus(status, versionCode, releaseName); + } catch (HttpClientErrorException.NotFound e) { + // No app registered under this package name for this service account. + return null; + } catch (HttpClientErrorException.Conflict e) { + log.warn("[GooglePlay] Edit conflict for {} — another edit session is already open " + + "(likely a human in Play Console); could not verify status this time.", packageName); + return null; + } catch (Exception e) { + log.warn("[GooglePlay] Status lookup failed for packageName={}: {}", packageName, e.getMessage()); + return null; + } finally { + if (editId != null) { + try { + delete("/applications/" + packageName + "/edits/" + editId); + } catch (Exception e) { + log.warn("[GooglePlay] Could not discard edit {} for {}: {}", editId, packageName, e.getMessage()); + } + } + } + } + + /* ------------------------------------------------------------------ internals */ + + private JsonNode get(String path) throws Exception { + return exchange(HttpMethod.GET, path, null); + } + + private JsonNode post(String path, Object body) throws Exception { + return exchange(HttpMethod.POST, path, body); + } + + private void delete(String path) throws Exception { + exchange(HttpMethod.DELETE, path, null); + } + + private JsonNode exchange(HttpMethod method, String path, Object body) throws Exception { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken()); + headers.setContentType(MediaType.APPLICATION_JSON); + RequestEntity request = new RequestEntity<>(body, headers, method, URI.create(API_BASE + path)); + ResponseEntity response = restTemplate.exchange(request, String.class); + String responseBody = response.getBody(); + return (responseBody == null || responseBody.isBlank()) ? objectMapper.createObjectNode() + : objectMapper.readTree(responseBody); + } + + private synchronized String accessToken() throws Exception { + long now = System.currentTimeMillis() / 1000; + if (cachedAccessToken != null && now < cachedTokenExpiresAtEpochSeconds - 30) { + return cachedAccessToken; + } + + long exp = now + TOKEN_TTL_SECONDS; + String assertion = Jwts.builder() + .setIssuer(clientEmail) + .claim("scope", SCOPE) + .setAudience(TOKEN_URL) + .setIssuedAt(new Date(now * 1000)) + .setExpiration(new Date(exp * 1000)) + .signWith(privateKey, SignatureAlgorithm.RS256) + .compact(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + org.springframework.util.MultiValueMap form = new org.springframework.util.LinkedMultiValueMap<>(); + form.add("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"); + form.add("assertion", assertion); + RequestEntity> request = + new RequestEntity<>(form, headers, HttpMethod.POST, URI.create(TOKEN_URL)); + + ResponseEntity response = restTemplate.exchange(request, String.class); + JsonNode json = objectMapper.readTree(response.getBody()); + String accessToken = json.path("access_token").asText(null); + if (accessToken == null) { + throw new IllegalStateException("Google token endpoint did not return access_token"); + } + long expiresIn = json.path("expires_in").asLong(TOKEN_TTL_SECONDS); + + cachedAccessToken = accessToken; + cachedTokenExpiresAtEpochSeconds = now + expiresIn; + return cachedAccessToken; + } +} diff --git a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/MicrosoftPartnerCenterClient.java b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/MicrosoftPartnerCenterClient.java new file mode 100644 index 0000000000..57fe32a44c --- /dev/null +++ b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/MicrosoftPartnerCenterClient.java @@ -0,0 +1,133 @@ +package vacademy.io.community_service.feature.appregistry.store; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; + +/** + * Microsoft Store submission API client (Partner Center), Azure AD client-credentials auth. + * + *

Unverified. Same caveat as {@link GooglePlayClient}: written to Microsoft's documented + * API shape, never exercised against a real Partner Center account — no Azure AD credential for + * this exists anywhere in the project. The field names read out of the application resource + * ({@code lastPublishedApplicationSubmission.status}, etc.) are Microsoft's documented shape as of + * when this was written; verify against a real response before trusting the status mapping. + */ +@Slf4j +public class MicrosoftPartnerCenterClient { + + private static final String API_BASE = "https://manage.devcenter.microsoft.com/v1.0/my"; + private static final String RESOURCE = "https://manage.devcenter.microsoft.com"; + + private final String tenantId; + private final String clientId; + private final String clientSecret; + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + private volatile String cachedAccessToken; + private volatile long cachedTokenExpiresAtEpochSeconds; + + private MicrosoftPartnerCenterClient(String tenantId, String clientId, String clientSecret) { + this.tenantId = tenantId; + this.clientId = clientId; + this.clientSecret = clientSecret; + } + + /** + * @return a client, or null if any of tenantId/clientId/clientSecret is missing. + */ + public static MicrosoftPartnerCenterClient of(String tenantId, String clientId, String clientSecret) { + if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(clientId) || !StringUtils.hasText(clientSecret)) { + return null; + } + return new MicrosoftPartnerCenterClient(tenantId, clientId, clientSecret); + } + + /** Result of an application lookup, or null if this account has no such application. */ + public record AppStatus(String submissionStatus, String versionOrPackageFamily) { + } + + /** + * @param applicationId the Microsoft Store application id (the "Store ID", e.g. 9NBLGGH4XXXX). + * @return the app's most recent submission status, or null if it isn't visible to this + * account or the lookup failed. + */ + public AppStatus fetchStatus(String applicationId) { + if (!StringUtils.hasText(applicationId)) { + return null; + } + try { + JsonNode app = get("/applications/" + applicationId); + + // Prefer the in-flight submission if one exists (it's the more current answer to + // "what's happening with this app right now"); fall back to the last published one. + JsonNode submission = app.path("pendingApplicationSubmission"); + if (submission.isMissingNode() || submission.isNull()) { + submission = app.path("lastPublishedApplicationSubmission"); + } + if (submission.isMissingNode() || submission.isNull()) { + // App is registered in Partner Center but has never had a submission. + return new AppStatus("None", ""); + } + + String status = submission.path("status").asText(""); + String packageFamilyName = app.path("packageFamilyName").asText(""); + return new AppStatus(status, packageFamilyName); + } catch (HttpClientErrorException.NotFound e) { + return null; + } catch (Exception e) { + log.warn("[MicrosoftPartnerCenter] Status lookup failed for applicationId={}: {}", + applicationId, e.getMessage()); + return null; + } + } + + /* ------------------------------------------------------------------ internals */ + + private JsonNode get(String path) throws Exception { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken()); + RequestEntity request = new RequestEntity<>(headers, HttpMethod.GET, URI.create(API_BASE + path)); + ResponseEntity response = restTemplate.exchange(request, String.class); + return objectMapper.readTree(response.getBody()); + } + + private synchronized String accessToken() throws Exception { + long now = System.currentTimeMillis() / 1000; + if (cachedAccessToken != null && now < cachedTokenExpiresAtEpochSeconds - 30) { + return cachedAccessToken; + } + + String tokenUrl = "https://login.microsoftonline.com/" + tenantId + "/oauth2/token"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("grant_type", "client_credentials"); + form.add("client_id", clientId); + form.add("client_secret", clientSecret); + form.add("resource", RESOURCE); + RequestEntity> request = + new RequestEntity<>(form, headers, HttpMethod.POST, URI.create(tokenUrl)); + + ResponseEntity response = restTemplate.exchange(request, String.class); + JsonNode json = objectMapper.readTree(response.getBody()); + String accessToken = json.path("access_token").asText(null); + if (accessToken == null) { + throw new IllegalStateException("Azure AD token endpoint did not return access_token"); + } + long expiresIn = json.path("expires_in").asLong(3300); + + cachedAccessToken = accessToken; + cachedTokenExpiresAtEpochSeconds = now + expiresIn; + return cachedAccessToken; + } +} diff --git a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/StoreCredential.java b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/StoreCredential.java new file mode 100644 index 0000000000..912c5fd281 --- /dev/null +++ b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/StoreCredential.java @@ -0,0 +1,73 @@ +package vacademy.io.community_service.feature.appregistry.store; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.type.SqlTypes; + +import java.util.Date; + +/** + * A store API credential (App Store Connect, Play Developer, Partner Center) for live status + * sync, scoped to one institute or shared as the platform-wide default. + * + *

Why this exists at all: the first version of this sync used a single App Store Connect + * credential straight from a k8s secret env var, one team for every institute. That's wrong for + * any institute with its own Apple Developer account — Shiksha Nation is the one that surfaced + * it, but nothing stops another brand from having the same setup. So credentials are now looked + * up per (instituteId, platform, provider), with a shared "institute_id IS NULL" row as the + * fallback that keeps every brand still on the original shared team working unchanged. + * + *

{@code credentialJson}'s shape depends on {@code provider} — for APP_STORE_CONNECT it's + * {@code {"issuerId": "...", "keyId": "...", "p8": "-----BEGIN PRIVATE KEY-----..."}}. Stored as + * plain jsonb, matching this codebase's existing convention for third-party API credentials (see + * {@code InstitutePaymentGatewayMapping.paymentGatewaySpecificData}) — access control is the + * database's, not a bespoke encryption layer here. + */ +@Entity +@Table(name = "store_credential", schema = "public", + indexes = { + @Index(name = "idx_store_credential_lookup", columnList = "institute_id,platform,provider") + }) +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(of = "id") +public class StoreCredential { + + @Id + @Column(name = "id") + private String id; + + /** Null means "shared default", used when no institute-specific row matches. */ + @Column(name = "institute_id") + private String instituteId; + + /** ANDROID / IOS / WINDOWS / MACOS. */ + @Column(name = "platform", nullable = false) + private String platform; + + /** APP_STORE_CONNECT / GOOGLE_PLAY / PARTNER_CENTER. */ + @Column(name = "provider", nullable = false) + private String provider; + + /** Human label for the SuperAdmin UI, e.g. "Shiksha Nation's own Apple Developer account". */ + @Column(name = "label") + private String label; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "credential_json", columnDefinition = "jsonb", nullable = false) + private String credentialJson; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private Date createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private Date updatedAt; +} diff --git a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/StoreCredentialAdminController.java b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/StoreCredentialAdminController.java new file mode 100644 index 0000000000..2df6e29189 --- /dev/null +++ b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/StoreCredentialAdminController.java @@ -0,0 +1,140 @@ +package vacademy.io.community_service.feature.appregistry.store; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Builder; +import lombok.Data; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import vacademy.io.common.auth.model.CustomUserDetails; +import vacademy.io.common.auth.util.SuperAdminAuthUtil; +import vacademy.io.common.exceptions.VacademyException; + +import java.util.List; +import java.util.UUID; + +/** + * SuperAdmin management of per-institute store API credentials — App Store Connect today, + * Play Developer / Partner Center once those provider integrations exist. See + * {@link StoreCredentialResolver} for how a credential is picked at sync time, and + * {@link StoreCredential}'s javadoc for why institute-scoped credentials exist at all. + * + *

Secrets are write-only through this API: {@link StoreCredentialView} never includes + * {@code credentialJson}, so a private key can be set here but never read back over HTTP — + * the same posture as changing a password without displaying the old one. + */ +@RestController +@RequestMapping("/community-service/super-admin/v1/store-credentials") +public class StoreCredentialAdminController { + + @Autowired + private StoreCredentialRepository repository; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @GetMapping + public ResponseEntity> list(@RequestAttribute("user") CustomUserDetails user) { + SuperAdminAuthUtil.requireSuperAdmin(user); + return ResponseEntity.ok(repository.findAllByOrderByInstituteIdAscPlatformAsc() + .stream().map(StoreCredentialView::of).toList()); + } + + @PostMapping + public ResponseEntity upsert(@RequestAttribute("user") CustomUserDetails user, + @RequestBody UpsertRequest request) { + SuperAdminAuthUtil.requireSuperAdmin(user); + + if (request.getPlatform() == null || request.getProvider() == null + || request.getCredentialJson() == null) { + throw new VacademyException(HttpStatus.BAD_REQUEST, "platform, provider and credentialJson are required"); + } + validateCredentialJson(request.getProvider(), request.getCredentialJson()); + + StoreCredential credential = request.getId() != null + ? repository.findById(request.getId()) + .orElseThrow(() -> new VacademyException(HttpStatus.NOT_FOUND, + "Credential not found: " + request.getId())) + : StoreCredential.builder().id(UUID.randomUUID().toString()).build(); + + credential.setInstituteId(request.getInstituteId()); + credential.setPlatform(request.getPlatform()); + credential.setProvider(request.getProvider()); + credential.setLabel(request.getLabel()); + credential.setCredentialJson(request.getCredentialJson()); + + return ResponseEntity.ok(StoreCredentialView.of(repository.save(credential))); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@RequestAttribute("user") CustomUserDetails user, @PathVariable String id) { + SuperAdminAuthUtil.requireSuperAdmin(user); + if (!repository.existsById(id)) { + throw new VacademyException(HttpStatus.NOT_FOUND, "Credential not found: " + id); + } + repository.deleteById(id); + return ResponseEntity.noContent().build(); + } + + private void validateCredentialJson(String provider, String credentialJson) { + JsonNode json; + try { + json = objectMapper.readTree(credentialJson); + } catch (Exception e) { + throw new VacademyException(HttpStatus.BAD_REQUEST, "credentialJson is not valid JSON"); + } + + switch (provider) { + case "APP_STORE_CONNECT" -> requireFields(provider, json, "issuerId", "keyId", "p8"); + case "GOOGLE_PLAY" -> requireFields(provider, json, "serviceAccountJson"); + case "PARTNER_CENTER" -> requireFields(provider, json, "tenantId", "clientId", "clientSecret"); + default -> throw new VacademyException(HttpStatus.BAD_REQUEST, + "Unknown provider: " + provider + " (expected APP_STORE_CONNECT, GOOGLE_PLAY or PARTNER_CENTER)"); + } + } + + private static void requireFields(String provider, JsonNode json, String... fields) { + for (String field : fields) { + if (!json.hasNonNull(field)) { + throw new VacademyException(HttpStatus.BAD_REQUEST, + provider + " credentialJson needs: " + String.join(", ", fields)); + } + } + } + + @Data + public static class UpsertRequest { + private String id; + private String instituteId; + private String platform; + private String provider; + private String label; + private String credentialJson; + } + + /** Redacted view — never carries {@code credentialJson} back out over HTTP. */ + @Data + @Builder + public static class StoreCredentialView { + private String id; + private String instituteId; + private String platform; + private String provider; + private String label; + private String createdAt; + private String updatedAt; + + static StoreCredentialView of(StoreCredential c) { + return StoreCredentialView.builder() + .id(c.getId()) + .instituteId(c.getInstituteId()) + .platform(c.getPlatform()) + .provider(c.getProvider()) + .label(c.getLabel()) + .createdAt(c.getCreatedAt() == null ? null : c.getCreatedAt().toInstant().toString()) + .updatedAt(c.getUpdatedAt() == null ? null : c.getUpdatedAt().toInstant().toString()) + .build(); + } + } +} diff --git a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/StoreCredentialRepository.java b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/StoreCredentialRepository.java new file mode 100644 index 0000000000..07467abbed --- /dev/null +++ b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/StoreCredentialRepository.java @@ -0,0 +1,17 @@ +package vacademy.io.community_service.feature.appregistry.store; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface StoreCredentialRepository extends JpaRepository { + + Optional findFirstByInstituteIdAndPlatformAndProvider( + String instituteId, String platform, String provider); + + Optional findFirstByInstituteIdIsNullAndPlatformAndProvider( + String platform, String provider); + + List findAllByOrderByInstituteIdAscPlatformAsc(); +} diff --git a/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/StoreCredentialResolver.java b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/StoreCredentialResolver.java new file mode 100644 index 0000000000..fb54e8bcf1 --- /dev/null +++ b/community_service/src/main/java/vacademy/io/community_service/feature/appregistry/store/StoreCredentialResolver.java @@ -0,0 +1,138 @@ +package vacademy.io.community_service.feature.appregistry.store; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * Resolves the right store-API client for a given (institute, platform, provider), in priority + * order: + * + *

    + *
  1. An institute-specific {@code store_credential} row — an institute with its own developer + * account (Shiksha Nation has its own Apple Developer account, distinct from the team most + * other brands share) overrides the shared default here.
  2. + *
  3. The shared default row ({@code institute_id IS NULL}) in the same table.
  4. + *
  5. For App Store Connect only: the original env-var credential from {@code vacademy-secrets}, + * kept as a last-resort fallback so nothing regresses if the table is ever empty. Google + * Play and Microsoft Partner Center never had an env-var credential to begin with — they + * only ever exist in {@code store_credential}, so there is no fallback tier for them.
  6. + *
+ * + *

Built clients are cached by credential id — signing a fresh token on every single request + * would work, but there is no reason to pay for it when the credential hasn't changed. A row's id + * never changes meaning (rotate a credential by creating a new row, matching how the app registry + * itself treats ids), so the cache never needs eviction beyond a process restart. + */ +@Component +@Slf4j +public class StoreCredentialResolver { + + private static final String PROVIDER_APP_STORE_CONNECT = "APP_STORE_CONNECT"; + private static final String PROVIDER_GOOGLE_PLAY = "GOOGLE_PLAY"; + private static final String PROVIDER_PARTNER_CENTER = "PARTNER_CENTER"; + + private final StoreCredentialRepository repository; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + private final AppStoreConnectClient envDefaultAppStoreConnectClient; + + public StoreCredentialResolver( + StoreCredentialRepository repository, + @Value("${APP_STORE_CONNECT_ISSUER_ID:}") String envIssuerId, + @Value("${APP_STORE_CONNECT_KEY_ID:}") String envKeyId, + @Value("${APP_STORE_CONNECT_P8:}") String envP8) { + this.repository = repository; + this.envDefaultAppStoreConnectClient = AppStoreConnectClient.of(envIssuerId, envKeyId, envP8); + } + + /** + * @param platform IOS or MACOS — kept distinct (rather than one Apple-wide lookup) because the + * schema allows an institute to register separate credentials per platform, + * even though in practice one Apple Developer account almost always covers + * both. + * @return a client for this institute's App Store Connect credential, or null if none is + * configured at all (own row, shared row, and env fallback all absent). + */ + public AppStoreConnectClient resolveAppStoreConnect(String instituteId, String platform) { + AppStoreConnectClient fromTable = resolve(instituteId, platform, PROVIDER_APP_STORE_CONNECT, + json -> AppStoreConnectClient.of( + json.path("issuerId").asText(null), + json.path("keyId").asText(null), + json.path("p8").asText(null))); + if (fromTable != null) { + return fromTable; + } + if (envDefaultAppStoreConnectClient == null) { + log.info("[StoreCredentialResolver] No App Store Connect credential in store_credential " + + "and no env-var fallback configured for institute={} platform={}.", instituteId, platform); + } + return envDefaultAppStoreConnectClient; + } + + /** + * @return a Google Play client for this institute, or null if no {@code store_credential} row + * (institute-specific or shared) exists — there is no env-var fallback for this + * provider. + */ + public GooglePlayClient resolveGooglePlay(String instituteId) { + return resolve(instituteId, "ANDROID", PROVIDER_GOOGLE_PLAY, + json -> GooglePlayClient.of(json.path("serviceAccountJson").asText(null))); + } + + /** + * @return a Microsoft Partner Center client for this institute, or null if no + * {@code store_credential} row exists — there is no env-var fallback for this + * provider. + */ + public MicrosoftPartnerCenterClient resolvePartnerCenter(String instituteId) { + return resolve(instituteId, "WINDOWS", PROVIDER_PARTNER_CENTER, + json -> MicrosoftPartnerCenterClient.of( + json.path("tenantId").asText(null), + json.path("clientId").asText(null), + json.path("clientSecret").asText(null))); + } + + /* ------------------------------------------------------------------ internals */ + + private T resolve(String instituteId, String platform, String provider, Function build) { + if (instituteId != null) { + Optional specific = repository.findFirstByInstituteIdAndPlatformAndProvider( + instituteId, platform, provider); + if (specific.isPresent()) { + return buildAndCache(specific.get(), build); + } + } + Optional shared = repository.findFirstByInstituteIdIsNullAndPlatformAndProvider( + platform, provider); + return shared.map(c -> buildAndCache(c, build)).orElse(null); + } + + @SuppressWarnings("unchecked") + private T buildAndCache(StoreCredential credential, Function build) { + return (T) cache.computeIfAbsent(credential.getId(), id -> { + try { + JsonNode json = objectMapper.readTree(credential.getCredentialJson()); + T client = build.apply(json); + if (client == null) { + log.warn("[StoreCredentialResolver] store_credential {} ({}) did not parse into a usable " + + "client — check its credential_json shape for provider={}.", + credential.getId(), credential.getLabel(), credential.getProvider()); + } + return client; + } catch (Exception e) { + log.warn("[StoreCredentialResolver] Could not read credential_json for store_credential {}: {}", + credential.getId(), e.getMessage()); + return null; + } + }); + } +} diff --git a/docs/ASSESSMENT_FEATURE.md b/docs/ASSESSMENT_FEATURE.md index 54f3debdd8..c36e450666 100644 --- a/docs/ASSESSMENT_FEATURE.md +++ b/docs/ASSESSMENT_FEATURE.md @@ -144,6 +144,13 @@ All correct answers live in `Question.autoEvaluationJson`. Service: [QuestionEva | ONE_WORD | `OneWordEvaluationDTO` | `{ "type":"ONE_WORD", "data":{ "answer":"photosynthesis" } }` | | LONG_ANSWER | `LongAnswerEvaluationDTO` | `{ "type":"LONG_ANSWER", "data":{ "answer":{ "html":"…", "plainText":"…" } } }` | +> **Casing trap.** The nested `data` classes (`MCQData`, `NumericalData`) do **not** +> inherit the outer class's `SnakeCaseStrategy` — Jackson does not propagate +> `@JsonNaming` to nested static classes. A generator emitting `correct_option_ids` +> therefore bound to nothing and left the list `null`, which grading then dereferenced. +> Both now carry `@JsonAlias` for the snake_case spelling; **serialization is still +> camelCase**, so stored rows and the learner report renderer are unaffected. + Auto-graded: MCQS, MCQM, TRUE_FALSE, NUMERIC, ONE_WORD. Manual-graded: LONG_ANSWER (and others when `evaluationType = MANUAL`). @@ -456,7 +463,75 @@ Publish ─────POST publish/v1/{id}───────▶ Status --- -## 6. Key File Index +## 6. Knowledge Base as a question source + +Questions can be generated from an institute's own books and notes. The knowledge base +itself (ingestion, chunking, embedding, topic tree, marketplace) lives in **`ai_service`** +(`app/services/kb/`) with its schema in **admin_core_service** Flyway +(V435 / V441 / V443 / V445 / V446); there is no Java KB code. `assessment_service` only +sees the finished questions. + +### 6.1 The three entry points in Step 2 + +| Where | Component | What it does | +|---|---|---| +| Beside **Add Section** | `Step2CreateAssessmentFromKnowledgeBase.tsx` | Plans and generates a **whole assessment**: blueprint → edit the plan → generate → review → each blueprint row becomes a section | +| Inside a section | `Step2CreateFromKnowledgeBase.tsx` | Fills **one section** with N questions of one type | +| Inside a section | `Step2PickFromQuestionBank.tsx` | **Reuses** questions already in the bank — no generation, no credits | + +All three **append**. None replaces what the section already holds. + +### 6.2 Generation pipeline + +``` +POST /ai-service/knowledge-base/v1/bases/{kb}/paper/blueprint plan (cheap, iterate) +POST /ai-service/knowledge-base/v1/bases/{kb}/paper/generate whole paper (202, async) +POST /ai-service/knowledge-base/v1/bases/{kb}/paper/section one section (202, async) +GET /ai-service/knowledge-base/v1/paper-jobs/{taskId} poll +POST /ai-service/knowledge-base/v1/bases/{kb}/paper/regenerate redo ONE question +POST /ai-service/knowledge-base/v1/bases/{kb}/paper/validate structural checks + -> POST /assessment-service/question-paper/manage/v1/add save to the bank +``` + +Generated questions pass through `ai_service/app/services/question_format.py`, the shared +converter **every** AI question source uses (KB, vsmart-upload, -audio, -prompt, -extract, +-image). It emits the exact `QuestionDTO` shape the question bank consumes. + +Supported types: `MCQS`, `MCQM`, `TRUE_FALSE`, `ONE_WORD`, `LONG_ANSWER`, `NUMERIC`. + +> NUMERIC and TRUE_FALSE were previously **skipped outright** by `format_questions`, which +> is why `kb/paper.py` used to store numericals as `ONE_WORD` (`STORAGE_QUESTION_TYPE`). +> Both now have handlers and are stored as themselves. **Questions saved before that +> change remain `ONE_WORD` and grade exactly as they always did.** + +### 6.3 Provenance (assessment_service V42) + +`question` carries three columns, all nullable: + +| Column | Meaning | +|---|---| +| `institute_id` | Owning institute, denormalised from `institute_question_paper`. Backfilled by V42. Lets a question be scoped without walking question → mapping → paper → institute | +| `source_type` | `MANUAL` \| `UPLOAD` \| `AI` \| `KNOWLEDGE_BASE` | +| `source_meta` | JSONB: `kb_id`, `generation_id`, `topic`, `section`, `node_ids`, `source_page`, `figures`, `planned_type` | + +Written by `kb/paper.py::pair_with_formatted`, carried on `QuestionDTO.sourceType` / +`sourceMeta`, persisted in `AddQuestionPaperFromImportManager.initializeQuestion`. + +### 6.4 Browsing individual questions + +`POST /assessment-service/question-bank/v1/questions/filter?instituteId=&pageNo=&pageSize=` + +Body (`QuestionBankFilter`, snake_case): `name`, `kb_ids`, `kb_node_ids`, `source_types`, +`question_types`, `difficulties`, `tag_ids`, `statuses`, `exclude_question_ids`. +Returns `Page`. + +This is the only question-**level** query in the service; `question-paper/view/v1/get-with-filters` +remains the paper-level one and is unchanged. KB filters use jsonb containment against the +GIN index on `source_meta`. + +--- + +## 7. Key File Index ### Backend - `assessment_service/src/main/java/vacademy/io/assessment_service/features/assessment/entity/` — `Assessment`, `Section`, `QuestionAssessmentSectionMapping`, `StudentAttempt`, `AssessmentUserRegistration` @@ -469,6 +544,14 @@ Publish ─────POST publish/v1/{id}───────▶ Status - `…/learner_assessment/manager/` — `LearnerAssessmentAttemptStartManager`, `LearnerAssessmentAttemptStatusManager` - `…/evaluation/service/QuestionEvaluationService.java` - `…/assessment/service/StudentAttemptService.java` +- `…/question_bank/controller/GetQuestionBankController.java`, `…/manager/GetQuestionBankManager.java`, `…/dto/QuestionBankFilter.java` — question-level browse +- `…/question_core/repository/QuestionRepository.findQuestionsByFilters` +- `src/main/resources/db/migration/V42__question_source_and_institute.sql` + +### ai_service (knowledge base) +- `ai_service/app/services/kb/` — `paper.py` (blueprint + generation + validation), `repository.py`, `retrieval.py`, `topics.py`, `ingest.py`, `generations.py` +- `ai_service/app/routers/kb_paper.py`, `knowledge_base.py`, `kb_library.py` +- `ai_service/app/services/question_format.py` — shared converter for **all** AI question sources ### Admin frontend - `frontend-admin-dashboard/src/routes/assessment/create-assessment/$assessmentId/$examtype/` @@ -478,6 +561,12 @@ Publish ─────POST publish/v1/{id}───────▶ Status - `-components/StepComponents/Step3AddingParticipants.tsx` - `-components/StepComponents/Step4AccessControl.tsx` - `-services/assessment-services.ts`, `-utils/*-schema.ts`, `-utils/zustand-global-states/*` + - `-components/StepComponents/-components/Step2CreateAssessmentFromKnowledgeBase.tsx` — whole assessment from a KB + - `-components/StepComponents/-components/Step2CreateFromKnowledgeBase.tsx` — one section from a KB + - `-components/StepComponents/-components/Step2PickFromQuestionBank.tsx` — reuse existing questions +- `frontend-admin-dashboard/src/routes/knowledge-base/` — the KB module itself (`-components/paper/BlueprintTable.tsx`, `ReviewBoard.tsx`, `TopicPicker.tsx`, `-services/paper-service.ts`) +- `frontend-admin-dashboard/src/routes/assessment/question-papers/-utils/merge-section-questions.ts` — append-don't-replace helper shared by every question-insert path +- `frontend-admin-dashboard/src/routes/assessment/question-papers/-utils/question-bank-services.ts` - `frontend-admin-dashboard/src/routes/assessment/question-papers/-components/` - `QuestionPaperUpload.tsx`, `QuestionPaperTemplate.tsx` - `QuestionPaperTemplatesTypes/MainViewComponentFactory.tsx` @@ -494,3 +583,32 @@ Publish ─────POST publish/v1/{id}───────▶ Status - `frontend-learner-dashboard-app/src/routes/assessment/reports/student-report/` - `frontend-learner-dashboard-app/src/components/common/student-test-records/test-report-dialog.tsx` - `frontend-learner-dashboard-app/src/components/common/student-test-records/question-response-renderer.tsx` + +--- + +## 8. Known issues, deliberately not fixed + +An audit of `assessment_service` and both frontends (2026-08) found these. They are real, +and each was left alone because fixing it would change behaviour that currently works — +so each needs its own pass, with a read-only preflight query against prod first. Recorded +here so they are not rediscovered from scratch. + +| # | Issue | Why it was deferred | +|---|---|---| +| 1 | `negativeMarkingPercentage` is read and discarded in MCQM/MCQS/ONE_WORD while NUMERIC applies it, so the same configured scheme penalises differently per type | Applying it changes live penalties for any institute that set a non-zero percentage | +| 2 | `NUMERICQuestionTypeBasedStrategy` compares with exact `Double` equality; no RANGE/tolerance despite `NumericQuestionTypes` existing | Changes scores, and the tolerance should be a per-question setting rather than a constant | +| 3 | Grading and preview queries in `QuestionAssessmentSectionMappingRepository` filter neither `qasm.status` nor `q.status`; the `activeSections` Hibernate filter is inert (enabled on a different session, and `Assessment` names a column that does not exist) | Adding a filter can only REMOVE questions from live papers, and legacy rows have NULL status — needs a prod count first, and must land with #7 | +| 4 | `StudentAttemptService.calculateTotalMarks` catches everything and returns 0 for the whole attempt, which is then written as `COMPLETED` and may be auto-released | The fix is right but changes what admins see (attempts held rather than released) and touches release/workflow side effects | +| 5 | The live grading path picks its marking strategy from `responseData.type` inside the learner's own submitted JSON; the revaluation path correctly uses `questionAsked.getQuestionType()` | Legacy attempts may depend on the submitted value where the stored type is null | +| 6 | Reattempt count is enforced nowhere — `registration.reattemptCount` is written but never gates a new attempt; submit is non-idempotent and enforces no time limit | Enforcing either would start blocking learners who can currently do it. The learner client retries submit 3x, so re-submit must return the existing result, not a 4xx | +| 7 | `AssessmentLinkQuestionsManager.softDeleteMappingsByQuestionIdsAndSectionId` runs a native hard `DELETE`; Hibernate-internal enums are used as domain status strings; `section.totalMarks` is trusted from the client | Hard-delete → soft-delete changes which rows the (unfiltered, #3) grading queries return | +| 8 | Publish validates nothing (`// Todo: Verify Assessment Details based on type`) — an assessment with no sections and no questions can be published and started | Any rule added could reject assessments that are already live; likely lands as warnings first | +| 9 | Editing a question paper orphans `option` and `assessment_rich_text_data` rows; `setQuestionMetadata` nulls media/explanation on a partial edit | Needs an `option.status` column plus a read filter on every option query | +| 10 | Deleting a question from one paper sets `question.status = DELETED` globally, across every other paper and assessment using it | Changes what admins currently experience as "delete" | +| 11 | Learner reports are readable before release, and the release filter on the list endpoint is client-supplied | Enforcing it hides results learners can currently see | +| 12 | `totalTimeInSeconds` is taken from the learner's own JSON and feeds leaderboards | Server-side timing changes existing leaderboard values | +| 13 | Restart grants a fresh full duration with no counter or cap | Capping it takes time away from learners mid-exam | +| 14 | Step-2 re-submit can duplicate sections (server ids are never written back into the form); Step-1 re-submit orphans the draft | The fixes flip create-vs-update decisions in the wizard | +| 15 | `sectionDetailsSchema` validates nothing, and the real submit gate is disabled entirely in edit mode | Turning validation on could block admins who currently save partial edits; pair with #8 | +| 16 | `AssessmentParticipantsManager` is not transactional; registration dates are dropped unless instructions HTML is also sent; removing participants hard-deletes rows `StudentAttempt` has FKs to | Its own area with its own blast radius | +| 17 | `AssessmentAccessManager.updateAccessToAssessment` ignores its current-access parameters; `deleteAccessToAssessment` is never called; a missing institute mapping returns 200 having done nothing | Access-control semantics need a product decision | diff --git a/docs/crm/AI_CALLING_SYSTEM.md b/docs/crm/AI_CALLING_SYSTEM.md index 6b8886f9c7..60e2e6de3d 100644 --- a/docs/crm/AI_CALLING_SYSTEM.md +++ b/docs/crm/AI_CALLING_SYSTEM.md @@ -11,6 +11,9 @@ the repo cannot tell you what production is actually running. **Companion docs** - [`VACADEMY_AI_AGENT.md`](./VACADEMY_AI_AGENT.md) — the original design + phase plan. +- [`AI_CALL_QUEUE.md`](./AI_CALL_QUEUE.md) — what happens BEFORE step 2 below: every AI dial + is now queued and placed by a single fleet-wide drainer, so the pre-dial throttles in §2.4 + are re-evaluated at dial time rather than at request time. - [`AI_CALL_DEEP_REVIEW.md`](./AI_CALL_DEEP_REVIEW.md) — humanness/latency review (2026-07-14). Some of its "not done yet" items have since shipped; this doc is the current state. - [`VACADEMY_VOICE_INTEGRATION.md`](./VACADEMY_VOICE_INTEGRATION.md) — Plivo telephony + IVR. - [`AAVTAAR_AI_CALLING.md`](./AAVTAAR_AI_CALLING.md) — the third-party AI provider that shares this pipeline. diff --git a/docs/crm/AI_CALL_QUEUE.md b/docs/crm/AI_CALL_QUEUE.md new file mode 100644 index 0000000000..385aadedd1 --- /dev/null +++ b/docs/crm/AI_CALL_QUEUE.md @@ -0,0 +1,460 @@ +# AI Call Queue — System Reference + +**What this is:** the reference for how an AI call gets from "someone asked for it" to +"a phone is ringing" — the durable queue in front of every AI dial, the single drainer +that places them, how the fleet's capacity is decided, and how one institute is stopped +from starving another. + +**Read from the code on 2026-08-27** (`feature/ai-call-queue`). File paths are given so +any claim here can be re-derived from source. + +**Companion docs** +- [`AI_CALLING_SYSTEM.md`](./AI_CALLING_SYSTEM.md) — everything downstream of the dial: + the agent, STT/LLM/TTS, the live pipeline, outcome → lead action. **Read that first;** + this doc only covers what happens *before* `AiCallService.placeCall` and the machinery + that decides *when* it runs. +- [`AAVTAAR_AI_CALLING.md`](./AAVTAAR_AI_CALLING.md) — the third-party AI provider, which + the queue governs differently (see §5). + +--- + +## Table of contents + +1. [Why it exists](#1-why-it-exists) +2. [The map](#2-the-map) +3. [The tables](#3-the-tables) +4. [The drain loop](#4-the-drain-loop) +5. [Capacity — capability vs policy](#5-capacity--capability-vs-policy) +6. [Fairness](#6-fairness) +7. [Guards, windows and deferral](#7-guards-windows-and-deferral) +8. [The manual fast path](#8-the-manual-fast-path) +9. [APIs](#9-apis) +10. [The UI](#10-the-ui) +11. [Runbook](#11-runbook) +12. [Traps](#12-traps) +13. [Not done](#13-not-done) + +--- + +## 1. Why it exists + +Before this, three **uncoordinated in-memory pacers** decided when an AI call went out: + +| Path | Pacer | Fleet-aware? | +|---|---|---| +| CALL_AI workflow node | single-thread executor, 300 ms pace | no | +| Bulk campaign | completion-aware sliding window, `MAX_PARALLEL = 3` **per campaign** | no | +| Manual click | straight to the provider | no | + +None knew about the others. `MAX_PARALLEL` was per *campaign*, so two institutes running +campaigns put twice the intended number of calls on a voice box that carries a fixed few. +The overflow was absorbed by the bot's own admission control, which answers **"all lines +busy" to a real lead**. The queues also lived in one replica's heap, so a deploy mid-run +silently dropped whatever had not dialled. + +All three now write to one durable queue, drained by one job. One dialler is what makes +the fleet-wide limit *exact* — there is no distributed counting to get wrong and no +per-replica share to rebalance when the deployment scales. + +--- + +## 2. The map + +``` + producers queue dialler + ───────── ───── ─────── + CALL_AI node ─┐ + bulk campaign ├─► AiCallQueueService.enqueue ─► ai_call_queue ─► AiCallQueueDrainJob + manual click ─┘ (durable INSERT) │ @Scheduled(2s) + │ @SchedulerLock + ▼ + AiCallService.placeCall (UNCHANGED) + │ + ▼ + provider → telephony_call_log +``` + +Key files, all under +`admin_core_service/src/main/java/vacademy/io/admin_core_service/features/telephony/`: + +| File | Role | +|---|---| +| `queue/AiCallQueueService.java` | enqueue, dedupe, cancel, all read models | +| `queue/AiCallQueueDrainJob.java` | **the only thing that places an AI call** | +| `queue/AiCallCapacityService.java` | how many may run, and how many one institute may hold | +| `queue/AiCallQueueSnapshotService.java` | the whole queue in one consistent payload | +| `queue/AiCallQueueDirectory.java` | id → institute/agent names, live call state | +| `queue/AiVoiceBoxService.java` | the capacity pool and its runtime knobs | +| `queue/AiVoiceBoxHealthPoller.java` | polls each box's `/health` | +| `queue/AiCallQueueTxOps.java` | inserts on their own transaction (see §3) | +| `core/CallingWindowUtil.java` | calling shifts, shared with `CallAiNodeHandler` | + +`AiCallService.placeCall` was **not modified**. The drainer is just another caller, so +every pre-dial guard it already enforced is inherited for free — see §7. + +--- + +## 3. The tables + +Created by `V472__ai_call_queue.sql`, corrected by `V473__ai_call_queue_id_varchar.sql`. + +### `ai_call_queue` + +One row per call waiting to go out. Carries everything `placeCall` needs, because the +dial can happen hours after the enqueue and nothing may be re-derived from state that has +since moved on. + +**Lifecycle** (`AiCallQueueStatus`): + +``` +QUEUED ──► DISPATCHING ──► DIALED + │ │ + │ └─► (failure) ──► QUEUED (backoff) ──► FAILED after 3 attempts + ├─► EXPIRED (waited past its TTL) + └─► CANCELLED (admin cancelled, lead deleted, lead assigned to a human) +``` + +> **`DIALED` never moves again.** It means "the provider accepted the dial", not "the call +> is happening" and not "the call finished". Whether a call is *live* lives in +> `telephony_call_log`, which is why the `LIVE` and `ACTIVE` filters are joins (§9). This +> is the single most misread thing in the schema. + +**Idempotency.** A partial unique index on `dedupe_key` covering only `QUEUED` + +`DISPATCHING`: + +```sql +CREATE UNIQUE INDEX ux_ai_call_queue_pending ON ai_call_queue (dedupe_key) + WHERE status IN ('QUEUED', 'DISPATCHING'); +``` + +Key is `institute|provider|subject`. This is load-bearing: **the workflow engine resumes a +run by RESTARTING it**, so a CALL_AI node re-enters many times for the same lead before +its first call ever goes out. Partial, so a legitimate later retry (after the first has +dialled) still enqueues. + +Because a unique-index violation marks the *calling* transaction rollback-only, inserts go +through `AiCallQueueTxOps` on `REQUIRES_NEW` — otherwise one de-duplicated call would roll +back the entire workflow step that asked for it. + +### `ai_call_lane` + +Per-institute overrides, **sparse by design** — an institute with no row uses the dynamic +default. `max_concurrent` (null = dynamic), `paused`, `weight`, `last_dispatched_at`. + +`last_dispatched_at` is written and never read. It is carried so that switching from FIFO +to a rotation (§6) is an `ORDER BY` change rather than a migration. + +### `ai_voice_box` + +The capacity pool — one row per voice box, with its own `max_concurrent`, `enabled`, +`health_status`, `active_calls`, `base_url`. Modelled on `bbb_server_pool` (V192). + +> **This table does not route calls.** Dialling still resolves the bot address from +> `telephony.vacademy-ai.bot-base-url`. `base_url` here exists only so the health poller +> knows who to ask, so a bad row can never send a call to the wrong host — the worst it +> can do is mis-state capacity. + +### `app_config` keys + +| Key | Default | Meaning | +|---|---|---| +| `ai_call_fleet_limit` | *(absent)* | **ops ceiling** on simultaneous calls. Absent/blank = hardware decides. `0` = pause. No migration seeds it — an absent row reads as "no limit", and `PUT /capacity` creates it on first use | +| `ai_call_capacity_enabled` | `true` | ⚠️ `false` **BYPASSES** the limit (unlimited). Not an on/off switch — see §12 | +| `ai_call_aavtaar_max_concurrent` | `20` | Aavtaar's own ceiling (their infra, not ours) | +| `ai_call_stuck_grace_sec` | `720` | a non-terminal call older than this stops holding a slot | +| `ai_call_queue_ttl_hours` | `48` | past this an item is `EXPIRED`, not dialled | +| `ai_call_avg_secs` | `180` | assumed call length; **ETA display only** | +| `ai_call_reserved_interactive` | `0` | slots held back for manual clicks | +| `ai_call_drain_batch` | `200` | max rows examined per tick | + +--- + +## 4. The drain loop + +`AiCallQueueDrainJob.drain()` — `@Scheduled(fixedDelay = 2s)` + `@SchedulerLock`. + +Per tick: + +1. **Expire** items past their TTL; **release** claims left by a drainer that died mid-tick. +2. Take one **capacity snapshot** (`AiCallCapacityService.snapshot()`). +3. Fetch candidates: **each lane's head**, not the oldest N overall (see §6). +4. For each, in FIFO order — skip if the lane is paused, at its cap, or the provider is + full; defer the whole lane if outside its calling window. +5. **CAS claim** `QUEUED → DISPATCHING`. Only the winner dials. +6. Call `placeCall`. On success: `DIALED` + `call_log_id`. Otherwise §7. + +**Why one drainer is safe with 2–4 replicas.** ShedLock means one pod runs a tick. That +lock can still lapse (`lockAtMostFor`) and let two overlap, so send-once does **not** rest +on it — the conditional `UPDATE ... WHERE status='QUEUED'` is the real guarantee. + +**Occupancy is derived, never counted.** In-flight comes from `telephony_call_log` +(`countAiCallsInFlight`), not a counter, for two reasons: a counter leaks — a call whose +webhook never lands would hold its slot forever, and lost AI webhooks are a documented +failure mode — and a counter is blind to calls placed outside the queue, which still +occupy the box. + +--- + +## 5. Capacity — capability vs policy + +Two different questions, deliberately two different knobs: + +| | Question | Where | Changes when | +|---|---|---|---| +| **Capability** | "what can this box carry?" | `ai_voice_box.max_concurrent` | hardware changes | +| **Policy** | "how hard do we drive it *now*?" | `ai_call_fleet_limit` | an incident, a campaign, overnight | + +``` +effective capacity = MIN( Σ max_concurrent over enabled, non-DOWN boxes , fleet limit ) +``` + +The limit only ever **caps**. A limit above the hardware is accepted and simply +non-binding, so this control can never promise capacity that does not exist. `0` means +dial nothing — and because the queue goes on accepting, **a pause defers calls rather than +losing them**, which is what makes it safe to reach for in a hurry. + +**Per provider.** `VACADEMY_AI` draws on our boxes. `AAVTAAR` dials on Aavtaar's own +infrastructure, so counting it against our boxes would throttle it for no physical reason +— it gets its own number. `MOCK` never leaves the process and is unlimited. + +**Health.** A box that fails its `/health` poll is marked `DOWN` and its slots leave the +fleet. A box that has never been polled successfully stays `UNKNOWN` and **still counts** — +an unconfigured poller must not be able to switch AI calling off. + +**Propagation.** The drainer resolves capacity from the database every tick, so a change +is live on every replica within ~2 s with no restart. Lowering below the calls already in +flight never cuts a live call off; it stops new dials until in-flight falls under the +limit. + +--- + +## 6. Fairness + +**Ordering is strict FIFO** on `(priority DESC, created_at)`. There is no rotation. + +What stops one institute's 500-lead upload from blocking another is the **per-lane +concurrency cap**: the scan steps over an item whose institute is already at its cap, so a +latecomer with five leads takes the next free line instead of waiting out the backlog. + +Default cap is dynamic: + +``` +laneCap = max(1, ceil(fleetCapacity / lanesWithWork)) +``` + +Ceiling, not floor — capacity 3 split two ways is 2+1, so the third slot is used, where +flooring would idle it at 1+1. One institute queuing alone gets the whole fleet. + +Worked example — fleet 3, cap 2. A uploads 500 at 10:00; B arrives at 14:00 with 5: + +| | strict FIFO, no cap | FIFO + cap | +|---|---|---| +| B's first call | after A's remaining 480 ≈ **8 h** | next free slot ≈ **3 min** | +| B's 5 calls done | ≈ 8 h | ≈ **15 min** | + +**The candidate query is a `LATERAL`** for exactly this reason. A flat +`ORDER BY created_at LIMIT 200` over a queue holding A's 500 returns only A's rows — the +drainer would never *see* B, so skipping a capped lane could not help it. Taking each +lane's head first bounds the candidate set while guaranteeing every waiting institute +appears in it. + +> **Known limit.** This holds while *simultaneously busy institutes ≤ fleet capacity*. At +> capacity 3 that is three institutes; a fourth starves, because FIFO always re-picks the +> earliest item and the earlier lanes are perpetually under cap. The fix at that point is a +> rotation — `ai_call_lane.last_dispatched_at` is already maintained for it. Raising fleet +> capacity also resolves it. + +--- + +## 7. Guards, windows and deferral + +**Every pre-dial guard is re-evaluated at DIAL time**, not enqueue time — an item can wait +hours, during which the lead may be assigned to a human or the institute may run out of +credits. This is free: the drainer calls `placeCall`, which owns them all. + +| `placeCall` outcome | Queue result | +|---|---| +| dispatched | `DIALED` + `call_log_id` | +| `SKIPPED_ASSIGNED` | `CANCELLED` — a human owns the lead now | +| `SKIPPED_DUPLICATE` | `CANCELLED` — dialled by another path moments ago | +| `SKIPPED_DAILY_CAP` | lane deferred 1 h | +| `ConflictException` "deleted" | `CANCELLED` | +| `ConflictException` (credits) | lane deferred 15 min | +| anything else | backoff 1/5/15 min, `FAILED` after 3 attempts | + +**Lane-wide, not per item.** Credits, the daily cap and calling windows are institute-wide +conditions, so they defer the *whole lane* (`deferLane`). Without that, an institute with +400 queued items and an empty wallet would make 400 credit checks working through its own +backlog before settling down. + +**Calling windows now gate the queue.** `callingShifts` previously gated only the retry +re-dialer, which was safe while dialling was instant. With a queue, a 10:00 bulk upload +would otherwise still be dialling at 01:00. `MANUAL` is exempt — it never had a window and +must not gain one. + +**Attempts.** The CAS claim increments `attempts` in the database, and the drainer mirrors +that onto its detached copy. Without that mirror every later `save()` writes the pre-claim +value back and the counter never advances — an item that keeps failing would retry for +ever. A *deferral* hands the attempt back; only a real failure consumes one. + +--- + +## 8. The manual fast path + +A counsellor's click enqueues like everything else, then immediately tries +`AiCallQueueDrainJob.dispatchNowIfLineFree`. With a free line and nothing of that +institute's already waiting, it dials **on the request thread** and returns exactly the +response it always did (`dispatched: true` + `callLogId`). Only on a busy fleet does it +come back `status: "QUEUED"` with a position and ETA. + +It **cannot jump the line**: `countAheadInLane == 0` is required, the lane cap still +applies, and it takes the same CAS claim, so this and a concurrent tick can never both +dial it. + +Conflicts are re-thrown on this path (`surfaceConflicts`) so "out of credits" and "lead +deleted" still reach a waiting human as the 409s they always were, rather than becoming a +silent deferral. + +> Consequence worth knowing: **a manual call is almost never in `QUEUED`.** That is why the +> UI defaults to `ACTIVE` and not `QUEUED` — see §10. + +--- + +## 9. APIs + +### Institute-scoped — `/admin-core-service/v1/telephony/ai-queue` + +Authenticated, `instituteAccessValidator` on every call. + +| | | +|---|---| +| `GET /` | paged rows. `status=ACTIVE` (default) \| `QUEUED` \| `LIVE` \| `DIALED` \| `FAILED` \| `EXPIRED` \| `CANCELLED` \| `ALL` | +| `GET /summary` | depth, in-flight, ETA, paused | +| `GET /bulk-run?audienceId=` | queue-side counts for one campaign | +| `POST /cancel` | cancel everything waiting (optionally one run) | +| `DELETE /{id}` | cancel one | + +> **No capacity figures.** `QueueSummary` deliberately carries no `fleetCapacity`, +> `laneCapacity` or `fleetInFlight`, and there is no institute-scoped `/lane` endpoint. How +> many lines exist and how many an institute may hold are internal operating facts; an +> institute seeing "2 of 3" learns it shares a small pool with other tenants, which is not +> its business. The wait is expressed as *time*, which is the part that concerns them. + +`ACTIVE` and `LIVE` are not statuses — they are joins against `telephony_call_log`, because +`DIALED` never moves (§3). `ACTIVE` uses a **LEFT** join: a `QUEUED` row has no +`call_log_id` yet, and an inner join would drop exactly the rows the queue is named after. + +### Super-admin — `/admin-core-service/super-admin/v1/ai-queue` + +`SuperAdminAuthUtil.requireSuperAdmin` (root JWT). Cross-tenant, so it *does* carry the +real numbers. This is the feed for the **Vacademy Health** dashboard. + +| | | +|---|---| +| `GET /overview?limit=&instituteId=` | fleet + boxes + every lane, one payload | +| `GET /items?instituteId=&status=&provider=&source=` | the calls, paged, with institute + agent names | +| `GET /capacity` | capacity, occupancy, boxes | +| **`PUT /capacity`** | `{"maxConcurrentCalls": N}` — throttle. `0` pauses, `null` clears | +| `GET\|POST\|PUT\|DELETE /boxes[/{id}]` | the capacity pool | +| `GET\|PUT /lanes[/{instituteId}]` | per-institute cap / pause | +| `PUT /settings/{key}` | the other runtime knobs (allow-listed) | + +`/overview` is assembled from **one** capacity snapshot. Fetching capacity and lanes +separately lets a polling dashboard render occupancy from one instant beside lane shares +from another — which is how "these numbers do not add up" tickets are born. + +On `PUT /capacity`, render **`vacademyAiCapacity`** (what is now enforced), not the number +submitted. Set 20 on a 3-call fleet and you get 3, honestly reported alongside +`physicalCapacity`. + +> An earlier `/internal/ai-queue/snapshot` variant, authenticated by a static secret in +> `client_secret_key`, was removed. It returned this exact payload from this exact +> assembler; all it bought was a second auth path and a per-environment DB row to forget. + +--- + +## 10. The UI + +**CRM → Calling → Call Queue** (`/calling/call-queue`), its own route — Call Log is what +already happened, this is what has not happened yet. + +**Hidden by default.** The sidebar sub-item `calling-call-queue` ships in +`SUB_ITEMS_HIDDEN_BY_DEFAULT`, so an institute opts in from Display Settings → Sidebar, +the same way it opts into Counsellors or Sales Dashboard. No bespoke toggle. + +Cards: **Waiting · On a call now · Clears in**. No denominators and no fleet figures. + +Filters default to **Active** (waiting + already dialling). Defaulting to `QUEUED` showed +an empty table exactly when calling was healthy, because of §8 — the page looked broken +while it was working. + +The **Lead** column falls back to the call log's `to_number` when the queue row has no +phone of its own, which is usual: the manual click and the CALL_AI node pass only a lead +id and the number is resolved downstream at dial time. + +--- + +## 11. Runbook + +**Throttle / pause / restore** + +```bash +# throttle to 2 simultaneous calls +curl -X PUT .../super-admin/v1/ai-queue/capacity -H "Authorization: Bearer $JWT" \ + -H 'Content-Type: application/json' -d '{"maxConcurrentCalls":2}' + +# stop dialling (queue keeps accepting — nothing is lost) +... -d '{"maxConcurrentCalls":0}' + +# back to whatever the hardware provides +... -d '{"maxConcurrentCalls":null}' +``` + +**Add a box** — `POST /boxes {"slug":"mumbai-2","baseUrl":"https://…","maxConcurrent":3}`. +Capacity rises on the next tick. + +**Pause one institute** — `PUT /lanes/{instituteId} {"paused":true}`. + +**"The queue will not move."** In order: is `vacademyAiCapacity` 0 (all boxes disabled or +`DOWN`)? Is `fleetLimit` 0? Is the lane paused? Is `not_before` in the future (calling +window, daily cap, no credits — `status_reason` says which)? Is the drainer running at all +(`grep "ai-call queue:"` in admin-core logs)? + +**"Nothing shows in the tab."** Default filter is `ACTIVE`; a call that already ended is +`DIALED` with a terminal call log. Try `ALL`. + +--- + +## 12. Traps + +**`ai_call_capacity_enabled = false` BYPASSES the limit.** It returns `UNLIMITED`, not +zero. It is an escape hatch for a broken limiter, but it reads like an off switch for AI +calling — an operator wanting to *stop* calls would flip it and uncap the entire fleet +mid-incident. The API reports it as `concurrencyLimitBypassed` for that reason. **Do not +surface it in any dashboard.** To stop calling, set `fleetLimit` to 0. + +**Primary keys are `VARCHAR(36)`, not `UUID`.** V472 declared them `UUID`; the entities map +`String` with `@UuidGenerator`, so Hibernate binds varchar and Postgres refuses the +implicit cast. Every enqueue failed until `V473` corrected it. Any new column that an +entity maps as `String` must be varchar. + +**`DIALED` is not "in progress".** See §3. Judge liveness by joining `telephony_call_log`. + +**The ETA is modelled, not measured.** It comes from `ai_call_avg_secs` (default 180) and +the lane's slot count. It is honest about *order of magnitude*, not minutes. + +--- + +## 13. Not done + +- **The drainer's dispatch path has never run in anger.** Everything exercised so far went + through the manual fast path (§8), which dials inline. Nothing has yet sat in `QUEUED` + and been picked up by a tick. +- **No tests.** None of this has unit or integration coverage. +- **`ai_voice_box.base_url` ships as `CONFIGURE_ME`**, so health polling is off until it is + set. Capacity still counts the box (`UNKNOWN` counts by design). +- **No audit trail on capacity changes.** `PUT /capacity` logs at WARN; who changed it is + not recorded in a queryable place. +- **Starvation beyond `fleetCapacity` busy institutes** — §6. +- **No global "pause all" separate from `fleetLimit: 0`**, which is adequate but overloads + one control. diff --git a/docs/crm/README.md b/docs/crm/README.md index b37953263c..5cb7b98bae 100644 --- a/docs/crm/README.md +++ b/docs/crm/README.md @@ -16,6 +16,7 @@ Onboarding doc set for the Vacademy CRM cluster (admin_core_service + frontend-a | [CRM_WORKBENCH_AND_SALES_DASHBOARD.md](CRM_WORKBENCH_AND_SALES_DASHBOARD.md) | Manager surfaces: counsellor workbench (`/counsellors`), sales dashboard, counsellor ratings, the reassign engine, org-team hierarchy & RBAC, the data-storage map, known issues | | [EXOTEL_CALL_INTEGRATION.md](EXOTEL_CALL_INTEGRATION.md) | Telephony: click-to-call from lead rows, provider-agnostic SPI (Exotel first), SSE live status, recording capture into the timeline | | [AI_CALLING_SYSTEM.md](AI_CALLING_SYSTEM.md) | **The AI calling reference (2026-08-11).** Agents + persona assembly, the STT/LLM/TTS stack (five TTS engines, per-engine pricing), the Pipecat real-time pipeline and turn-taking, end-of-call analysis → disposition → lead action, credits & metering, the per-call diagnostics blob, where the logs live, and a runbook | +| [AI_CALL_QUEUE.md](AI_CALL_QUEUE.md) | **The AI call queue (2026-08-27).** The durable queue in front of every AI dial and the single drainer that places them — how fleet capacity is decided (hardware vs the ops limit), how strict FIFO plus a per-lane cap stops one institute starving another, the guards re-evaluated at dial time, the manual fast path, the institute and super-admin APIs, and a runbook | | [TTS_SPEECH_CACHE.md](TTS_SPEECH_CACHE.md) | **TTS speech cache (2026-08-22).** Replaying audio already paid for on an exact sentence match — the key, the six gates that decide what may be cached, why live audio is never recorded, the pipecat audio-ordering hazard, per-agent rollout via `ai_agent.speech_cache_mode`, and where to read the savings | | [TTS_CACHE_API.md](TTS_CACHE_API.md) | **Speech-cache analytics API (2026-08-25).** The six super-admin endpoints behind the cache tab — curl, response shapes, and the two traps: reads are a 2-minute mirror rather than live, and a flush is queued rather than immediate | | [AAVTAAR_AI_CALLING.md](AAVTAAR_AI_CALLING.md) | **DRAFT** — Aavtaar.ai AI voice-agent calling (Plivo-backed): outbound AI-calling workflow with retrial + counsellor assignment driven by webhook lead-response, configurable disposition→action policy, webhook→resume bridge, inbound AI-sales flow | diff --git a/docs/crm/TTS_SPEECH_CACHE.md b/docs/crm/TTS_SPEECH_CACHE.md index e89a2c0f59..d0f4f2e396 100644 --- a/docs/crm/TTS_SPEECH_CACHE.md +++ b/docs/crm/TTS_SPEECH_CACHE.md @@ -168,7 +168,7 @@ sweeper defers when the box is at half its concurrent-call cap or above — synt network but the decode is real CPU on a 1 vCPU node, and background work must never be why a live caller hears a glitch. The periodic tick then catches up during a lull. -**Admission:** an LLM sentence is rendered after `TTS_CACHE_MIN_SEEN` (default 2) qualifying +**Admission:** an LLM sentence is rendered after `TTS_CACHE_MIN_SEEN` (default 1) qualifying sightings, so it first hits on its third. Break-even is 3 uses. **A fixed line is rendered after ONE sighting.** The threshold exists because an LLM sentence @@ -343,7 +343,7 @@ LLM-sentence half earns its complexity. | `TTS_CACHE_LLM_ENABLED` | `true` | ops KILL switch for the LLM-sentence path | | `TTS_CACHE_AGENTS` | *(empty)* | optional extra ops restriction. Empty = no restriction | | `TTS_CACHE_SALT` | `v1` | bump to invalidate everything | -| `TTS_CACHE_MIN_SEEN` | `2` | qualifying sightings before one render is spent | +| `TTS_CACHE_MIN_SEEN` | `1` | qualifying sightings before one render is spent. 1 makes a line free from its SECOND use rather than its third | | `TTS_SPEECH_CACHE_MAX_BYTES` | 2 GB | own eviction budget | | `TTS_CACHE_MIN_BLOB_MS` | `200` | G5 floor | @@ -454,7 +454,7 @@ priciest engine and the one whose renders are non-deterministic. |---|---| | Fixed lines | ~10–15% of TTS chars on a 5-min call. **Only the opening is pre-warmed**, and only if it has no `{{placeholder}}`; the farewells, handbacks, fillers and nudge reach the cache the same way an LLM sentence does — see the row below | | LLM sentences | **Unknown until the ledger runs.** `NoRepeatGate` needed *fuzzy* matching at 0.80 precisely because exact repeats were not frequent enough, and paraphrases of one question score ~0.6. Plan for 15–35%, not 60% | -| **What hits on call 1** | Only a pre-warmed, placeholder-free opening. Everything else needs `TTS_CACHE_MIN_SEEN` sightings first, so its first hit is call 3 (or call 2 if the line is spoken twice in one call). A `hits=0` first call is the expected reading, not a broken cache — read `misses=N` and `laddered N` to confirm the plumbing works | +| **What hits on call 1** | Only a pre-warmed, placeholder-free opening. Everything else needs `TTS_CACHE_MIN_SEEN` sightings first — at the default of 1 that means its first hit is call 2. A `hits=0` first call is the expected reading, not a broken cache — read `misses=N` and `laddered N` to confirm the plumbing works | | Ramp | starts near zero and climbs. G3/G4 make "qualifying" stricter than "spoken", so it climbs slower than a naive model predicts | | Where money is at stake | `sarvam` (₹2.34/min) and high-volume `google` (₹2.06/min past its 1M chars/month free tier ≈ 1,284 call-min). `edge` is free, `rumik` ₹0.45/min | | **The bigger lever** | not the cache — script determinism. `AI_CALL_ACTIONS.md` §10 flags the prompt at ~19k chars with the two-sentence turn cap not holding. A tighter script fixes that bug **and** raises the exact-match rate from the same edit | diff --git a/docs/erp/erp-ui-plan.md b/docs/erp/erp-ui-plan.md new file mode 100644 index 0000000000..eadb35b25f --- /dev/null +++ b/docs/erp/erp-ui-plan.md @@ -0,0 +1,131 @@ +# ERP UI Plan — HR & Payroll in the Admin Dashboard + +Date: 2026-08-27. Backend: everything through Phase F is built (see `hr-payroll-review-and-gap-plan.md`); Waves 1–E deployed, Phase F awaiting push. This spec maps every built API onto screens in `frontend-admin-dashboard`, following its existing conventions exactly. + +**Decisions (user-confirmed):** a new top-level **ERP** rail category beside CRM/LMS/AI; **My HR** (employee self-service) lives inside ERP as a non-adminOnly module; **payroll ops ship first**; written spec is the deliverable. + +--- + +## 1. Navigation & registration + +The sidebar rail categories are a typed union (`'LMS' | 'CRM' | 'AI'` on `SidebarItemsType.category`). Adding ERP touches: + +1. `src/types/layout-container/layout-container-types.ts` — extend the category union with `'ERP'`. +2. `src/components/common/layout-container/sidebar/category-rail.tsx` (+ `collapsed-category-flyout.tsx`, `sidebar-colors.ts`) — ERP rail entry (icon: `Buildings` or `Bank` from phosphor), color. +3. `src/components/common/layout-container/sidebar/utils.ts` — the nav entries (below). +4. `src/components/common/layout-container/sidebar/constant.ts` — add module ids to `controlledTabs` (admin-toggleable). +5. `src/constants/display-settings/admin-defaults.ts` — add ids to `OPT_IN_TAB_IDS` (**ships hidden until the institute opts in** — the safe rollout switch) + default tab configs. +6. `src/components/common/layout-container/sidebar/mySidebar.tsx` — strip self-service items when the user has no employee profile (same pattern as the `mentorship-my-mentorship` strip via `useIsMentor()`). +7. `src/constants/urls.ts` — `HR_*` endpoint constants (one banner-grouped block; ~60 endpoints). +8. `src/routes/settings/-constants/terms.ts` + `-utils/utils.ts` + `-components/ErpSettings.tsx` — settings tab (domain "Operations"). +9. `src/routeTree.gen.ts` regenerates itself. + +**ERP nav tree** (one `SidebarItemsType` per module; sub-items role-gated): + +| id | title | sub-items (→ route) | visible to | +|---|---|---|---| +| `erp-my-hr` | My HR | Overview `/erp/my-hr` · My Leave `/erp/my-hr/leave` · My Payslips `/erp/my-hr/payslips` · My Tax `/erp/my-hr/tax` · My Claims `/erp/my-hr/claims` | any staff with an employee profile (`useMyEmployeeProfile`) | +| `erp-people` | People | Employees `/erp/people` · Departments & Designations `/erp/people/org` · Staff Coverage `/erp/people/staff-bridge` | HR staff | +| `erp-attendance` | Attendance | Daily Board `/erp/attendance` · Regularizations `/erp/attendance/regularizations` · Shifts & Holidays `/erp/attendance/setup` | HR staff | +| `erp-leave` | Leave | Requests `/erp/leave` · Balances `/erp/leave/balances` · Types & Policies `/erp/leave/setup` (adminOnly) | HR staff | +| `erp-payroll` | Payroll | Runs `/erp/payroll` · Variable Pay `/erp/payroll/adjustments` · Loans & Claims `/erp/payroll/loans` · Salary Setup `/erp/payroll/salary-setup` (adminOnly) | HR staff; mutations HR admin | +| `erp-compliance` | Compliance | Filings `/erp/compliance` · Challans `/erp/compliance/challans` · Provisions `/erp/compliance/provisions` | HR admin (adminOnly) | +| `erp-finance` | Finance | Journal `/erp/finance/journal` · P&L Snapshot `/erp/finance/pnl` | HR staff view / admin export | + +Teaching Pay and Incentives live as tabs inside Payroll → Variable Pay (they are producers of adjustments, not standalone modules). + +## 2. Roles & visibility model + +JWT authorities already carry `HR_ADMIN` / `HR_MANAGER` per institute (`getRolesForCurrentInstitute()`); backend enforces regardless — UI gating is UX, not security. + +New hooks (pattern: `use-is-mentor.ts`): +- `useHrRole()` → `{ isHrAdmin, isHrStaff }` from JWT roles (`ADMIN | HR_ADMIN | HR_MANAGER`). +- `useMyEmployeeProfile()` → TanStack query on `GET /hr/employees/staff-bridge`-lite or a `GET /employees?userId=me` self-resolve; drives My HR visibility + employeeId for self-service calls. Cache 5 min. + +Gating rules: `erp-people/attendance/leave/payroll/finance` require `isHrStaff`; `erp-compliance` and all mutating actions require `isHrAdmin` (buttons hidden/disabled with tooltip, not error-on-click); `erp-my-hr` requires an employee profile only. `filterSidebarByRole` gets an HR clause mirroring these. + +## 3. Screen inventory (mapped to built APIs) + +Conventions everywhere: `LayoutContainer` + `setNavHeading`; `MyTable`/`MyPagination` (server-side, `TableData` shape adapted client-side where APIs return arrays); `FilterChips` + `StatusChip`; `MyDialog`/`Sheet`; RHF+zod, snake_case payloads, `getInstituteId()` as `instituteId` param; `reportApiError` + sonner; `getTerminology` for renamable nouns; phosphor icons; design-lint clean. + +### 3.1 Payroll — Runs (`/erp/payroll`) — THE FLAGSHIP, ships first +- **Runs list**: `GET /hr/payroll/runs?instituteId[&year]`. Columns: period (Month YYYY), type chip (REGULAR/OFF_CYCLE/FNF/BONUS), status `StatusChip` (DRAFT→INFO, PROCESSING→WARNING, PROCESSED→INFO, APPROVED→SUCCESS, PAID→SUCCESS, CANCELLED→DANGER), employees, net pay (currency), actions. "New run" dialog: **MonthPicker (new shared component — none exists)** + run-type select + notes → `POST /runs`. +- **Run detail** (`/erp/payroll/$runId`): header = period + status **stepper** (Draft → Processed → Approved → Paid) with the transition buttons rendered by state: Process (`POST /runs/{id}/process`, confirm dialog, poll/refetch on completion), Approve (`PUT /approve` — copy notes it posts the accounting journal), Reject (`PUT /reject`, AlertDialog explaining full reversal), Mark Paid (`PUT /mark-paid`), Cancel (`DELETE`, AlertDialog: reverses loans/reimbursements). KPI cards: gross / deductions / net / employer cost (reuse `PaymentKpiCards` pattern). + - **Entries tab**: `GET /runs/{id}/entries`. Columns: employee, days (present/absent/leave), gross, deductions, net, status. Row expand → component breakdown (`components[]`: earnings vs deductions vs employer, TDS highlighted). Row actions: Hold (dialog w/ reason → `PUT /entries/{id}/hold`), Release. Held rows visually muted with reason tooltip. + - **Errors tab** (badge with count): `GET /runs/{id}/errors` — employee, stage, message; empty-state "all employees processed". + - **Payslips tab**: Generate (`POST /payslips/generate`), per-entry Download (`GET /payslips/{id}/download`, `downloadFileFromUrl`), Email all (`POST /payslips/email` → result dialog with SENT/FAILED per employee). + - **Bank file tab**: format select (CSV/XLSX/HDFC/ICICI/SBI) → `POST /reports/bank-export` → result panel showing `skipped` list (employee + reason — the thing admins must fix) + Download (`GET /reports/bank-export/{id}/download`). + - **Journal chip** in header once APPROVED: links to Finance → Journal filtered to the period. + +### 3.2 Payroll — Variable Pay (`/erp/payroll/adjustments`) +Tabs: **Adjustments** (list `GET /payroll/adjustments?year&month`, MonthPicker; create dialog: employee picker (new shared `EmployeePicker` combobox over employees list), type EARNING/DEDUCTION, code/label, amount, run-scope select; delete unconsumed; consumed rows show payroll-entry link) · **Teaching Pay** (`GET /hr/teaching/summary` table + Preview/Materialize buttons → `POST /pay/preview|materialize`; rate-missing rows flagged `unrated` with a link to the employee's custom fields) · **Incentives** (`GET /hr/incentives/preview` with commissionPct/fixedPerConversion inputs → preview table (revenue, paying leads, incentive, unlinked flags) → Materialize with payout-month picker) · **F&F** (exiting-employee picker → `POST /payroll/fnf/prepare` (notice-recovery input) → summary panel → "Create FNF run" shortcut). + +### 3.3 Payroll — Loans & Claims (`/erp/payroll/loans`) +Tabs: **Loans** (list/create `POST /payroll/loans`, approve, repayment schedule sheet `GET /loans/{id}/repayments`) · **Reimbursements** (queue `GET /payroll/reimbursements?status=`, approve/reject dialog). + +### 3.4 Payroll — Salary Setup (`/erp/payroll/salary-setup`, adminOnly) +Tabs: **Components** (CRUD; columns type/category/taxable/statutory + **GL account code** column — the journal mapping) · **Templates** (list; template editor dialog: component rows with calculation type FIXED/%CTC/%BASIC/%GROSS/FORMULA + SpEL formula input with the variable hints, min/max; tie-out note: "Special Allowance auto-balances to CTC") · **Assign** (employee picker + template + CTC + effective-from + currency → `POST /salary/structures`; preview of computed breakdown; revision history sheet per employee `GET /salary/structures?employeeId` + `/revisions`). + +### 3.5 People (`/erp/people`) +- **Employees list**: `GET /hr/employees` (+ filter dialog dept/designation/status/type). Columns: code, name, dept, designation, status chip, join date. "Add employee" full-form dialog (`POST /employees`) AND "Add from staff" (opens Staff Coverage). +- **Employee detail** (`/erp/people/$employeeId`, Sheet-or-page with tabs): Profile (edit; masked PAN/UAN with the ignore-masked-round-trip semantics already server-side) · Bank (masked accounts, add/edit) · Documents (upload via `use-file-upload`, list, expiry badges) · Salary (current structure + history + revise) · Leave balances (`GET /leaves/balances?employeeId`) · Loans · Status action (dialog: status + dates → `PUT /employees/{id}/status`; TERMINATED/RELIEVED flows prompt "prepare F&F?"). +- **Departments & Designations** (`/erp/people/org`): two simple CRUD tables; link to existing Org Chart at `manage-institute/teams`. +- **Staff Coverage** (`/erp/people/staff-bridge`): `GET /hr/employees/staff-bridge` — coverage cards (total staff / with HR profile / teaching without profile), roster table with roles + `teaches` + blocked-reason, row action "Create HR profile" → `POST /from-staff` (minimal dialog: code/join date/dept/designation). + +### 3.6 Attendance (`/erp/attendance`) +- **Daily Board**: date picker (default today, institute TZ) — grid of employees × status for the day; bulk-mark bar (`POST /attendance/mark`); month-lock banner when the month's payroll is processed. Summary strip from `GET /attendance/summary`. +- **Regularizations**: pending queue → approve/reject dialog (`PUT /regularization/{id}/approve`). +- **Shifts & Holidays**: shift CRUD + assign dialog; holiday calendar (year view, bulk-import dialog with duplicate-skip report); **Config** section (mode TIME_TRACKING/DAY_LEVEL, timezone select, weekend days, geo-fence map-less lat/lng/radius inputs, IP allowlist w/ CIDR hint, auto-checkout, thresholds) → `POST /attendance/config`. + +### 3.7 Leave (`/erp/leave`) +- **Requests**: queue (`GET /leaves/applications?status=PENDING` default; filters). Approve/reject dialog shows balance check; month-lock and insufficient-balance errors surfaced verbatim (backend messages are good). +- **Balances**: employee × leave-type matrix for the year; admin adjust dialog; comp-off sub-tab (pending approvals + expiry dates); accrual/year-end buttons (adminOnly, AlertDialog explaining idempotency). +- **Types & Policies** (adminOnly): two CRUD tables (paid/carry-forward/encashable flags; policy quota/accrual/pro-rata). + +### 3.8 Compliance (`/erp/compliance`, adminOnly) +- **Filings hub**: MonthPicker + FY selector; card grid, one per filing with status-of-data warnings from the APIs: PF ECR (preview table + skipped + download `.txt`), ESI return, PT return, WPS (shown only for Gulf institutes — country from tax config; SIF/Mudad download), Form 24Q (per-quarter: deductor block, challan match indicator w/ mismatch DANGER chip, annexure table, CSV), Form 16 (employee picker + FY → JSON view + PDF download; also surfaced in My HR). +- **Challans**: CRUD table (`/hr/compliance/challans`), quarter totals vs TDS deducted comparison. +- **Provisions**: Gratuity report table + CSV (India) / EOSB (Gulf — statutory vs accounting split, capped flags); Bonus computation (pct input → table → Materialize into BONUS adjustments). + +### 3.9 Finance (`/erp/finance`) +- **Journal**: period browser (`GET /erp/finance/journal?year&month`) — entries with expandable balanced lines (Dr/Cr columns, `tabular-nums`), REVERSED badge, Export CSV (admin). +- **P&L Snapshot**: `GET /pnl-snapshot` — revenue vs payroll-cost cards, margin, dept cost table, journal-presence indicator, currency-mismatch warning, CSV. + +### 3.10 My HR (`/erp/my-hr`) — self-service, phase 2 +- **Overview**: my profile card (read-only + emergency-contact edit), check-in/out button when TIME_TRACKING (`POST /attendance/check-in|check-out` — employeeId omitted, backend resolves self), this-month attendance strip, leave balance cards, latest payslip shortcut. +- **My Leave**: balances + apply dialog (`POST /leaves/apply`) + my applications list + cancel; comp-off request. +- **My Payslips**: list + PDF download; **My Tax**: regime picker + declarations form (80C/80D/HRA rent/metro fields keyed to the engine's declaration keys) → submit/update; status chip (SUBMITTED/VERIFIED); Form 16 download per FY. **My Claims**: submit reimbursement (receipt upload), my loans + repayment schedule, my teaching summary (teachers). + +### 3.11 ERP Settings (settings tab) +Tax configuration (country, state, FY start month, statutory toggles, declaration identifiers: TAN/PAN/PF establishment/ESI code/PT registration — the `statutory_settings` keys), HR role assignment shortcut (links to Teams), module opt-in note. + +## 4. Key flows + +1. **Run a month's payroll**: Runs → New run → Process (watch errors tab) → fix (hold/release, reject-recalculate loop) → Approve (journal posts) → Payslips generate + email → Bank file download → Mark Paid. The run-detail stepper is the backbone; every state shows exactly its legal next actions. +2. **Onboard an employee**: Staff Coverage → Create from staff → employee detail → assign salary structure → appears in next run. Coverage cards measure progress. +3. **Month close**: process locks attendance/leave — surface the lock as a banner on Attendance/Leave screens with a link to the run. +4. **Exit**: employee status → RELIEVED (+ last working date) → F&F prepare → FNF run. + +## 5. New shared components + +- **`MonthPicker`** (none exists — build once in `@/components/design-system/month-picker.tsx`, used by Payroll/Compliance/Finance/Adjustments). +- **`EmployeePicker`** — searchable combobox over the employees API (used by adjustments, loans, salary assign, F&F, Form 16). +- **`RunStatusStepper`** — payroll lifecycle visual. +- **`MoneyCell`** — `formatCurrency` + currency code + `tabular-nums` right-aligned. +- Reuse: `PaymentKpiCards` pattern, `DateRangeDropdown`, `export-dialog-pdf-csv`, `simple-pdf-viewer` (payslips/Form16), `StatusChip`, org-chart canvas. + +## 6. Phased build plan (payroll ops first) + +| Phase | Scope | Outcome | +|---|---|---| +| **U1 — Foundation + Payroll core** | ERP category plumbing (§1), hooks (§2), urls.ts block, MonthPicker/EmployeePicker/MoneyCell/Stepper, People (list/detail/departments/staff-bridge), Salary Setup, Payroll Runs + Run detail (entries/errors/hold), Adjustments tab | An HR admin runs a real payroll end-to-end in the UI | +| **U2 — Pay it + prove it** | Payslips tab (generate/download/email), Bank file tab, Loans & Claims, F&F, Compliance hub (ECR/ESI/PT/24Q/challans/Form 16), Finance (journal + P&L) | Payout + statutory outputs all UI-driven | +| **U3 — Attendance & Leave** | Daily board, regularizations, shifts/holidays/config, Leave requests/balances/setup, month-lock banners | Daily-ops adoption; LOP feeds payroll visibly | +| **U4 — My HR** | Self-service overview, leave, payslips, tax declarations, claims, check-in | Every employee touches the system | +| **U5 — Connected + polish** | Teaching Pay + Incentives tabs, Provisions (gratuity/EOSB/bonus), WPS (Gulf), ERP dashboard widget(s), Capacitor check-in ergonomics | The connected-ERP story visible | + +Per-phase definition of done: `pnpm build` (tsc) clean, design-lint clean, naming-lint clean, co-located tests for hooks/utils (vitest), loading/empty/error states on every table, both themes checked. + +## 7. Guardrails + +Follow `CLAUDE.md` + `docs/design-system/*` strictly: tokens only (no raw hex/arbitrary values), phosphor icons only, `getTerminology` for renamable nouns, snake_case payloads, `reportApiError` for failures, `MyButton onAsyncClick` for mutations (double-submit guard), zero-indexed pagination, ship behind `OPT_IN_TAB_IDS` until QA'd on a pilot institute. diff --git a/docs/erp/hr-payroll-review-and-gap-plan.md b/docs/erp/hr-payroll-review-and-gap-plan.md new file mode 100644 index 0000000000..a147c33e94 --- /dev/null +++ b/docs/erp/hr-payroll-review-and-gap-plan.md @@ -0,0 +1,152 @@ +# HR & Payroll — Backend Review & Gap-Closure Plan + +> **STATUS 2026-08-27 — Wave 1 BUILT (not committed/deployed).** Phase A (security) + B1 (payroll crash/state-machine) implemented and compiling: +> HR_ADMIN/HR_MANAGER roles seeded (auth_service V17) + `HrAccessGuard` on all 17 HR controllers per the access matrix; every by-ID load institute-scoped; all body-instituteId spoof vectors closed; self-service employeeIds bound to the JWT; AES-256-GCM at-rest encryption for bank account/PAN/UAN/statutory_info (`HR_FIELD_ENCRYPTION_KEY` env, `ENCv1:` prefix, legacy plaintext reads through; V480 widens columns); `@Auditable` on sensitive mutations; `@Version` on the 5 financial entities; TDS NOT-NULL crash fixed via get-or-create system component; silent catches → `hr_payroll_entry_error` table; run processing row-locked; new PUT `/runs/{id}/reject` (PROCESSED→DRAFT with full reversal); cancel reverses loans/reimbursements and no longer blocks the month (partial unique + `run_type`); mark-paid sets entry PAID; totals recompute on hold/release; loan mutations deferred to post-calculation; tax computations upsert (V480 unique). Also fixed in passing: overlapping shift-mapping 500, holiday bulk duplicate 500, leave approval balance re-validation + not-self rule, template cross-tenant component refs. +> **Deploy prereqs:** set `HR_FIELD_ENCRYPTION_KEY` (openssl rand -base64 32) in admin_core; assign HR_ADMIN/HR_MANAGER roles to HR users via the existing add-user-roles flow. Remaining waves below unchanged. +> +> **STATUS 2026-08-27 (later) — Wave 2 BUILT (Wave 1 pushed as 9d83c8176d; Wave 2 not yet committed).** Phase B2+B3+E1 implemented and compiling: +> **Tax (B2):** TaxRegimeEngine interface rebuilt (TaxInput/TaxResult/StatutoryItem, pure functions); IndiaTaxRegimeEngine rewritten for FY 2025-26 — correct new-regime slabs incl. 25% band, §87A full rebate to ₹12L with marginal relief, old regime (slabs, SD ₹50k, §87A ₹12.5k) honoring the declared regime, COMPUTED HRA exemption (min of received / rent−10% basic / 50-40% basic; never trusts a self-declared amount), 80C (auto-counts employee PF) / 80D (senior-aware) / 80CCD(1B)/(2) / 80E / 80TTA with caps, surcharge tiers + marginal relief (new-regime 25% cap), 4% cess; all rates data-driven via tax_rules JSONB with per-FY override keys and built-in defaults. EPF 12/12 on actual BASIC (ceiling 15k, EPS 8.33 split in detail), ESI 0.75/3.25 with Apr–Sep/Oct–Mar stickiness (gross at period start from structure history), PT state slab table (MH/KA/WB/TN/TS/AP/GJ/MP built-ins, JSONB-overridable). TDS is a YTD true-up (cumulative TaxComputation rows; remaining liability ÷ months left). Declarations gated: VERIFIED always; SUBMITTED only Apr–Dec. Statutory materialized as system components (PF/ESI/PT + _ER) unless the salary template already carries the scheme (alias dedup). financial_year_start_month honored. +> **Payroll:** joiner/exit employment-window proration (NOTICE_PERIOD included), structure selected by effective dates (ACTIVE/SUPERSEDED overlapping the month), currency stamped on entries. +> **Salary (B-leftovers):** CTC tie-out with auto-balancing SPECIAL_ALLOWANCE (overshoot = clean error), PERCENTAGE_OF_GROSS single-base semantics, FORMULA via sandboxed SpEL (#CTC/#BASIC/#GROSS/#), revisions supersede prior structure (effectiveTo = D-1). +> **Attendance/leave (B3):** per-institute timezone (V481 column; all day-bucketing zone-aware — fixes pre-05:30-IST misdating), DAY_LEVEL vs TIME_TRACKING mode enforced, server-derived IP + real IPv4 CIDR matching, regularization status re-derived from hours, weekend_days config in leave counting, half-day-on-holiday rejected, INACTIVE types rejected, unpaid/LOP leave without balance rows, leave approval writes ON_LEAVE attendance rows (cancel reverts), accrual ledger table hr_leave_accrual_txn (V481) with MONTHLY/QUARTERLY/YEARLY + pro-rata + idempotent year-end CARRY markers, comp-off requires weekend/holiday + attendance evidence. +> **E1:** currency VARCHAR(3) DEFAULT 'INR' on structure/run/entry/payslip/bank-export/loan/reimbursement (V481), threaded through DTOs, payslip HTML, bank CSV. +> Still open for Wave 3 (Phase C): payslip PDF/email pipeline, real bank formats/XLSX, scheduled jobs, month-lock, workflow-engine approvals, F&F/off-cycle, variable-pay API, tests. +> +> **STATUS 2026-08-27 (later still) — Wave 3 BUILT, 21/21 unit tests green (not committed).** Phase C complete except workflow-engine approvals (deferred to the connected phase — hr_approval containment fixes from Wave 1 stand): +> **Payslips:** real PDF pipeline (openhtmltopdf → MediaService S3 → real file_id; legacy HTML rows auto-regenerated), GET /payslips/{id}/download, POST /payslips/email (per-employee attachment emails via sendAttachmentEmailViaUnified, SENT/FAILED tracked), all interpolation HTML-escaped. +> **Bank export:** honors CSV / real POI XLSX / HDFC/ICICI/SBI v1 text formats (flagged for bank-portal spec verification); missing bank-detail entries excluded + returned as `skipped` list; file persisted to media with GET /reports/bank-export/{id}/download; real names/emails via user join. NOTE API change: export creation now returns JSON (log + skipped), file comes from the download endpoint. +> **Schedulers (all @SchedulerLock'd — 4 replicas):** daily leave accrual (ledger-idempotent), comp-off expiry (EXPIRED + balance deduction), 30-min auto-checkout, daily auto-absent (kills the "no records = full pay" cliff; skips locked months), probation-end (T-7) and document-expiry (T-30/T-7) HR alerts (HR_ADMIN → ADMIN → manager fallback). +> **Month-lock:** HrMonthLockService — a REGULAR run past DRAFT freezes the month's attendance/leave mutations (bulk-mark, regularization approval, check-in/out, leave approve/cancel-approved); reject/cancel unlocks. +> **Notifications:** best-effort emails on leave apply/approve/reject, comp-off, regularization, loan approval, reimbursement decisions (HrNotificationService, failures never break the operation). +> **Variable pay:** hr_payroll_adjustment (V482) + CRUD endpoints; adjustments materialize as components, taxed via the YTD true-up, unlinked on reject/cancel. **Off-cycle:** run_type on creation — OFF_CYCLE/BONUS runs pay exactly the pending adjustments; **FNF** runs auto-select the month's exiting employees (window-prorated final salary) with POST /payroll/fnf/prepare creating leave-encashment + notice-recovery adjustments. GET /runs/{id}/errors exposes per-employee failures. +> **Tests:** 21 unit tests green — IndiaTaxRegimeEngine vs hand-computed FY 2025-26 law (§87A + marginal relief, slabs, old-regime HRA/80C/80D, YTD true-up, EPF/EPS, ESI stickiness + round-up, MH PT Feb, JSONB overrides), CTC tie-out/formula, adjustment code sanitizing. Engine matched law on every checked rule. +> Remaining for later phases: workflow-engine approvals (C5), Phase D compliance pack, Phase E Gulf engines, Phase F connections, Phase G payouts. +> +> **STATUS 2026-08-27 (final) — Phase D BUILT (compiles, 21/21 tests green; not committed).** New `hr_compliance` package (V483): +> **TDS:** challan register (hr_tds_challan + CRUD); Form 16 Part B per employee/FY — JSON + PDF (self-or-staff), assembled from the cumulative TaxComputation trail via FY-ordered deltas, Part-A-from-TRACES noted; Form 24Q quarterly — deductor block + challan mapping + deductee annexure (PAN, monthly income/TDS deltas, s.192), quarter-vs-challan mismatch flag, CSV download (v1 preparer-input, not FVU). +> **PF ECR** monthly — real ECR v2 `#~#` text format, wage base recovered from the PF component (÷0.12), EPS 8.33 split, NCP days from absences; missing-UAN rows excluded + reported. **ESI return** monthly CSV (IP numbers from statutory_info, paid days = present+leave). **PT return** monthly CSV (state + registration header, empirical slab summary). PROCESSED runs included with a regenerate-after-approval warning. +> **Gratuity provision** report — s.4 formula (15/26 × basic × rounded years, >6-month round-up, ₹20L cap), 4y+240d vesting flag (Mettur Beardsell), vested/unvested split, 4.81% monthly run-rate, CSV. **Statutory bonus** — s.2(13)/s.10-12 computation (≤₹21k eligibility, ₹7k wage cap, 8.33–20%, half-month proration) + idempotent materialize into BONUS-scoped payroll adjustments. +> Deductor/scheme identifiers live in hr_tax_configuration.statutory_settings keys: deductor_name, deductor_address, employer_pan, tan, pf_establishment_id, esi_employer_code, pt_registration_number. +> Known v1 caveats: 24Q CSV feeds a return preparer (not FVU e-file); ECR/bank formats pending portal validation; Bonus Act's minimum-wage floor (s.12) not modeled; LWF still absent (needs state-wise config — flagged for a later slice). + +> **STATUS 2026-08-27 (Phase E) — Gulf readiness BUILT (compiles, 40 test executions green; not committed).** +> **Engines:** UaeTaxRegimeEngine (ARE) — zero income tax; GPSSA 5%/12.5% for UAE nationals on the AED 1k–50k band; EOSB accrual 21 days/yr (<5y) / 30 days/yr as a monthly employer cost (FDL 33/2021 art. 51). SaudiTaxRegimeEngine (SAU) — zero income tax; GOSI 9.75%/11.75% nationals vs 2% expat employer-only on SAR 1.5k–45k; EOSB half/full month per year (art. 84). Both flow through the statutory-component pipeline (GPSSA/GOSI/EOSB + _ER, template-alias dedup). TaxInput gained nationality + serviceYears (payroll populates from profile/joinDate). Factory normalizes aliases (UAE→ARE, KSA→SAU, IN→IND). +> **WPS exports** (hr_compliance): UAE SIF (EDR rows + SCR trailer, mol_person_id/wps_agent_id/IBAN sourcing, missing-IBAN skip list) and Saudi Mudad-style CSV (gosi_number, BASIC recovery) — format picked from the institute's country config, both v1 pending portal validation. Config keys: mol_establishment_id, employer_bank_code, wps_reference. +> **EOSB provision report** — band-split pro-rated UAE/Saudi liability with statutory-vs-accounting split (1-year UAE floor, basic×24 cap flagged), monthly run-rate, CSV export. +> **Tests:** +17 Gulf engine tests (GPSSA/GOSI clamps, nationality gating, EOSB bands, disable flags) — zero discrepancies vs statute. +> Gulf caveats: GPSSA/GOSI base is BASIC (statutorily basic+housing — needs a HOUSING component convention later); GCC-national home-scheme rates and KSA art. 85 resignation reductions not modeled; WPS/Mudad layouts need portal validation. +> **STATUS 2026-08-27 (Phase F) — Connected ERP BUILT (compiles, 40 tests green; not committed). Waves 2-3+D+E pushed as 40e7de83b1 and deployed.** +> **GL journal (F4)** — `erp_finance` package + V484: erp_journal_entry/_line (the Accounting-module seed), payroll approval posts a balanced double entry (component-level GL overrides via hr_salary_component.gl_account_code, default chart 5100/5110/5120/5150 vs 2100/2110/2120/1210, HELD excluded, adjustment double-count avoided, plug line for net-floor clamps), mirror reversal on reject/cancel, idempotent per run (partial unique), CSV export for Zoho Books/Tally import. **P&L snapshot** (F4b): dept-wise payroll employer cost vs collected fee revenue (allocation-ledger cash-in query verbatim from CollectionDashboardRepositoryImpl), margin/ratio, journal-presence, CSV. +> **Teaching→pay (F2)** — `hr_teaching`: monthly per-teacher summaries from session_schedules × live_session_logs (taught minutes: seconds→minutes→scheduled fallback, only for logged occurrences); attendance-sync upserts PRESENT rows (month-lock aware, never downgrades); teaching pay from customFields rates (per-session/per-hour, v1 store) materialized as TEACHING_PAY adjustments, idempotent per month. +> **CRM incentives (F3)** — `hr_incentive`: per-counsellor collected revenue (RevenueReportService CTEs verbatim: linked_users-first attribution, PAID payment_log window), commissionPct + fixedPerConversion preview, idempotent materialize into CRM_INCENTIVE adjustments with provenance notes; unlinked counsellors surfaced. +> **Staff bridge (F1)** — hr_employee StaffUnificationService: unified roster (user_role ADMIN/TEACHER/EVALUATOR/COUNSELLOR + faculty `teaches` flag + HR coverage counts), POST /from-staff creates a profile via the canonical createEmployee path; cross-institute user_id constraint surfaced as blocked_reason. +> **Workflow events (F5)** — 11 HR events added to WorkflowTriggerEvent (leave/loan/reimbursement requested+decided, comp-off decided, payroll processed/approved/paid, employee status changed), emitted emit-and-forget from HR services per the platform convention — institutes attach approval/escalation/notification workflows to them. hr_approval remains contained; a workflow-native approval NODE is a future engine feature. +> Remaining from the original plan: Phase G payout providers (RazorpayX/Cashfree behind a PayoutProvider abstraction) — demand-gated. + + +Date: 2026-08-27. Scope: the 8 `hr_*` packages in admin_core_service (203 files, ~12.4k lines, migrations V128–V149) built from `docs/erp/plan.md`, reviewed by four parallel deep audits (employee/attendance/leave, salary/payroll/payslip, tax engine, cross-cutting security/integration). + +Product decisions locked for this plan: +- **Geographies:** India (fully real) + Gulf (UAE/Saudi: WPS, EOSB gratuity, GOSI/GPSSA). +- **Payouts:** bank-file export now, payout-provider abstraction so RazorpayX/Cashfree can plug in later. +- **Connected v1:** LMS (teacher work → pay), Finance (payroll cost vs fee revenue + journal export), CRM (incentives as variable pay), existing workflow engine + notification_service. +- **ERP next:** Accounting/GL + HR-suite deepening — architect for both now. + +--- + +## 1. Verdict + +The **skeleton is real and worth keeping**: all 34 planned tables exist with correct constraints, all planned entities/CRUD/endpoints exist, and three pieces of logic are genuinely careful — attendance/leave-overlap proration in payroll, loan EMI amortization with reprocess-reversal, and leave application validation (balance, overlap, holiday exclusion). + +But the system is **not shippable and not yet "advanced"**: + +1. **Security is absent, systemically (P0).** The plan's HR_ADMIN/HR_MANAGER roles exist nowhere in the codebase — not in auth_service, not seeded, not checked. Every one of the 17 HR controllers validates only "caller belongs to the instituteId query param". Consequences: a **student** of an institute can process payroll, approve their own leave, read every employee's salary structure and payslip, and download the bank-export CSV with plaintext account numbers. Every by-ID endpoint has **cross-tenant IDOR** (validate against institute A, pass institute B's resource id — the same pattern as the prior report-endpoints incident), and several writes trust `dto.instituteId` from the body while validating the query param (attendance config, approval chains, payroll runs, salary templates — cross-tenant *write*). No self-service binding: any user can check in / apply leave as any employeeId (acknowledged by a TODO in the code). Bank accounts, PAN, UAN, statutory JSON stored plaintext. + +2. **The India tax engine mis-deducts real money (P0).** FY 2024-25 slabs (one year stale), the 25% slab missing entirely, **no §87A rebate** (a ₹10L employee is charged ~₹44k that should be ₹0), new-regime slabs combined with old-regime deductions (a legally nonexistent hybrid), regime selection ignored, HRA exemption is whatever number the employee types (uncapped, unverified — can zero out tax), no surcharge. EPF/ESI/PT code exists but `getStatutoryDeductions()`/`getEmployerContributions()` are **never called** — statutory deductions don't happen. Rates are hardcoded in Java; the `tax_rules` JSONB design is decorative; no effective-dating. + +3. **Payroll processing crashes silently for tax-configured institutes (P0).** The TDS entry component is created with a null `component_id` against a NOT NULL column; the failure is swallowed by empty catch blocks (per-employee and tax-block), so entries silently vanish mid-run. Also: a **CANCELLED run permanently blocks that month** (unique constraint + no re-create), and cancelling leaks state — loan balances stay debited and pending reimbursements are eaten forever. No PROCESSED→DRAFT path, so a wrong run can never be recalculated. + +4. **A long tail of stored-but-never-read features.** The TIME_TRACKING/DAY_LEVEL mode switch (the plan's central attendance decision), YEARLY/QUARTERLY accrual (leave module unusable for yearly-quota institutes), pro-rata, comp-off expiry, auto-checkout, shift grace/thresholds, `weekend_days` in leave counting (hardcoded Sat/Sun), optional-holiday quotas, formula components, loan start month, `payment_ref`, document verification. The approval engine is a status counter wired to nothing — no approver resolution, any non-requester can approve all levels, and none of leave/loan/reimbursement/salary-revision use it (each has its own single-approver field; salary revisions are born self-approved). + +5. **Integrations are 0%.** No notification calls (payslip `email_status` stuck at NOT_SENT), no media_service (payslips are **raw HTML stored in a DB column** with a fake UUID file id — no PDF, no download endpoint, despite OpenHtmlToPdf being in pom.xml and used by two other features; bank "XLSX" is CSV bytes in a `.xlsx` filename — POI is in pom.xml, never imported), **zero `@Scheduled` jobs** (accrual, comp-off expiry, auto-checkout, probation-end, auto-approve are all inert), zero `@Auditable` usage (the platform's audit pattern is used in 23 other files; salary and bank-detail changes leave no trail), zero `@Version` locking (leave balance and run transitions race). + +6. **Correctness bugs beyond tax:** UTC/IST — check-ins before 05:30 IST land on the previous date and there is no per-institute timezone anywhere; leave-balance approval doesn't re-validate balance (concurrent approvals go negative); accrual re-invocation double-accrues for mid-year joiners; mid-month joiners get a **full month's salary** (no DOJ proration), NOTICE_PERIOD employees get **no salary** (excluded from runs); salary structure selection ignores effective dates; annual tax projection = LOP-distorted month × 12 with no YTD catch-up; run totals ≠ bank total after a hold; PERCENTAGE_OF_GROSS ordering bug; no CTC tie-out (no balancing component); IP restriction compares client-supplied IP and CIDR entries never match (enabling it blocks everyone); approved leave never writes ON_LEAVE attendance so summaries are wrong; N+1s everywhere (attendance summary ≈ 5 queries/employee; payroll ≈ 13–16/employee, row-by-row saves, one giant transaction). + +7. **Duplication with the platform:** `hr_employee_profile` is a third parallel "person who works here" beside `staff` and `faculty` with no bridge; `hr_approval` rebuilds a weak subset of the existing workflow engine (which has triggers, SpEL, idempotency, schedulers); HR holidays are invisible to session scheduling; live-session teaching activity doesn't feed HR attendance; payroll produces no finance-side entry. + +Zero tests exist for any hr_* package. + +--- + +## 2. Gap-Closure Plan + +Phases A–C are pre-deploy blockers. D before the first Indian customer runs a real payroll year. E gates the first Gulf customer. F is the "connected ERP" differentiator and can proceed in parallel after A+B. + +### Phase A — Make it safe (blocker) +1. **Roles.** Add HR_ADMIN, HR_MANAGER to the role system (auth_service + seeding); implement the plan-G access matrix via a small `HrAccessGuard` used by every controller: admin ops require HR_ADMIN/ADMIN, team ops require HR_MANAGER + reporting-line check, self-service resolves `employeeId` **from the JWT user id** (never from the body). +2. **Tenant ownership.** Repo convention: every by-ID load becomes `findByIdAndInstituteId(...)` (or post-load `entity.instituteId == validatedInstituteId` check) across all ~20 services; delete every `dto.getInstituteId()` write-path read — the validated query-param/path institute is the only source of truth. +3. **Data protection.** AES converter (JPA `AttributeConverter`) for `account_number`, `pan_number`, `uan_number`, `statutory_info`; privileged unmask path for payroll/bank-export only; mask `statutory_info` in DTOs (currently unmasked); fix the round-trip bug where a masked PAN (`****1234`) gets persisted on update. +4. **Audit + locking.** `@Auditable` (existing platform aspect) on salary assignment/revision, bank-detail change, payroll process/approve/mark-paid, hold/release (hold history table — release currently wipes the reason); `@Version` on PayrollRun, PayrollEntry, LeaveBalance, EmployeeLoan, EmployeeSalaryStructure. + +### Phase B — Make it correct (blocker) +1. **Payroll engine fixes.** + - Seed system `SalaryComponent`s (TDS, PF, ESI, PT, OT, arrears) per institute → fixes the NOT NULL crash and makes overtime/reimbursements/statutory visible on payslips as components. + - Replace empty catches with a `hr_payroll_entry_error` table + per-run error report; fail loudly on tax-engine errors instead of silent zero tax. + - State machine: add PROCESSED→DRAFT (reject/recalculate); allow a new run after CANCELLED (drop the hard unique, add `run_type` + `supersedes_run_id`, unique on *active* runs only); cancel performs full cleanup (loan reversal, reimbursement unlink, tax-computation delete); recompute run totals after hold/release; entry-level PAID + `payment_ref` set by mark-paid; block hold/release on PAID runs; `SELECT … FOR UPDATE` on run during process. + - Proration: DOJ/exit-date proration; include NOTICE_PERIOD in runs; pick salary structure by effective date vs the run period; split-period blending for mid-month revisions with **arrears** generation (arrears engine replaces the hardcoded zero). + - CTC tie-out: a balancing component (Special Allowance = CTC − Σ others) + validation that template resolves to 100% of CTC; fix PERCENTAGE_OF_GROSS ordering; implement FORMULA via the workflow engine's SpEL evaluator. + - Performance: hoist per-institute lookups, batch attendance/leave prefetch per run, `saveAll` + JDBC batching, chunked commits (per-employee or per-100), async processing with a progress endpoint for large institutes; add missing indexes (`hr_loan_repayment(payroll_entry_id)`, `hr_reimbursement(payroll_entry_id)`, `hr_attendance_record(institute_id, attendance_date)`, `hr_leave_application(applied_to)`, `hr_employee_salary_structure(employee_id, status)`). +2. **India tax engine rebuild (data-driven).** + - Move slabs/rates/caps into `tax_rules` JSONB with `effective_from`/`financial_year` (add the column, drop in-place upsert) — FY changes become data, not deploys. Seed FY 2025-26: new regime 0-4/4-8/8-12/12-16/16-20/20-24/>24 (5/10/15/20/25/30%), §87A rebate to ₹12L, SD ₹75k; old regime slabs + §87A to ₹5L + SD ₹50k; surcharge tiers + marginal relief; 4% cess. + - Honor the employee's declared regime; enforce regime-legal deductions only (80C/80D/80CCD/80E/HRA under OLD only); compute HRA exemption properly (min of actual HRA component, rent−10% basic, 50/40% basic) from the declared rent — never trust a self-declared exemption amount; only VERIFIED declarations reduce TDS after the institute's proof-cutoff date. + - Wire `getStatutoryDeductions`/`getEmployerContributions` into the run as components; EPF on the **actual BASIC component** (not gross×0.5) with EPS 8.33/3.67 split and ₹15k ceiling; ESI with Apr–Sep/Oct–Mar contribution-period stickiness; PT as a per-state slab table (config-driven, Maharashtra Feb ₹300 etc.); add LWF (state-wise). + - TDS = true-up: YTD actual income + projected remaining months → (annual tax − TDS already deducted) ÷ months remaining; fix `TaxComputation` to hold real cumulative values, delete on reprocess, unique (employee, fy, month). + - Fix `TaxConfigurationRepository` Optional-vs-multiple-rows bug; honor `financial_year_start_month`. +3. **Attendance/leave correctness.** + - Per-institute **timezone** (new column on AttendanceConfig or institute settings); all `LocalDate.now()`/day-bucketing through it (JVM stays UTC per platform rule). + - Implement the mode switch (DAY_LEVEL institutes skip check-in machinery; summaries branch on mode); leave day-counting uses configured `weekend_days`; fix half-day-on-holiday ordering bug; approved leave writes ON_LEAVE attendance records; auto-absent job marks missing days (kills the "no records = full pay" cliff by making records exist). + - Accrual: implement YEARLY/QUARTERLY; replace the broken idempotency heuristic with an **accrual-ledger table** (one row per employee/type/period, unique) — also gives pro-rata for mid-year joiners; re-validate balance at approval under lock; LOP/unpaid leave as a first-class output consumed by payroll. + - Comp-off as consumable units: expiry job, attendance-verified eligibility (worked a holiday/weekend per records), server-set earned days. + - Server-derived IP (X-Forwarded-For) + real CIDR matching; shift assignment closes prior mappings (fixes the NonUniqueResult 500 on second assignment); regularization validates out>in and re-derives status. + +### Phase C — Make it complete (blocker for launch quality) +1. **Payslip pipeline:** OpenHtmlToPdf (InvoiceService pattern) → media_service S3 upload → real `file_id` → download endpoint → notification_service email with payslip attached (flip `email_status`); HTML-escape all interpolated fields; payslip regeneration versioning. +2. **Bank export:** real XLSX via POI; bank-specific formats (HDFC/ICICI/SBI/NEFT text); exclude-and-flag entries with missing bank details; persist the file to S3 with a download endpoint; join real employee names/emails. +3. **Schedulers:** leave accrual (monthly), comp-off expiry, auto-checkout, auto-absent, probation-end notification, document-expiry alerts — all as `@Scheduled` jobs following the platform's existing scheduler patterns. +4. **Attendance/payroll month-lock:** processing a run locks that month's attendance/leave for the institute; regularization/bulk-mark/cancel-leave refuse locked months (or generate arrears via Phase B's engine). +5. **Approvals — ride the existing workflow engine** (per product decision). Retire hr_approval's parallel engine: HR entities emit workflow triggers; approver resolution (REPORTING_MANAGER/DEPARTMENT_HEAD/HR_ADMIN) implemented once as workflow actions; leave/regularization/reimbursement/loan/salary-revision approval all route through it; approval outcome calls back into the HR service to mutate entity status; auto-approve/escalation via workflow scheduling. Salary revisions require a second approver (no more born-approved). +6. **F&F settlement + off-cycle:** exit flow computes final settlement (pro-rated salary, leave encashment payout, notice recovery, gratuity if eligible) as an off-cycle run (`run_type=FNF/OFF_CYCLE/BONUS`). +7. **Variable pay input API:** per-run per-employee component adjustments (the entity that CRM incentives and manual bonuses both feed). +8. **Tests:** engine-level unit tests (PayrollCalculationService, tax regimes vs hand-computed scenarios), concurrency tests on balance/run transitions, and the plan-J integration flow. + +### Phase D — India compliance pack +Form 16 Part B generation (needs `computation_details` actually populated + PAN/TAN on institute tax config); Form 24Q quarterly data + challan tracking; **PF ECR file** (needs UAN + EPS split from Phase B); ESI return file (IP numbers); PT returns per state; gratuity provision accrual (4.81% of basic) + Payment of Gratuity eligibility; statutory bonus (Payment of Bonus Act) computation. + +### Phase E — Gulf readiness +1. **Currency (prerequisite, do in Phase B migrations):** `currency` column on salary structure, payroll run/entry, payslip, bank export, loans, reimbursements; institute default currency; all DTOs carry it. No FX in v1 — one currency per institute. +2. **UAE engine:** no income tax; **EOSB gratuity accrual** (21/30 days per year rules) as an employer-cost component + provision report; **WPS SIF file** export (agent/employer IDs on institute config, per-employee routing via IBAN — fields already exist); unpaid-leave day reporting per WPS. +3. **Saudi engine:** GOSI contributions (nationals vs expats), Mudad/WPS equivalent, EOSB per Saudi labor law. +4. Weekend/holiday configurability already covered in Phase B (Sun–Thu, Fri–Sat institutes); FY start month honored. + +### Phase F — Connected ERP (the differentiator) +1. **One person, one record:** bridge `hr_employee_profile` ↔ `staff`/`faculty` on (user_id, institute_id) — creating faculty offers HR profile creation and vice versa; org chart and payroll see teaching staff. +2. **LMS → pay:** live-session participation (existing live_session_logs + provider sync) feeds hr_attendance as `source=SYSTEM` for teaching staff; per-session/per-hour pay components computed from sessions taught (rate on designation or employee), entering payroll as variable pay via C7. +3. **CRM → incentives:** commission structures (per enrollment/lead conversion, from existing lead/enquiry data) computed monthly into the variable-pay API; incentive statement on the payslip. +4. **Finance/GL (architect now for the Accounting module):** `erp_journal_entry` + `erp_journal_line` tables with (source_module, source_id) refs and a component→GL-account mapping on SalaryComponent; payroll approval posts a journal (salary expense / statutory payable / net payable); department cost vs fee revenue report joins existing fee_management income; export journals to Zoho Books/Tally (CSV first, API later). Fees and future accounting post into the same journal layer — this table pair is the seed of the GL module. +5. **Notifications everywhere:** leave/approval/payslip/payroll events through notification_service (email/WhatsApp templates), driven by workflow triggers from C5. + +### Phase G — Payout provider abstraction (design now, ship later) +`PayoutProvider` interface (initiate, status, reconcile) mirroring the existing PaymentServiceFactory pattern; v1 implementation = `BankFileProvider` (generates the file, admin marks paid, UTR bulk-import endpoint fills `payment_ref` per entry); later `RazorpayXProvider`/`CashfreeProvider` slot in without touching the run flow. Reconciliation report: run totals vs UTR-confirmed totals. + +--- + +## 3. Suggested execution order + +| Wave | Content | Gate | +|---|---|---| +| 1 | Phase A (security) + B1 state-machine/crash fixes | nothing deploys before this | +| 2 | B2 tax rebuild + B3 attendance/leave correctness + E1 currency columns | first pilot institute (India) | +| 3 | Phase C (payslips, bank files, schedulers, workflow approvals, F&F, variable pay, tests) | GA for India ex-compliance | +| 4 | Phase D (statutory filings) ∥ F1–F2 (person unification, LMS→pay) | first full Indian FY / teacher-payroll pitch | +| 5 | Phase E (Gulf) ∥ F3–F5 (CRM incentives, GL journal, notifications) | first Gulf customer / accounting module kickoff | +| 6 | Phase G payout API | when manual bank upload becomes the complaint | diff --git a/docs/erp/plan.md b/docs/erp/plan.md new file mode 100644 index 0000000000..32d0d6fea2 --- /dev/null +++ b/docs/erp/plan.md @@ -0,0 +1,1167 @@ +HR & Payroll Management System — Implementation Plan +Context +Vacademy is a multi-tenant EdTech platform. Institutes (tenants) manage students, courses, assessments, and staff. Currently, staff are represented only as User + UserRole with no HR capabilities — no departments, designations, salary structures, attendance, leave, or payroll. This plan adds a production-grade, multi-country HR & Payroll system inside admin_core_service. +Key decisions: +Lives inside admin_core_service as new feature packages +EmployeeProfile linked 1:1 to existing User (non-invasive) +Attendance: configurable per institute (time-tracking OR day-level) +Salary: CTC-based with configurable components +Tax: pluggable multi-country engine (Strategy pattern) +Disbursement: payslips (PDF) + bank-ready file export (CSV/Excel) + +Package Structure +admin_core_service/src/main/java/vacademy/io/admin_core_service/features/ +├── hr_employee/ # Employee profiles, departments, designations +│ ├── controller/ +│ ├── service/ +│ ├── repository/ +│ ├── dto/ +│ ├── entity/ +│ └── enums/ +├── hr_attendance/ # Attendance, shifts, holidays, regularization +│ ├── controller/ +│ ├── service/ +│ ├── repository/ +│ ├── dto/ +│ ├── entity/ +│ └── enums/ +├── hr_leave/ # Leave types, policies, applications, balances +│ ├── controller/ +│ ├── service/ +│ ├── repository/ +│ ├── dto/ +│ ├── entity/ +│ └── enums/ +├── hr_salary/ # Salary templates, components, structures +│ ├── controller/ +│ ├── service/ +│ ├── repository/ +│ ├── dto/ +│ ├── entity/ +│ └── enums/ +├── hr_payroll/ # Payroll runs, entries, loans, reimbursements +│ ├── controller/ +│ ├── service/ +│ ├── repository/ +│ ├── dto/ +│ ├── entity/ +│ └── enums/ +├── hr_tax/ # Tax engine, declarations, computations +│ ├── controller/ +│ ├── service/ +│ │ ├── engine/ # TaxRegime strategy implementations +│ │ └── ... +│ ├── repository/ +│ ├── dto/ +│ ├── entity/ +│ └── enums/ +├── hr_payslip/ # Payslip generation, bank export, reports +│ ├── controller/ +│ ├── service/ +│ ├── repository/ +│ ├── dto/ +│ ├── entity/ +│ └── enums/ +└── hr_approval/ # Generic workflow/approval engine + ├── controller/ + ├── service/ + ├── repository/ + ├── dto/ + ├── entity/ + └── enums/ + + +A. Entity Design +A1. Employee Management (hr_employee) +Department — hr_department +id UUID PK +institute_id UUID NOT NULL (FK → institutes) +name VARCHAR(255) NOT NULL +code VARCHAR(50) +parent_id UUID (FK → hr_department, self-ref for hierarchy) +head_user_id UUID (FK → users, department head) +description TEXT +status VARCHAR(20) DEFAULT 'ACTIVE' -- ACTIVE, INACTIVE +created_at TIMESTAMP +updated_at TIMESTAMP +UNIQUE(institute_id, code) + +Designation — hr_designation +id UUID PK +institute_id UUID NOT NULL +name VARCHAR(255) NOT NULL +code VARCHAR(50) +level INT -- seniority level (1=entry, 10=C-suite) +grade VARCHAR(50) -- pay grade (A, B, C or custom) +description TEXT +status VARCHAR(20) DEFAULT 'ACTIVE' +created_at TIMESTAMP +updated_at TIMESTAMP +UNIQUE(institute_id, code) + +EmployeeProfile — hr_employee_profile +id UUID PK +user_id UUID NOT NULL UNIQUE (FK → users, 1:1) +institute_id UUID NOT NULL +employee_code VARCHAR(50) +department_id UUID (FK → hr_department) +designation_id UUID (FK → hr_designation) +reporting_manager_id UUID (FK → hr_employee_profile, self-ref) +employment_type VARCHAR(20) -- FULL_TIME, PART_TIME, CONTRACT, INTERN +employment_status VARCHAR(20) -- ACTIVE, PROBATION, NOTICE_PERIOD, RELIEVED, TERMINATED, ABSCONDING +join_date DATE NOT NULL +probation_end_date DATE +confirmation_date DATE +notice_period_days INT DEFAULT 30 +resignation_date DATE +last_working_date DATE +exit_reason TEXT +emergency_contact_name VARCHAR(255) +emergency_contact_phone VARCHAR(25) +emergency_contact_relation VARCHAR(50) +nationality VARCHAR(100) +blood_group VARCHAR(5) +marital_status VARCHAR(20) +pan_number VARCHAR(20) -- India tax ID (generic: tax_id_number) +tax_id_number VARCHAR(50) -- country-agnostic tax identifier +uan_number VARCHAR(20) -- India PF universal account (generic via JSON) +statutory_info JSONB -- country-specific IDs: { "epf_number": "...", "esi_number": "...", "ssn": "..." } +custom_fields JSONB -- institute-defined custom fields +created_at TIMESTAMP +updated_at TIMESTAMP +UNIQUE(institute_id, employee_code) + +EmployeeBankDetail — hr_employee_bank_detail +id UUID PK +employee_id UUID NOT NULL (FK → hr_employee_profile) +account_holder_name VARCHAR(255) +account_number VARCHAR(50) NOT NULL +bank_name VARCHAR(255) +branch_name VARCHAR(255) +ifsc_code VARCHAR(20) -- India-specific +swift_code VARCHAR(20) -- international +routing_number VARCHAR(20) -- US/other +iban VARCHAR(50) -- international +is_primary BOOLEAN DEFAULT TRUE +status VARCHAR(20) DEFAULT 'ACTIVE' +created_at TIMESTAMP +updated_at TIMESTAMP + +EmployeeDocument — hr_employee_document +id UUID PK +employee_id UUID NOT NULL (FK → hr_employee_profile) +document_type VARCHAR(50) -- OFFER_LETTER, APPOINTMENT_LETTER, ID_PROOF, PAN_CARD, AADHAAR, PASSPORT, DEGREE, EXPERIENCE_LETTER, RELIEVING_LETTER, PAYSLIP, FORM16, OTHER +document_name VARCHAR(255) +file_id VARCHAR(255) -- S3 file reference (existing media_service pattern) +file_url TEXT +expiry_date DATE +verified BOOLEAN DEFAULT FALSE +verified_by UUID +verified_at TIMESTAMP +notes TEXT +created_at TIMESTAMP +updated_at TIMESTAMP + +A2. Attendance Management (hr_attendance) +AttendanceConfig — hr_attendance_config +id UUID PK +institute_id UUID NOT NULL UNIQUE +mode VARCHAR(20) NOT NULL -- TIME_TRACKING, DAY_LEVEL +auto_checkout_enabled BOOLEAN DEFAULT FALSE +auto_checkout_time TIME +geo_fence_enabled BOOLEAN DEFAULT FALSE +geo_fence_lat DOUBLE +geo_fence_lng DOUBLE +geo_fence_radius_m INT +ip_restriction_enabled BOOLEAN DEFAULT FALSE +allowed_ips JSONB -- ["192.168.1.0/24", ...] +overtime_enabled BOOLEAN DEFAULT FALSE +overtime_threshold_min INT DEFAULT 480 -- minutes after which OT starts +half_day_threshold_min INT DEFAULT 240 -- min minutes for half day +weekend_days JSONB DEFAULT '["SATURDAY","SUNDAY"]' +settings JSONB -- additional config +created_at TIMESTAMP +updated_at TIMESTAMP + +Shift — hr_shift +id UUID PK +institute_id UUID NOT NULL +name VARCHAR(100) NOT NULL +code VARCHAR(20) +start_time TIME NOT NULL +end_time TIME NOT NULL +break_duration_min INT DEFAULT 60 +is_night_shift BOOLEAN DEFAULT FALSE +grace_period_min INT DEFAULT 15 +min_hours_full_day DECIMAL(4,2) DEFAULT 8.0 +min_hours_half_day DECIMAL(4,2) DEFAULT 4.0 +is_default BOOLEAN DEFAULT FALSE +status VARCHAR(20) DEFAULT 'ACTIVE' +created_at TIMESTAMP +updated_at TIMESTAMP + +EmployeeShiftMapping — hr_employee_shift_mapping +id UUID PK +employee_id UUID NOT NULL (FK → hr_employee_profile) +shift_id UUID NOT NULL (FK → hr_shift) +effective_from DATE NOT NULL +effective_to DATE +created_at TIMESTAMP + +AttendanceRecord — hr_attendance_record +id UUID PK +employee_id UUID NOT NULL (FK → hr_employee_profile) +institute_id UUID NOT NULL +attendance_date DATE NOT NULL +shift_id UUID (FK → hr_shift) +-- Time tracking fields +check_in_time TIMESTAMP +check_out_time TIMESTAMP +total_hours DECIMAL(5,2) +overtime_hours DECIMAL(5,2) DEFAULT 0 +break_duration_min INT +-- Day-level fields +status VARCHAR(20) NOT NULL -- PRESENT, ABSENT, HALF_DAY, ON_LEAVE, HOLIDAY, WEEKEND, COMP_OFF +-- Metadata +check_in_lat DOUBLE +check_in_lng DOUBLE +check_out_lat DOUBLE +check_out_lng DOUBLE +check_in_ip VARCHAR(45) +check_out_ip VARCHAR(45) +source VARCHAR(20) DEFAULT 'MANUAL' -- MANUAL, BIOMETRIC, GEO, ADMIN +remarks TEXT +is_regularized BOOLEAN DEFAULT FALSE +created_at TIMESTAMP +updated_at TIMESTAMP +UNIQUE(employee_id, attendance_date) + +AttendanceRegularization — hr_attendance_regularization +id UUID PK +attendance_id UUID NOT NULL (FK → hr_attendance_record) +employee_id UUID NOT NULL +original_status VARCHAR(20) +requested_status VARCHAR(20) +original_check_in TIMESTAMP +original_check_out TIMESTAMP +requested_check_in TIMESTAMP +requested_check_out TIMESTAMP +reason TEXT NOT NULL +approval_status VARCHAR(20) DEFAULT 'PENDING' -- PENDING, APPROVED, REJECTED +approved_by UUID +approved_at TIMESTAMP +remarks TEXT +created_at TIMESTAMP +updated_at TIMESTAMP + +HolidayCalendar — hr_holiday +id UUID PK +institute_id UUID NOT NULL +name VARCHAR(255) NOT NULL +date DATE NOT NULL +type VARCHAR(20) -- NATIONAL, REGIONAL, OPTIONAL, RESTRICTED +is_optional BOOLEAN DEFAULT FALSE +max_optional_allowed INT -- per year for optional holidays +year INT NOT NULL +description TEXT +created_at TIMESTAMP +updated_at TIMESTAMP +UNIQUE(institute_id, date) + +A3. Leave Management (hr_leave) +LeaveType — hr_leave_type +id UUID PK +institute_id UUID NOT NULL +name VARCHAR(100) NOT NULL -- Casual Leave, Sick Leave, Earned Leave, etc. +code VARCHAR(20) NOT NULL +is_paid BOOLEAN DEFAULT TRUE +is_carry_forward BOOLEAN DEFAULT FALSE +max_carry_forward INT DEFAULT 0 +is_encashable BOOLEAN DEFAULT FALSE +requires_document BOOLEAN DEFAULT FALSE -- medical certificate for sick leave +min_days DECIMAL(3,1) DEFAULT 0.5 -- minimum 0.5 for half-day +max_consecutive_days INT +applicable_gender VARCHAR(10) -- ALL, MALE, FEMALE (for maternity/paternity) +description TEXT +status VARCHAR(20) DEFAULT 'ACTIVE' +created_at TIMESTAMP +updated_at TIMESTAMP +UNIQUE(institute_id, code) + +LeavePolicy — hr_leave_policy +id UUID PK +institute_id UUID NOT NULL +leave_type_id UUID NOT NULL (FK → hr_leave_type) +annual_quota DECIMAL(5,1) NOT NULL -- total days per year +accrual_type VARCHAR(20) -- YEARLY, MONTHLY, QUARTERLY +accrual_amount DECIMAL(5,2) -- days per accrual period +pro_rata_enabled BOOLEAN DEFAULT TRUE -- for mid-year joiners +applicable_after_days INT DEFAULT 0 -- apply after N days from joining +applicable_employment_types JSONB DEFAULT '["FULL_TIME"]' +effective_from DATE NOT NULL +effective_to DATE +status VARCHAR(20) DEFAULT 'ACTIVE' +created_at TIMESTAMP +updated_at TIMESTAMP + +LeaveBalance — hr_leave_balance +id UUID PK +employee_id UUID NOT NULL (FK → hr_employee_profile) +leave_type_id UUID NOT NULL (FK → hr_leave_type) +year INT NOT NULL +opening_balance DECIMAL(5,1) DEFAULT 0 +accrued DECIMAL(5,1) DEFAULT 0 +used DECIMAL(5,1) DEFAULT 0 +adjustment DECIMAL(5,1) DEFAULT 0 -- manual admin adjustment +carried_forward DECIMAL(5,1) DEFAULT 0 +encashed DECIMAL(5,1) DEFAULT 0 +closing_balance DECIMAL(5,1) GENERATED ALWAYS AS (opening_balance + accrued + carried_forward + adjustment - used - encashed) STORED +created_at TIMESTAMP +updated_at TIMESTAMP +UNIQUE(employee_id, leave_type_id, year) + +LeaveApplication — hr_leave_application +id UUID PK +employee_id UUID NOT NULL (FK → hr_employee_profile) +institute_id UUID NOT NULL +leave_type_id UUID NOT NULL (FK → hr_leave_type) +from_date DATE NOT NULL +to_date DATE NOT NULL +total_days DECIMAL(5,1) NOT NULL +is_half_day BOOLEAN DEFAULT FALSE +half_day_type VARCHAR(10) -- FIRST_HALF, SECOND_HALF +reason TEXT +document_file_id VARCHAR(255) -- supporting document (S3) +status VARCHAR(20) DEFAULT 'PENDING' -- PENDING, APPROVED, REJECTED, CANCELLED, REVOKED +applied_to UUID -- manager user_id +approved_by UUID +approved_at TIMESTAMP +rejection_reason TEXT +created_at TIMESTAMP +updated_at TIMESTAMP + +CompensatoryOff — hr_comp_off +id UUID PK +employee_id UUID NOT NULL (FK → hr_employee_profile) +worked_on_date DATE NOT NULL -- the holiday/weekend they worked +earned_days DECIMAL(3,1) DEFAULT 1.0 +expiry_date DATE -- comp offs expire +used BOOLEAN DEFAULT FALSE +used_leave_application_id UUID (FK → hr_leave_application) +approved_by UUID +status VARCHAR(20) DEFAULT 'PENDING' -- PENDING, APPROVED, REJECTED, USED, EXPIRED +created_at TIMESTAMP +updated_at TIMESTAMP + +A4. Salary Structure (hr_salary) +SalaryComponent — hr_salary_component +id UUID PK +institute_id UUID NOT NULL +name VARCHAR(100) NOT NULL -- Basic Salary, HRA, DA, Special Allowance, PF, ESI, TDS... +code VARCHAR(30) NOT NULL +type VARCHAR(30) NOT NULL -- EARNING, DEDUCTION, EMPLOYER_CONTRIBUTION +category VARCHAR(30) -- FIXED, VARIABLE, STATUTORY +is_taxable BOOLEAN DEFAULT TRUE +is_statutory BOOLEAN DEFAULT FALSE +is_active BOOLEAN DEFAULT TRUE +display_order INT DEFAULT 0 +description TEXT +created_at TIMESTAMP +updated_at TIMESTAMP +UNIQUE(institute_id, code) + +SalaryTemplate — hr_salary_template +id UUID PK +institute_id UUID NOT NULL +name VARCHAR(255) NOT NULL +description TEXT +is_default BOOLEAN DEFAULT FALSE +status VARCHAR(20) DEFAULT 'ACTIVE' +created_at TIMESTAMP +updated_at TIMESTAMP + +SalaryTemplateComponent — hr_salary_template_component +id UUID PK +template_id UUID NOT NULL (FK → hr_salary_template) +component_id UUID NOT NULL (FK → hr_salary_component) +calculation_type VARCHAR(30) NOT NULL -- FIXED_AMOUNT, PERCENTAGE_OF_BASIC, PERCENTAGE_OF_CTC, PERCENTAGE_OF_GROSS, FORMULA +percentage_value DECIMAL(8,4) -- e.g., 40.0000 for 40% +fixed_value DECIMAL(15,2) -- used when calculation_type = FIXED_AMOUNT +formula TEXT -- custom formula expression (advanced) +min_value DECIMAL(15,2) -- floor +max_value DECIMAL(15,2) -- ceiling cap +display_order INT DEFAULT 0 +is_mandatory BOOLEAN DEFAULT TRUE +created_at TIMESTAMP +updated_at TIMESTAMP + +EmployeeSalaryStructure — hr_employee_salary_structure +id UUID PK +employee_id UUID NOT NULL (FK → hr_employee_profile) +template_id UUID (FK → hr_salary_template) +effective_from DATE NOT NULL +effective_to DATE +ctc_annual DECIMAL(15,2) NOT NULL +ctc_monthly DECIMAL(15,2) GENERATED ALWAYS AS (ctc_annual / 12) STORED +gross_monthly DECIMAL(15,2) +net_monthly DECIMAL(15,2) -- estimated (before variable deductions) +status VARCHAR(20) DEFAULT 'ACTIVE' -- ACTIVE, SUPERSEDED, DRAFT +revision_reason TEXT +approved_by UUID +approved_at TIMESTAMP +created_at TIMESTAMP +updated_at TIMESTAMP + +EmployeeSalaryComponent — hr_employee_salary_component +id UUID PK +salary_structure_id UUID NOT NULL (FK → hr_employee_salary_structure) +component_id UUID NOT NULL (FK → hr_salary_component) +monthly_amount DECIMAL(15,2) NOT NULL +annual_amount DECIMAL(15,2) NOT NULL +calculation_type VARCHAR(30) -- override from template +percentage_value DECIMAL(8,4) +is_overridden BOOLEAN DEFAULT FALSE -- manually adjusted? +created_at TIMESTAMP +updated_at TIMESTAMP + +SalaryRevisionHistory — hr_salary_revision +id UUID PK +employee_id UUID NOT NULL (FK → hr_employee_profile) +old_structure_id UUID (FK → hr_employee_salary_structure) +new_structure_id UUID NOT NULL (FK → hr_employee_salary_structure) +old_ctc DECIMAL(15,2) +new_ctc DECIMAL(15,2) +increment_pct DECIMAL(5,2) +reason TEXT +effective_date DATE NOT NULL +approved_by UUID +created_at TIMESTAMP + +A5. Payroll Processing (hr_payroll) +PayrollRun — hr_payroll_run +id UUID PK +institute_id UUID NOT NULL +month INT NOT NULL (1-12) +year INT NOT NULL +run_date DATE +status VARCHAR(20) DEFAULT 'DRAFT' -- DRAFT, PROCESSING, PROCESSED, APPROVED, PAID, CANCELLED +total_employees INT +total_gross DECIMAL(18,2) +total_deductions DECIMAL(18,2) +total_net_pay DECIMAL(18,2) +total_employer_cost DECIMAL(18,2) -- gross + employer contributions +processed_by UUID +processed_at TIMESTAMP +approved_by UUID +approved_at TIMESTAMP +paid_at TIMESTAMP +notes TEXT +created_at TIMESTAMP +updated_at TIMESTAMP +UNIQUE(institute_id, month, year) + +PayrollEntry — hr_payroll_entry +id UUID PK +payroll_run_id UUID NOT NULL (FK → hr_payroll_run) +employee_id UUID NOT NULL (FK → hr_employee_profile) +salary_structure_id UUID (FK → hr_employee_salary_structure) +-- Summary +gross_salary DECIMAL(15,2) NOT NULL +total_earnings DECIMAL(15,2) +total_deductions DECIMAL(15,2) +total_employer_contributions DECIMAL(15,2) +net_pay DECIMAL(15,2) NOT NULL +-- Attendance-based +total_working_days INT +days_present DECIMAL(5,1) +days_absent DECIMAL(5,1) +days_on_leave DECIMAL(5,1) +days_holiday INT +overtime_hours DECIMAL(5,2) DEFAULT 0 +-- Adjustments +arrears DECIMAL(15,2) DEFAULT 0 +reimbursements DECIMAL(15,2) DEFAULT 0 +loan_deduction DECIMAL(15,2) DEFAULT 0 +other_earnings DECIMAL(15,2) DEFAULT 0 +other_deductions DECIMAL(15,2) DEFAULT 0 +-- Status +status VARCHAR(20) DEFAULT 'CALCULATED' -- CALCULATED, HELD, PAID +hold_reason TEXT +-- Bank +bank_account_id UUID (FK → hr_employee_bank_detail) +payment_ref VARCHAR(255) -- UTR / transaction reference +created_at TIMESTAMP +updated_at TIMESTAMP +UNIQUE(payroll_run_id, employee_id) + +PayrollEntryComponent — hr_payroll_entry_component +id UUID PK +payroll_entry_id UUID NOT NULL (FK → hr_payroll_entry) +component_id UUID NOT NULL (FK → hr_salary_component) +component_type VARCHAR(30) -- EARNING, DEDUCTION, EMPLOYER_CONTRIBUTION +amount DECIMAL(15,2) NOT NULL +created_at TIMESTAMP + +EmployeeLoan — hr_employee_loan +id UUID PK +employee_id UUID NOT NULL (FK → hr_employee_profile) +institute_id UUID NOT NULL +loan_type VARCHAR(30) -- SALARY_ADVANCE, PERSONAL_LOAN, OTHER +principal_amount DECIMAL(15,2) NOT NULL +interest_rate DECIMAL(5,2) DEFAULT 0 +tenure_months INT NOT NULL +emi_amount DECIMAL(15,2) NOT NULL +disbursed_amount DECIMAL(15,2) +balance_amount DECIMAL(15,2) +start_month INT +start_year INT +status VARCHAR(20) DEFAULT 'PENDING' -- PENDING, APPROVED, ACTIVE, CLOSED, REJECTED +approved_by UUID +approved_at TIMESTAMP +notes TEXT +created_at TIMESTAMP +updated_at TIMESTAMP + +LoanRepayment — hr_loan_repayment +id UUID PK +loan_id UUID NOT NULL (FK → hr_employee_loan) +payroll_entry_id UUID (FK → hr_payroll_entry) +amount DECIMAL(15,2) NOT NULL +repayment_date DATE +month INT +year INT +balance_after DECIMAL(15,2) +created_at TIMESTAMP + +Reimbursement — hr_reimbursement +id UUID PK +employee_id UUID NOT NULL (FK → hr_employee_profile) +institute_id UUID NOT NULL +type VARCHAR(50) -- TRAVEL, MEDICAL, FOOD, PHONE, INTERNET, OTHER +amount DECIMAL(15,2) NOT NULL +description TEXT +receipt_file_id VARCHAR(255) -- S3 reference +expense_date DATE +status VARCHAR(20) DEFAULT 'PENDING' -- PENDING, APPROVED, REJECTED, PAID +approved_by UUID +approved_at TIMESTAMP +payroll_entry_id UUID (FK → hr_payroll_entry) -- linked when paid +rejection_reason TEXT +created_at TIMESTAMP +updated_at TIMESTAMP + +A6. Tax Engine (hr_tax) +TaxConfiguration — hr_tax_configuration +id UUID PK +institute_id UUID NOT NULL +country_code VARCHAR(3) NOT NULL -- IND, USA, GBR, UAE, etc. +state_code VARCHAR(10) -- for state-level tax (professional tax in India) +financial_year_start_month INT DEFAULT 4 -- April for India, January for US +tax_rules JSONB -- country-specific slab/rules configuration +employer_contributions JSONB -- PF/ESI/SSN employer rates +statutory_settings JSONB -- additional country-specific settings +status VARCHAR(20) DEFAULT 'ACTIVE' +created_at TIMESTAMP +updated_at TIMESTAMP +UNIQUE(institute_id, country_code) + +TaxDeclaration — hr_tax_declaration +id UUID PK +employee_id UUID NOT NULL (FK → hr_employee_profile) +financial_year VARCHAR(10) NOT NULL -- "2025-26" +regime VARCHAR(20) -- OLD, NEW (India-specific; stored generically) +declarations JSONB NOT NULL -- { "section_80c": 150000, "section_80d": 25000, "hra_rent_paid": 240000, ... } +proof_submitted BOOLEAN DEFAULT FALSE +proof_verified BOOLEAN DEFAULT FALSE +verified_by UUID +verified_at TIMESTAMP +status VARCHAR(20) DEFAULT 'DRAFT' -- DRAFT, SUBMITTED, VERIFIED, LOCKED +created_at TIMESTAMP +updated_at TIMESTAMP +UNIQUE(employee_id, financial_year) + +TaxComputation — hr_tax_computation +id UUID PK +employee_id UUID NOT NULL (FK → hr_employee_profile) +financial_year VARCHAR(10) NOT NULL +month INT NOT NULL +year INT NOT NULL +-- Projected +projected_annual_income DECIMAL(15,2) +projected_annual_tax DECIMAL(15,2) +projected_monthly_tax DECIMAL(15,2) +-- Actual +actual_income_till_date DECIMAL(15,2) +actual_tax_deducted DECIMAL(15,2) +-- Exemptions +total_exemptions DECIMAL(15,2) +total_deductions_80c DECIMAL(15,2) +computation_details JSONB -- full breakdown +created_at TIMESTAMP +updated_at TIMESTAMP + +A7. Payslip & Reports (hr_payslip) +Payslip — hr_payslip +id UUID PK +payroll_entry_id UUID NOT NULL (FK → hr_payroll_entry) +employee_id UUID NOT NULL +institute_id UUID NOT NULL +month INT NOT NULL +year INT NOT NULL +file_id VARCHAR(255) -- S3 PDF reference +file_url TEXT +generated_at TIMESTAMP +emailed_at TIMESTAMP +email_status VARCHAR(20) -- PENDING, SENT, FAILED +created_at TIMESTAMP +UNIQUE(payroll_entry_id) + +BankExportLog — hr_bank_export_log +id UUID PK +payroll_run_id UUID NOT NULL (FK → hr_payroll_run) +institute_id UUID NOT NULL +file_id VARCHAR(255) -- S3 reference +file_name VARCHAR(255) +format VARCHAR(20) -- CSV, XLSX, HDFC_FORMAT, SBI_FORMAT, ICICI_FORMAT +total_records INT +total_amount DECIMAL(18,2) +generated_by UUID +generated_at TIMESTAMP +created_at TIMESTAMP + +A8. Approval Workflow (hr_approval) +ApprovalChain — hr_approval_chain +id UUID PK +institute_id UUID NOT NULL +entity_type VARCHAR(50) NOT NULL -- LEAVE_APPLICATION, ATTENDANCE_REGULARIZATION, REIMBURSEMENT, LOAN, SALARY_REVISION +approval_levels INT DEFAULT 1 +level_config JSONB -- [{ "level": 1, "approver_type": "REPORTING_MANAGER" }, { "level": 2, "approver_type": "HR_ADMIN" }] +auto_approve_after_days INT -- auto-approve if no action +status VARCHAR(20) DEFAULT 'ACTIVE' +created_at TIMESTAMP +updated_at TIMESTAMP +UNIQUE(institute_id, entity_type) + +ApprovalRequest — hr_approval_request +id UUID PK +institute_id UUID NOT NULL +entity_type VARCHAR(50) NOT NULL +entity_id UUID NOT NULL -- FK to the entity (leave_application_id, etc.) +requester_id UUID NOT NULL -- employee who requested +current_level INT DEFAULT 1 +total_levels INT DEFAULT 1 +status VARCHAR(20) DEFAULT 'PENDING' -- PENDING, APPROVED, REJECTED, CANCELLED +created_at TIMESTAMP +updated_at TIMESTAMP + +ApprovalAction — hr_approval_action +id UUID PK +request_id UUID NOT NULL (FK → hr_approval_request) +level INT NOT NULL +action VARCHAR(20) NOT NULL -- APPROVED, REJECTED +actor_id UUID NOT NULL +comments TEXT +acted_at TIMESTAMP NOT NULL +created_at TIMESTAMP + + +B. Enums +// hr_employee +EmploymentType { FULL_TIME, PART_TIME, CONTRACT, INTERN } +EmploymentStatus { ACTIVE, PROBATION, NOTICE_PERIOD, RELIEVED, TERMINATED, ABSCONDING } +DocumentType { OFFER_LETTER, APPOINTMENT_LETTER, ID_PROOF, PAN_CARD, AADHAAR, PASSPORT, DEGREE, EXPERIENCE_LETTER, RELIEVING_LETTER, OTHER } + +// hr_attendance +AttendanceMode { TIME_TRACKING, DAY_LEVEL } +AttendanceStatus { PRESENT, ABSENT, HALF_DAY, ON_LEAVE, HOLIDAY, WEEKEND, COMP_OFF } +AttendanceSource { MANUAL, BIOMETRIC, GEO, ADMIN } + +// hr_leave +LeaveStatus { PENDING, APPROVED, REJECTED, CANCELLED, REVOKED } +HalfDayType { FIRST_HALF, SECOND_HALF } +AccrualType { YEARLY, MONTHLY, QUARTERLY } + +// hr_salary +ComponentType { EARNING, DEDUCTION, EMPLOYER_CONTRIBUTION } +ComponentCategory { FIXED, VARIABLE, STATUTORY } +CalculationType { FIXED_AMOUNT, PERCENTAGE_OF_BASIC, PERCENTAGE_OF_CTC, PERCENTAGE_OF_GROSS, FORMULA } + +// hr_payroll +PayrollStatus { DRAFT, PROCESSING, PROCESSED, APPROVED, PAID, CANCELLED } +PayrollEntryStatus { CALCULATED, HELD, PAID } +LoanType { SALARY_ADVANCE, PERSONAL_LOAN, OTHER } +LoanStatus { PENDING, APPROVED, ACTIVE, CLOSED, REJECTED } +ReimbursementType { TRAVEL, MEDICAL, FOOD, PHONE, INTERNET, OTHER } + +// hr_tax +TaxRegime { OLD, NEW } // India; extensible per country +DeclarationStatus { DRAFT, SUBMITTED, VERIFIED, LOCKED } + +// hr_approval +ApprovalEntityType { LEAVE_APPLICATION, ATTENDANCE_REGULARIZATION, REIMBURSEMENT, LOAN, SALARY_REVISION } +ApprovalStatus { PENDING, APPROVED, REJECTED, CANCELLED } +ApproverType { REPORTING_MANAGER, DEPARTMENT_HEAD, HR_ADMIN, CUSTOM } + + +C. Tax Engine — Strategy Pattern +TaxRegime (interface) +├── calculateMonthlyTax(employee, month, year, income) → TaxBreakdown +├── calculateAnnualProjection(employee, year) → AnnualTaxProjection +├── getEmployerContributions(employee, grossSalary) → Map +├── getStatutoryDeductions(employee, grossSalary) → Map +└── getSupportedComponents() → List + +IndiaTaxRegime implements TaxRegime +├── TDS calculation (old regime 5-slab, new regime 6-slab) +├── EPF: 12% employee + 12% employer (capped at ₹15,000 basic) +├── ESI: 0.75% employee + 3.25% employer (if gross ≤ ₹21,000) +├── Professional Tax: state-wise monthly slabs +├── Section 80C/80D/HRA exemption processing +└── Form 16 data preparation + +USATaxRegime implements TaxRegime +├── Federal income tax brackets +├── Social Security (6.2% employee + 6.2% employer) +├── Medicare (1.45% + 1.45%) +├── State income tax (configurable per state) +└── W-2 data preparation + +UAETaxRegime implements TaxRegime +├── No income tax +├── Gratuity calculation +└── WPS (Wage Protection System) compliance + +TaxRegimeFactory +└── getRegime(countryCode) → TaxRegime + +Follows the exact pattern used by PaymentServiceFactory in admin_core_service. + +D. API Endpoints (RESTful) +Base path: /admin-core-service/api/v1/hr +Employee Management +POST /employees - Create employee profile +GET /employees?instituteId=&page=&size=&dept=&status= - List employees (paginated, filtered) +GET /employees/{id} - Get employee detail +PUT /employees/{id} - Update employee profile +PUT /employees/{id}/status - Change employment status (terminate, relieve, etc.) +GET /employees/{id}/org-chart - Get reporting hierarchy + +POST /departments - Create department +GET /departments?instituteId= - List departments (tree) +PUT /departments/{id} - Update department +DELETE /departments/{id} - Deactivate department + +POST /designations - Create designation +GET /designations?instituteId= - List designations +PUT /designations/{id} - Update designation + +POST /employees/{id}/bank-details - Add bank detail +PUT /employees/{id}/bank-details/{bid} - Update bank detail +GET /employees/{id}/bank-details - List bank details + +POST /employees/{id}/documents - Upload document +GET /employees/{id}/documents - List documents +DELETE /employees/{id}/documents/{did} - Remove document + +Attendance +POST /attendance/check-in - Employee check-in (with geo/IP) +POST /attendance/check-out - Employee check-out +POST /attendance/mark - Admin marks day-level attendance (bulk) +GET /attendance?instituteId=&month=&year=&employeeId= - Get attendance records +GET /attendance/summary?instituteId=&month=&year= - Monthly summary (all employees) +PUT /attendance/{id} - Admin edit attendance record + +POST /attendance/regularization - Request regularization +PUT /attendance/regularization/{id}/approve - Approve/reject regularization + +POST /attendance/config - Set attendance config +GET /attendance/config?instituteId= - Get attendance config + +POST /shifts - Create shift +GET /shifts?instituteId= - List shifts +PUT /shifts/{id} - Update shift +POST /shifts/assign - Assign shift to employees (bulk) + +POST /holidays - Create holiday +GET /holidays?instituteId=&year= - List holidays +PUT /holidays/{id} - Update holiday +DELETE /holidays/{id} - Remove holiday +POST /holidays/bulk - Bulk create holidays + +Leave +POST /leaves/types - Create leave type +GET /leaves/types?instituteId= - List leave types +PUT /leaves/types/{id} - Update leave type + +POST /leaves/policies - Create leave policy +GET /leaves/policies?instituteId= - List leave policies +PUT /leaves/policies/{id} - Update leave policy + +POST /leaves/apply - Apply for leave +GET /leaves/applications?instituteId=&status=&employeeId= - List applications +PUT /leaves/applications/{id}/action - Approve/reject leave +PUT /leaves/applications/{id}/cancel - Cancel leave application + +GET /leaves/balances?employeeId=&year= - Get leave balances +PUT /leaves/balances/{id}/adjust - Admin adjust balance + +POST /leaves/comp-off - Request comp off +PUT /leaves/comp-off/{id}/action - Approve/reject comp off +POST /leaves/accrue - Trigger monthly accrual (scheduled/manual) +POST /leaves/year-end-process - Year-end carry forward + encashment + +Salary +POST /salary/components - Create salary component +GET /salary/components?instituteId= - List components +PUT /salary/components/{id} - Update component + +POST /salary/templates - Create salary template +GET /salary/templates?instituteId= - List templates +GET /salary/templates/{id} - Get template with components +PUT /salary/templates/{id} - Update template + +POST /salary/structures - Assign salary structure to employee +GET /salary/structures?employeeId= - Get employee salary history +GET /salary/structures/{id} - Get structure with component breakdown +PUT /salary/structures/{id} - Revise salary structure + +GET /salary/revisions?employeeId= - Get revision history + +Payroll +POST /payroll/runs - Create payroll run (month/year) +GET /payroll/runs?instituteId=&year= - List payroll runs +GET /payroll/runs/{id} - Get payroll run detail +POST /payroll/runs/{id}/process - Process payroll (calculate all entries) +PUT /payroll/runs/{id}/approve - Approve payroll +PUT /payroll/runs/{id}/mark-paid - Mark as paid +DELETE /payroll/runs/{id} - Cancel payroll run + +GET /payroll/runs/{id}/entries - List all entries in a run +GET /payroll/entries/{id} - Get single entry detail with components +PUT /payroll/entries/{id}/hold - Hold employee payment +PUT /payroll/entries/{id}/release - Release held payment + +POST /payroll/loans - Create loan/advance +GET /payroll/loans?employeeId= - List loans +PUT /payroll/loans/{id}/approve - Approve loan +GET /payroll/loans/{id}/repayments - Get repayment schedule + +POST /payroll/reimbursements - Submit reimbursement +GET /payroll/reimbursements?employeeId=&status= - List reimbursements +PUT /payroll/reimbursements/{id}/action - Approve/reject reimbursement + +Tax +POST /tax/config - Set tax configuration for institute +GET /tax/config?instituteId= - Get tax config + +POST /tax/declarations - Submit tax declaration +GET /tax/declarations?employeeId=&fy= - Get declarations +PUT /tax/declarations/{id} - Update declaration +PUT /tax/declarations/{id}/verify - Verify declaration (HR) + +GET /tax/computation?employeeId=&fy= - Get tax computation summary + +Payslip & Reports +POST /payslips/generate - Generate payslips for a payroll run +GET /payslips?employeeId=&year= - List payslips +GET /payslips/{id}/download - Download payslip PDF +POST /payslips/email - Email payslips to all employees + +POST /reports/bank-export - Generate bank disbursement file +GET /reports/bank-export/{id}/download - Download bank file +GET /reports/payroll-summary?instituteId=&month=&year= - Payroll summary +GET /reports/department-cost?instituteId=&month=&year= - Dept-wise cost +GET /reports/attendance-summary?instituteId=&month=&year= - Attendance report +GET /reports/leave-balance?instituteId=&year= - Leave balance report +GET /reports/tax-summary?instituteId=&fy= - Tax deduction report + +Approval Workflow +POST /approvals/chains - Configure approval chain +GET /approvals/chains?instituteId= - List approval chains +PUT /approvals/chains/{id} - Update chain + +GET /approvals/pending?approverId= - List pending approvals for a manager +POST /approvals/{id}/action - Approve/reject +GET /approvals/history?entityType=&entityId= - Approval audit trail + + +E. Service Layer Design +Service +Responsibility +EmployeeService +CRUD for employee profiles, status transitions, org chart queries +DepartmentService +Department CRUD, hierarchy traversal +DesignationService +Designation CRUD +EmployeeBankService +Bank detail management (encrypted storage) +EmployeeDocumentService +Document upload/download via media_service +AttendanceService +Check-in/out, day marking, bulk operations, summary calculation +AttendanceConfigService +Per-institute attendance configuration +ShiftService +Shift CRUD and employee assignment +HolidayService +Holiday calendar management +RegularizationService +Attendance correction requests +LeaveTypeService +Leave type and policy management +LeaveApplicationService +Apply, approve, reject, cancel leaves +LeaveBalanceService +Balance tracking, accrual, year-end processing +CompOffService +Compensatory off management +SalaryComponentService +Component definitions +SalaryTemplateService +Template CRUD with component config +SalaryStructureService +Assign/revise salary, compute component amounts from CTC +PayrollRunService +Create, process, approve payroll runs +PayrollCalculationService +Core payroll engine — attendance-based proration, component calculation, deductions, net pay +LoanService +Loan/advance lifecycle, EMI scheduling +ReimbursementService +Reimbursement lifecycle +TaxRegimeFactory +Returns correct TaxRegime impl based on country_code +IndiaTaxRegime +Indian tax/statutory calculations +TaxDeclarationService +Employee tax declaration management +TaxComputationService +Monthly/annual tax projection +PayslipService +PDF generation (OpenHtmlToPdf, same pattern as InvoiceService) +BankExportService +Generate CSV/XLSX in bank-specific formats +HrReportService +Aggregate reports (payroll summary, dept cost, etc.) +ApprovalService +Generic approval workflow engine +HrNotificationService +Sends leave/payroll/approval notifications via notification_service + + +F. Flyway Migrations (starting at V128) +Order respecting FK dependencies: +V128 — hr_department, hr_designation +V129 — hr_employee_profile (depends on department, designation) +V130 — hr_employee_bank_detail, hr_employee_document (depends on employee_profile) +V131 — hr_attendance_config, hr_shift +V132 — hr_employee_shift_mapping (depends on employee_profile, shift) +V133 — hr_attendance_record (depends on employee_profile, shift) +V134 — hr_attendance_regularization (depends on attendance_record) +V135 — hr_holiday +V136 — hr_leave_type, hr_leave_policy +V137 — hr_leave_balance, hr_leave_application (depends on leave_type, employee_profile) +V138 — hr_comp_off (depends on employee_profile, leave_application) +V139 — hr_salary_component +V140 — hr_salary_template, hr_salary_template_component +V141 — hr_employee_salary_structure, hr_employee_salary_component +V142 — hr_salary_revision +V143 — hr_payroll_run +V144 — hr_payroll_entry, hr_payroll_entry_component +V145 — hr_employee_loan, hr_loan_repayment +V146 — hr_reimbursement +V147 — hr_tax_configuration, hr_tax_declaration, hr_tax_computation +V148 — hr_payslip, hr_bank_export_log +V149 — hr_approval_chain, hr_approval_request, hr_approval_action + + +G. Security / Authorization +New roles to add to the system: +HR_ADMIN — full access to all HR & payroll features for the institute +HR_MANAGER — can manage employees, approve leaves/reimbursements, view payroll +EMPLOYEE (existing) — self-service: own profile, attendance, leave apply, payslip view, tax declaration +Access matrix: +Feature +HR_ADMIN +HR_MANAGER +EMPLOYEE (self) +ADMIN +Employee CRUD +Full +View + Edit +View own +Full +Department/Designation +Full +View +- +Full +Attendance Config +Full +View +- +Full +Attendance Mark +Full +Own team +Own check-in/out +Full +Leave Types/Policies +Full +View +View +Full +Leave Apply +Full (on behalf) +Full (on behalf) +Own +Own +Leave Approve +Full +Own team +- +Full +Salary Templates +Full +View +- +Full +Salary Structures +Full +View team +View own +Full +Payroll Process +Full +View +- +Full +Payslip View +Full +Own team +Own +Full +Tax Declarations +Full +View team +Own +Full +Reports +Full +Department +- +Full +Loans/Reimbursements +Full +Approve team +Own requests +Full + + +H. Integration Points +System +Integration +auth_service +Validate JWT, extract user_id + institute_id + roles. New HR roles added via existing role system +notification_service +Email payslips, leave approval/rejection notifications, payroll processed alerts, attendance reminders. Via existing NotificationService (RestTemplate + HMAC) +media_service +Upload payslip PDFs, employee documents, bank export files to S3. Via existing S3 upload pattern +Existing Invoice system +Payslip PDF uses same OpenHtmlToPdf pattern as InvoiceService +Existing Payment Gateways +Not used for payroll (bank export instead), but could be extended for reimbursement payouts + + +I. Implementation Order +Phase 1: Foundation (Migrations + Entities) +All Flyway migrations (V128–V149) +All JPA entities with relationships +All enums +All repositories with key queries +Phase 2: Employee Management +Department/Designation CRUD +EmployeeProfile CRUD + status management +Bank details + Document management +Org chart / reporting hierarchy +Phase 3: Attendance & Leave +Attendance config + Shifts + Holidays +Attendance recording (both modes) +Attendance regularization +Leave types + policies +Leave application + approval +Leave balance + accrual +Compensatory off +Phase 4: Salary & Payroll +Salary components + templates +Salary structure assignment + CTC breakdown +Payroll run + processing engine +Payroll calculation (attendance proration, deductions) +Loans + reimbursements +Phase 5: Tax & Payslip +Tax engine (India first, then pluggable) +Tax declarations + computation +Payslip PDF generation +Bank export file generation +Reports +Phase 6: Workflow & Polish +Generic approval workflow +Notification integration +API documentation (OpenAPI/Swagger annotations) + +J. Verification Plan +Unit tests for each service (especially PayrollCalculationService, TaxRegime implementations) +Integration tests for payroll flow: create employee → assign salary → run payroll → verify net pay +API tests via Swagger UI at /admin-core-service/swagger-ui.html +Manual verification: +Create department + designation +Create employee with salary structure +Mark attendance for a month +Process payroll → verify component-wise calculation +Generate payslip PDF → verify formatting +Export bank file → verify CSV format +Tax verification: Compare TDS calculation against manual computation for known scenarios + +K. Key Files to Modify/Reference +File +Purpose +admin_core_service/pom.xml +Add Apache POI dependency for Excel export +admin_core_service/src/main/resources/db/migration/ +New V128–V149 migrations +admin_core_service/.../features/invoice/service/InvoiceService.java +Reference for PDF generation pattern +admin_core_service/.../features/payments/manager/PaymentServiceFactory.java +Reference for TaxRegimeFactory pattern +admin_core_service/.../features/notification_service/service/NotificationService.java +Integration for email/WhatsApp notifications +common_service/.../auth/entity/User.java +EmployeeProfile links to this 1:1 +common_service/.../institute/entity/Institute.java +Institute reference for multi-tenancy + +User approved the plan +Plan approved. Let me set up the todo list and start implementing. This is a massive system — I'll begin with Phase 1 (Foundation: migrations + entities + enums + repositories). +Update Todos +Create all Flyway migrations V128–V149 (34 tables) +Create all enums (20+ enum classes) +Create Employee Management entities (Department, Designation, EmployeeProfile, BankDetail, Document) +Create Attendance entities (Config, Shift, ShiftMapping, Record, Regularization, Holiday) +Create Leave entities (LeaveType, LeavePolicy, LeaveBalance, LeaveApplication, CompOff) +Create Salary entities (Component, Template, TemplateComponent, Structure, EmployeeComponent, Revision) +Create Payroll entities (Run, Entry, EntryComponent, Loan, LoanRepayment, Reimbursement) +Create Tax entities (Configuration, Declaration, Computation) +Create Payslip entities (Payslip, BankExportLog) +Create Approval entities (Chain, Request, Action) +Create all JPA Repositories with key queries +Create Employee Management services + controllers + DTOs +Create Attendance services + controllers + DTOs +Create Leave services + controllers + DTOs +Create Salary services + controllers + DTOs +Create Payroll services + controllers + DTOs (including PayrollCalculationService) +Create Tax engine (TaxRegime interface, IndiaTaxRegime, TaxRegimeFactory) + services + controllers +Create Payslip + BankExport + Reports services + controllers +Create Approval workflow services + controllers +Add Apache POI dependency to pom.xml + diff --git a/frontend-admin-dashboard/package.json b/frontend-admin-dashboard/package.json index aaf57b12b5..ead60744ed 100644 --- a/frontend-admin-dashboard/package.json +++ b/frontend-admin-dashboard/package.json @@ -14,8 +14,8 @@ ], "scripts": { "dev": "vite", - "build": "concurrently -n tsc,vite -c blue,green --kill-others-on-fail \"pnpm run build:tsc\" \"pnpm run build:vite\"", - "build:tsc": "cross-env NODE_OPTIONS=--max-old-space-size=6144 tsc", + "build": "pnpm run build:tsc && pnpm run build:vite", + "build:tsc": "cross-env NODE_OPTIONS=--max-old-space-size=4608 tsc", "build:vite": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vite build", "serve": "vite preview", "test": "vitest", @@ -23,8 +23,9 @@ "lint": "eslint src --max-warnings=0", "lint:naming": "node scripts/lint-naming-terms.cjs", "typecheck": "tsc --project tsconfig.json --noEmit", + "gen:routes": "node scripts/generate-routes.mjs", "precommit": "pnpm run typecheck && pnpm run lint && pnpm run build", - "prepare": "husky", + "prepare": "cd .. && husky .husky || true", "lint-staged": "lint-staged", "storybook": "concurrently 'pnpm:watch*'", "watch:tailwind": "tailwindcss -i ./src/index.css -o ./src/styles/tailwind.css --watch", diff --git a/frontend-admin-dashboard/pnpm-lock.yaml b/frontend-admin-dashboard/pnpm-lock.yaml index fc6066222f..6d1104776f 100644 --- a/frontend-admin-dashboard/pnpm-lock.yaml +++ b/frontend-admin-dashboard/pnpm-lock.yaml @@ -414,7 +414,7 @@ importers: version: 2.8.26 '@zoom/meetingsdk': specifier: ^3.0.0 - version: 3.13.2(lodash@4.17.21)(react-dom@18.3.1(react@18.3.1))(react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux-thunk@2.4.2(redux@4.2.1))(redux@4.2.1) + version: 3.13.2(lodash@4.17.21)(react-dom@18.3.1(react@18.3.1))(react-redux@8.1.2(@types/react-dom@19.0.4(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1)(redux-thunk@2.4.2(redux@4.2.1))(redux@4.2.1) assemblyai: specifier: ^4.13.2 version: 4.14.0 @@ -1084,7 +1084,7 @@ importers: version: 1.7.0(vite@5.4.19(@types/node@22.16.4)(sass@1.51.0)(terser@5.43.1)) vite-plugin-pwa: specifier: ^0.21.2 - version: 0.21.2(vite@5.4.19(@types/node@22.16.4)(sass@1.51.0)(terser@5.43.1))(workbox-build@6.6.0(@types/babel__core@7.20.5))(workbox-window@6.6.0) + version: 0.21.2(vite@5.4.19(@types/node@22.16.4)(sass@1.51.0)(terser@5.43.1))(workbox-build@7.4.1(@types/babel__core@7.20.5))(workbox-window@7.4.1) vite-plugin-svgr: specifier: ^4.2.0 version: 4.3.0(rollup@4.21.2)(typescript@5.6.3)(vite@5.4.19(@types/node@22.16.4)(sass@1.51.0)(terser@5.43.1)) @@ -3190,6 +3190,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -3308,6 +3312,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.4': resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.29': resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} @@ -4803,17 +4810,57 @@ packages: '@types/babel__core': optional: true + '@rollup/plugin-babel@6.1.0': + resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@types/babel__core': ^7.1.9 + rollup: 4.21.2 + peerDependenciesMeta: + '@types/babel__core': + optional: true + rollup: + optional: true + '@rollup/plugin-node-resolve@11.2.1': resolution: {integrity: sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==} engines: {node: '>= 10.0.0'} peerDependencies: rollup: 4.21.2 + '@rollup/plugin-node-resolve@16.0.3': + resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: 4.21.2 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/plugin-replace@2.4.2': resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} peerDependencies: rollup: 4.21.2 + '@rollup/plugin-replace@6.0.3': + resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: 4.21.2 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-terser@1.0.0': + resolution: {integrity: sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + rollup: 4.21.2 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/pluginutils@3.1.0': resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} engines: {node: '>= 8.0.0'} @@ -5801,6 +5848,10 @@ packages: '@trapezedev/project@7.1.4': resolution: {integrity: sha512-b5rszBgT5XiRp/m4V2S2Ara2fdFXhPiduxhvCIVTSHq51PgLBjiTEStL6NbUz3V0K5bebF971O+SLRtyBxfCNA==} + '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1': + resolution: {integrity: sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==} + engines: {node: '>=12'} + '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -6167,6 +6218,9 @@ packages: '@types/resolve@1.17.1': resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==} + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} @@ -6216,6 +6270,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.3': + resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==} + '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} @@ -6599,6 +6656,7 @@ packages: '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@xobotyi/scrollbar-width@1.9.5': resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} @@ -7960,6 +8018,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. crypto-random-string@2.0.0: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} @@ -9122,6 +9181,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eta@4.6.0: + resolution: {integrity: sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==} + engines: {node: '>=20'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -9571,6 +9634,12 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -10290,6 +10359,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + jake@10.9.2: resolution: {integrity: sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==} engines: {node: '>=10'} @@ -10921,6 +10994,9 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} @@ -13246,6 +13322,27 @@ packages: react-native: optional: true + react-redux@8.1.2: + resolution: {integrity: sha512-xJKYI189VwfsFc4CJvHqHlDrzyFTY/3vZACbE+rr/zQ34Xx1wQfB4OTOSeOSNrF6BDVe8OOdxIrAnMGXA3ggfw==} + peerDependencies: + '@types/react': ^16.8 || ^17.0 || ^18.0 + '@types/react-dom': ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + react-native: '>=0.59' + redux: ^4 || ^5.0.0-beta.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + react-dom: + optional: true + react-native: + optional: true + redux: + optional: true + react-refresh@0.11.0: resolution: {integrity: sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==} engines: {node: '>=0.10.0'} @@ -13844,6 +13941,10 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + serialize-javascript@7.1.0: + resolution: {integrity: sha512-RNEqWOyhhUQYN9V1GfHwu9AR/g+NTciH6Z5u3/no6X3/w+04J2lVDL+svFQVXgXrEGBMG2puMVN3gq2SNGuTGw==} + engines: {node: '>=20.0.0'} + seroval-plugins@1.5.1: resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} engines: {node: '>=10'} @@ -13998,6 +14099,10 @@ packages: resolution: {integrity: sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==} deprecated: Unsupported + smob@1.6.2: + resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} + engines: {node: '>=20.0.0'} + snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -15456,51 +15561,97 @@ packages: workbox-background-sync@6.6.0: resolution: {integrity: sha512-jkf4ZdgOJxC9u2vztxLuPT/UjlH7m/nWRQ/MgGL0v8BJHoZdVGJd18Kck+a0e55wGXdqyHO+4IQTk0685g4MUw==} + workbox-background-sync@7.4.1: + resolution: {integrity: sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==} + workbox-broadcast-update@6.6.0: resolution: {integrity: sha512-nm+v6QmrIFaB/yokJmQ/93qIJ7n72NICxIwQwe5xsZiV2aI93MGGyEyzOzDPVz5THEr5rC3FJSsO3346cId64Q==} + workbox-broadcast-update@7.4.1: + resolution: {integrity: sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==} + workbox-build@6.6.0: resolution: {integrity: sha512-Tjf+gBwOTuGyZwMz2Nk/B13Fuyeo0Q84W++bebbVsfr9iLkDSo6j6PST8tET9HYA58mlRXwlMGpyWO8ETJiXdQ==} engines: {node: '>=10.0.0'} + workbox-build@7.4.1: + resolution: {integrity: sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==} + engines: {node: '>=20.0.0'} + workbox-cacheable-response@6.6.0: resolution: {integrity: sha512-JfhJUSQDwsF1Xv3EV1vWzSsCOZn4mQ38bWEBR3LdvOxSPgB65gAM6cS2CX8rkkKHRgiLrN7Wxoyu+TuH67kHrw==} deprecated: workbox-background-sync@6.6.0 + workbox-cacheable-response@7.4.1: + resolution: {integrity: sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==} + workbox-core@6.6.0: resolution: {integrity: sha512-GDtFRF7Yg3DD859PMbPAYPeJyg5gJYXuBQAC+wyrWuuXgpfoOrIQIvFRZnQ7+czTIQjIr1DhLEGFzZanAT/3bQ==} + workbox-core@7.4.1: + resolution: {integrity: sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==} + workbox-expiration@6.6.0: resolution: {integrity: sha512-baplYXcDHbe8vAo7GYvyAmlS4f6998Jff513L4XvlzAOxcl8F620O91guoJ5EOf5qeXG4cGdNZHkkVAPouFCpw==} + workbox-expiration@7.4.1: + resolution: {integrity: sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==} + workbox-google-analytics@6.6.0: resolution: {integrity: sha512-p4DJa6OldXWd6M9zRl0H6vB9lkrmqYFkRQ2xEiNdBFp9U0LhsGO7hsBscVEyH9H2/3eZZt8c97NB2FD9U2NJ+Q==} deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained + workbox-google-analytics@7.4.1: + resolution: {integrity: sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==} + workbox-navigation-preload@6.6.0: resolution: {integrity: sha512-utNEWG+uOfXdaZmvhshrh7KzhDu/1iMHyQOV6Aqup8Mm78D286ugu5k9MFD9SzBT5TcwgwSORVvInaXWbvKz9Q==} + workbox-navigation-preload@7.4.1: + resolution: {integrity: sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==} + workbox-precaching@6.6.0: resolution: {integrity: sha512-eYu/7MqtRZN1IDttl/UQcSZFkHP7dnvr/X3Vn6Iw6OsPMruQHiVjjomDFCNtd8k2RdjLs0xiz9nq+t3YVBcWPw==} + workbox-precaching@7.4.1: + resolution: {integrity: sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==} + workbox-range-requests@6.6.0: resolution: {integrity: sha512-V3aICz5fLGq5DpSYEU8LxeXvsT//mRWzKrfBOIxzIdQnV/Wj7R+LyJVTczi4CQ4NwKhAaBVaSujI1cEjXW+hTw==} + workbox-range-requests@7.4.1: + resolution: {integrity: sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==} + workbox-recipes@6.6.0: resolution: {integrity: sha512-TFi3kTgYw73t5tg73yPVqQC8QQjxJSeqjXRO4ouE/CeypmP2O/xqmB/ZFBBQazLTPxILUQ0b8aeh0IuxVn9a6A==} + workbox-recipes@7.4.1: + resolution: {integrity: sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==} + workbox-routing@6.6.0: resolution: {integrity: sha512-x8gdN7VDBiLC03izAZRfU+WKUXJnbqt6PG9Uh0XuPRzJPpZGLKce/FkOX95dWHRpOHWLEq8RXzjW0O+POSkKvw==} + workbox-routing@7.4.1: + resolution: {integrity: sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==} + workbox-strategies@6.6.0: resolution: {integrity: sha512-eC07XGuINAKUWDnZeIPdRdVja4JQtTuc35TZ8SwMb1ztjp7Ddq2CJ4yqLvWzFWGlYI7CG/YGqaETntTxBGdKgQ==} + workbox-strategies@7.4.1: + resolution: {integrity: sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==} + workbox-streams@6.6.0: resolution: {integrity: sha512-rfMJLVvwuED09CnH1RnIep7L9+mj4ufkTyDPVaXPKlhi9+0czCu+SJggWCIFbPpJaAZmp2iyVGLqS3RUmY3fxg==} + workbox-streams@7.4.1: + resolution: {integrity: sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==} + workbox-sw@6.6.0: resolution: {integrity: sha512-R2IkwDokbtHUE4Kus8pKO5+VkPHD2oqTgl+XJwh4zbF1HyjAbgNmK/FneZHVU7p03XUt9ICfuGDYISWG9qV/CQ==} + workbox-sw@7.4.1: + resolution: {integrity: sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==} + workbox-webpack-plugin@6.6.0: resolution: {integrity: sha512-xNZIZHalboZU66Wa7x1YkjIqEy1gTR+zPM+kjrYJzqN7iurYZBctBLISyScjhkJKYuRrZUP0iqViZTh8rS0+3A==} engines: {node: '>=10.0.0'} @@ -15510,6 +15661,9 @@ packages: workbox-window@6.6.0: resolution: {integrity: sha512-L4N9+vka17d16geaJXXRjENLFldvkWy7JyGxElRD0JvBxvFEd8LOhr+uXCcar/NzAmIBRv9EZ+M+Qr4mOoBITw==} + workbox-window@7.4.1: + resolution: {integrity: sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -15979,7 +16133,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.29.0 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -16010,6 +16164,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-create-class-features-plugin@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.27.1 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.28.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-create-regexp-features-plugin@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16017,6 +16184,13 @@ snapshots: regexpu-core: 6.2.0 semver: 6.3.1 + '@babel/helper-create-regexp-features-plugin@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + regexpu-core: 6.2.0 + semver: 6.3.1 + '@babel/helper-define-polyfill-provider@0.6.5(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16028,6 +16202,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-define-polyfill-provider@0.6.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-plugin-utils': 7.27.1 + debug: 4.4.1 + lodash.debounce: 4.0.8 + resolve: 1.22.10 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.28.0': {} '@babel/helper-member-expression-to-functions@7.27.1': @@ -16060,6 +16245,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.27.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -16084,6 +16278,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-wrap-function': 7.27.1 + '@babel/traverse': 7.28.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-replace-supers@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16093,6 +16296,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-replace-supers@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.27.1 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.28.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.0 @@ -16149,16 +16361,34 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/traverse': 7.28.0 + transitivePeerDependencies: + - supports-color + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16168,6 +16398,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16176,6 +16415,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/traverse': 7.28.0 + transitivePeerDependencies: + - supports-color + '@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16226,6 +16473,10 @@ snapshots: dependencies: '@babel/core': 7.28.0 + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-private-property-in-object@7.21.11(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16241,21 +16492,41 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16271,21 +16542,41 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-assertions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16301,41 +16592,81 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16352,11 +16683,22 @@ snapshots: '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.0) '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-async-generator-functions@7.28.0(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16366,6 +16708,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-async-generator-functions@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.28.0 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-async-to-generator@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16375,16 +16726,35 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-async-to-generator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-block-scoping@7.28.0(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-block-scoping@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-class-properties@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16393,6 +16763,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-class-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.27.1(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-class-static-block@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16401,6 +16779,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-class-static-block@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.27.1(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-classes@7.28.0(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16413,12 +16799,30 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-classes@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.28.0 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-computed-properties@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 '@babel/template': 7.27.2 + '@babel/plugin-transform-computed-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/template': 7.27.2 + '@babel/plugin-transform-destructuring@7.28.0(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16427,38 +16831,78 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-destructuring@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/traverse': 7.28.0 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-dotall-regex@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.0) '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-dotall-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.0) '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-exponentiation-operator@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-exponentiation-operator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16473,6 +16917,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16482,26 +16934,55 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/traverse': 7.28.0 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-json-strings@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-json-strings@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-logical-assignment-operators@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-logical-assignment-operators@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16510,6 +16991,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.27.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16518,6 +17007,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.27.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-modules-systemjs@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16528,6 +17025,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-modules-systemjs@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.27.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + '@babel/traverse': 7.28.0 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16536,27 +17043,56 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.27.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-named-capturing-groups-regex@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.0) '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-named-capturing-groups-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-nullish-coalescing-operator@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-nullish-coalescing-operator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-numeric-separator@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-numeric-separator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-object-rest-spread@7.28.0(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16568,6 +17104,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-object-rest-spread@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-destructuring': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/traverse': 7.28.0 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16576,11 +17123,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-optional-catch-binding@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-optional-catch-binding@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-optional-chaining@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16589,11 +17149,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-optional-chaining@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-private-methods@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16602,6 +17175,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-private-methods@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.27.1(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-private-property-in-object@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16611,11 +17192,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-private-property-in-object@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.27.1(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-constant-elements@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16647,10 +17242,10 @@ snapshots: dependencies: '@babel/core': 7.28.0 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-module-imports': 7.27.1 + '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0) - '@babel/types': 7.28.1 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -16665,17 +17260,33 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-regenerator@7.28.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-regexp-modifiers@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.0) '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-regexp-modifiers@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-runtime@7.28.0(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16693,6 +17304,11 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-spread@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16701,21 +17317,44 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-spread@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-typescript@7.28.0(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16732,24 +17371,47 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-unicode-property-regex@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.0) '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-unicode-property-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.0) '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-unicode-sets-regex@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.0) '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-unicode-sets-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/preset-env@7.26.9(@babel/core@7.28.0)': dependencies: '@babel/compat-data': 7.28.0 @@ -16825,6 +17487,81 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/preset-env@7.26.9(@babel/core@7.29.0)': + dependencies: + '@babel/compat-data': 7.28.0 + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0) + '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-dotall-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-exponentiation-operator': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-json-strings': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-systemjs': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-numeric-separator': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.28.1(@babel/core@7.29.0) + '@babel/plugin-transform-regexp-modifiers': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-property-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-sets-regex': 7.27.1(@babel/core@7.29.0) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.0) + babel-plugin-polyfill-corejs2: 0.4.14(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.11.1(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.5(@babel/core@7.29.0) + core-js-compat: 3.44.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -16832,6 +17569,13 @@ snapshots: '@babel/types': 7.28.1 esutils: 2.0.3 + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/types': 7.28.1 + esutils: 2.0.3 + '@babel/preset-react@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -18285,6 +19029,8 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/cliui@9.0.0': {} + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -18456,7 +19202,7 @@ snapshots: '@jest/transform@27.5.1': dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.29.0 '@jest/types': 27.5.1 babel-plugin-istanbul: 6.1.1 chalk: 4.1.2 @@ -18519,6 +19265,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.4': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.29': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -18846,14 +19594,14 @@ snapshots: '@mapbox/node-pre-gyp@1.0.11': dependencies: - detect-libc: 2.0.4 + detect-libc: 2.1.2 https-proxy-agent: 5.0.1 make-dir: 3.1.0 node-fetch: 2.7.0 nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 - semver: 7.7.2 + semver: 7.8.5 tar: 6.2.1 transitivePeerDependencies: - encoding @@ -20322,6 +21070,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@rollup/plugin-babel@6.1.0(@babel/core@7.29.0)(@types/babel__core@7.20.5)(rollup@4.21.2)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@rollup/pluginutils': 5.2.0(rollup@4.21.2) + optionalDependencies: + '@types/babel__core': 7.20.5 + rollup: 4.21.2 + transitivePeerDependencies: + - supports-color + '@rollup/plugin-node-resolve@11.2.1(rollup@4.21.2)': dependencies: '@rollup/pluginutils': 3.1.0(rollup@4.21.2) @@ -20332,12 +21091,37 @@ snapshots: resolve: 1.22.10 rollup: 4.21.2 + '@rollup/plugin-node-resolve@16.0.3(rollup@4.21.2)': + dependencies: + '@rollup/pluginutils': 5.2.0(rollup@4.21.2) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.10 + optionalDependencies: + rollup: 4.21.2 + '@rollup/plugin-replace@2.4.2(rollup@4.21.2)': dependencies: '@rollup/pluginutils': 3.1.0(rollup@4.21.2) magic-string: 0.25.9 rollup: 4.21.2 + '@rollup/plugin-replace@6.0.3(rollup@4.21.2)': + dependencies: + '@rollup/pluginutils': 5.2.0(rollup@4.21.2) + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.21.2 + + '@rollup/plugin-terser@1.0.0(rollup@4.21.2)': + dependencies: + serialize-javascript: 7.1.0 + smob: 1.6.2 + terser: 5.43.1 + optionalDependencies: + rollup: 4.21.2 + '@rollup/pluginutils@3.1.0(rollup@4.21.2)': dependencies: '@types/estree': 0.0.39 @@ -21514,6 +22298,13 @@ snapshots: - supports-color - typescript + '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1': + dependencies: + ejs: 3.1.10 + json5: 2.2.3 + magic-string: 0.30.21 + string.prototype.matchall: 4.0.12 + '@trysound/sax@0.2.0': {} '@tsconfig/node10@1.0.12': {} @@ -21528,24 +22319,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.7 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.29.0 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@types/babel__traverse@7.20.7': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.29.0 '@types/backbone@1.4.15': dependencies: @@ -21925,6 +22716,8 @@ snapshots: dependencies: '@types/node': 22.16.4 + '@types/resolve@1.20.2': {} + '@types/resolve@1.20.6': {} '@types/retry@0.12.0': {} @@ -21974,6 +22767,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.3': {} + '@types/use-sync-external-store@0.0.6': {} '@types/uuid@10.0.0': {} @@ -22770,12 +23565,12 @@ snapshots: '@zip.js/zip.js@2.8.26': {} - '@zoom/meetingsdk@3.13.2(lodash@4.17.21)(react-dom@18.3.1(react@18.3.1))(react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux-thunk@2.4.2(redux@4.2.1))(redux@4.2.1)': + '@zoom/meetingsdk@3.13.2(lodash@4.17.21)(react-dom@18.3.1(react@18.3.1))(react-redux@8.1.2(@types/react-dom@19.0.4(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1)(redux-thunk@2.4.2(redux@4.2.1))(redux@4.2.1)': dependencies: lodash: 4.17.21 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-redux: 7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-redux: 8.1.2(@types/react-dom@19.0.4(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) redux: 4.2.1 redux-thunk: 2.4.2(redux@4.2.1) @@ -22961,7 +23756,7 @@ snapshots: '@rc-component/trigger': 2.2.7(react-dom@16.14.0(react@16.14.0))(react@16.14.0) classnames: 2.5.1 copy-to-clipboard: 3.3.3 - dayjs: 1.11.13 + dayjs: 1.11.19 rc-cascader: 3.34.0(react-dom@16.14.0(react@16.14.0))(react@16.14.0) rc-checkbox: 3.5.0(react-dom@16.14.0(react@16.14.0))(react@16.14.0) rc-collapse: 3.9.0(react-dom@16.14.0(react@16.14.0))(react@16.14.0) @@ -22977,7 +23772,7 @@ snapshots: rc-motion: 2.9.5(react-dom@16.14.0(react@16.14.0))(react@16.14.0) rc-notification: 5.6.4(react-dom@16.14.0(react@16.14.0))(react@16.14.0) rc-pagination: 5.1.0(react-dom@16.14.0(react@16.14.0))(react@16.14.0) - rc-picker: 4.11.3(date-fns@4.1.0)(dayjs@1.11.13)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) + rc-picker: 4.11.3(date-fns@4.1.0)(dayjs@1.11.19)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) rc-progress: 4.0.0(react-dom@16.14.0(react@16.14.0))(react@16.14.0) rc-rate: 2.13.1(react-dom@16.14.0(react@16.14.0))(react@16.14.0) rc-resize-observer: 1.4.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0) @@ -23221,6 +24016,20 @@ snapshots: transitivePeerDependencies: - supports-color + babel-jest@27.5.1(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/transform': 27.5.1 + '@jest/types': 27.5.1 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 27.5.1(@babel/core@7.29.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + babel-loader@8.4.1(@babel/core@7.28.0)(webpack@5.100.1(@swc/core@1.12.14(@swc/helpers@0.5.17))(esbuild@0.25.6)): dependencies: '@babel/core': 7.28.0 @@ -23266,6 +24075,15 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.29.0): + dependencies: + '@babel/compat-data': 7.28.0 + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + babel-plugin-polyfill-corejs3@0.11.1(@babel/core@7.28.0): dependencies: '@babel/core': 7.28.0 @@ -23274,6 +24092,14 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-polyfill-corejs3@0.11.1(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.29.0) + core-js-compat: 3.44.0 + transitivePeerDependencies: + - supports-color + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.28.0): dependencies: '@babel/core': 7.28.0 @@ -23289,6 +24115,13 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-polyfill-regenerator@0.6.5(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + babel-plugin-transform-react-remove-prop-types@0.4.24: {} babel-preset-current-node-syntax@1.1.0(@babel/core@7.28.0): @@ -23310,12 +24143,37 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.0) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.0) + babel-preset-current-node-syntax@1.1.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + babel-preset-jest@27.5.1(@babel/core@7.28.0): dependencies: '@babel/core': 7.28.0 babel-plugin-jest-hoist: 27.5.1 babel-preset-current-node-syntax: 1.1.0(@babel/core@7.28.0) + babel-preset-jest@27.5.1(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 27.5.1 + babel-preset-current-node-syntax: 1.1.0(@babel/core@7.29.0) + babel-preset-react-app@10.1.0: dependencies: '@babel/core': 7.28.0 @@ -25613,6 +26471,8 @@ snapshots: esutils@2.0.3: {} + eta@4.6.0: {} + etag@1.8.1: {} eventemitter3@2.0.3: {} @@ -25993,7 +26853,7 @@ snapshots: async-validator: 3.5.2 classnames: 2.5.1 color: 3.2.1 - dayjs: 1.11.13 + dayjs: 1.11.19 lodash-es: 4.17.21 rc-color-picker: 1.2.6(react-dom@16.14.0(react@16.14.0))(react@16.14.0) react: 16.14.0 @@ -26012,7 +26872,7 @@ snapshots: async-validator: 3.5.2 classnames: 2.5.1 color: 3.2.1 - dayjs: 1.11.13 + dayjs: 1.11.19 lodash-es: 4.17.21 rc-color-picker: 1.2.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -26207,6 +27067,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -26935,8 +27804,8 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: - '@babel/core': 7.28.0 - '@babel/parser': 7.28.0 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 6.3.1 @@ -26985,6 +27854,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + jake@10.9.2: dependencies: async: 3.2.6 @@ -27054,10 +27927,10 @@ snapshots: jest-config@27.5.1(canvas@2.11.2)(ts-node@10.9.2(@swc/core@1.12.14(@swc/helpers@0.5.17))(@types/node@22.16.4)(typescript@5.6.3)): dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.29.0 '@jest/test-sequencer': 27.5.1 '@jest/types': 27.5.1 - babel-jest: 27.5.1(@babel/core@7.28.0) + babel-jest: 27.5.1(@babel/core@7.29.0) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 @@ -27312,16 +28185,16 @@ snapshots: jest-snapshot@27.5.1: dependencies: - '@babel/core': 7.28.0 - '@babel/generator': 7.28.0 - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.0) - '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 '@jest/transform': 27.5.1 '@jest/types': 27.5.1 '@types/babel__traverse': 7.20.7 '@types/prettier': 2.7.3 - babel-preset-current-node-syntax: 1.1.0(@babel/core@7.28.0) + babel-preset-current-node-syntax: 1.1.0(@babel/core@7.29.0) chalk: 4.1.2 expect: 27.5.1 graceful-fs: 4.2.11 @@ -27333,7 +28206,7 @@ snapshots: jest-util: 27.5.1 natural-compare: 1.4.0 pretty-format: 27.5.1 - semver: 7.7.2 + semver: 7.8.5 transitivePeerDependencies: - supports-color @@ -27924,6 +28797,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.4 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: dependencies: '@babel/parser': 7.28.0 @@ -30299,7 +31176,7 @@ snapshots: react: 16.14.0 react-dom: 16.14.0(react@16.14.0) - rc-picker@4.11.3(date-fns@4.1.0)(dayjs@1.11.13)(react-dom@16.14.0(react@16.14.0))(react@16.14.0): + rc-picker@4.11.3(date-fns@4.1.0)(dayjs@1.11.19)(react-dom@16.14.0(react@16.14.0))(react@16.14.0): dependencies: '@babel/runtime': 7.27.6 '@rc-component/trigger': 2.2.7(react-dom@16.14.0(react@16.14.0))(react@16.14.0) @@ -30311,7 +31188,7 @@ snapshots: react-dom: 16.14.0(react@16.14.0) optionalDependencies: date-fns: 4.1.0 - dayjs: 1.11.13 + dayjs: 1.11.19 rc-progress@4.0.0(react-dom@16.14.0(react@16.14.0))(react@16.14.0): dependencies: @@ -30842,6 +31719,21 @@ snapshots: optionalDependencies: react-dom: 18.3.1(react@18.3.1) + react-redux@8.1.2(@types/react-dom@19.0.4(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1): + dependencies: + '@babel/runtime': 7.27.6 + '@types/hoist-non-react-statics': 3.3.6 + '@types/use-sync-external-store': 0.0.3 + hoist-non-react-statics: 3.3.2 + react: 18.3.1 + react-is: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 19.0.4(@types/react@18.3.23) + react-dom: 18.3.1(react@18.3.1) + redux: 4.2.1 + react-refresh@0.11.0: {} react-refresh@0.14.2: {} @@ -31618,6 +32510,8 @@ snapshots: dependencies: randombytes: 2.1.0 + serialize-javascript@7.1.0: {} + seroval-plugins@1.5.1(seroval@1.5.1): dependencies: seroval: 1.5.1 @@ -31846,6 +32740,8 @@ snapshots: sliced@1.0.1: {} + smob@1.6.2: {} + snake-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -33075,14 +33971,14 @@ snapshots: pathe: 0.2.0 vite: 5.4.19(@types/node@22.16.4)(sass@1.51.0)(terser@5.43.1) - vite-plugin-pwa@0.21.2(vite@5.4.19(@types/node@22.16.4)(sass@1.51.0)(terser@5.43.1))(workbox-build@6.6.0(@types/babel__core@7.20.5))(workbox-window@6.6.0): + vite-plugin-pwa@0.21.2(vite@5.4.19(@types/node@22.16.4)(sass@1.51.0)(terser@5.43.1))(workbox-build@7.4.1(@types/babel__core@7.20.5))(workbox-window@7.4.1): dependencies: debug: 4.4.1 pretty-bytes: 6.1.1 tinyglobby: 0.2.14 vite: 5.4.19(@types/node@22.16.4)(sass@1.51.0)(terser@5.43.1) - workbox-build: 6.6.0(@types/babel__core@7.20.5) - workbox-window: 6.6.0 + workbox-build: 7.4.1(@types/babel__core@7.20.5) + workbox-window: 7.4.1 transitivePeerDependencies: - supports-color @@ -33491,10 +34387,19 @@ snapshots: idb: 7.1.1 workbox-core: 6.6.0 + workbox-background-sync@7.4.1: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.1 + workbox-broadcast-update@6.6.0: dependencies: workbox-core: 6.6.0 + workbox-broadcast-update@7.4.1: + dependencies: + workbox-core: 7.4.1 + workbox-build@6.6.0(@types/babel__core@7.20.5): dependencies: '@apideck/better-ajv-errors': 0.3.6(ajv@8.17.1) @@ -33538,17 +34443,71 @@ snapshots: - '@types/babel__core' - supports-color + workbox-build@7.4.1(@types/babel__core@7.20.5): + dependencies: + '@apideck/better-ajv-errors': 0.3.6(ajv@8.17.1) + '@babel/core': 7.29.0 + '@babel/preset-env': 7.26.9(@babel/core@7.29.0) + '@babel/runtime': 7.27.6 + '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.0)(@types/babel__core@7.20.5)(rollup@4.21.2) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.21.2) + '@rollup/plugin-replace': 6.0.3(rollup@4.21.2) + '@rollup/plugin-terser': 1.0.0(rollup@4.21.2) + '@trickfilm400/rollup-plugin-off-main-thread': 3.0.0-pre1 + ajv: 8.17.1 + common-tags: 1.8.2 + eta: 4.6.0 + fast-json-stable-stringify: 2.1.0 + fs-extra: 9.1.0 + glob: 11.1.0 + pretty-bytes: 5.6.0 + rollup: 4.21.2 + source-map: 0.8.0-beta.0 + stringify-object: 3.3.0 + strip-comments: 2.0.1 + tempy: 0.6.0 + upath: 1.2.0 + workbox-background-sync: 7.4.1 + workbox-broadcast-update: 7.4.1 + workbox-cacheable-response: 7.4.1 + workbox-core: 7.4.1 + workbox-expiration: 7.4.1 + workbox-google-analytics: 7.4.1 + workbox-navigation-preload: 7.4.1 + workbox-precaching: 7.4.1 + workbox-range-requests: 7.4.1 + workbox-recipes: 7.4.1 + workbox-routing: 7.4.1 + workbox-strategies: 7.4.1 + workbox-streams: 7.4.1 + workbox-sw: 7.4.1 + workbox-window: 7.4.1 + transitivePeerDependencies: + - '@types/babel__core' + - supports-color + workbox-cacheable-response@6.6.0: dependencies: workbox-core: 6.6.0 + workbox-cacheable-response@7.4.1: + dependencies: + workbox-core: 7.4.1 + workbox-core@6.6.0: {} + workbox-core@7.4.1: {} + workbox-expiration@6.6.0: dependencies: idb: 7.1.1 workbox-core: 6.6.0 + workbox-expiration@7.4.1: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.1 + workbox-google-analytics@6.6.0: dependencies: workbox-background-sync: 6.6.0 @@ -33556,20 +34515,41 @@ snapshots: workbox-routing: 6.6.0 workbox-strategies: 6.6.0 + workbox-google-analytics@7.4.1: + dependencies: + workbox-background-sync: 7.4.1 + workbox-core: 7.4.1 + workbox-routing: 7.4.1 + workbox-strategies: 7.4.1 + workbox-navigation-preload@6.6.0: dependencies: workbox-core: 6.6.0 + workbox-navigation-preload@7.4.1: + dependencies: + workbox-core: 7.4.1 + workbox-precaching@6.6.0: dependencies: workbox-core: 6.6.0 workbox-routing: 6.6.0 workbox-strategies: 6.6.0 + workbox-precaching@7.4.1: + dependencies: + workbox-core: 7.4.1 + workbox-routing: 7.4.1 + workbox-strategies: 7.4.1 + workbox-range-requests@6.6.0: dependencies: workbox-core: 6.6.0 + workbox-range-requests@7.4.1: + dependencies: + workbox-core: 7.4.1 + workbox-recipes@6.6.0: dependencies: workbox-cacheable-response: 6.6.0 @@ -33579,21 +34559,45 @@ snapshots: workbox-routing: 6.6.0 workbox-strategies: 6.6.0 + workbox-recipes@7.4.1: + dependencies: + workbox-cacheable-response: 7.4.1 + workbox-core: 7.4.1 + workbox-expiration: 7.4.1 + workbox-precaching: 7.4.1 + workbox-routing: 7.4.1 + workbox-strategies: 7.4.1 + workbox-routing@6.6.0: dependencies: workbox-core: 6.6.0 + workbox-routing@7.4.1: + dependencies: + workbox-core: 7.4.1 + workbox-strategies@6.6.0: dependencies: workbox-core: 6.6.0 + workbox-strategies@7.4.1: + dependencies: + workbox-core: 7.4.1 + workbox-streams@6.6.0: dependencies: workbox-core: 6.6.0 workbox-routing: 6.6.0 + workbox-streams@7.4.1: + dependencies: + workbox-core: 7.4.1 + workbox-routing: 7.4.1 + workbox-sw@6.6.0: {} + workbox-sw@7.4.1: {} + workbox-webpack-plugin@6.6.0(@types/babel__core@7.20.5)(webpack@5.100.1(@swc/core@1.12.14(@swc/helpers@0.5.17))(esbuild@0.25.6)): dependencies: fast-json-stable-stringify: 2.1.0 @@ -33611,6 +34615,11 @@ snapshots: '@types/trusted-types': 2.0.7 workbox-core: 6.6.0 + workbox-window@7.4.1: + dependencies: + '@types/trusted-types': 2.0.7 + workbox-core: 7.4.1 + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 diff --git a/frontend-admin-dashboard/pnpm-workspace.yaml b/frontend-admin-dashboard/pnpm-workspace.yaml new file mode 100644 index 0000000000..040cc18ae1 --- /dev/null +++ b/frontend-admin-dashboard/pnpm-workspace.yaml @@ -0,0 +1,17 @@ +packages: + - '.' + +# Native postinstalls we allow to run. Deliberately does NOT include `canvas`: +# its install script falls back to a from-source node-gyp compile (twice — the +# tree has canvas@2 and canvas@3), which costs ~20 min on the CI builder. It is +# a Node-only addon pulled in as an OPTIONAL dep of pdfjs-dist/fabric/react-to-pdf; +# a browser bundle never loads it, and every green deploy so far has had it +# skipped. The rest below ship prebuilt binaries, so they cost seconds. +onlyBuiltDependencies: + - '@firebase/util' + - '@swc/core' + - core-js + - core-js-pure + - esbuild + - protobufjs + - sharp diff --git a/frontend-admin-dashboard/public/_routes.json b/frontend-admin-dashboard/public/_routes.json index e09e59ea8f..78661bc130 100644 --- a/frontend-admin-dashboard/public/_routes.json +++ b/frontend-admin-dashboard/public/_routes.json @@ -4,6 +4,9 @@ "exclude": [ "/badge-library/*", "/email-editor/*", + "/locales/*", + "/sw.js", + "/service-worker.js", "/favicon.ico", "/robots.txt", "/styles.css", diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsAcademicInfoSection.json b/frontend-admin-dashboard/public/locales/ar/admissionsAcademicInfoSection.json new file mode 100644 index 0000000000..fd647ceec9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsAcademicInfoSection.json @@ -0,0 +1,70 @@ +{ + "sections": { + "previousSchoolDetails": "بيانات المدرسة الحالية / السابقة", + "transferCertificateDetails": "بيانات شهادة النقل (TC)", + "applyingFor": "التقديم للالتحاق بـ" + }, + "fields": { + "previousSchoolName": { + "label": "اسم المدرسة السابقة", + "placeholder": "أدخل اسم المدرسة السابقة" + }, + "previousSchoolBoard": { + "label": "مجلس المدرسة السابقة", + "placeholder": "اختر المجلس", + "hint": "CBSE / ICSE" + }, + "lastClassAttended": { + "label": "آخر صف تم دراسته", + "placeholder": "اختر الصف" + }, + "academicYear": { + "label": "العام الدراسي", + "placeholder": "سيتم تعبئة الدورة تلقائيًا", + "hint": "بناءً على الدورة المحددة" + }, + "lastExamResult": { + "label": "نتيجة آخر امتحان / النسبة المئوية", + "placeholder": "مثال: 85% أو تقدير A1 أو 9 CGPA" + }, + "subjectsStudied": { + "label": "المواد المدروسة (الصف السابق)", + "placeholder": "مثال: الإنجليزية، الهندية، الرياضيات، العلوم، الدراسات الاجتماعية" + }, + "tcNumber": { + "label": "رقم شهادة النقل", + "placeholder": "أدخل رقم شهادة النقل" + }, + "tcIssueDate": { + "label": "تاريخ إصدار شهادة النقل" + }, + "tcPending": { + "label": "شهادة النقل معلقة / سيتم تقديمها لاحقًا", + "hint": "(لا يمكن تأكيد القبول دون شهادة النقل)" + }, + "applyingForClass": { + "label": "الصف / المرحلة المطلوب الالتحاق بها", + "placeholder": "اختر الصف", + "hint": "اختر الصف أو المرحلة المطلوب الالتحاق بها" + }, + "preferredBoard": { + "label": "المجلس المفضل", + "placeholder": "اختر المجلس" + } + }, + "boardOptions": { + "cbse": "CBSE", + "icse": "ICSE", + "stateBoard": "مجلس الولاية", + "ib": "IB", + "igcse": "IGCSE", + "other": "أخرى" + }, + "classLevelOptions": { + "kindergarten": "روضة الأطفال", + "nursery": "الحضانة", + "lkg": "LKG", + "ukg": "UKG" + }, + "classNumber": "الصف {{number}}" +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsActivityLogDialog.json b/frontend-admin-dashboard/public/locales/ar/admissionsActivityLogDialog.json new file mode 100644 index 0000000000..17f19ac44e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsActivityLogDialog.json @@ -0,0 +1,19 @@ +{ + "dialogTitle": "إضافة سجل نشاط", + "dialogDescription": "سجّل نشاطًا أو ملاحظة لهذا الاستفسار", + "actionTypes": { + "note": "ملاحظة", + "phoneCall": "مكالمة هاتفية", + "emailSent": "بريد إلكتروني مُرسَل", + "campusVisit": "زيارة الحرم الجامعي" + }, + "notePlaceholder": "اكتب ملاحظتك هنا…", + "cancel": "إلغاء", + "submit": "إضافة نشاط", + "submitting": "جارٍ الحفظ…", + "toast": { + "success": "تم تسجيل النشاط بنجاح", + "error": "تعذّرت إضافة النشاط. يرجى المحاولة مرة أخرى.", + "emptyWarning": "يرجى إدخال ملاحظة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsAddressSection.json b/frontend-admin-dashboard/public/locales/ar/admissionsAddressSection.json new file mode 100644 index 0000000000..a10adcebcd --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsAddressSection.json @@ -0,0 +1,82 @@ +{ + "sections": { + "residentialAddress": "عنوان الإقامة (الحالي)" + }, + "fields": { + "houseNo": { + "label": "رقم المنزل / رقم الشقة / اسم المبنى", + "placeholder": "مثال: 12-A، شقق صن رايز" + }, + "street": { + "label": "اسم الشارع / الطريق", + "placeholder": "مثال: طريق إم جي" + }, + "area": { + "label": "المنطقة / الحي / القطاع", + "placeholder": "مثال: حي أريرا" + }, + "landmark": { + "label": "علامة مميزة", + "placeholder": "مثال: بالقرب من سيتي مول" + }, + "city": { + "label": "المدينة", + "placeholder": "مثال: بوبال" + }, + "state": { + "label": "الولاية", + "placeholder": "اختر الولاية" + }, + "pincode": { + "label": "الرمز البريدي", + "placeholder": "مثال: 462001", + "hint": "6 أرقام" + }, + "country": { + "label": "الدولة", + "placeholder": "اختر الدولة" + } + }, + "stateOptions": { + "andhraPradesh": "أندرا براديش", + "arunachalPradesh": "أروناتشال براديش", + "assam": "آسام", + "bihar": "بيهار", + "chhattisgarh": "تشاتيسجار", + "goa": "جوا", + "gujarat": "غوجارات", + "haryana": "هاريانا", + "himachalPradesh": "هيماشال براديش", + "jharkhand": "جهارخاند", + "karnataka": "كارناتاكا", + "kerala": "كيرالا", + "madhyaPradesh": "ماديا براديش", + "maharashtra": "ماهاراشترا", + "manipur": "مانيبور", + "meghalaya": "ميغالايا", + "mizoram": "ميزورام", + "nagaland": "ناغلاند", + "odisha": "أوديشا", + "punjab": "البنجاب", + "rajasthan": "راجاستان", + "sikkim": "سيكيم", + "tamilNadu": "تاميل نادو", + "telangana": "تيلانغانا", + "tripura": "تريبورا", + "uttarPradesh": "أوتار براديش", + "uttarakhand": "أوتاراخند", + "westBengal": "البنغال الغربية", + "andamanAndNicobarIslands": "جزر أندامان ونيكوبار", + "chandigarh": "تشانديغار", + "dadraAndNagarHaveliAndDamanAndDiu": "دادرا ونجار هافيلي ودامان وديو", + "delhi": "دلهي", + "jammuAndKashmir": "جامو وكشمير", + "ladakh": "لداخ", + "lakshadweep": "لاكشادويب", + "puducherry": "بودوتشيري" + }, + "countryOptions": { + "india": "الهند", + "other": "أخرى" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionBulkImportDialog.json b/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionBulkImportDialog.json new file mode 100644 index 0000000000..cf45a05560 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionBulkImportDialog.json @@ -0,0 +1,89 @@ +{ + "dialog": { + "title": "استيراد القبولات بالجملة", + "description": "قم بتحميل ملف CSV، واختر الصف اختياريًا، ثم عاين التسجيلات وأكّد الاستيراد" + }, + "steps": { + "label": "الخطوة {{number}}" + }, + "upload": { + "templateHint": "قم بتنزيل نموذج CSV وتحميل استجابات المتعلمين المعبأة", + "downloadTemplateButton": "تنزيل النموذج", + "clickToUpload": "انقر لتحميل ملف `.csv`", + "rowsSummary": "الصفوف الصالحة: {{validCount}} | الصفوف المتجاوَزة: {{skippedCount}}" + }, + "classStep": { + "description": "اختر الصف/الدفعة المطلوبة لتقديم القبولات. إذا تخطيت هذه الخطوة، سيُستخدم أول خيار متاح.", + "noClassOption": "لم يتم اختيار صف" + }, + "preview": { + "summary_zero": "لا توجد صفوف صالحة للمعاينة ({{count}})", + "summary_one": "معاينة صف صالح واحد ({{count}})", + "summary_two": "معاينة صفين صالحين ({{count}})", + "summary_few": "معاينة {{count}} صفوف صالحة", + "summary_many": "معاينة {{count}} صفًا صالحًا", + "summary_other": "معاينة {{count}} صف صالح", + "columns": { + "studentName": "اسم الطالب", + "gender": "الجنس", + "dateOfBirth": "تاريخ الميلاد", + "fatherName": "اسم الأب", + "fatherEmail": "البريد الإلكتروني للأب", + "fatherMobile": "رقم جوال الأب", + "motherName": "اسم الأم", + "motherEmail": "البريد الإلكتروني للأم", + "motherMobile": "رقم جوال الأم", + "guardianName": "اسم ولي الأمر", + "guardianMobile": "رقم جوال ولي الأمر", + "status": "الحالة", + "source": "المصدر" + } + }, + "actions": { + "back": "رجوع", + "cancel": "إلغاء", + "next": "التالي", + "confirmImport": "تأكيد الاستيراد", + "importing": "جارٍ الاستيراد..." + }, + "errors": { + "onlyCsvSupported": "يتم دعم ملفات .csv فقط", + "missingColumns": "أعمدة مطلوبة مفقودة: {{columns}}", + "failedToParseCsv": "فشل تحليل ملف CSV", + "instituteNotLoaded": "لم يتم تحميل بيانات المؤسسة", + "sessionRequired": "الجلسة (الدورة) مطلوبة", + "selectClassRequired": "يرجى اختيار الصف/الدفعة" + }, + "toast": { + "importSuccess_zero": "لم يتم استيراد أي قبولات ({{count}}) ({{failedCount}} فشل)", + "importSuccess_one": "تم استيراد قبول واحد ({{count}}) ({{failedCount}} فشل)", + "importSuccess_two": "تم استيراد قبولين ({{count}}) ({{failedCount}} فشل)", + "importSuccess_few": "تم استيراد {{count}} قبولات ({{failedCount}} فشل)", + "importSuccess_many": "تم استيراد {{count}} قبولًا ({{failedCount}} فشل)", + "importSuccess_other": "تم استيراد {{count}} قبول ({{failedCount}} فشل)", + "importError": "فشل استيراد القبولات" + }, + "genderLabel": { + "MALE": "ذكر", + "FEMALE": "أنثى", + "OTHER": "آخر" + }, + "statusLabel": { + "NEW": "جديد", + "CONTACTED": "تم التواصل", + "QUALIFIED": "مؤهل", + "NOT_ELIGIBLE": "غير مؤهل", + "FOLLOW_UP": "متابعة", + "CLOSED": "مغلق", + "CONVERTED": "تم التحويل", + "ADMITTED": "تم القبول" + }, + "sourceLabel": { + "WEBSITE": "الموقع الإلكتروني", + "GOOGLE_ADS": "إعلانات جوجل", + "FACEBOOK": "فيسبوك", + "INSTAGRAM": "إنستغرام", + "REFERRAL": "إحالة", + "OTHER": "آخر" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionDashboard.json b/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionDashboard.json new file mode 100644 index 0000000000..beb891b749 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionDashboard.json @@ -0,0 +1,32 @@ +{ + "navHeading": "لوحة القبول", + "filterBar": { + "title": "مسار القبول", + "allClasses": "جميع الفصول" + }, + "kpi": { + "totalEnquiries": "إجمالي الاستفسارات", + "totalApplications": "إجمالي الطلبات", + "totalAdmissions": "إجمالي القبولات" + }, + "conversion": { + "enquiryToApplication": "من الاستفسار إلى الطلب", + "applicationToAdmission": "من الطلب إلى القبول", + "overall": "معدل التحويل الإجمالي" + }, + "breakdown": { + "title": "تفصيل القبولات", + "fromEnquiry": { + "label": "من الاستفسار", + "description": "المسار الكامل: استفسار ← طلب ← قبول" + }, + "fromApplicationOnly": { + "label": "من الطلب فقط", + "description": "بدأ عند مرحلة الطلب" + }, + "direct": { + "label": "مباشر (حضوري)", + "description": "بدون استفسار أو طلب" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionEntryScreen.json b/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionEntryScreen.json new file mode 100644 index 0000000000..3d06dc18d9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionEntryScreen.json @@ -0,0 +1,120 @@ +{ + "header": { + "title": "نموذج القبول الافتراضي", + "admissionForm": "نموذج القبول", + "bulkImport": "استيراد جماعي" + }, + "filters": { + "status": "الحالة", + "source": "المصدر", + "dateRange": "النطاق الزمني", + "class": "الصف", + "section": "الشعبة", + "applyFilter": "تطبيق الفلتر", + "applying": "جارٍ التطبيق...", + "clearAll": "مسح الكل" + }, + "dateRanges": { + "today": "اليوم", + "last7Days": "آخر 7 أيام", + "last30Days": "آخر 30 يومًا", + "last3Months": "آخر 3 أشهر", + "last6Months": "آخر 6 أشهر", + "lastYear": "العام الماضي" + }, + "overallStatuses": { + "application": "طلب التقديم", + "admission": "القبول" + }, + "sourceTypes": { + "website": "الموقع الإلكتروني", + "googleAds": "إعلانات جوجل", + "facebook": "فيسبوك", + "google": "جوجل", + "friends": "الأصدقاء", + "zohoForms": "نماذج زوهو", + "audienceCampaign": "حملة الجمهور", + "directApplication": "تقديم مباشر", + "manualAdmission": "قبول يدوي", + "other": "أخرى" + }, + "searchByOptions": { + "studentName": "اسم الطالب", + "applicationNo": "رقم الطلب", + "parentMobile": "جوال ولي الأمر" + }, + "search": { + "criteriaHeading": "معايير البحث", + "searchByLabel": "البحث حسب", + "enterDetailsLabel": "أدخل التفاصيل", + "enterDetailsPlaceholder": "أدخل {{field}}", + "searchButton": "بحث", + "searching": "جارٍ البحث..." + }, + "results": { + "totalResponses": "إجمالي الردود:", + "noRecordsFound": "لم يتم العثور على سجلات مطابقة لمعايير البحث.", + "createAdmission": "إنشاء قبول", + "alreadyAdmitted": "تم القبول بالفعل" + }, + "table": { + "sNo": "م.", + "class": "الصف", + "studentName": "اسم الطالب", + "gender": "الجنس", + "dateOfBirth": "تاريخ الميلاد", + "parentName": "اسم ولي الأمر", + "parentEmail": "بريد ولي الأمر الإلكتروني", + "parentMobile": "جوال ولي الأمر", + "trackingId": "رقم التتبع", + "status": "الحالة", + "source": "المصدر", + "actions": "الإجراءات" + }, + "genderLabels": { + "male": "ذكر", + "female": "أنثى", + "other": "آخر" + }, + "statusLabels": { + "new": "جديد", + "contacted": "تم التواصل", + "followUp": "متابعة", + "qualified": "مؤهل", + "notEligible": "غير مؤهل", + "enquiry": "استفسار", + "application": "طلب التقديم", + "admission": "القبول" + }, + "emptyState": { + "title": "اختر طالبًا للبدء", + "description": "استخدم لوحة البحث أعلاه للعثور على استفسار أو طلب تقديم موجود. أو انقر على 'نموذج القبول' لبدء نموذج جديد." + }, + "admissionTypeModal": { + "title": "اختر نوع القبول", + "subtitle": "اختر الطريقة التي تريد بها إنشاء قبول جديد", + "newAdmissionTitle": "قبول جديد", + "newAdmissionDesc": "ابدأ نموذج قبول جديد", + "fromEnquiryTitle": "من استفسار", + "fromEnquiryDesc": "إنشاء قبول من استفسار موجود", + "fromApplicationTitle": "من طلب تقديم", + "fromApplicationDesc": "إنشاء قبول من طلب تقديم موجود" + }, + "applicationModal": { + "title": "أدخل تفاصيل الطلب", + "applicationIdLabel": "رقم الطلب / رقم التتبع", + "applicationIdPlaceholder": "مثال: APP-12345", + "or": "أو", + "phoneLabel": "رقم الهاتف", + "phonePlaceholder": "مثال: 9876543210", + "helperText": "أدخل رقم الطلب أو رقم هاتف الطلب الذي تريد تحويله إلى قبول", + "cancel": "إلغاء", + "continueButton": "متابعة", + "loading": "جارٍ التحميل..." + }, + "alerts": { + "enterIdOrPhone": "يرجى إدخال رقم الطلب أو رقم الهاتف", + "noApplicationFound": "لم يتم العثور على طلب بالتفاصيل المدخلة.", + "fetchFailed": "فشل جلب تفاصيل الطلب. يرجى التحقق من الرقم أو رقم الهاتف." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionFormIndexLazy.json b/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionFormIndexLazy.json new file mode 100644 index 0000000000..3c99b669bc --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionFormIndexLazy.json @@ -0,0 +1,6 @@ +{ + "page": { + "title": "استمارة القبول", + "metaDescription": "استمارة قبول متعددة الخطوات للطلاب الجدد." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionFormPrintTemplate.json b/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionFormPrintTemplate.json new file mode 100644 index 0000000000..0874147841 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionFormPrintTemplate.json @@ -0,0 +1,71 @@ +{ + "instituteNameFallback": "اسم المؤسسة", + "instituteLogoAlt": "شعار المؤسسة", + "formTitle": "استمارة القبول", + "date": "التاريخ", + "pastePhoto": "الصق الصورة", + "sections": { + "studentDetails": "بيانات الطالب", + "previousSchoolPersonalDetails": "المدرسة السابقة والبيانات الشخصية", + "parentGuardianDetails": "بيانات ولي الأمر", + "addressDetails": "بيانات العنوان" + }, + "fields": { + "firstName": "الاسم الأول", + "middleName": "الاسم الأوسط", + "lastName": "اسم العائلة", + "gender": "الجنس", + "dateOfBirth": "تاريخ الميلاد", + "dateOfAdmission": "تاريخ القبول", + "class": "الصف", + "section": "الشعبة", + "group": "المجموعة", + "applicationNo": "رقم الطلب", + "studentType": "نوع الطالب", + "admissionType": "نوع القبول", + "residentialPhone": "هاتف المنزل", + "transport": "النقل", + "aadhaarNumber": "رقم آدهار", + "previousSchoolName": "اسم المدرسة السابقة", + "previousClass": "الصف السابق", + "board": "المجلس التعليمي", + "yearOfPassing": "سنة التخرج", + "percentage": "النسبة المئوية", + "previousAdmissionNo": "رقم القبول السابق", + "religion": "الديانة", + "caste": "الطائفة", + "motherTongue": "اللغة الأم", + "bloodGroup": "فصيلة الدم", + "nationality": "الجنسية", + "howDidYouKnow": "كيف عرفت عنّا", + "fatherName": "اسم الأب", + "fatherMobile": "جوال الأب", + "fatherEmail": "البريد الإلكتروني للأب", + "fatherAadhaar": "آدهار الأب", + "fatherQualification": "مؤهل الأب", + "fatherOccupation": "مهنة الأب", + "motherName": "اسم الأم", + "motherMobile": "جوال الأم", + "motherEmail": "البريد الإلكتروني للأم", + "motherAadhaar": "آدهار الأم", + "motherQualification": "مؤهل الأم", + "motherOccupation": "مهنة الأم", + "guardianName": "اسم ولي الأمر", + "guardianMobile": "جوال ولي الأمر", + "currentAddress": "العنوان الحالي", + "locality": "الحي", + "pinCode": "الرمز البريدي", + "permanentAddress": "العنوان الدائم", + "permanentLocality": "الحي الدائم" + }, + "declaration": { + "heading": "إقرار", + "text": "أقر بأن جميع المعلومات المقدمة أعلاه صحيحة ودقيقة حسب علمي. وأدرك أن أي معلومات كاذبة قد تؤدي إلى إلغاء القبول." + }, + "signature": { + "parentGuardian": "توقيع ولي الأمر", + "student": "توقيع الطالب", + "principal": "توقيع المدير" + }, + "footer": "هذا مستند تم إنشاؤه بواسطة الحاسوب. تم إنشاؤه بتاريخ {{date}}." +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionFormWizard.json b/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionFormWizard.json new file mode 100644 index 0000000000..3756edc64f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionFormWizard.json @@ -0,0 +1,36 @@ +{ + "pageTitle": "استمارة القبول", + "backToAdmissionList": "العودة إلى قائمة القبول", + "downloadPdf": "تنزيل PDF", + "downloadingPdf": "جارٍ الإنشاء...", + "print": "طباعة", + "previous": "السابق", + "saveNext": "حفظ ومتابعة", + "submitAdmission": "إرسال طلب القبول", + "submitting": "جارٍ الإرسال...", + "printDocumentTitle": "استمارة القبول - {{studentName}}", + "steps": { + "studentDetails": "بيانات {{learnerLabel}}", + "previousSchoolPersonalDetails": "المدرسة السابقة والبيانات الشخصية", + "parentDetails": "بيانات ولي أمر {{learnerLabel}}", + "addressDetails": "بيانات العنوان", + "finish": "إنهاء", + "feeAssignment": "تحديد الرسوم" + }, + "trackingLabel": { + "application": "رقم تتبع الطلب", + "enquiry": "رقم تتبع الاستفسار", + "admission": "رقم تتبع القبول" + }, + "toast": { + "pdfTemplateNotReady": "قالب PDF غير جاهز. يرجى المحاولة مرة أخرى.", + "generatingPdf": "جارٍ إنشاء ملف PDF...", + "pdfDownloaded": "تم تنزيل ملف PDF!", + "pdfGenerationFailed": "فشل إنشاء ملف PDF. يرجى المحاولة مرة أخرى.", + "popupBlocked": "تم حظر النافذة المنبثقة. يرجى السماح بالنوافذ المنبثقة للطباعة.", + "instituteDetailsUnavailable": "بيانات المؤسسة غير متوفرة. يرجى المحاولة مرة أخرى.", + "admissionSubmitted": "تم إرسال طلب القبول بنجاح! المعرّف: {{admissionId}}", + "admissionSubmitFailed": "فشل إرسال استمارة القبول. يرجى المحاولة مرة أخرى.", + "submissionError": "حدث خطأ. يرجى التحقق من اتصال الشبكة والمحاولة مرة أخرى." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionListIndexLazy.json b/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionListIndexLazy.json new file mode 100644 index 0000000000..819bf8a85b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsAdmissionListIndexLazy.json @@ -0,0 +1,6 @@ +{ + "page": { + "title": "قائمة القبول", + "metaDescription": "عرض وإدارة طلبات القبول." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsApplicationBulkImportDialog.json b/frontend-admin-dashboard/public/locales/ar/admissionsApplicationBulkImportDialog.json new file mode 100644 index 0000000000..b50c7a5656 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsApplicationBulkImportDialog.json @@ -0,0 +1,70 @@ +{ + "dialog": { + "title": "استيراد الطلبات دفعة واحدة", + "description": "ارفع ملف CSV، واختر الصف/الدفعة، ثم راجع وأكّد الاستيراد" + }, + "steps": { + "label": "الخطوة {{number}}" + }, + "upload": { + "templateHint": "نزّل نموذج CSV واملأه ببيانات المتقدمين ثم ارفعه", + "dropzoneCta": "انقر لرفع ملف `.csv`", + "validRowsLabel": "الصفوف الصالحة", + "skippedRowsLabel": "الصفوف المتجاوَزة" + }, + "classSelect": { + "helperText": "اختر الصف/الدفعة المطلوب لتقديم الطلبات.", + "placeholder": "اختر صفًا/دفعة" + }, + "sample": { + "studentName": "أحمد الطالب", + "fatherName": "محمد الأب", + "motherName": "فاطمة الأم" + }, + "columns": { + "studentName": "اسم الطالب", + "gender": "الجنس", + "dateOfBirth": "تاريخ الميلاد", + "fatherName": "اسم الأب", + "fatherEmail": "البريد الإلكتروني للأب", + "fatherMobile": "رقم جوال الأب", + "motherName": "اسم الأم", + "motherEmail": "البريد الإلكتروني للأم", + "motherMobile": "رقم جوال الأم", + "status": "الحالة", + "source": "المصدر" + }, + "preview": { + "summary_zero": "معاينة {{count}} صف صالح", + "summary_one": "معاينة {{count}} صف صالح واحد", + "summary_two": "معاينة {{count}} صفّين صالحين", + "summary_few": "معاينة {{count}} صفوف صالحة", + "summary_many": "معاينة {{count}} صفًا صالحًا", + "summary_other": "معاينة {{count}} صف صالح" + }, + "errors": { + "onlyCsvSupported": "يُدعم ملفات .csv فقط", + "missingRequiredColumns": "أعمدة مطلوبة مفقودة: {{columns}}", + "failedToParseCsv": "فشل في تحليل ملف CSV" + }, + "toasts": { + "importResult_zero": "تم استيراد {{count}} طلب ({{failedCount}} فشل)", + "importResult_one": "تم استيراد {{count}} طلب واحد ({{failedCount}} فشل)", + "importResult_two": "تم استيراد {{count}} طلبين ({{failedCount}} فشل)", + "importResult_few": "تم استيراد {{count}} طلبات ({{failedCount}} فشل)", + "importResult_many": "تم استيراد {{count}} طلبًا ({{failedCount}} فشل)", + "importResult_other": "تم استيراد {{count}} طلب ({{failedCount}} فشل)", + "importFailedFallback": "فشل استيراد الطلبات", + "instituteDetailsUnavailable": "تفاصيل المؤسسة غير متاحة", + "selectClassBatch": "يرجى اختيار صف/دفعة", + "invalidClassBatchSelection": "اختيار الصف/الدفعة غير صالح" + }, + "buttons": { + "downloadTemplate": "تنزيل النموذج", + "back": "رجوع", + "cancel": "إلغاء", + "next": "التالي", + "confirmImport": "تأكيد الاستيراد", + "importing": "جارٍ الاستيراد..." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsApplicationFormPrintTemplate.json b/frontend-admin-dashboard/public/locales/ar/admissionsApplicationFormPrintTemplate.json new file mode 100644 index 0000000000..523a1b2a35 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsApplicationFormPrintTemplate.json @@ -0,0 +1,76 @@ +{ + "header": { + "applicationFormTitle": "استمارة الطلب", + "institutePlaceholder": "اسم المؤسسة", + "instituteLogoAlt": "شعار المؤسسة", + "pastePhoto": "الصق الصورة", + "dateLabel": "التاريخ:", + "academicYearLabel": "السنة الدراسية:" + }, + "sections": { + "studentDetails": "بيانات الطالب", + "healthInformation": "المعلومات الصحية", + "academicInformation": "المعلومات الأكاديمية", + "fathersDetails": "بيانات الأب", + "mothersDetails": "بيانات الأم", + "guardianDetails": "بيانات ولي الأمر", + "emergencyContact": "جهة الاتصال في حالات الطوارئ", + "addressDetails": "بيانات العنوان" + }, + "fields": { + "fullNameBirthCertificate": "الاسم الكامل (كما في شهادة الميلاد)", + "dateOfBirth": "تاريخ الميلاد", + "gender": "الجنس", + "nationality": "الجنسية", + "religion": "الديانة", + "category": "الفئة", + "bloodGroup": "فصيلة الدم", + "motherTongue": "اللغة الأم", + "languagesKnown": "اللغات المعروفة", + "idType": "نوع الهوية", + "idNumber": "رقم الهوية", + "medicalConditions": "الحالات الطبية / الحساسية", + "dietaryRestrictions": "القيود الغذائية", + "specialEducationNeeds": "احتياجات التعليم الخاص", + "physicallyChallenged": "ذوو الإعاقة الجسدية", + "applyingForClass": "الصف المتقدم إليه", + "preferredBoard": "المنهج المفضل", + "academicYear": "السنة الدراسية", + "previousSchoolName": "اسم المدرسة السابقة", + "previousSchoolBoard": "منهج المدرسة السابقة", + "lastClassAttended": "آخر صف تم الالتحاق به", + "lastExamResult": "نتيجة آخر امتحان", + "subjectsStudied": "المواد الدراسية", + "tcNumber": "رقم شهادة النقل", + "tcIssueDate": "تاريخ إصدار شهادة النقل", + "tcPending": "شهادة النقل معلّقة", + "name": "الاسم", + "mobile": "رقم الجوال", + "email": "البريد الإلكتروني", + "qualification": "المؤهل", + "occupation": "المهنة", + "annualIncome": "الدخل السنوي", + "relation": "صلة القرابة", + "relationship": "صلة القرابة", + "currentAddress": "العنوان الحالي", + "permanentAddress": "العنوان الدائم", + "sameAsCurrentAddress": "نفس العنوان الحالي" + }, + "values": { + "yes": "نعم", + "no": "لا" + }, + "signature": { + "roleParentGuardian": "ولي الأمر / الوصي", + "roleStudent": "الطالب", + "rolePrincipal": "المدير", + "label": "توقيع {{role}}" + }, + "declaration": { + "title": "إقرار", + "text": "أُقر بأن جميع المعلومات المقدمة أعلاه صحيحة ودقيقة على حد علمي. وأتفهم أن أي معلومات غير صحيحة قد تؤدي إلى إلغاء الطلب / القبول." + }, + "footer": { + "generatedOn": "هذا مستند تم إنشاؤه بواسطة الحاسوب. تم إنشاؤه بتاريخ {{date}}." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsAssignCounsellorDialog.json b/frontend-admin-dashboard/public/locales/ar/admissionsAssignCounsellorDialog.json new file mode 100644 index 0000000000..821c76c816 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsAssignCounsellorDialog.json @@ -0,0 +1,17 @@ +{ + "title": "تعيين مستشار", + "description": "ابحث عن مستشار وحدده لتعيينه لهذا الاستفسار", + "searchLabel": "البحث عن مستشار", + "searchPlaceholder": "اكتب للبحث بالاسم...", + "searching": "جارٍ البحث...", + "noCounsellorsFound": "لم يتم العثور على مستشارين", + "selectedCounsellorLabel": "المستشار المحدد", + "cancel": "إلغاء", + "assign": "تعيين", + "assigning": "جارٍ التعيين...", + "toast": { + "assignSuccess": "تم تعيين المستشار بنجاح", + "assignFailed": "فشل تعيين المستشار", + "selectCounsellor": "يرجى اختيار مستشار" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsCounsellorNameCell.json b/frontend-admin-dashboard/public/locales/ar/admissionsCounsellorNameCell.json new file mode 100644 index 0000000000..c068cdcde6 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsCounsellorNameCell.json @@ -0,0 +1,5 @@ +{ + "assign": "تعيين", + "errorLoading": "خطأ في التحميل", + "unknown": "غير معروف" +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsCounsellorSettingsCard.json b/frontend-admin-dashboard/public/locales/ar/admissionsCounsellorSettingsCard.json new file mode 100644 index 0000000000..d6f2e18ca3 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsCounsellorSettingsCard.json @@ -0,0 +1,53 @@ +{ + "heading": "تخصيص المرشدين", + "subheading": "قم بتهيئة كيفية تعيين المرشدين للاستفسارات", + "autoAssign": { + "label": "تعيين تلقائي", + "desc": "تعيين الاستفسارات تلقائيًا إلى المرشدين" + }, + "allowParent": { + "label": "السماح لولي الأمر", + "desc": "السماح لأولياء الأمور باختيار المرشد المفضل لديهم" + }, + "assignmentStrategy": { + "label": "استراتيجية التعيين" + }, + "strategies": { + "roundRobin": { + "label": "التناوب الدوري", + "desc": "يوزّع بالتساوي بالتناوب" + }, + "random": { + "label": "عشوائي", + "desc": "يختار أي مرشد عشوائيًا" + }, + "weighted": { + "label": "موزون", + "desc": "المرشدون الأقدم يتعاملون مع عدد أكبر", + "disabledNote": "الأوزان لكل مرشد قريبًا" + }, + "performance": { + "label": "الأداء", + "desc": "يوجّه إلى المرشد الأعلى تحويلًا" + }, + "leastLoaded": { + "label": "الأقل تحميلاً", + "desc": "يعيّن إلى المرشد صاحب أقل عدد من العملاء النشطين" + } + }, + "advanced": { + "trigger": "متقدم", + "maxActiveLeads": { + "label": "الحد الأقصى للعملاء النشطين لكل مرشد", + "placeholder": "بلا حد", + "helper": "اتركه فارغًا لعدم وجود حد. ستذهب العملاء الجدد التي تتجاوز هذا الحد إلى المرشد المتاح التالي." + } + }, + "counsellors": { + "label": "المرشدون", + "optional": "(اختياري)", + "desc": "أضف معرّفات المرشدين لتجمع التعيين", + "inputPlaceholder": "أدخل معرّف المرشد", + "addButton": "إضافة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsCreateEnquiryDialog.json b/frontend-admin-dashboard/public/locales/ar/admissionsCreateEnquiryDialog.json new file mode 100644 index 0000000000..54026c6e11 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsCreateEnquiryDialog.json @@ -0,0 +1,3 @@ +{ + "heading": "إنشاء نموذج استفسار" +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsCustomEnquiryFieldsCard.json b/frontend-admin-dashboard/public/locales/ar/admissionsCustomEnquiryFieldsCard.json new file mode 100644 index 0000000000..b83661e761 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsCustomEnquiryFieldsCard.json @@ -0,0 +1,10 @@ +{ + "header": { + "title": "معلومات إضافية", + "description": "التفاصيل المطلوبة والاختيارية لهذا الاستفسار" + }, + "placeholder": { + "select": "اختر {{fieldName}}", + "enter": "أدخل {{fieldName}}" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsDocumentUploadSection.json b/frontend-admin-dashboard/public/locales/ar/admissionsDocumentUploadSection.json new file mode 100644 index 0000000000..42a5e946da --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsDocumentUploadSection.json @@ -0,0 +1,12 @@ +{ + "requiredDocuments": "المستندات المطلوبة", + "supportedFormats": "الصيغ المدعومة: JPG وPNG وPDF (بحد أقصى 2 ميجابايت)", + "upload": "رفع", + "documents": { + "photo": "صورة بحجم جواز السفر", + "birthCert": "شهادة الميلاد", + "aadhar": "بطاقة آدهار", + "tc": "شهادة النقل", + "reportCard": "بطاقة التقرير السابقة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsEnquiriesIndex.json b/frontend-admin-dashboard/public/locales/ar/admissionsEnquiriesIndex.json new file mode 100644 index 0000000000..adcb6a6fba --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsEnquiriesIndex.json @@ -0,0 +1,65 @@ +{ + "pageTitle": "الاستفسارات", + "selectEnquiryPlaceholder": "اختر استفسارًا", + "enquiryTypeLabel": "نوع الاستفسار: {{type}}", + "addNewEnquiryResponse": "إضافة رد استفسار جديد", + "copyFormLink": "نسخ رابط النموذج", + "copyLinkTitle": "نسخ رابط الاستفسار إلى الحافظة", + "walkInRegister": "تسجيل حضوري", + "walkInRegisterTitle": "فتح نموذج التسجيل الحضوري", + "toast": { + "linkCopied": "تم نسخ رابط الاستفسار إلى الحافظة!", + "copyFailed": "فشل نسخ الرابط" + }, + "filters": { + "status": "الحالة", + "source": "المصدر", + "dateRange": "النطاق الزمني", + "class": "الصف" + }, + "searchPlaceholder": "البحث بالاسم أو رقم الجوال...", + "sortBy": { + "placeholder": "الترتيب حسب...", + "defaultOrder": "الترتيب الافتراضي", + "dateSubmitted": "تاريخ الإرسال", + "leadScore": "نقاط العميل المحتمل", + "parentName": "اسم ولي الأمر" + }, + "showDuplicates": "إظهار التكرارات", + "clearAll": "مسح الكل", + "selectEnquiryPrompt": "يرجى اختيار استفسار لعرض الردود", + "createNewEnquiryForm": "إنشاء نموذج استفسار جديد", + "walkInRegistration": "التسجيل الحضوري", + "walkInRegistrationFormTitle": "نموذج التسجيل الحضوري", + "dateRanges": { + "today": "اليوم", + "last7Days": "آخر 7 أيام", + "last30Days": "آخر 30 يومًا", + "last3Months": "آخر 3 أشهر", + "last6Months": "آخر 6 أشهر", + "lastYear": "العام الماضي" + }, + "enquiryStatuses": { + "new": "جديد", + "contacted": "تم التواصل", + "notEligible": "غير مؤهل", + "qualified": "مؤهل", + "followUp": "متابعة", + "closed": "مغلق", + "converted": "تم التحويل", + "admitted": "تم القبول" + }, + "sourceTypes": { + "website": "الموقع الإلكتروني", + "googleAds": "إعلانات جوجل", + "facebook": "فيسبوك", + "instagram": "إنستغرام", + "referral": "إحالة", + "other": "أخرى" + }, + "leadTiers": { + "hot": "ساخن", + "warm": "دافئ", + "cold": "بارد" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryBulkImportDialog.json b/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryBulkImportDialog.json new file mode 100644 index 0000000000..9b1a364cc5 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryBulkImportDialog.json @@ -0,0 +1,63 @@ +{ + "dialog": { + "title": "استيراد ردود الاستفسارات بالجملة", + "description": "قم بتحميل ملف CSV، واختر الفصل اختياريًا، ثم عاين وأكد الاستيراد" + }, + "steps": { + "stepLabel": "الخطوة {{step}}" + }, + "upload": { + "templateHint": "قم بتنزيل قالب CSV وتحميل ردود المتعلمين المعبأة", + "downloadTemplateButton": "تنزيل القالب", + "dropzoneHint": "انقر لتحميل ملف `.csv`", + "validSummary": "الصفوف الصالحة: {{valid}} | الصفوف المتجاوزة: {{skipped}}", + "onlyCsvSupported": "يتم دعم ملفات .csv فقط", + "missingColumns_zero": "لا توجد أعمدة مطلوبة مفقودة: {{columns}}", + "missingColumns_one": "عمود مطلوب واحد مفقود: {{columns}}", + "missingColumns_two": "عمودان مطلوبان مفقودان: {{columns}}", + "missingColumns_few": "أعمدة مطلوبة مفقودة: {{columns}}", + "missingColumns_many": "أعمدة مطلوبة مفقودة كثيرة: {{columns}}", + "missingColumns_other": "أعمدة مطلوبة مفقودة: {{columns}}", + "parseFailed": "فشل تحليل ملف CSV" + }, + "classStep": { + "selectClassHint": "اختر الفصل (اختياري). إذا لم يتم التحديد، لن يتم إرسال الفصل ضمن البيانات.", + "noClassOption": "لم يتم تحديد فصل" + }, + "preview": { + "previewingRows_zero": "لا توجد صفوف صالحة للمعاينة ({{count}})", + "previewingRows_one": "معاينة صف صالح واحد ({{count}})", + "previewingRows_two": "معاينة صفين صالحين ({{count}})", + "previewingRows_few": "معاينة {{count}} صفوف صالحة", + "previewingRows_many": "معاينة {{count}} صفًا صالحًا", + "previewingRows_other": "معاينة {{count}} صف صالح" + }, + "columns": { + "studentName": "اسم الطالب", + "gender": "الجنس", + "dateOfBirth": "تاريخ الميلاد", + "parentName": "اسم ولي الأمر", + "parentEmail": "البريد الإلكتروني لولي الأمر", + "parentMobile": "رقم جوال ولي الأمر", + "relationWithChild": "صلة القرابة بالطفل", + "relation": "الصلة", + "status": "الحالة", + "source": "المصدر" + }, + "actions": { + "back": "رجوع", + "cancel": "إلغاء", + "next": "التالي", + "importing": "جارٍ الاستيراد...", + "confirmImport": "تأكيد الاستيراد" + }, + "toasts": { + "importResult_zero": "لم يتم استيراد أي استجابات استفسار ({{success}}) ({{failed}} فشلت)", + "importResult_one": "تم استيراد استجابة استفسار واحدة ({{success}}) ({{failed}} فشلت)", + "importResult_two": "تم استيراد استجابتي استفسار ({{success}}) ({{failed}} فشلت)", + "importResult_few": "تم استيراد {{success}} استجابات استفسار ({{failed}} فشلت)", + "importResult_many": "تم استيراد {{success}} استجابةً استفسار ({{failed}} فشلت)", + "importResult_other": "تم استيراد {{success}} استجابة استفسار ({{failed}} فشلت)", + "importFailed": "فشل استيراد الاستفسارات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryCustomFieldsCard.json b/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryCustomFieldsCard.json new file mode 100644 index 0000000000..f11aef9c9d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryCustomFieldsCard.json @@ -0,0 +1,10 @@ +{ + "header": { + "title": "حقول نموذج الاستفسار", + "description": "قم بتهيئة الحقول من إعدادات الحقول المخصصة" + }, + "emptyState": { + "title": "لا توجد حقول مخصصة مفعّلة لموقع \"الاستفسار\".", + "description": "انتقل إلى الإعدادات ← الحقول المخصصة لتفعيل الحقول الخاصة بموقع الاستفسار." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryDetails.json b/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryDetails.json new file mode 100644 index 0000000000..5d585e6c3d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryDetails.json @@ -0,0 +1,73 @@ +{ + "loading": "جارٍ تحميل تفاصيل الاستفسار…", + "loadError": "فشل تحميل تفاصيل الاستفسار.", + "duplicateBanner": { + "message": "هذا عميل محتمل مكرر.", + "primaryLabel": "الأساسي: {{id}}…" + }, + "header": { + "trackingId": "معرّف التتبع", + "copy": "نسخ", + "copiedToast": "تم نسخ معرّف التتبع!", + "enquiryStatus": "حالة الاستفسار", + "overallStatus": "الحالة العامة" + }, + "sections": { + "child": { + "title": "الطالب / الطفل", + "name": "الاسم", + "dob": "تاريخ الميلاد", + "gender": "الجنس", + "applyingForClass": "الصف المتقدَّم إليه", + "academicYear": "العام الدراسي", + "previousSchool": "المدرسة السابقة" + }, + "parent": { + "title": "ولي الأمر", + "name": "الاسم", + "email": "البريد الإلكتروني", + "phone": "الهاتف", + "address": "العنوان", + "city": "المدينة", + "pinCode": "الرمز البريدي" + }, + "enquiryInfo": { + "title": "معلومات الاستفسار", + "mode": "الوسيلة", + "referenceSource": "مصدر الإحالة", + "leadInterestScore": "درجة اهتمام العميل المحتمل", + "feeRangeExpectation": "نطاق الرسوم المتوقع", + "transportRequirement": "الحاجة إلى النقل", + "currentStage": "المرحلة الحالية", + "createdAt": "تاريخ الإنشاء", + "updatedAt": "تاريخ التحديث", + "notes": "ملاحظات" + }, + "campaign": { + "title": "الاستفسار", + "enquiry": "الاستفسار", + "source": "المصدر", + "classLevel": "الصف / المستوى", + "session": "الجلسة" + }, + "applicationStatus": { + "title": "حالة الطلب", + "applied": "تم التقديم", + "applicantId": "معرّف المتقدم", + "assignedCounselor": "المرشد المخصص", + "yes": "نعم", + "no": "لا" + }, + "customFields": { + "title": "حقول مخصصة" + } + }, + "status": { + "NEW": "جديد", + "CONTACTED": "تم التواصل", + "QUALIFIED": "مؤهَّل", + "NOT_ELIGIBLE": "غير مؤهَّل", + "ENQUIRY": "استفسار", + "APPLICATION": "طلب التحاق" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsEnquirySchema.json b/frontend-admin-dashboard/public/locales/ar/admissionsEnquirySchema.json new file mode 100644 index 0000000000..14e5fc2017 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsEnquirySchema.json @@ -0,0 +1,10 @@ +{ + "validation": { + "campaignNameRequired": "اسم الاستفسار مطلوب", + "campaignNameMinLength": "يجب أن يتكون الاسم من 3 أحرف على الأقل", + "campaignTypeRequired": "نوع الاستفسار مطلوب", + "sessionRequired": "الجلسة مطلوبة", + "startDateRequired": "تاريخ البدء مطلوب", + "endDateRequired": "تاريخ الانتهاء مطلوب" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsEnquirySearchModal.json b/frontend-admin-dashboard/public/locales/ar/admissionsEnquirySearchModal.json new file mode 100644 index 0000000000..e42bb6f80a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsEnquirySearchModal.json @@ -0,0 +1,43 @@ +{ + "title": "بحث عن استفسار", + "filters": { + "nameLabel": "اسم الطالب / ولي الأمر", + "namePlaceholder": "مثال: جون دو", + "phoneLabel": "رقم الهاتف", + "phonePlaceholder": "مثال: 9876543210", + "trackingIdLabel": "رقم التتبع", + "trackingIdPlaceholder": "مثال: A9KQ2" + }, + "actions": { + "searching": "جارٍ البحث...", + "searchEnquiries": "البحث عن الاستفسارات", + "apply": "تقديم طلب", + "applied": "تم التقديم", + "admit": "قبول", + "admitted": "مقبول" + }, + "table": { + "headers": { + "trackingId": "رقم التتبع", + "studentName": "اسم الطالب", + "parentName": "اسم ولي الأمر", + "phone": "الهاتف", + "status": "الحالة", + "application": "الطلب", + "admission": "القبول" + }, + "empty": { + "noResults": "لم يتم العثور على استفسارات مطابقة لبحثك.", + "prompt": "أدخل معايير البحث وانقر فوق بحث للعثور على الاستفسارات." + } + }, + "statusBadge": { + "enquiry": "استفسار", + "applied": "تم التقديم", + "admitted": "مقبول" + }, + "toast": { + "enterAtLeastOneCriteria": "يرجى إدخال معيار بحث واحد على الأقل", + "searchFailed": "فشل البحث عن الاستفسارات. يرجى المحاولة مرة أخرى." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsEnquirySidebar.json b/frontend-admin-dashboard/public/locales/ar/admissionsEnquirySidebar.json new file mode 100644 index 0000000000..c1d75a1768 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsEnquirySidebar.json @@ -0,0 +1,3 @@ +{ + "title": "تفاصيل الاستفسار" +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryTable.json b/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryTable.json new file mode 100644 index 0000000000..f95c377b29 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryTable.json @@ -0,0 +1,72 @@ +{ + "toast": { + "bulkUpdateSuccess_zero": "لم يتم تحديث أي استفسارات ({{count}})", + "bulkUpdateSuccess_one": "تم تحديث استفسار واحد بنجاح ({{count}})", + "bulkUpdateSuccess_two": "تم تحديث استفسارين بنجاح ({{count}})", + "bulkUpdateSuccess_few": "تم تحديث {{count}} استفسارات بنجاح", + "bulkUpdateSuccess_many": "تم تحديث {{count}} استفسارًا بنجاح", + "bulkUpdateSuccess_other": "تم تحديث {{count}} استفسار بنجاح", + "bulkUpdateError": "فشل تحديث حالات الاستفسارات", + "selectAtLeastOneStatus": "يرجى تحديد حالة واحدة على الأقل للتحديث", + "downloadStarting": "جارٍ بدء التنزيل...", + "noDataToDownload": "لا توجد بيانات للتنزيل", + "downloadSuccess": "اكتمل التنزيل بنجاح", + "downloadError": "فشل تنزيل البيانات" + }, + "loading": { + "enquiryResponses": "جارٍ تحميل ردود الاستفسارات..." + }, + "error": { + "loadingEnquiryResponses": "حدث خطأ أثناء تحميل ردود الاستفسارات" + }, + "empty": { + "noResponses": "لا توجد ردود لهذا الاستفسار بعد!", + "addNewEnquiryResponse": "إضافة رد استفسار جديد" + }, + "header": { + "totalResponses": "إجمالي الردود:", + "rowsSelected_zero": "لا توجد صفوف محددة ({{count}})", + "rowsSelected_one": "صف واحد محدد ({{count}})", + "rowsSelected_two": "صفان محددان ({{count}})", + "rowsSelected_few": "{{count}} صفوف محددة", + "rowsSelected_many": "{{count}} صفًا محددًا", + "rowsSelected_other": "{{count}} صف محدد", + "bulkImport": "استيراد جماعي", + "downloadCsv": "تنزيل ملف CSV", + "downloading": "جارٍ التنزيل..." + }, + "pagination": { + "previous": "السابق", + "next": "التالي", + "pageOf": "صفحة {{page}} من {{total}}" + }, + "bulkActionBar": { + "selectedLabel": "محدد", + "enquiryStatusLabel": "حالة الاستفسار", + "conversionLabel": "التحويل", + "selectPlaceholder": "اختر…", + "apply": "تطبيق", + "applying": "جارٍ التطبيق…", + "clear": "مسح" + }, + "csv": { + "headers": { + "enquiryId": "معرّف الاستفسار", + "class": "الصف", + "studentName": "اسم الطالب", + "gender": "الجنس", + "dateOfBirth": "تاريخ الميلاد", + "parentName": "اسم ولي الأمر", + "parentEmail": "البريد الإلكتروني لولي الأمر", + "parentMobile": "رقم جوال ولي الأمر", + "trackingId": "معرّف التتبع", + "status": "الحالة", + "source": "المصدر", + "counsellor": "المستشار", + "submittedAt": "تاريخ الإرسال" + }, + "assigned": "مُسنَد", + "notAssigned": "غير مُسنَد", + "filenameFallback": "الاستفسارات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryTableColumns.json b/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryTableColumns.json new file mode 100644 index 0000000000..e38fc6d744 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsEnquiryTableColumns.json @@ -0,0 +1,45 @@ +{ + "aria": { + "selectAll": "تحديد الكل", + "selectRow": "تحديد الصف", + "openMenu": "فتح القائمة" + }, + "columns": { + "serialNumber": "الرقم التسلسلي", + "class": "الصف", + "studentName": "اسم {{learnerLabel}}", + "gender": "الجنس", + "dateOfBirth": "تاريخ الميلاد", + "parentName": "اسم ولي الأمر", + "parentEmail": "البريد الإلكتروني لولي الأمر", + "parentMobile": "رقم جوال ولي الأمر", + "trackingId": "رقم التتبع", + "status": "الحالة", + "source": "المصدر", + "counsellor": "المرشد", + "leadInterestScore": "درجة اهتمام العميل المحتمل", + "actions": "الإجراءات" + }, + "status": { + "new": "جديد", + "contacted": "تم التواصل", + "qualified": "مؤهل", + "notEligible": "غير مؤهل", + "followUp": "متابعة", + "closed": "مغلق", + "converted": "محوَّل", + "admitted": "مقبول" + }, + "source": { + "website": "الموقع الإلكتروني", + "googleAds": "إعلانات جوجل", + "facebook": "فيسبوك", + "instagram": "إنستغرام", + "referral": "إحالة", + "other": "أخرى" + }, + "actionsMenu": { + "viewDetails": "عرض التفاصيل", + "activityLog": "سجل النشاط" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsIndexLazy.json b/frontend-admin-dashboard/public/locales/ar/admissionsIndexLazy.json new file mode 100644 index 0000000000..80c570615b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsIndexLazy.json @@ -0,0 +1,3 @@ +{ + "placeholder": "مرحبًا /admissions/!" +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsNewEnquiryAudienceIdIndex.json b/frontend-admin-dashboard/public/locales/ar/admissionsNewEnquiryAudienceIdIndex.json new file mode 100644 index 0000000000..96afe552d3 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsNewEnquiryAudienceIdIndex.json @@ -0,0 +1,101 @@ +{ + "header": { + "title": "إضافة استفسار جديد", + "sessionLabel": "الدورة: {{campaignName}}", + "unknownSession": "غير معروف" + }, + "studentCard": { + "title": "معلومات الطالب", + "description": "أدخل البيانات الأساسية للطالب", + "fullNameLabel": "الاسم الكامل", + "fullNamePlaceholder": "أدخل الاسم الكامل للطالب", + "dobLabel": "تاريخ الميلاد", + "genderLabel": "الجنس", + "genderPlaceholder": "اختر الجنس", + "genderMale": "ذكر", + "genderFemale": "أنثى", + "genderOther": "آخر" + }, + "classCard": { + "title": "الصف", + "description": "اختر الصف لهذا الاستفسار", + "label": "الصف", + "placeholder": "اختر الصف" + }, + "parentCard": { + "title": "معلومات ولي الأمر/الوصي", + "description": "بيانات ولي الأمر (سيُستخدم العنوان للطالب أيضًا)", + "nameLabel": "اسم ولي الأمر", + "namePlaceholder": "أدخل اسم ولي الأمر", + "emailLabel": "البريد الإلكتروني لولي الأمر", + "emailPlaceholder": "أدخل البريد الإلكتروني لولي الأمر", + "emailInvalid": "أدخل عنوان بريد إلكتروني صالح", + "mobileLabel": "رقم جوال ولي الأمر", + "mobilePlaceholder": "أدخل رقم جوال ولي الأمر", + "relationLabel": "صلة القرابة بالطالب", + "relationPlaceholder": "اختر صلة القرابة", + "relationFather": "الأب", + "relationMother": "الأم", + "relationGuardian": "الوصي", + "addressLabel": "العنوان", + "addressPlaceholder": "أدخل العنوان الكامل", + "cityLabel": "المدينة", + "cityPlaceholder": "أدخل المدينة", + "regionLabel": "الولاية/المنطقة", + "regionPlaceholder": "أدخل الولاية/المنطقة", + "pinCodeLabel": "الرمز البريدي", + "pinCodePlaceholder": "أدخل الرمز البريدي" + }, + "enquiryCard": { + "title": "تفاصيل الاستفسار", + "description": "معلومات إضافية للتتبع والتفضيلات", + "statusLabel": "حالة الاستفسار", + "statusNew": "جديد", + "statusContacted": "تم التواصل", + "statusQualified": "مؤهل", + "statusNotEligible": "غير مؤهل", + "sourceLabel": "نوع المصدر", + "sourceWebsite": "الموقع الإلكتروني", + "sourceGoogleAds": "إعلانات جوجل", + "sourceFacebook": "فيسبوك", + "sourceInstagram": "إنستغرام", + "sourceReferral": "إحالة", + "sourceOther": "آخر", + "referenceLabel": "مصدر الإحالة", + "referencePlaceholder": "مثال: اسم صديق، حملة إعلانية", + "modeLabel": "النمط", + "modeOnline": "عبر الإنترنت", + "modeOffline": "حضوري", + "feeLabel": "التوقع لنطاق الرسوم", + "feePlaceholder": "مثال: 50000-100000", + "transportLabel": "الحاجة للنقل", + "transportPlaceholder": "اختر الحاجة للنقل", + "transportYes": "نعم - مطلوب", + "transportNo": "لا - غير مطلوب", + "transportOptional": "اختياري", + "counsellorSearchLabel": "البحث عن مرشد", + "counsellorSearchPlaceholder": "اكتب للبحث بالاسم...", + "searching": "جارٍ البحث...", + "noCounsellorsFound": "لم يتم العثور على مرشدين مطابقين لـ \"{{query}}\"", + "assignedCounsellor": "المرشد المعيّن", + "notesLabel": "ملاحظات", + "notesPlaceholder": "أدخل أي ملاحظات أو تعليقات إضافية" + }, + "actions": { + "cancel": "إلغاء", + "save": "حفظ", + "saving": "جارٍ الحفظ..." + }, + "toasts": { + "submitSuccess": "تم إرسال الاستفسار بنجاح!", + "submitSuccessDescription": "رقم الاستفسار: {{enquiryId}}", + "submitFailed": "فشل إرسال الاستفسار", + "childNameRequired": "اسم الطالب مطلوب", + "parentNameRequired": "اسم ولي الأمر مطلوب", + "parentEmailRequired": "البريد الإلكتروني لولي الأمر مطلوب", + "invalidEmail": "يرجى إدخال عنوان بريد إلكتروني صالح", + "parentMobileRequired": "رقم جوال ولي الأمر مطلوب", + "invalidPhone": "يرجى إدخال رقم هاتف صالح للدولة المختارة", + "relationRequired": "صلة القرابة بالطالب مطلوبة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsNewEnquiryIndex.json b/frontend-admin-dashboard/public/locales/ar/admissionsNewEnquiryIndex.json new file mode 100644 index 0000000000..662fafd587 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsNewEnquiryIndex.json @@ -0,0 +1,3 @@ +{ + "placeholder": "مرحبًا \"/admissions/new-enquiry/\"!" +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsParentGuardianSection.json b/frontend-admin-dashboard/public/locales/ar/admissionsParentGuardianSection.json new file mode 100644 index 0000000000..332ab56612 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsParentGuardianSection.json @@ -0,0 +1,17 @@ +{ + "tabsAriaLabel": "علامات التبويب", + "tabFatherDetails": "بيانات الأب", + "tabMotherDetails": "بيانات الأم", + "tabGuardianDetails": "بيانات ولي الأمر", + "roleFather": "الأب", + "roleMother": "الأم", + "roleGuardian": "ولي الأمر", + "selectPlaceholder": "اختر", + "fullNameLabel": "الاسم الكامل", + "fullNamePlaceholder": "الاسم الكامل لـ{{title}}", + "mobileNumberLabel": "رقم الجوال", + "mobileNumberPlaceholder": "أدخل رقم الجوال", + "emailAddressLabel": "البريد الإلكتروني", + "emailPlaceholderExample": "example@email.com", + "emailValidationError": "أدخل بريدًا إلكترونيًا صالحًا" +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsParentTypeModal.json b/frontend-admin-dashboard/public/locales/ar/admissionsParentTypeModal.json new file mode 100644 index 0000000000..f2304459cd --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsParentTypeModal.json @@ -0,0 +1,8 @@ +{ + "title": "اختر نوع الوالد", + "description": "حدد ما إذا كانت بيانات الاتصال تخص الأب أم الأم", + "father": "الأب", + "fatherDescription": "ربط البيانات بمعلومات الأب", + "mother": "الأم", + "motherDescription": "ربط البيانات بمعلومات الأم" +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsPaymentSection.json b/frontend-admin-dashboard/public/locales/ar/admissionsPaymentSection.json new file mode 100644 index 0000000000..3792433e2d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsPaymentSection.json @@ -0,0 +1,130 @@ +{ + "common": { + "notAvailable": "غير متاح", + "remove": "إزالة", + "generateButton": "إنشاء", + "confirmPaymentButton": "تأكيد الدفع", + "recordingEllipsis": "جارٍ التسجيل…", + "optionalLabel": "(اختياري)" + }, + "defaults": { + "feeName": "رسوم التقديم / التسجيل", + "recipientName": "ولي الأمر" + }, + "paymentMethodLabel": { + "online": "الدفع الإلكتروني", + "upi": "الدفع عبر UPI" + }, + "paymentModeLabels": { + "upi": "UPI", + "cash": "نقدًا", + "card": "بطاقة", + "cheque": "شيك", + "online": "إلكتروني", + "sendLink": "رابط الدفع", + "manual": "يدوي" + }, + "upiDeepLink": { + "paymentNote": "دفعة {{feeName}}" + }, + "toast": { + "linkRequiresApplication": "يرجى تقديم الطلب أولاً لإنشاء رابط دفع.", + "paymentOptionNotConfigured": "لم يتم تكوين خيار الدفع.", + "linkCopied": "تم نسخ رابط {{method}} إلى الحافظة!", + "upiNotConfigured": "لم يتم تكوين معرّف UPI لمرحلة الدفع هذه.", + "upiLinkCopied": "تم نسخ رابط UPI! أرسله لولي الأمر لفتحه في أي تطبيق UPI.", + "linkEmailSent": "تم إرسال رابط الدفع بالبريد الإلكتروني بنجاح", + "linkEmailFailed": "فشل إرسال البريد الإلكتروني. يرجى المحاولة مرة أخرى.", + "proofUploaded": "تم رفع الإثبات", + "proofUploadFailed": "فشل رفع الإثبات", + "applicationNotSubmitted": "لم يتم تقديم الطلب بعد. يرجى تقديم النموذج أولاً.", + "enterTransactionId": "يرجى إدخال أو إنشاء رقم المعاملة", + "paymentRecorded": "تم تسجيل الدفعة بنجاح!", + "paymentRecordFailed": "فشل تسجيل الدفعة. يرجى المحاولة مرة أخرى." + }, + "paid": { + "title": "تم استلام الدفعة", + "summaryLine": "{{amount}} • الطريقة: {{mode}} • التاريخ: {{date}}", + "todayFallback": "اليوم", + "refLabel": "المرجع: {{transactionId}}", + "undoButton": "↩ تراجع — وضع علامة غير مدفوع" + }, + "receipt": { + "receiptNoLabel": "رقم الإيصال", + "dateLabel": "التاريخ", + "studentLabel": "الطالب", + "feeDescriptionLabel": "وصف الرسوم", + "paymentModeLabel": "طريقة الدفع", + "transactionIdLabel": "رقم المعاملة", + "amountPaidLabel": "المبلغ المدفوع", + "downloadButton": "تنزيل الإيصال" + }, + "header": { + "title": "أكمل عملية الدفع", + "subtitle": "رسوم لمرة واحدة لتقديم الطلب" + }, + "invoice": { + "title": "دفع الرسوم", + "gstLabel": "ضريبة السلع والخدمات", + "processingChargeLabel": "رسوم المعالجة", + "waivedLabel": "معفاة", + "totalDueLabel": "إجمالي المبلغ المستحق" + }, + "tabs": { + "payNow": "ادفع الآن", + "generateLink": "إنشاء رابط" + }, + "methods": { + "sectionTitle": "ادفع الآن", + "upi": { + "label": "UPI / رمز QR", + "desc": "امسح وادفع فورًا" + }, + "cash": { + "label": "تم استلام النقد", + "desc": "تسجيل دفعة نقدية" + } + }, + "cashPanel": { + "title": "دفع نقدي", + "amountToReceiveLabel": "المبلغ المطلوب استلامه", + "proofLabel": "إثبات الدفع", + "proofHintCheque": "صورة الشيك", + "proofHintReceipt": "إيصال / لقطة شاشة", + "uploadProofButton": "📎 رفع الإيصال / لقطة الشاشة", + "txnLabelCheque": "رقم الشيك / الحوالة المصرفية", + "txnLabelReceipt": "رقم المعاملة / الإيصال", + "placeholderCheque": "رقم الشيك / الحوالة المصرفية", + "placeholderCash": "إنشاء تلقائي أو إدخال رقم إيصال", + "placeholderDefault": "أدخل رقم المعاملة" + }, + "upiPanel": { + "title": "الدفع عبر UPI", + "clickToEnlargeTitle": "انقر للتكبير", + "openHoverLabel": "فتح", + "openLargeQrTitle": "فتح رمز QR الكبير", + "qrCodeLabel": "رمز QR", + "notConfiguredLabel": "غير مُهيأ", + "proofLabel": "صورة الدفع", + "uploadImageButton": "رفع صورة", + "transactionIdLabel": "رقم المعاملة", + "transactionIdPlaceholder": "مثال: 312345678901" + }, + "linkTab": { + "title": "إنشاء رابط دفع للسداد", + "subtitle": "شارك رابطًا مع أولياء الأمور لإتمام الدفع من أجهزتهم", + "onlineLinkButton": "رابط إلكتروني", + "upiAppLinkButton": "رابط تطبيق UPI", + "configureUpiTitle": "قم بتكوين معرّف UPI في إعدادات المرحلة", + "scanQrLabel": "امسح رمز QR لفتح رابط الدفع", + "sendViaEmailButton": "إرسال الرابط عبر البريد الإلكتروني", + "emailPlaceholder": "parent@example.com", + "sendingEllipsis": "جارٍ الإرسال...", + "sendButton": "إرسال" + }, + "qrDialog": { + "scanToPayLabel": "امسح للدفع — {{amount}}", + "upiIdLabel": "معرّف UPI: {{upiId}}", + "closeHint": "انقر خارج النافذة أو اضغط Escape للإغلاق" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsPipelineUsersTable.json b/frontend-admin-dashboard/public/locales/ar/admissionsPipelineUsersTable.json new file mode 100644 index 0000000000..8c356f004e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsPipelineUsersTable.json @@ -0,0 +1,23 @@ +{ + "title": "الطلاب حسب المرحلة", + "stages": { + "ENQUIRY": "استفسار", + "APPLICATION": "طلب التحاق", + "ADMITTED": "مقبول" + }, + "emptyState": "لا يوجد طلاب في مرحلة {{stage}}", + "columns": { + "index": "#", + "studentName": "اسم الطالب", + "parentName": "اسم ولي الأمر", + "source": "المصدر", + "date": "التاريخ", + "stage": "المرحلة" + }, + "pagination": { + "showing": "عرض {{from}}–{{to}} من {{total}}", + "pageOf": "صفحة {{current}} من {{total}}", + "previous": "السابق", + "next": "التالي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsRegistrationFormPage.json b/frontend-admin-dashboard/public/locales/ar/admissionsRegistrationFormPage.json new file mode 100644 index 0000000000..4475fad0f3 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsRegistrationFormPage.json @@ -0,0 +1,67 @@ +{ + "pageTitle": "طلب التحاق جديد", + "sectionHasErrors": "توجد أخطاء", + "stepProgress": "الخطوة {{current}} من {{total}}", + "sections": { + "student": { + "label": "بيانات الطالب", + "shortLabel": "الطالب" + }, + "academic": { + "label": "المعلومات الأكاديمية", + "shortLabel": "أكاديمي" + }, + "parent": { + "label": "ولي الأمر والوصي", + "shortLabel": "ولي الأمر" + }, + "address": { + "label": "العنوان", + "shortLabel": "العنوان" + }, + "payment": { + "label": "الدفع", + "shortLabel": "الدفع" + } + }, + "buttons": { + "downloadPdf": "تنزيل PDF", + "generatingPdf": "جارٍ الإنشاء...", + "print": "طباعة", + "previous": "السابق", + "next": "التالي", + "submitApplication": "إرسال الطلب", + "backToList": "العودة إلى القائمة" + }, + "toasts": { + "pdfTemplateNotReady": "قالب PDF غير جاهز. يرجى المحاولة مرة أخرى.", + "generatingPdf": "جارٍ إنشاء ملف PDF...", + "pdfDownloaded": "تم تنزيل ملف PDF!", + "pdfGenerationFailed": "فشل إنشاء ملف PDF. يرجى المحاولة مرة أخرى.", + "popupBlocked": "تم حظر النافذة المنبثقة. يرجى السماح بالنوافذ المنبثقة للطباعة.", + "submitFailed": "فشل إرسال الطلب. يرجى المحاولة مرة أخرى." + }, + "validation": { + "studentNameRequired": "اسم الطالب مطلوب", + "dobRequired": "تاريخ الميلاد مطلوب", + "genderRequired": "الجنس مطلوب", + "nationalityRequired": "الجنسية مطلوبة", + "categoryRequired": "الفئة مطلوبة", + "classRequired": "يرجى اختيار الصف/المرحلة من قسم المعلومات الأكاديمية", + "boardRequired": "تفضيل المجلس التعليمي مطلوب", + "parentRequired": "يلزم توفير بيانات أحد الوالدين (الأب أو الأم) مع الاسم ورقم الجوال", + "fatherEmailInvalid": "البريد الإلكتروني للأب غير صحيح", + "motherEmailInvalid": "البريد الإلكتروني للأم غير صحيح", + "addressFieldsRequired": "يرجى تعبئة جميع حقول العنوان المطلوبة (الشارع، المنطقة، المدينة، الولاية)", + "countryRequired": "الدولة مطلوبة", + "pincodeInvalid": "يرجى إدخال رمز بريدي صحيح مكون من 6 أرقام", + "sessionMissing": "معرّف الجلسة مفقود. يرجى اختيار جلسة من قائمة التسجيل" + }, + "confirmSubmit": "هل أنت متأكد من رغبتك في إرسال هذا الطلب؟ يرجى مراجعة جميع التفاصيل قبل المتابعة.", + "print": { + "documentTitle": "استمارة الطلب - {{name}}" + }, + "payment": { + "defaultFeeName": "رسوم الطلب / التسجيل" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsRegistrationListPage.json b/frontend-admin-dashboard/public/locales/ar/admissionsRegistrationListPage.json new file mode 100644 index 0000000000..f0a67b766b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsRegistrationListPage.json @@ -0,0 +1,68 @@ +{ + "pageTitle": "الطلبات - القبول", + "navHeading": "إدارة الطلبات", + "search": { + "placeholder": "البحث برقم التتبع أو الاسم..." + }, + "filters": { + "classLabel": "الصف", + "statusLabel": "الحالة" + }, + "session": { + "placeholder": "اختر الفصل الدراسي" + }, + "buttons": { + "newApplication": "طلب جديد", + "bulkImport": "استيراد جماعي", + "previous": "السابق", + "next": "التالي" + }, + "table": { + "loading": "جارٍ تحميل الطلبات...", + "empty": { + "title": "لم يتم العثور على طلبات", + "subtitle": "حاول تعديل عوامل التصفية" + }, + "headers": { + "trackingId": "رقم التتبع", + "studentName": "اسم الطالب", + "class": "الصف", + "parentName": "اسم ولي الأمر", + "mobile": "الجوال", + "date": "التاريخ", + "status": "الحالة", + "actions": "الإجراءات" + } + }, + "status": { + "submitted": "تم الإرسال", + "admitted": "تم القبول", + "approved": "معتمد", + "pending": "قيد الانتظار", + "rejected": "مرفوض", + "admissionCompleted": "مكتمل" + }, + "pagination": { + "showing_zero": "عرض {{from}} إلى {{to}} من أصل {{count}} إدخال", + "showing_one": "عرض {{from}} إلى {{to}} من أصل إدخال واحد ({{count}})", + "showing_two": "عرض {{from}} إلى {{to}} من أصل إدخالين ({{count}})", + "showing_few": "عرض {{from}} إلى {{to}} من أصل {{count}} إدخالات", + "showing_many": "عرض {{from}} إلى {{to}} من أصل {{count}} إدخالًا", + "showing_other": "عرض {{from}} إلى {{to}} من أصل {{count}} إدخال" + }, + "modal": { + "title": "اختر نوع الطلب", + "subtitle": "اختر الطريقة التي تريد بها إنشاء طلب جديد", + "newApplication": { + "title": "طلب جديد", + "description": "ابدأ نموذج طلب جديد" + }, + "fromEnquiry": { + "title": "من استفسار", + "description": "إنشاء طلب من استفسار موجود" + } + }, + "toast": { + "enquiryAlreadyConverted": "تم بالفعل تحويل هذا الاستفسار إلى طلب." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsStep1StudentDetails.json b/frontend-admin-dashboard/public/locales/ar/admissionsStep1StudentDetails.json new file mode 100644 index 0000000000..276c54e42d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsStep1StudentDetails.json @@ -0,0 +1,66 @@ +{ + "heading": "بيانات الطالب", + "studentFirstName": { + "label": "الاسم الأول للطالب", + "placeholder": "أدخل الاسم الأول" + }, + "middleName": { + "label": "الاسم الأوسط", + "placeholder": "أدخل الاسم الأوسط" + }, + "lastName": { + "label": "اسم العائلة", + "placeholder": "أدخل اسم العائلة" + }, + "gender": { + "label": "الجنس", + "select": "اختر الجنس", + "male": "ذكر", + "female": "أنثى", + "other": "آخر" + }, + "applicationNumber": { + "label": "رقم الطلب", + "placeholder": "يُنشأ تلقائيًا / قابل للتعديل" + }, + "class": { + "label": "الصف", + "select": "اختر الصف" + }, + "section": { + "label": "الشعبة", + "select": "اختر الشعبة" + }, + "dateOfAdmission": { + "label": "تاريخ القبول" + }, + "dateOfBirth": { + "label": "تاريخ الميلاد" + }, + "residentialPhone": { + "label": "رقم هاتف السكن" + }, + "studentType": { + "label": "نوع الطالب", + "select": "اختر النوع", + "regular": "منتظم", + "transfer": "منقول" + }, + "admissionType": { + "label": "نوع القبول", + "select": "اختر النوع", + "dayScholar": "طالب غير مقيم", + "hostel": "سكن داخلي" + }, + "transport": { + "label": "الحاجة إلى النقل", + "no": "لا", + "yes": "نعم" + }, + "aadhaarType": { + "label": "نوع بطاقة آدهار الخاصة بالطالب", + "select": "اختر النوع", + "standard": "قياسي", + "temporary": "مؤقت" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsStep2PreviousSchool.json b/frontend-admin-dashboard/public/locales/ar/admissionsStep2PreviousSchool.json new file mode 100644 index 0000000000..6d55555257 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsStep2PreviousSchool.json @@ -0,0 +1,71 @@ +{ + "previousSchool": { + "heading": "تفاصيل المدرسة السابقة", + "schoolName": { + "label": "اسم المدرسة", + "placeholder": "أدخل اسم المدرسة السابقة" + }, + "previousClass": { + "label": "الصف السابق", + "placeholder": "اختر الصف" + }, + "board": { + "label": "المجلس التعليمي", + "placeholder": "اختر المجلس التعليمي" + }, + "yearOfPassing": { + "label": "سنة النجاح", + "placeholder": "YYYY" + }, + "percentage": { + "label": "النسبة المئوية", + "placeholder": "%" + }, + "percentageScience": { + "label": "النسبة المئوية في العلوم", + "placeholder": "%" + }, + "percentageMaths": { + "label": "النسبة المئوية في الرياضيات", + "placeholder": "%" + }, + "previousAdmissionNo": { + "label": "رقم القبول السابق", + "placeholder": "أدخل الرقم" + } + }, + "otherDetails": { + "heading": "تفاصيل أخرى", + "religion": { + "label": "الديانة", + "placeholder": "أدخل الديانة" + }, + "caste": { + "label": "الطائفة", + "placeholder": "أدخل الطائفة" + }, + "motherTongue": { + "label": "اللغة الأم", + "placeholder": "أدخل اللغة الأم" + }, + "bloodGroup": { + "label": "فصيلة الدم", + "placeholder": "اختر الفصيلة" + }, + "nationality": { + "label": "الجنسية", + "placeholder": "مثال: هندي" + }, + "howDidYouKnow": { + "label": "كيف تعرفت علينا", + "placeholder": "اختر خيارًا", + "options": { + "socialMedia": "وسائل التواصل الاجتماعي", + "friendsFamily": "الأصدقاء/العائلة", + "advertisement": "إعلان", + "website": "الموقع الإلكتروني", + "other": "أخرى" + } + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsStep3ParentDetails.json b/frontend-admin-dashboard/public/locales/ar/admissionsStep3ParentDetails.json new file mode 100644 index 0000000000..1ea0f1cccb --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsStep3ParentDetails.json @@ -0,0 +1,65 @@ +{ + "father": { + "sectionLabel": "الأب", + "sectionHeading": "التفاصيل", + "name": { + "label": "اسم الأب", + "placeholder": "أدخل اسم الأب" + }, + "mobile": { + "label": "رقم الجوال" + }, + "email": { + "label": "البريد الإلكتروني", + "placeholder": "أدخل البريد الإلكتروني" + }, + "aadhaar": { + "label": "رقم آدهار" + }, + "qualification": { + "label": "المؤهل الدراسي", + "placeholder": "أدخل المؤهل الدراسي" + }, + "occupation": { + "label": "المهنة", + "placeholder": "أدخل المهنة" + } + }, + "mother": { + "sectionLabel": "الأم", + "sectionHeading": "التفاصيل", + "name": { + "label": "اسم الأم", + "placeholder": "أدخل اسم الأم" + }, + "mobile": { + "label": "رقم الجوال" + }, + "email": { + "label": "البريد الإلكتروني", + "placeholder": "أدخل البريد الإلكتروني" + }, + "aadhaar": { + "label": "رقم آدهار" + }, + "qualification": { + "label": "المؤهل الدراسي", + "placeholder": "أدخل المؤهل الدراسي" + }, + "occupation": { + "label": "المهنة", + "placeholder": "أدخل المهنة" + } + }, + "guardian": { + "sectionLabel": "ولي الأمر", + "sectionHeading": "التفاصيل", + "name": { + "label": "اسم ولي الأمر", + "placeholder": "أدخل اسم ولي الأمر" + }, + "mobile": { + "label": "رقم جوال ولي الأمر" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsStep4AddressDetails.json b/frontend-admin-dashboard/public/locales/ar/admissionsStep4AddressDetails.json new file mode 100644 index 0000000000..4db9fbd229 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsStep4AddressDetails.json @@ -0,0 +1,20 @@ +{ + "current": { + "heading": "العنوان الحالي" + }, + "address": { + "label": "العنوان", + "placeholder": "أدخل العنوان الكامل" + }, + "locality": { + "label": "أقرب حي", + "placeholder": "أدخل الحي/العلامة المميزة" + }, + "sameAsPermanent": { + "label": "العنوان الحالي نفس العنوان الدائم" + }, + "permanent": { + "heading": "العنوان الدائم", + "addressPlaceholder": "أدخل العنوان الدائم الكامل" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsStep5AFeeAssignment.json b/frontend-admin-dashboard/public/locales/ar/admissionsStep5AFeeAssignment.json new file mode 100644 index 0000000000..cc9af993e0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsStep5AFeeAssignment.json @@ -0,0 +1,88 @@ +{ + "dash": "-", + "selectPrompt": "اختر خطة رسوم لعرض التفاصيل.", + "emptyState": { + "selectPackage": "اختر جلسة الباقة في الخطوة 1 لعرض خطط الرسوم." + }, + "success": { + "title": "اكتمل التسجيل", + "messageNamed": "تم تسجيل {{name}} في خطة الرسوم المختارة.", + "messageGeneric": "تم تسجيل الطالب في خطة الرسوم المختارة.", + "planLabel": "الخطة: {{name}}", + "totalAssignedLabel": "إجمالي المبلغ المخصص: {{amount}}", + "paymentModeLabel": "طريقة الدفع: غير متصل", + "goToAdmissions": "الذهاب إلى القبول" + }, + "header": { + "title": "تعيين خطة الرسوم", + "subtitle": "تم الجلب من CPO المرتبط بجلسة الباقة المختارة.", + "refresh": "تحديث" + }, + "status": { + "loadingFeePlans": "جارٍ تحميل خطط الرسوم...", + "loadError": "فشل تحميل خيارات CPO. يرجى إعادة المحاولة.", + "noFeePlans": "لم يتم العثور على خطط رسوم لجلسة الباقة هذه." + }, + "cpoCard": { + "defaultPaymentOption": "خيار الدفع الافتراضي:", + "available": "متاح", + "notSet": "غير محدد" + }, + "detailHeader": { + "linked": "مرتبط بجلسة الباقة الحالية", + "notLinked": "لم يتم العثور على رابط جلسة" + }, + "feeTable": { + "feeType": "نوع الرسوم", + "amount": "المبلغ", + "installments": "الأقساط" + }, + "installmentsTable": { + "title": "الأقساط", + "fee": "الرسوم", + "number": "#", + "amount": "المبلغ", + "due": "تاريخ الاستحقاق", + "start": "البداية", + "end": "النهاية", + "noneConfigured": "لم يتم تكوين أي أقساط." + }, + "paymentForm": { + "recordTitle": "تسجيل دفعة (اختياري)", + "recordSubtitle": "أدخل تفاصيل الدفع غير المتصل قبل التسجيل.", + "hide": "إخفاء", + "payNow": "ادفع الآن", + "amountLabel": "المبلغ المستلم (اختياري)", + "transactionIdLabel": "معرّف المعاملة", + "generate": "إنشاء", + "receiptLabel": "الإيصال (اختياري)", + "receiptUploaded": "تم رفع الإيصال", + "uploadReceipt": "رفع الإيصال", + "uploading": "جارٍ الرفع..." + }, + "footer": { + "totalLabel": "الإجمالي: {{amount}}", + "enroll": "التسجيل بهذه الخطة", + "enrolling": "جارٍ التسجيل..." + }, + "installmentLabel": { + "oneTime": "دفعة لمرة واحدة", + "count_zero": "لا توجد أقساط ({{count}})", + "count_one": "قسط واحد ({{count}})", + "count_two": "قسطان ({{count}})", + "count_few": "{{count}} أقساط", + "count_many": "{{count}} قسطًا", + "count_other": "{{count}} قسط", + "startsSuffix": " • يبدأ في {{date}}" + }, + "toast": { + "packageSessionRequired": "جلسة الباقة مطلوبة للتسجيل.", + "selectFeePlan": "اختر خطة رسوم للمتابعة.", + "childUserIdMissing": "معرّف مستخدم الطفل مفقود من استجابة القبول.", + "paymentOptionRequired": "خيار الدفع مطلوب. حدد خيارًا افتراضيًا أو حدّث CPO.", + "enrollSuccess": "تم تسجيل الطالب وتعيين خطة الرسوم.", + "enrollFailed": "فشل تسجيل الطالب. يرجى إعادة المحاولة.", + "receiptUploaded": "تم رفع الإيصال", + "receiptUploadFailed": "فشل رفع الإيصال" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsStep6Finish.json b/frontend-admin-dashboard/public/locales/ar/admissionsStep6Finish.json new file mode 100644 index 0000000000..3f63bac126 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsStep6Finish.json @@ -0,0 +1,12 @@ +{ + "readyToSubmitHeading": "جاهز للتقديم", + "reachedEndPrefix": "لقد وصلت إلى نهاية", + "admissionFormLabel": "استمارة القبول", + "reachedEndSuffix": ". يرجى مراجعة جميع التفاصيل قبل التقديم النهائي.", + "admissionSummaryHeading": "ملخص القبول", + "studentNameLabel": "اسم الطالب:", + "classLabel": "الصف:", + "clickPrefix": "انقر فوق", + "submitAdmissionLabel": "تقديم طلب القبول", + "clickSuffix": "أدناه لإتمام هذا السجل." +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsStudentDetailsSection.json b/frontend-admin-dashboard/public/locales/ar/admissionsStudentDetailsSection.json new file mode 100644 index 0000000000..24e6c25e8a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsStudentDetailsSection.json @@ -0,0 +1,123 @@ +{ + "sections": { + "basicInformation": "المعلومات الأساسية", + "demographics": "البيانات الديموغرافية", + "identification": "إثبات الهوية", + "healthInformation": "المعلومات الصحية" + }, + "fields": { + "fullName": { + "label": "الاسم الكامل", + "hint": "(حسب شهادة الميلاد)", + "placeholder": "أدخل الاسم الكامل للطالب" + }, + "dateOfBirth": { + "label": "تاريخ الميلاد" + }, + "gender": { + "label": "الجنس" + }, + "nationality": { + "label": "الجنسية", + "placeholder": "اختر الجنسية" + }, + "religion": { + "label": "الديانة", + "placeholder": "اختر" + }, + "category": { + "label": "الفئة", + "placeholder": "اختر" + }, + "bloodGroup": { + "label": "فصيلة الدم", + "placeholder": "اختر" + }, + "motherTongue": { + "label": "اللغة الأم", + "placeholder": "اختر" + }, + "languagesKnown": { + "label": "اللغات المعروفة", + "placeholder": "مثال: الإنجليزية، الهندية، المراثية" + }, + "idType": { + "label": "نوع الهوية", + "placeholder": "اختر نوع الهوية" + }, + "idNumber": { + "label": "رقم الهوية", + "placeholder": "أدخل رقم الهوية" + }, + "aadhaarNumber": "رقم آدهار", + "medicalConditions": { + "label": "الحالات الطبية / الحساسية (إن وجدت)", + "placeholder": "مثال: الربو، السكري، حساسية الطعام، إلخ" + }, + "dietaryRestrictions": { + "label": "القيود الغذائية", + "placeholder": "مثال: نباتي، بدون مكسرات، إلخ" + } + }, + "genderLabels": { + "male": "ذكر", + "female": "أنثى", + "other": "آخر" + }, + "nationalityOptions": { + "indian": "هندي", + "other": "أخرى" + }, + "religionOptions": { + "hindu": "هندوسي", + "muslim": "مسلم", + "christian": "مسيحي", + "sikh": "سيخي", + "jain": "جايني", + "buddhist": "بوذي", + "other": "أخرى" + }, + "categoryOptions": { + "general": "عام", + "obc": "OBC", + "sc": "SC", + "st": "ST", + "ews": "EWS" + }, + "bloodGroupOptions": { + "aPositive": "A+", + "aNegative": "A-", + "bPositive": "B+", + "bNegative": "B-", + "oPositive": "O+", + "oNegative": "O-", + "abPositive": "AB+", + "abNegative": "AB-" + }, + "motherTongueOptions": { + "hindi": "الهندية", + "english": "الإنجليزية", + "gujarati": "الغوجاراتية", + "marathi": "المراثية", + "tamil": "التاميلية", + "telugu": "التيلوغوية", + "kannada": "الكانادية", + "bengali": "البنغالية", + "malayalam": "المالايالامية", + "punjabi": "البنجابية", + "odia": "الأوديا", + "urdu": "الأردية", + "other": "أخرى" + }, + "checkboxLanguages": { + "hindi": "الهندية", + "english": "الإنجليزية", + "regional": "إقليمية" + }, + "idTypeOptions": { + "aadhaarCard": "بطاقة آدهار", + "birthCertificate": "شهادة الميلاد", + "passport": "جواز السفر", + "other": "أخرى" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsSubmitAdmissionBulk.json b/frontend-admin-dashboard/public/locales/ar/admissionsSubmitAdmissionBulk.json new file mode 100644 index 0000000000..f1fe9bc520 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsSubmitAdmissionBulk.json @@ -0,0 +1,5 @@ +{ + "errors": { + "bulkSubmitFailed": "فشل استيراد القبول الجماعي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsSubmitApplicationBulk.json b/frontend-admin-dashboard/public/locales/ar/admissionsSubmitApplicationBulk.json new file mode 100644 index 0000000000..9ccaf9253c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsSubmitApplicationBulk.json @@ -0,0 +1,5 @@ +{ + "errors": { + "bulkSubmitFailed": "فشل الاستيراد الجماعي لطلبات الالتحاق" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsSubmitEnquiry.json b/frontend-admin-dashboard/public/locales/ar/admissionsSubmitEnquiry.json new file mode 100644 index 0000000000..f2097e9214 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsSubmitEnquiry.json @@ -0,0 +1,6 @@ +{ + "errors": { + "submitFailed": "فشل إرسال الاستفسار", + "bulkSubmitFailed": "فشل الاستيراد الجماعي للاستفسارات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsTimelinePanel.json b/frontend-admin-dashboard/public/locales/ar/admissionsTimelinePanel.json new file mode 100644 index 0000000000..d213443ad0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsTimelinePanel.json @@ -0,0 +1,40 @@ +{ + "header": { + "title": "الأنشطة والملاحظات" + }, + "loading": "جارٍ تحميل النشاط…", + "error": "تعذّر تحميل الجدول الزمني للنشاط.", + "empty": { + "title": "لا يوجد نشاط بعد", + "description": "ستظهر هنا الملاحظات وتغييرات الحالة والأحداث الأخرى" + }, + "pagination": { + "pageInfo": "صفحة {{current}} من {{total}}", + "previous": "السابق", + "next": "التالي" + }, + "event": { + "actorPrefix": "بواسطة", + "viewMore": "عرض المزيد", + "viewLess": "عرض أقل" + }, + "addNote": { + "collapsedPlaceholder": "أضف ملاحظة أو سجّل نشاطًا…", + "notePlaceholder": "اكتب ملاحظتك هنا…", + "submitHint": "اضغط Ctrl+Enter للإرسال", + "cancel": "إلغاء", + "submit": "إضافة ملاحظة", + "submitting": "جارٍ الحفظ…", + "actionTypes": { + "note": "ملاحظة", + "callLog": "سجل مكالمة", + "followUp": "متابعة", + "meeting": "اجتماع" + }, + "toast": { + "success": "تمت إضافة الملاحظة بنجاح", + "error": "تعذّرت إضافة الملاحظة. يرجى المحاولة مرة أخرى.", + "emptyWarning": "يرجى إدخال ملاحظة أو إضافة تسجيل" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/admissionsUpdateEnquiryStatus.json b/frontend-admin-dashboard/public/locales/ar/admissionsUpdateEnquiryStatus.json new file mode 100644 index 0000000000..eb5d3704a8 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/admissionsUpdateEnquiryStatus.json @@ -0,0 +1,16 @@ +{ + "status": { + "new": "جديد", + "contacted": "تم التواصل", + "qualified": "مؤهل", + "notEligible": "غير مؤهل", + "followUp": "متابعة", + "closed": "مغلق", + "converted": "محوَّل", + "admitted": "مقبول" + }, + "conversion": { + "hot": "ساخن", + "cold": "بارد" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterAICardsData.json b/frontend-admin-dashboard/public/locales/ar/aiCenterAICardsData.json new file mode 100644 index 0000000000..d00403de12 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterAICardsData.json @@ -0,0 +1,53 @@ +{ + "cards": { + "newPaper": { + "title": "إنشاء ورقة أسئلة جديدة", + "description": "ابدأ من موضوع أو مستند أو تسجيل صوتي. عدّل قبل النشر.", + "features": { + "assessment": { + "heading": "إنشاء ورقة أسئلة", + "subheading": "أضف ملف PDF، أو الصق موضوعًا، أو ارفع تسجيلًا صوتيًا. احصل على مسودة يمكنك تعديلها قبل الإرسال.", + "tags": ["PDF أو صوت أو موضوع", "جاهزة خلال ٣٠ ثانية تقريبًا", "مسودة قابلة للتعديل"] + } + } + }, + "existingPaper": { + "title": "رقمنة ورقة أسئلة موجودة", + "description": "حوّل الأوراق المطبوعة أو الممسوحة ضوئيًا إلى مجموعات أسئلة قابلة للتعديل.", + "features": { + "question": { + "heading": "إعادة استخدام أسئلة موجودة", + "subheading": "ارفع ورقة مطبوعة — صورة أو مسح ضوئي أو PDF — واحصل على مجموعة أسئلة رقمية قابلة للتعديل.", + "tags": ["صورة أو مسح ضوئي أو PDF", "التعرف الضوئي مُدار تلقائيًا", "الحفظ في بنك الأسئلة"] + } + } + }, + "questionBank": { + "title": "تنظيم بنك الأسئلة الخاص بك", + "description": "جمّع الأسئلة حسب الموضوع لتتمكن من إعادة استخدامها في الفصل الدراسي القادم.", + "features": { + "sortSplitPdf": { + "heading": "تنظيم بنك أسئلتي", + "subheading": "جمّع الأسئلة تلقائيًا حسب الموضوع، أو اختر تقسيماتك الخاصة. نقّح النتيجة بالسحب والإفلات.", + "tags": ["تجميع تلقائي حسب الموضوع", "حسب الفصل", "السحب للتنقيح"] + } + } + }, + "lecturePlanning": { + "title": "خطط لمحاضراتك وحسّنها", + "description": "أعدّ مسودة درس قبل الحصة. واحصل على ملاحظات لطيفة ومحددة بعدها.", + "features": { + "planLecture": { + "heading": "مخطط الدروس", + "subheading": "صف ما تُدرّسه والمدة المتاحة لديك. احصل على خطة مبدئية يمكنك تنقيحها.", + "tags": ["جدول زمني مرحلي", "قابل للتعديل مباشرة", "الواجب المنزلي اختياري"] + }, + "evaluateLecture": { + "heading": "مدرّب المحاضرات", + "subheading": "أضف تسجيلًا لمحاضرة قمت بتدريسها. احصل على مراجعة واضحة وبنّاءة.", + "tags": ["الإيقاع والتفاعل", "اللحظات القوية", "اقتراحات محددة"] + } + } + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterAIChatWithPDFPreview.json b/frontend-admin-dashboard/public/locales/ar/aiCenterAIChatWithPDFPreview.json new file mode 100644 index 0000000000..757f44157c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterAIChatWithPDFPreview.json @@ -0,0 +1,18 @@ +{ + "failedDialog": { + "heading": "فشل تحميل الأسئلة", + "clickPrefix": "انقر", + "hereLink": "هنا", + "retrySuffix": "لإعادة المحاولة" + }, + "retryButton": { + "label": "إعادة المحاولة" + }, + "openChatButton": { + "label": "فتح المحادثة", + "loading": "جارٍ التحميل…" + }, + "toast": { + "noDataExists": "لا توجد بيانات!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterAIEvaluatePreview.json b/frontend-admin-dashboard/public/locales/ar/aiCenterAIEvaluatePreview.json new file mode 100644 index 0000000000..f0f4dba621 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterAIEvaluatePreview.json @@ -0,0 +1,18 @@ +{ + "failedDialog": { + "heading": "فشل تحميل الأسئلة", + "clickPrefix": "انقر", + "hereLink": "هنا", + "retrySuffix": "لإعادة المحاولة" + }, + "retryButton": { + "label": "إعادة المحاولة" + }, + "viewReviewButton": { + "label": "عرض المراجعة", + "loading": "جارٍ التحميل…" + }, + "toast": { + "noDataExists": "لا توجد بيانات!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterAIPlanLecturePreview.json b/frontend-admin-dashboard/public/locales/ar/aiCenterAIPlanLecturePreview.json new file mode 100644 index 0000000000..8866061d00 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterAIPlanLecturePreview.json @@ -0,0 +1,10 @@ +{ + "failedDialog": { + "title": "تعذر تحميل الأسئلة", + "message": "انقر هنا لإعادة المحاولة" + }, + "retryButton": "إعادة المحاولة", + "viewPlanButton": "عرض الخطة", + "loading": "جارٍ التحميل…", + "noDataToast": "لا توجد بيانات!" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterAIQuestionsPreview.json b/frontend-admin-dashboard/public/locales/ar/aiCenterAIQuestionsPreview.json new file mode 100644 index 0000000000..3317dc5326 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterAIQuestionsPreview.json @@ -0,0 +1,36 @@ +{ + "instituteLogoAlt": "الشعار", + "noResponseDialog": { + "title": "تعذر تحميل الأسئلة", + "message": "لم نتمكن من إنشاء الأسئلة لك. يرجى المحاولة مرة أخرى.", + "retryNow": "أعد المحاولة الآن" + }, + "toast": { + "noDataExists": "لا توجد بيانات!", + "incompleteQuestions": "بعض أسئلتك غير مكتملة أو تحتاج إلى انتباه!" + }, + "trigger": { + "retrying": "جارٍ إعادة المحاولة...", + "retry": "إعادة المحاولة", + "loading": "جارٍ التحميل…", + "viewQuestions": "عرض الأسئلة" + }, + "header": { + "saving": "جارٍ الحفظ...", + "saveChanges": "حفظ التغييرات", + "close": "إغلاق", + "moreTagsBadge_zero": "+{{count}}", + "moreTagsBadge_one": "+{{count}}", + "moreTagsBadge_two": "+{{count}}", + "moreTagsBadge_few": "+{{count}}", + "moreTagsBadge_many": "+{{count}}", + "moreTagsBadge_other": "+{{count}}" + }, + "questionCard": { + "duplicate": "تكرار", + "delete": "حذف" + }, + "content": { + "noQuestionsExist": "لا توجد أسئلة." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterAITasksList.json b/frontend-admin-dashboard/public/locales/ar/aiCenterAITasksList.json new file mode 100644 index 0000000000..67a88bab81 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterAITasksList.json @@ -0,0 +1,43 @@ +{ + "statusFilters": { + "all": "الكل", + "ready": "جاهز", + "inProgress": "قيد التنفيذ", + "failed": "فشل" + }, + "dateBuckets": { + "today": "اليوم", + "yesterday": "أمس", + "earlierThisWeek": "في وقت سابق من هذا الأسبوع", + "older": "أقدم" + }, + "taskCard": { + "download": "تنزيل" + }, + "dialog": { + "viewAll": "عرض الكل", + "refresh": "تحديث", + "close": "إغلاق", + "searchPlaceholder": "ابحث بالاسم أو اسم الملف…", + "clearSearch": "مسح البحث", + "itemCount_zero": "{{count}} عنصر", + "itemCount_one": "{{count}} عنصر واحد", + "itemCount_two": "{{count}} عنصران", + "itemCount_few": "{{count}} عناصر", + "itemCount_many": "{{count}} عنصرًا", + "itemCount_other": "{{count}} عنصر" + }, + "emptyState": { + "nothingHereYet": "لا يوجد شيء هنا بعد", + "nothingHereYetDescription": "سيظهر هنا كل ما تنشئه باستخدام هذه الأداة.", + "noMatches": "لا توجد نتائج مطابقة", + "noMatchesDescription": "جرّب مصطلح بحث أو عامل تصفية مختلفًا.", + "clearFilters": "مسح عوامل التصفية" + }, + "pagination": { + "showingRange": "عرض {{start}}–{{end}} من {{total}}", + "prev": "السابق", + "next": "التالي", + "pageOf": "صفحة {{current}} من {{total}}" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterAIToolPageData.json b/frontend-admin-dashboard/public/locales/ar/aiCenterAIToolPageData.json new file mode 100644 index 0000000000..37f4523eea --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterAIToolPageData.json @@ -0,0 +1,407 @@ +{ + "tools": { + "assessment": { + "heading": "Vsmart Upload", + "instructionsHeading": "اتبع هذه الخطوات البسيطة لإنشاء ورقة أسئلة من ملفات PDF أو Word أو PPT الخاصة بك:", + "instructions": [ + { + "stepHeading": "قم بتحميل ملفك", + "steps": [ + "انقر على تحميل الملف", + "الصيغ المدعومة: ‎.pdf، ‎.doc، ‎.docx، ‎.ppt، ‎.pptx" + ] + }, + { + "stepHeading": "اختر كيفية إنشاء الأسئلة", + "steps": [ + "الخيار 1: الإنشاء من الملف بالكامل. ستقوم الأداة بمسح المستند بأكمله وإنشاء أسئلة من كل المحتوى", + "الخيار 2: نسخ نص محدد. الصق يدويًا جزءًا من المحتوى الذي ترغب في إنشاء أسئلة منه." + ] + }, + { + "stepHeading": "خصّص الناتج", + "stepSubHeading": "بعد تحديد مصدر المحتوى، حدد التفاصيل التالية:", + "steps": ["أنشئ مجموعة من الأسئلة عن طريق إضافة المواضيع"] + }, + { + "stepHeading": "انقر على إنشاء", + "steps": [ + "استرخِ بينما يقوم الذكاء الاصطناعي بمعالجة مدخلاتك. قد يستغرق ذلك بضع ثوانٍ", + "يمكنك الاستمرار في العمل أو الانتقال إلى صفحة أخرى — سيستمر النظام في العمل في الخلفية." + ] + }, + { + "stepHeading": "عرض الحالة في My AI Task", + "steps": [ + "انتقل إلى علامة التبويب My AI Task لتتبع تقدم أوراق الأسئلة التي تم إنشاؤها.", + "بمجرد الانتهاء، سترى الحالة تتغير إلى مكتمل (Completed)." + ] + }, + { + "stepHeading": "التصدير والاستخدام", + "steps": [ + "بمجرد الإنشاء، انقر على تصدير لتنزيل ورقة الأسئلة بصيغة PDF أو DOC.", + "أنت الآن جاهز لطباعتها أو مشاركتها أو تحميلها في نظام التقييم الخاص بك." + ] + } + ] + }, + "audio": { + "heading": "Vsmart Audio", + "instructionsHeading": "كيفية استخدام Vsmart Audio", + "instructions": [ + { + "stepHeading": "قم بتحميل ملفك الصوتي", + "steps": ["انقر على تحميل الملف", "الصيغ المدعومة: WAV، FLAC، MP3، AAC، M4A"] + }, + { + "stepHeading": "حدد تفضيلاتك", + "stepSubHeading": "املأ التفاصيل لتحديد شكل ورقة الأسئلة الخاصة بك:", + "steps": [ + "عدد الأسئلة (مثال: 5، 10، 20...)", + "المستوى (مثال: مبتدئ، متوسط، متقدم)", + "الموضوع أو مجال التركيز (اختياري)", + "نوع الأسئلة (اختيار من متعدد، إجابة قصيرة، مقالية، مختلطة)", + "لغة الأسئلة (اختر من اللغات المدعومة)" + ] + }, + { + "stepHeading": "انقر على إنشاء", + "steps": [ + "بعد تحميل ملفك وإدخال التفاصيل، اضغط على زر إنشاء. سيقوم الذكاء الاصطناعي بمعالجة الملف الصوتي والبدء في صياغة الأسئلة.", + "قد يستغرق هذا بعض الوقت حسب مدة الملف الصوتي. لا تتردد في استكشاف الصفحة أو العودة لاحقًا." + ] + }, + { + "stepHeading": "عرض الحالة في My AI Task", + "steps": [ + "انتقل إلى علامة التبويب My AI Task لتتبع تقدم أوراق الأسئلة التي تم إنشاؤها.", + "بمجرد الانتهاء، سترى الحالة تتغير إلى مكتمل (Completed)." + ] + }, + { + "stepHeading": "التصدير والاستخدام", + "steps": [ + "بمجرد الإنشاء، انقر على تصدير لتنزيل ورقة الأسئلة بصيغة PDF أو DOC.", + "أنت الآن جاهز لطباعتها أو مشاركتها أو تحميلها في نظام التقييم الخاص بك." + ] + } + ] + }, + "text": { + "heading": "Vsmart Topics", + "instructionsHeading": "كيفية استخدام Vsmart Topics", + "instructions": [ + { + "stepHeading": "انقر على إنشاء الأسئلة", + "steps": ["ابدأ بالنقر على زر إنشاء الأسئلة لفتح لوحة الموجّه (prompt)"] + }, + { + "stepHeading": "أدخل موجّهك (prompt)", + "steps": [ + "صف الموضوع الذي تريد أسئلة عنه – كن محددًا أو عامًا كما تشاء.", + "مثال: \"أنشئ مجموعة من الأسئلة تغطي جميع مبادئ عملية التمثيل الضوئي، بما في ذلك العملية والعوامل المؤثرة فيها وأهميتها في النظام البيئي. ركّز على الفهم المفاهيمي والتطبيق\"" + ] + }, + { + "stepHeading": "أضف تفضيلات الإنشاء", + "stepSubHeading": "اضبط ورقة الأسئلة بدقة عن طريق إدخال التفاصيل التالية:", + "steps": [ + "عدد الأسئلة (مثال: 5، 10، 20...)", + "المستوى (مثال: سهل، متوسط، صعب، متوسط الصعوبة...)", + "الموضوع أو اسم الفصل (اختياري)", + "نوع الأسئلة (اختيار من متعدد، إجابة قصيرة، مقالية، مختلطة)", + "لغة الأسئلة (اختر من اللغات المدعومة)" + ] + }, + { + "stepHeading": "انقر على إنشاء", + "steps": [ + "عندما تكون جاهزًا، انقر على زر إنشاء. سيبدأ الذكاء الاصطناعي العمل على ورقتك المخصصة.", + "يمكنك البقاء في الصفحة أو مواصلة الاستكشاف — يتم الإنشاء في الخلفية." + ] + }, + { + "stepHeading": "عرض الحالة في My AI Task", + "steps": [ + "انتقل إلى علامة التبويب My AI Task لتتبع تقدم أوراق الأسئلة التي تم إنشاؤها.", + "بمجرد الانتهاء، سترى الحالة تتغير إلى مكتمل (Completed)." + ] + }, + { + "stepHeading": "التصدير والاستخدام", + "steps": [ + "بمجرد الإنشاء، انقر على تصدير لتنزيل ورقة الأسئلة بصيغة PDF أو DOC.", + "أنت الآن جاهز لطباعتها أو مشاركتها أو تحميلها في نظام التقييم الخاص بك." + ] + } + ] + }, + "chat": { + "heading": "Vsmart Chat", + "instructionsHeading": "كيفية استخدام Vsmart Upload", + "instructionsSubHeading": "تتيح لك هذه الأداة تحميل ملفات تعليمية — مثل PDF أو DOC أو PPT — وتنشئ أسئلة تلقائيًا من المحتوى. بعد ذلك، من خلال واجهة محادثة مدمجة، يمكنك تخصيص تلك الأسئلة باستخدام موجّهات (prompts) بسيطة.", + "instructions": [ + { + "stepHeading": "قم بتحميل ملفك", + "steps": ["انقر على تحميل الملف", "الصيغ المدعومة: pdf، doc، docx، ppt، pptx"] + }, + { + "stepHeading": "دع الذكاء الاصطناعي ينشئ الأسئلة", + "steps": [ + "بمجرد التحميل، يقوم الذكاء الاصطناعي بمسح المحتوى وإنشاء مسودة ورقة أسئلة تلقائيًا استنادًا إلى المادة" + ] + }, + { + "stepHeading": "عدّل باستخدام المحادثة", + "steps": [ + "عندما تصبح الأسئلة جاهزة، يمكنك التحدث مع الذكاء الاصطناعي لتحسينها:", + "اطلب أشياء مثل:", + "\"أضف المزيد من الأسئلة/الاختيار من متعدد\"", + "\"أضف المزيد من أسئلة التفكير العليا\"", + "\"بسّط السؤال 3\"", + "\"أضف المزيد من الأسئلة من النصف الثاني من المستند\"" + ], + "stepFooter": "الأمر بسيط مثل إرسال رسالة نصية — لا حاجة إلى أي مهارات تقنية." + }, + { + "stepHeading": "عرض الحالة في My AI Task", + "steps": [ + "يمكنك الانتقال إلى علامة التبويب My AI Tasks للاطلاع على جميع أوراق الأسئلة التي تم إنشاؤها.", + "هناك، يمكنك مراجعتها أو إعادة إنشائها أو تصديرها في أي وقت." + ] + } + ] + }, + "question": { + "heading": "Vsmart Extract", + "instructionsHeading": "كيفية استخدام Vsmart Extract", + "instructionsSubHeading": "تقرأ هذه الأداة المحتوى الذي حمّلته وتستخرج تلقائيًا الأسئلة ذات الصلة — مثالية لإعادة استخدام مواد من الملاحظات أو الكتب المدرسية أو العروض التقديمية.", + "instructions": [ + { + "stepHeading": "قم بتحميل ملفك", + "steps": ["انقر على تحميل الملف", "الصيغ المدعومة: pdf، doc، docx، ppt، pptx"] + }, + { + "stepHeading": "دع الذكاء الاصطناعي يقوم بالعمل", + "steps": [ + "بمجرد التحميل، تبدأ الأداة في معالجة ملفك لتحديد الأسئلة المحتملة واستخراجها." + ] + }, + { + "stepHeading": "عرض الحالة في My AI Task", + "steps": [ + "انتقل إلى علامة التبويب My AI Task لتتبع تقدم أوراق الأسئلة التي تم إنشاؤها.", + "بمجرد الانتهاء، سترى الحالة تتغير إلى مكتمل (Completed)." + ] + }, + { + "stepHeading": "التحسين باستخدام الموجّهات (اختياري)", + "steps": [ + "استخدم خيار إعادة الإنشاء لإنشاء أسئلة إضافية.", + "عدّل الأسئلة الحالية باستخدام موجّه مخصص لتغيير النوع أو مستوى الصعوبة أو التنسيق." + ] + }, + { + "stepHeading": "التصدير والاستخدام", + "steps": [ + "عند الرضا عن النتيجة، انقر على تصدير لتنزيل ورقة الأسئلة بصيغة PDF أو DOC.", + "أنت الآن جاهز لطباعتها أو مشاركتها أو تحميلها في نظام التقييم الخاص بك." + ] + } + ] + }, + "image": { + "heading": "Vsmart Image", + "instructionsHeading": "كيفية استخدام Vsmart Image", + "instructionsSubHeading": "تتيح لك هذه الأداة تحميل صورة للمحتوى — ملاحظات مكتوبة بخط اليد، صفحات مطبوعة، لقطات من كتاب مدرسي — وتستخرج تلقائيًا الأسئلة ذات الصلة منها.", + "instructions": [ + { + "stepHeading": "قم بتحميل صورتك", + "steps": ["انقر على تحميل", "الصيغ المدعومة: jpg، jpeg، png"] + }, + { + "stepHeading": "دع الذكاء الاصطناعي يعالجها", + "steps": [ + "بمجرد التحميل، تقوم الأداة بمسح الصورة وقراءة النص والبدء في إنشاء أسئلة ذات صلة من المحتوى المستخرج" + ] + }, + { + "stepHeading": "عرض الحالة في My AI Task", + "steps": [ + "انتقل إلى علامة التبويب My AI Task لتتبع تقدم أوراق الأسئلة التي تم إنشاؤها", + "بمجرد الانتهاء، سترى الحالة تتغير إلى مكتمل (Completed)" + ] + }, + { + "stepHeading": "التحسين باستخدام الموجّهات (اختياري)", + "steps": [ + "استخدم خيار إعادة الإنشاء لإنشاء أسئلة إضافية", + "عدّل الأسئلة الحالية باستخدام موجّه مخصص لتغيير النوع أو مستوى الصعوبة أو التنسيق" + ] + }, + { + "stepHeading": "التصدير والاستخدام", + "steps": [ + "عند الرضا عن النتيجة، انقر على تصدير لتنزيل ورقة الأسئلة بصيغة PDF أو DOC", + "أنت الآن جاهز لطباعتها أو مشاركتها أو تحميلها في نظام التقييم الخاص بك" + ] + } + ] + }, + "sortSplitPdf": { + "heading": "Vsmart Organizer", + "instructionsHeading": "كيفية استخدام Vsmart Organizer", + "instructionsSubHeading": "حمّل ملف PDF أو PPT أو DOC لإنشاء الأسئلة وتجميعها تلقائيًا حسب الموضوع — مثالي للاختبارات المقسّمة حسب الفصول أو التقييمات المتوافقة مع المنهج الدراسي.", + "instructions": [ + { + "stepHeading": "قم بتحميل ملفك", + "steps": ["انقر على تحميل الملف", "الصيغ المدعومة: pdf، doc، docx، ppt، pptx"] + }, + { + "stepHeading": "دع الذكاء الاصطناعي ينظّمه", + "steps": [ + "تحلّل الأداة المحتوى، وتنشئ أسئلة ذات صلة، وترتّبها تلقائيًا تحت عناوين مواضيع محددة" + ] + }, + { + "stepHeading": "انقر على إعادة الإنشاء (اختياري)", + "steps": [ + "هل تريد مجموعة أو بنية مختلفة من الأسئلة؟ استخدم زر إعادة الإنشاء وأضف موجّهًا مخصصًا لتعديل الترتيب أو مستوى الصعوبة أو أسلوب الأسئلة." + ] + }, + { + "stepHeading": "عرض الحالة في My AI Task", + "steps": [ + "انتقل إلى علامة التبويب My AI Task لتتبع تقدم أوراق الأسئلة التي تم إنشاؤها.", + "بمجرد الانتهاء، سترى الحالة تتغير إلى مكتمل (Completed)." + ] + }, + { + "stepHeading": "التصدير والاستخدام", + "steps": [ + "بمجرد الإنشاء، انقر على تصدير لتنزيل ورقة الأسئلة بصيغة PDF أو DOC.", + "أنت الآن جاهز لطباعتها أو مشاركتها أو تحميلها في نظام التقييم الخاص بك." + ] + } + ] + }, + "sortTopicsPdf": { + "heading": "Vsmart Sorter", + "instructionsHeading": "كيفية استخدام Vsmart Sorter", + "instructionsSubHeading": "نظّم ورقة الأسئلة الخاصة بك بدقة — رتّب حسب المواضيع وحدد الترتيب الدقيق باستخدام موجّهات بسيطة.", + "instructions": [ + { + "stepHeading": "قم بتحميل ملفك", + "steps": ["انقر على تحميل الملف", "الصيغ المدعومة: pdf، doc، docx، ppt، pptx"] + }, + { + "stepHeading": "الذكاء الاصطناعي ينشئ الأسئلة ويرتّبها", + "steps": [ + "بمجرد التحميل، تقرأ الأداة المحتوى، وتنشئ الأسئلة، وتجمّعها تحت المواضيع أو الفصول ذات الصلة" + ] + }, + { + "stepHeading": "إعادة الترتيب باستخدام الموجّهات", + "steps": [ + "هل تريد ظهور أسئلة معينة أولًا؟ فقط اكتب تعليمتك.", + "مثال: \"أريد أن يكون السؤالان الخامس والسادس من 'تغذية النبات' أول سؤالين في الورقة\"" + ] + }, + { + "stepHeading": "عرض الحالة في My AI Task", + "steps": [ + "انتقل إلى علامة التبويب My AI Task لتتبع تقدم أوراق الأسئلة التي تم إنشاؤها.", + "بمجرد الانتهاء، سترى الحالة تتغير إلى مكتمل (Completed)." + ] + }, + { + "stepHeading": "التصدير والاستخدام", + "steps": [ + "بمجرد الإنشاء، انقر على تصدير لتنزيل ورقة الأسئلة بصيغة PDF أو DOC.", + "أنت الآن جاهز لطباعتها أو مشاركتها أو تحميلها في نظام التقييم الخاص بك." + ] + } + ] + }, + "planLecture": { + "heading": "Vsmart Lecturer", + "instructionsHeading": "كيفية استخدام Vsmart Sorter", + "instructionsSubHeading": "تساعدك هذه الأداة على إنشاء خطة محاضرة كاملة مصممة خصيصًا لأسلوب تدريسك ومواضيعك ولغتك وإطارك الزمني — بما في ذلك أسئلة اختيارية أثناء المحاضرة وواجبات في نهايتها.", + "instructions": [ + { + "stepHeading": "انقر على تخطيط المحاضرة", + "steps": [ + "عنوان المحاضرة والمواضيع", + "المستوى الدراسي (مثال: الصف الثامن)", + "أسلوب التدريس المفضل (مثال: سرد القصص، مزيد من الأمثلة)", + "اللغة (الإنجليزية أو الهندية)", + "المدة الإجمالية للمحاضرة", + "هل تريد تضمين أسئلة أثناء المحاضرة؟ (نعم/لا)", + "هل تريد إضافة واجب أو مهمة في النهاية؟ (نعم/لا)" + ] + }, + { + "stepHeading": "انقر على إنشاء", + "steps": [ + "بمجرد ضبط تفضيلاتك، اضغط على زر إنشاء. سيستغرق الذكاء الاصطناعي بضع لحظات لصياغة خطة محاضرة ذكية ومنظمة وتفاعلية استنادًا إلى مدخلاتك." + ] + }, + { + "stepHeading": "تصدير خطتك", + "steps": [ + "بنية زمنية", + "تفصيل حسب الموضوع", + "نقاط تفاعل مدمجة (إن تم اختيارها)", + "قسم الواجبات (إن تم اختياره)", + "إنشاء محتوى قائم على أسلوب التدريس" + ] + } + ] + }, + "evaluateLecture": { + "heading": "Vsmart Feedback", + "instructionsHeading": "كيفية استخدام Vsmart Feedback", + "instructionsSubHeading": "تساعدك هذه الأداة على تحميل ملف صوتي لمحاضرتك، وتنشئ تلقائيًا تقرير تقييم مفصلًا — يبرز نقاط القوة ومجالات التحسين ودرجة الأداء عبر عدة معايير.", + "instructions": [ + { + "stepHeading": "قم بتحميل ملفك", + "steps": ["انقر على تحميل الملف", "الصيغ المدعومة: WAV، FLAC، MP3، AAC، M4A"] + }, + { + "stepHeading": "دع الذكاء الاصطناعي يقيّمها", + "steps": [ + "بمجرد التحميل، تستمع الأداة إلى المحاضرة وتحلّلها عبر ثمانية مجالات رئيسية — من الوضوح والتفاعل إلى جودة المحتوى والاحترافية.", + "يُمنح كل قسم درجة، مع تعليقات واقتراحات." + ] + }, + { + "stepHeading": "أمثلة على مجالات التقييم", + "steps": [ + "الإلقاء والعرض (20 نقطة)", + "جودة المحتوى (20 نقطة)", + "تفاعل الطلاب (15 نقطة)", + "التقييم والتغذية الراجعة (10 نقاط)", + "الشمولية واللغة (10 نقاط)", + "إدارة الصف (10 نقاط)", + "الوسائل التعليمية (10 نقاط)", + "الاحترافية (5 نقاط)" + ] + }, + { + "stepHeading": "تحقق لاحقًا في My AI Tools", + "steps": [ + "قد يستغرق التقييم بضع دقائق حسب طول الملف. يمكنك الانتظار أو العودة لاحقًا للتحقق من الحالة في علامة التبويب My Builts.", + "بمجرد الجاهزية، سيظهر تقريرك الكامل — مع أبرز النقاط والدرجات والاقتراحات التي أنشأها الذكاء الاصطناعي." + ] + }, + { + "stepHeading": "تصدير تقريرك", + "steps": [ + "نزّل تقرير التقييم النهائي بصيغة PDF لمشاركته مع الزملاء، أو حفظه للرجوع إليه مستقبلًا، أو استخدامه لتطورك الشخصي." + ] + } + ] + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterAiModels.json b/frontend-admin-dashboard/public/locales/ar/aiCenterAiModels.json new file mode 100644 index 0000000000..00d2a8089d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterAiModels.json @@ -0,0 +1,21 @@ +{ + "models": { + "claudeOpus45": { + "name": "Claude Opus 4.5", + "description": "Anthropic" + }, + "gemini3ProPreview": { + "name": "Gemini 3 Pro Preview", + "description": "Google" + }, + "gemini31ProPreview": { + "name": "Gemini 3.1 Pro Preview", + "description": "Google" + }, + "gpt54": { + "name": "GPT-5.4", + "description": "OpenAI" + } + }, + "fallbackDescription": "نموذج ذكاء اصطناعي" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterAiToolsIndex.json b/frontend-admin-dashboard/public/locales/ar/aiCenterAiToolsIndex.json new file mode 100644 index 0000000000..95118d0295 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterAiToolsIndex.json @@ -0,0 +1,67 @@ +{ + "navHeading": "المساعد التعليمي", + "greetingMorning": "صباح الخير.", + "greetingMorningNamed": "صباح الخير، {{name}}.", + "greetingAfternoon": "نهارك سعيد.", + "greetingAfternoonNamed": "نهارك سعيد، {{name}}.", + "greetingEvening": "مساء الخير.", + "greetingEveningNamed": "مساء الخير، {{name}}.", + "headerSubtitle": "لنساعدك في الوصول إلى الأداة المناسبة خلال نقرتين فقط.", + "step1Heading": "ما الذي أتى بك إلى هنا اليوم؟", + "step1Subtitle": "اختر الهدف العام، وسنحدد التفاصيل في الخطوة التالية.", + "ctaOpen": "فتح", + "ctaContinue": "متابعة", + "back": "رجوع", + "step2HeadingFallback": "{{title}} — أي نوع؟", + "step2Subtitle": "اختر كيف تريد البدء. ستطلب منك الشاشة التالية التفاصيل.", + "recentWorkHeading": "أعمالك الأخيرة", + "viewAll": "عرض الكل", + "topicBasedQuestionsFallback": "أسئلة قائمة على موضوع", + "categories": { + "assessment": { + "title": "إنشاء تقييم", + "subtitle": "ورقة أسئلة أو اختبار قصير أو امتحان — من أي مصدر لديك.", + "step2Heading": "كيف تريد إنشاء الأسئلة؟" + }, + "lecture-plan": { + "title": "التخطيط لمحاضرة", + "subtitle": "خطة زمنية مع النقاط الرئيسية وواجب منزلي اختياري." + }, + "lecture-review": { + "title": "مراجعة أسلوب تدريسي", + "subtitle": "احصل على مراجعة لطيفة ودقيقة لحصة سجلتها." + } + }, + "subOptions": { + "assessment": { + "fromTopic": { + "title": "من موضوع", + "subtitle": "اكتب موضوعًا، وسنصيغ الأسئلة." + }, + "fromDocument": { + "title": "من مستند", + "subtitle": "PDF أو Word أو PowerPoint — أسئلة جديدة حول محتواه." + }, + "fromAudio": { + "title": "من تسجيل صوتي", + "subtitle": "ارفع تسجيلاً — أسئلة بناءً على ما قيل." + }, + "fromPhoto": { + "title": "من صورة لورقة أسئلة", + "subtitle": "التقط صورة لورقة مطبوعة — حوّلها إلى أسئلة قابلة للتعديل." + }, + "fromExistingPaper": { + "title": "من ورقة أسئلة موجودة", + "subtitle": "حوّل ورقة أسئلة لديك بالفعل إلى نسخة رقمية." + }, + "fromQuestionBank": { + "title": "من بنك الأسئلة الخاص بي", + "subtitle": "فرز تلقائي حسب الموضوع، أو اختيار أسئلة محددة." + }, + "chatWithDocument": { + "title": "الدردشة مع مستند", + "subtitle": "تبادل الحديث حول ملف PDF — اطلب أسئلة أو ملخصات." + } + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterAnswerSpacingQuestionPaperDialog.json b/frontend-admin-dashboard/public/locales/ar/aiCenterAnswerSpacingQuestionPaperDialog.json new file mode 100644 index 0000000000..c1a3d3b790 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterAnswerSpacingQuestionPaperDialog.json @@ -0,0 +1,25 @@ +{ + "title": "إدارة تباعد الإجابات", + "helper": { + "text": "حدد المساحة (بالمليمتر) التي يجب توفيرها بعد كل سؤال للإجابات. النطاق الصالح يتراوح بين {{min}} مم و{{max}} مم." + }, + "table": { + "headers": { + "qNo": "رقم السؤال", + "section": "القسم", + "question": "السؤال", + "type": "النوع", + "space": "المساحة (مم)" + }, + "empty": "لم يتم العثور على أسئلة مؤهلة. يمكن فقط لأسئلة الإجابة الطويلة والكلمة الواحدة الحصول على تباعد مخصص.", + "type": { + "longAnswer": "إجابة طويلة", + "oneWord": "كلمة واحدة" + }, + "unit": "مم" + }, + "actions": { + "cancel": "إلغاء", + "save": "حفظ الإعدادات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterDraftingDonePanel.json b/frontend-admin-dashboard/public/locales/ar/aiCenterDraftingDonePanel.json new file mode 100644 index 0000000000..ccc8563d8e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterDraftingDonePanel.json @@ -0,0 +1,6 @@ +{ + "defaultTitle": "إليك ما أعددناه لك", + "defaultSubtitle": "راجع وعدّل قبل الحفظ أو التصدير. القرار النهائي دائمًا للمعلم.", + "draftAnotherLabel": "إعداد مسودة أخرى", + "aiGeneratedBadge": "من إنشاء الذكاء الاصطناعي" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterEvaluateLectureAI.json b/frontend-admin-dashboard/public/locales/ar/aiCenterEvaluateLectureAI.json new file mode 100644 index 0000000000..158ddde1f7 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterEvaluateLectureAI.json @@ -0,0 +1,43 @@ +{ + "header": { + "title": "مدرّب المحاضرات", + "subtitle": "أرسل تسجيلاً لمحاضرة قمت بتدريسها. سنقدّم لك مراجعة واضحة وبنّاءة." + }, + "dropzone": { + "instruction": "أفلت تسجيلك هنا، أو انقر للاختيار", + "formats": "MP3 أو WAV أو FLAC أو AAC أو M4A." + }, + "fileStatus": { + "uploading": "جارٍ الرفع…", + "listening": "جارٍ الاستماع…", + "reviewing": "جارٍ المراجعة…", + "done": "تم", + "removeFile": "إزالة الملف" + }, + "workingLabels": { + "uploading": "جارٍ رفع تسجيلك…", + "processing": "جارٍ الاستماع إلى محاضرتك…", + "generating": "مراجعة الإيقاع والتفاعل والوضوح — تستغرق عادةً حوالي دقيقة واحدة." + }, + "errors": { + "unsupportedFormat": "لا يمكننا قراءة ملفات .{{extension}}. جرّب MP3 أو WAV أو FLAC أو AAC أو M4A.", + "uploadIncomplete": "لم تكتمل عملية الرفع. هل تريد المحاولة مرة أخرى؟", + "readFailed": "حدث خطأ ما أثناء قراءة تسجيلك. هل تريد المحاولة مرة أخرى؟", + "generateFailed": "تعذّرت مراجعة هذا التسجيل. هل تريد تجربة ملف آخر؟", + "taskFailed": "تعذّر إنهاء هذه المراجعة. هل تريد المحاولة مرة أخرى؟" + }, + "generating": { + "title": "جارٍ مراجعة محاضرتك", + "subtitle": "نستمع لتقييم الإيقاع والتفاعل والوضوح. تستغرق عادةً حوالي دقيقة واحدة." + }, + "draftingDone": { + "title": "إليك مراجعة محاضرتك", + "subtitle": "افتحها لترى ما سار بشكل جيد وما يمكن تعديله." + }, + "recentFiles": { + "title": "مراجعاتك الأخيرة", + "fallbackLabel": "مراجعة محاضرة", + "emptyHint": "ستظهر مراجعات محاضراتك هنا. أفلت تسجيلاً أعلاه للحصول على واحدة." + }, + "productName": "Vsmart Feedback" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterEvaluateLectureComponent.json b/frontend-admin-dashboard/public/locales/ar/aiCenterEvaluateLectureComponent.json new file mode 100644 index 0000000000..534a3922c4 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterEvaluateLectureComponent.json @@ -0,0 +1,8 @@ +{ + "trigger": { + "label": "تقييم المحاضرة" + }, + "tasksList": { + "heading": "Vsmart Feedback" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterEvaluateReportPreview.json b/frontend-admin-dashboard/public/locales/ar/aiCenterEvaluateReportPreview.json new file mode 100644 index 0000000000..5089ee53d9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterEvaluateReportPreview.json @@ -0,0 +1,32 @@ +{ + "dialog": { + "defaultReportTitle": "تقرير التقييم" + }, + "header": { + "defaultTitle": "تقييم المحاضرة", + "export": "تصدير" + }, + "info": { + "lectureTitle": "عنوان المحاضرة:", + "duration": "المدة:", + "evaluationDate": "تاريخ التقييم:", + "notAvailable": "غير متاح" + }, + "score": { + "totalScore": "النتيجة الإجمالية", + "criterionScore": "(النتيجة: {{score}})" + }, + "performance": { + "needsImprovement": "يحتاج إلى تحسين", + "average": "متوسط", + "good": "جيد", + "excellent": "ممتاز" + }, + "criteria": { + "heading": "معايير التقييم", + "scopeOfImprovement": "مجالات التحسين:" + }, + "summary": { + "heading": "الملخص" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterExportHandlerQuestionPaperAI.json b/frontend-admin-dashboard/public/locales/ar/aiCenterExportHandlerQuestionPaperAI.json new file mode 100644 index 0000000000..e8483bcccc --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterExportHandlerQuestionPaperAI.json @@ -0,0 +1,13 @@ +{ + "button": { + "exporting": "جارٍ التصدير...", + "export": "تصدير {{format}}", + "setSuffix": " (المجموعة {{letter}})" + }, + "dialog": { + "generatingPdf": "جارٍ إنشاء ملف PDF", + "pleaseWait": "قد يستغرق هذا بعض الوقت", + "cancelAriaLabel": "إلغاء إنشاء ملف PDF", + "progressComplete": "اكتمل {{progress}}%" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterExportQuestionPaperAI.json b/frontend-admin-dashboard/public/locales/ar/aiCenterExportQuestionPaperAI.json new file mode 100644 index 0000000000..6cf4216721 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterExportQuestionPaperAI.json @@ -0,0 +1,7 @@ +{ + "exportPdf": "تصدير كملف PDF", + "exportSettings": "إعدادات التصدير", + "close": "إغلاق", + "letterhead": "الترويسة", + "deleteLetterhead": "حذف الترويسة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterExportQuestionPaperSettingDialog.json b/frontend-admin-dashboard/public/locales/ar/aiCenterExportQuestionPaperSettingDialog.json new file mode 100644 index 0000000000..0aa4a886ca --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterExportQuestionPaperSettingDialog.json @@ -0,0 +1,78 @@ +{ + "title": "إعدادات التصدير", + "sections": { + "layout": "إعدادات التخطيط", + "display": "إعدادات العرض", + "paper": "إعدادات الورقة", + "customFields": "إعدادات الحقول المخصصة", + "advanced": "إعدادات متقدمة" + }, + "layout": { + "columnsPerPage": "عدد الأعمدة في الصفحة", + "spaceForRoughWork": "مساحة للعمل التقريبي", + "roughWorkPosition": { + "none": "بلا", + "bottom": "أسفل" + }, + "roughWorkSize": { + "label": "حجم مساحة العمل التقريبي", + "small": "صغير (50 مم)", + "medium": "متوسط (100 مم)", + "large": "كبير (150 مم)" + }, + "pagePadding": { + "label": "هامش الصفحة", + "low": "منخفض (10 مم)", + "medium": "متوسط (20 مم)", + "high": "مرتفع (30 مم)" + }, + "fontSize": { + "label": "حجم الخط", + "small": "صغير (10 نقطة)", + "medium": "متوسط (12 نقطة)", + "large": "كبير (14 نقطة)" + }, + "imageSize": { + "label": "حجم الصورة", + "maintainAspectRatio": "الحفاظ على نسبة أبعاد الصورة" + } + }, + "display": { + "showInstitutionLetterhead": "إظهار ترويسة المؤسسة", + "showFirstPageInstructions": "إظهار التعليمات في الصفحة الأولى", + "showAdaptiveMarkingRules": "إظهار قواعد التصحيح التكيفي - التقييم بأكمله", + "showSectionInstructions": "إظهار تعليمات كل قسم", + "showSectionDuration": "إظهار مدة كل قسم", + "showMarksPerQuestion": "إظهار درجات كل سؤال", + "showAdaptiveMarkingRulesSection": "إظهار قواعد التصحيح التكيفي - حسب القسم", + "showCheckboxesBeforeOptions": "إظهار مربعات الاختيار قبل الخيارات", + "showPageNumbers": "إظهار أرقام الصفحات" + }, + "paper": { + "createSets": "إنشاء مجموعات من ورقة الأسئلة", + "includeQuestionSetCode": "تضمين رمز مجموعة الأسئلة", + "randomizeQuestions": "ترتيب الأسئلة عشوائيًا", + "randomizeOptions": "ترتيب الخيارات عشوائيًا" + }, + "customFields": { + "include": "تضمين حقول إدخال مخصصة", + "selectTypePlaceholder": "اختر نوع الحقل", + "type": { + "blank": "فارغ (افتراضي)", + "blocks": "كتل", + "input": "مربع إدخال", + "checkbox": "مربع اختيار" + }, + "newFieldPlaceholder": "أدخل تسمية الحقل الجديد", + "add": "إضافة" + }, + "advanced": { + "answerSpacing": "تباعد الإجابات", + "customSpacing": "تباعد مخصص", + "customSpacingHint": "تكوين مساحة مخصصة للإجابات." + }, + "actions": { + "cancel": "إلغاء", + "save": "حفظ" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterExtractQuestionsComponent.json b/frontend-admin-dashboard/public/locales/ar/aiCenterExtractQuestionsComponent.json new file mode 100644 index 0000000000..703cec3c8e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterExtractQuestionsComponent.json @@ -0,0 +1,14 @@ +{ + "trigger": { + "label": "استخراج الأسئلة" + }, + "dialog": { + "title": "استخراج الأسئلة", + "promptLabel": "الموجه", + "promptPlaceholder": "أدخل موجهك هنا", + "extractButton": "استخراج" + }, + "tasksList": { + "heading": "Vsmart Extract" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterFormat.json b/frontend-admin-dashboard/public/locales/ar/aiCenterFormat.json new file mode 100644 index 0000000000..63c681ef01 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterFormat.json @@ -0,0 +1,50 @@ +{ + "source": { + "pdf": "من ملف PDF", + "audio": "من ملف صوتي", + "image": "من صورة", + "doc": "من مستند", + "none": "من موضوع" + }, + "status": { + "completed": "جاهز", + "failed": "فشل", + "inProgress": "قيد التنفيذ" + }, + "friendlyHeading": { + "vsmartUpload": "أوراق الأسئلة الخاصة بك", + "vsmartExtract": "المقتطفات الخاصة بك", + "vsmartImage": "المقتطفات الخاصة بك", + "vsmartAudio": "أوراق الأسئلة الصوتية الخاصة بك", + "vsmartTopics": "أوراق الأسئلة حسب الموضوع الخاصة بك", + "vsmartChat": "جلسات الدردشة الخاصة بك", + "vsmartOrganizer": "المجموعات المصنّفة الخاصة بك", + "vsmartSorter": "المجموعات المصنّفة الخاصة بك", + "vsmartLecturer": "خطط الدروس الخاصة بك", + "vsmartFeedback": "مراجعات المحاضرات الخاصة بك" + }, + "relativeTime": { + "justNow": "الآن", + "minutesAgo_zero": "قبل {{count}} دقيقة", + "minutesAgo_one": "قبل {{count}} دقيقة", + "minutesAgo_two": "قبل {{count}} دقيقتين", + "minutesAgo_few": "قبل {{count}} دقائق", + "minutesAgo_many": "قبل {{count}} دقيقة", + "minutesAgo_other": "قبل {{count}} دقيقة", + "hoursAgo_zero": "قبل {{count}} ساعة", + "hoursAgo_one": "قبل {{count}} ساعة", + "hoursAgo_two": "قبل {{count}} ساعتين", + "hoursAgo_few": "قبل {{count}} ساعات", + "hoursAgo_many": "قبل {{count}} ساعة", + "hoursAgo_other": "قبل {{count}} ساعة", + "daysAgo_zero": "قبل {{count}} يوم", + "daysAgo_one": "قبل {{count}} يوم", + "daysAgo_two": "قبل {{count}} يومين", + "daysAgo_few": "قبل {{count}} أيام", + "daysAgo_many": "قبل {{count}} يومًا", + "daysAgo_other": "قبل {{count}} يوم" + }, + "taskDisplayName": { + "untitledDraft": "مسودة بدون عنوان" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateAssessment.json b/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateAssessment.json new file mode 100644 index 0000000000..20e549774d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateAssessment.json @@ -0,0 +1,49 @@ +{ + "header": { + "title": "إنشاء ورقة أسئلة", + "subtitle": "أسقط ملف PDF أو Word أو PowerPoint. سنصوغ أسئلة يمكنك تعديلها قبل الإرسال." + }, + "dropzone": { + "title": "أسقط ملفك هنا، أو انقر للاختيار", + "subtitle": "PDF أو Word أو PowerPoint — حتى بضع مئات من الصفحات." + }, + "fileCard": { + "removeFile": "إزالة الملف", + "status": { + "uploading": "جارٍ الرفع…", + "processing": "جارٍ القراءة…", + "ready": "جاهز للصياغة", + "generating": "الصياغة قيد التقدم" + } + }, + "workingStatus": { + "uploading": "جارٍ قراءة ملفك…", + "processing": "جارٍ تجهيز مستندك…", + "generating": "جارٍ صياغة ورقتك — عادةً ما يستغرق الأمر حوالي ٣٠ ثانية.", + "preparingPages": "جارٍ تجهيز الصفحات لتختار منها…" + }, + "errors": { + "unsupportedFileType": "لا يمكننا قراءة ملفات .{{ext}} بعد. جرّب PDF أو Word أو PowerPoint.", + "uploadIncomplete": "لم يكتمل الرفع. هل تريد المحاولة مرة أخرى؟", + "fileUnreadable": "تعذّرت علينا قراءة هذا الملف. هل تجرّب ملفًا آخر؟", + "genericReadError": "حدث خطأ ما أثناء قراءة ملفك. حاول مرة أخرى؟", + "generateFailed": "تعذّر علينا صياغة أسئلة من هذا الملف. هل تريد المحاولة مرة أخرى؟", + "taskFailed": "تعذّر علينا إنهاء ورقتك. هل تريد المحاولة مرة أخرى؟" + }, + "toolName": "Vsmart Upload", + "generatingState": { + "draftingTitle": "جارٍ صياغة ورقتك", + "draftingSubtitle": "نقرأ مستندك ونصوغ الأسئلة. يستغرق الأمر عادةً ٣٠ ثانية تقريبًا.", + "preparingPagesTitle": "جارٍ تجهيز الصفحات", + "preparingPagesSubtitle": "نجهّز مستندك لتختار منه." + }, + "questionConfig": { + "ctaLabel": "صياغة ورقتي", + "pickPagesLabel": "أو اختر صفحات محددة ←" + }, + "recentFiles": { + "title": "مسوداتك الأخيرة", + "fallbackLabel": "مسودة بلا عنوان", + "emptyHint": "ستظهر مسوداتك هنا. أسقط ملفًا أعلاه لتبدأ أول مسودة لك." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateAssessmentDialog.json b/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateAssessmentDialog.json new file mode 100644 index 0000000000..d28e3e6534 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateAssessmentDialog.json @@ -0,0 +1,5 @@ +{ + "title": "إنشاء تقييم", + "selectFromAllPages": "اختيار الأسئلة من جميع الصفحات", + "selectFromSpecificPages": "اختيار الأسئلة من صفحات محددة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateCompleteAssessment.json b/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateCompleteAssessment.json new file mode 100644 index 0000000000..ba17f663c4 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateCompleteAssessment.json @@ -0,0 +1,20 @@ +{ + "header": { + "logoAlt": "الشعار" + }, + "generateMore": { + "triggerButton": "إنشاء المزيد", + "dialogTitle": "إنشاء المزيد من الأسئلة", + "topicsPlaceholder": "أدخل المواضيع لإنشاء الأسئلة", + "numQuestionsPlaceholder": "عدد الأسئلة (مثال: 5)", + "difficultyPlaceholder": "مستوى الصعوبة (سهل، متوسط، صعب)", + "languagePlaceholder": "اللغة (الإنجليزية، الهندية، إلخ)", + "submitButton": "إنشاء الأسئلة" + }, + "closeButton": "إغلاق", + "questionCard": { + "duplicate": "تكرار", + "delete": "حذف" + }, + "noQuestions": "لا يوجد أي سؤال." +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterGeneratePageWiseAssessment.json b/frontend-admin-dashboard/public/locales/ar/aiCenterGeneratePageWiseAssessment.json new file mode 100644 index 0000000000..01569666a7 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterGeneratePageWiseAssessment.json @@ -0,0 +1,15 @@ +{ + "actions": { + "close": "إغلاق" + }, + "viewer": { + "allQuestions": "جميع الأسئلة", + "selectedQuestions": "الأسئلة المحددة" + }, + "copyConfirm": { + "title": "تنبيه", + "message": "هل أنت متأكد أنك تريد نسخ النص المحدد؟", + "no": "لا", + "yes": "نعم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterGeneratePageWiseAssessmentQuestionsDialog.json b/frontend-admin-dashboard/public/locales/ar/aiCenterGeneratePageWiseAssessmentQuestionsDialog.json new file mode 100644 index 0000000000..aea49034d7 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterGeneratePageWiseAssessmentQuestionsDialog.json @@ -0,0 +1,23 @@ +{ + "trigger": { + "extracting": "جارٍ الاستخراج…", + "extractCopiedQuestions": "استخراج الأسئلة المنسوخة" + }, + "header": { + "instituteLogoAlt": "شعار المؤسسة" + }, + "generateMore": { + "triggerLabel": "إنشاء المزيد", + "dialogTitle": "إنشاء المزيد من الأسئلة", + "inputPlaceholder": "أدخل المواضيع لإنشاء الأسئلة", + "submitLabel": "إنشاء الأسئلة" + }, + "closeButton": "إغلاق", + "pageActions": { + "duplicate": "تكرار", + "delete": "حذف" + }, + "emptyState": { + "noQuestions": "لا توجد أسئلة." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateQuestionsFromAudio.json b/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateQuestionsFromAudio.json new file mode 100644 index 0000000000..2fd6f8c0c6 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateQuestionsFromAudio.json @@ -0,0 +1,71 @@ +{ + "header": { + "title": "أسئلة من تسجيل صوتي", + "subtitle": "أسقط تسجيلاً صوتيًا. سنُفرِغه نصيًا، ثم نسألك عن نوع الأسئلة التي تريدها." + }, + "sections": { + "dropRecording": "أسقط تسجيلك الصوتي", + "questionKind": "ما نوع الأسئلة التي تريدها؟" + }, + "upload": { + "dropzoneTitle": "أسقط تسجيلك هنا، أو انقر للاختيار", + "dropzoneHint": "MP3 أو WAV أو FLAC أو AAC أو M4A.", + "removeFile": "إزالة الملف", + "uploading": "جارٍ رفع تسجيلك…", + "transcribing": "جارٍ تفريغه نصيًا — عادةً حوالي 30 ثانية." + }, + "fileStatus": { + "uploading": "جارٍ الرفع…", + "transcribing": "جارٍ التفريغ النصي…", + "readyToConfigure": "جاهز للإعداد", + "drafting": "جارٍ صياغة الأسئلة", + "done": "تم" + }, + "fields": { + "focusAreas": { + "label": "مجالات التركيز", + "placeholder": "مثال: التركيز على المفاهيم الأساسية للتمثيل الضوئي، بما في ذلك العملية والعوامل المؤثرة فيها" + }, + "numQuestions": { + "label": "كم عدد الأسئلة؟", + "placeholder": "10" + }, + "difficulty": { + "label": "مستوى الصعوبة" + }, + "language": { + "label": "اللغة" + } + }, + "difficulties": { + "easy": "سهل", + "medium": "متوسط", + "hard": "صعب" + }, + "languages": { + "english": "الإنجليزية", + "hindi": "الهندية" + }, + "errors": { + "taskFailed": "تعذّر علينا إتمام هذه المسودة. هل تريد المحاولة مرة أخرى؟", + "generateFailed": "تعذّر علينا صياغة أسئلة من هذا التسجيل. هل تريد المحاولة مرة أخرى؟", + "unsupportedFormat": "لا يمكننا قراءة ملفات .{{ext}}. جرّب MP3 أو WAV أو FLAC أو AAC أو M4A.", + "uploadIncomplete": "لم يكتمل الرفع. هل تريد المحاولة مرة أخرى؟", + "readError": "حدث خطأ ما أثناء قراءة تسجيلك. حاول مرة أخرى؟", + "missingFocus": "أخبرنا بما يجب أن تركز عليه الأسئلة.", + "missingCount": "كم عدد الأسئلة التي ترغب بها؟" + }, + "progress": { + "title": "جارٍ صياغة أسئلتك", + "subtitle": "الاستماع إلى التسجيل وصياغة الأسئلة. عادةً ما يستغرق ذلك ~30 ثانية." + }, + "actions": { + "draftQuestions": "أعدّ أسئلتي" + }, + "taskListHeading": "Vsmart Audio", + "recentDrafts": { + "title": "مسوداتك الأخيرة", + "fallbackLabel": "مسودة قائمة على تسجيل صوتي", + "emptyHint": "ستظهر مسوداتك القائمة على التسجيلات الصوتية هنا. أسقط تسجيلاً أعلاه للبدء." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateQuestionsFromText.json b/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateQuestionsFromText.json new file mode 100644 index 0000000000..bce809ef41 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterGenerateQuestionsFromText.json @@ -0,0 +1,72 @@ +{ + "header": { + "title": "أسئلة من موضوع", + "subtitle": "أخبرنا بالموضوع وما الذي يجب أن يتعلمه الطلاب. سنُعِدّ لك مجموعة من الأسئلة يمكنك تعديلها قبل الإرسال." + }, + "sections": { + "topic": "ما هو الموضوع؟", + "audience": "لمن هذا، وكم عدد الأسئلة؟" + }, + "fields": { + "topicName": { + "label": "اسم الموضوع", + "placeholder": "مثال: التمثيل الضوئي", + "error": "يرجى إعطاء الموضوع اسمًا." + }, + "learningGoal": { + "label": "ما الذي يجب أن يتعلمه الطلاب؟", + "placeholder": "مثال: اختبار فهم عملية التمثيل الضوئي، والعوامل المؤثرة فيها، وأهميتها في النظم البيئية", + "error": "يرجى وصف ما يجب أن تغطيه الأسئلة." + }, + "classLevel": { + "label": "الصف", + "placeholder": "مثال: الصف الثامن", + "error": "يرجى إدخال الصف" + }, + "numQuestions": { + "label": "عدد الأسئلة", + "placeholder": "10", + "error": "كم عدد الأسئلة التي ترغب بها؟" + }, + "questionType": { + "label": "نوع السؤال" + }, + "language": { + "label": "اللغة" + } + }, + "questionTypes": { + "mcq": "اختيار من متعدد", + "trueFalse": "صح/خطأ", + "numeric": "رقمي", + "shortAnswer": "إجابة قصيرة", + "mixed": "مختلط" + }, + "languages": { + "english": "الإنجليزية", + "hindi": "الهندية" + }, + "errors": { + "generateFailed": "تعذّر علينا صياغة أسئلة من هذا. هل تريد المحاولة مرة أخرى؟", + "taskFailed": "تعذّر علينا إتمام هذه المسودة. هل تريد المحاولة مرة أخرى؟" + }, + "progress": { + "title": "جارٍ صياغة أسئلتك", + "subtitle": "يتم إعداد أسئلة حول موضوعك. عادةً ما يستغرق ذلك ~30 ثانية." + }, + "result": { + "heading": "إليك ما أعددناه لك", + "aiGeneratedBadge": "منشأ بالذكاء الاصطناعي", + "description": "راجع الأسئلة وعدّلها قبل الحفظ أو التصدير. القرار النهائي دائمًا للمعلم.", + "draftAnother": "إعداد مسودة أخرى" + }, + "actions": { + "draftQuestions": "أعدّ أسئلتي" + }, + "taskListHeading": "Vsmart Topics", + "recentDrafts": { + "title": "مسوداتك الأخيرة", + "fallbackLabel": "مسودة قائمة على موضوع", + "emptyHint": "ستظهر مسوداتك القائمة على المواضيع هنا. املأ الموضوع أعلاه وأعدّ أول مسودة لك." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterHelper.json b/frontend-admin-dashboard/public/locales/ar/aiCenterHelper.json new file mode 100644 index 0000000000..d860a4770a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterHelper.json @@ -0,0 +1,5 @@ +{ + "svgAlt": "صورة SVG محوّلة", + "diagramNotAvailable": "الرسم التوضيحي غير متاح", + "invalidDate": "تاريخ غير صالح" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterIndex.json b/frontend-admin-dashboard/public/locales/ar/aiCenterIndex.json new file mode 100644 index 0000000000..3a462a6af3 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterIndex.json @@ -0,0 +1,5 @@ +{ + "navHeading": "المساعد التعليمي", + "tabMyResources": "مواردي", + "tabAiTools": "أدوات الذكاء الاصطناعي" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterModelSelector.json b/frontend-admin-dashboard/public/locales/ar/aiCenterModelSelector.json new file mode 100644 index 0000000000..67d78b9f30 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterModelSelector.json @@ -0,0 +1,10 @@ +{ + "aiModelLabel": "نموذج الذكاء الاصطناعي", + "unableToLoadModels": "تعذّر تحميل النماذج", + "tooltipDescription": "اختر نموذج ذكاء اصطناعي للمعالجة. يُنصح باستخدام النموذج الافتراضي في معظم الحالات.", + "selectModel": "اختر نموذجًا", + "defaultRecommended": "الافتراضي (موصى به)", + "defaultModelSummary": "{{name}} - {{description}}", + "availableModels": "النماذج المتاحة", + "footerNote": "تختلف قدرات النماذج فيما بينها. يعمل النموذج الافتراضي بشكل أفضل لمعظم المهام." +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterMyResourcesGenerateQuestions.json b/frontend-admin-dashboard/public/locales/ar/aiCenterMyResourcesGenerateQuestions.json new file mode 100644 index 0000000000..6ae6657172 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterMyResourcesGenerateQuestions.json @@ -0,0 +1,14 @@ +{ + "trigger": { + "label": "إنشاء أسئلة" + }, + "dialog": { + "title": "إنشاء أسئلة", + "promptLabel": "الطلب", + "promptPlaceholder": "أدخل طلبك هنا", + "generateButton": "إنشاء" + }, + "tasksList": { + "heading": "Vsmart Upload" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterMyResourcesGenerateQuestionsFromAudio.json b/frontend-admin-dashboard/public/locales/ar/aiCenterMyResourcesGenerateQuestionsFromAudio.json new file mode 100644 index 0000000000..7c7b77ebb0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterMyResourcesGenerateQuestionsFromAudio.json @@ -0,0 +1,29 @@ +{ + "trigger": { + "label": "إنشاء أسئلة" + }, + "dialog": { + "title": "إنشاء أسئلة من الصوت", + "fields": { + "topics": { + "label": "المواضيع", + "placeholder": "على سبيل المثال، أنشئ مجموعة من الأسئلة تغطي المبادئ الأساسية لعملية التمثيل الضوئي، بما في ذلك العملية والعوامل المؤثرة فيها وأهميتها في النظام البيئي. ركّز على الفهم المفاهيمي والتطبيق" + }, + "numQuestions": { + "label": "عدد الأسئلة", + "placeholder": "على سبيل المثال، 10" + }, + "difficulty": { + "label": "أدخل مستوى الصعوبة", + "placeholder": "على سبيل المثال سهل ومتوسط وصعب" + }, + "language": { + "label": "لغة الأسئلة" + } + }, + "submitButton": "إرسال" + }, + "tasksList": { + "heading": "Vsmart Audio" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterMyResourcesIndex.json b/frontend-admin-dashboard/public/locales/ar/aiCenterMyResourcesIndex.json new file mode 100644 index 0000000000..a9aefa2d45 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterMyResourcesIndex.json @@ -0,0 +1,4 @@ +{ + "navHeading": "قائمة مصادري", + "pageTitle": "مصادري" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterPaperSetQuestionsAI.json b/frontend-admin-dashboard/public/locales/ar/aiCenterPaperSetQuestionsAI.json new file mode 100644 index 0000000000..ec8f943e13 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterPaperSetQuestionsAI.json @@ -0,0 +1,5 @@ +{ + "letterheadAlt": "الترويسة", + "customFieldLabel": "{{label}}:", + "roughWorkHeading": "مساحة للأعمال التحضيرية" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterPlanLectureAI.json b/frontend-admin-dashboard/public/locales/ar/aiCenterPlanLectureAI.json new file mode 100644 index 0000000000..78937f2aec --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterPlanLectureAI.json @@ -0,0 +1,69 @@ +{ + "header": { + "title": "مخطِّط الدروس", + "subtitle": "أخبرنا بما ينبغي أن يتعلمه الطلاب، وسنعدّ لك خطة يمكنك تنقيحها." + }, + "steps": { + "goal": { + "title": "ماذا ينبغي أن يتعلم الطلاب أو يكونوا قادرين على فعله؟", + "hint": "كلما كان هدفك أوضح، كانت الخطة أفضل.", + "placeholder": "بنهاية هذه المحاضرة، ينبغي أن يفهم الطلاب عملية التمثيل الضوئي وأن يكونوا قادرين على شرح أهميتها في النظام البيئي.", + "requiredError": "يرجى وصف ما ينبغي أن يتعلمه الطلاب." + }, + "audience": { + "title": "من الذي تُدرّسه، ولأي مدة؟", + "classLabel": "الصف", + "classPlaceholder": "مثال: الصف الثامن", + "classRequiredError": "يرجى إدخال الصف", + "durationLabel": "المدة", + "hoursPlaceholder": "0", + "minutesPlaceholder": "40", + "hoursUnit": "ساعات", + "minutesUnit": "دقائق" + }, + "refine": { + "title": "اضبط الأسلوب", + "hint": "اختياري — لقد اخترنا لك إعدادات افتراضية مناسبة.", + "teachingStyleLabel": "أسلوب التدريس", + "languageLabel": "اللغة", + "questionsToggleLabel": "تضمين أسئلة في الخطة", + "questionsToggleDescription": "أضِف أسئلة نقاش أو أسئلة للتحقق من الفهم في كل جزء.", + "homeworkToggleLabel": "تضمين واجب منزلي", + "homeworkToggleDescription": "أضِف واجبًا قصيرًا يمكن للطلاب إنجازه بعد المحاضرة." + } + }, + "teachingMethodOptions": { + "conceptFirst": "المفهوم أولًا", + "storytelling": "سرد قصصي", + "problemSolution": "مشكلة ثم حل", + "questionLed": "قائم على الأسئلة", + "stepByStepTutorial": "درس تعليمي خطوة بخطوة", + "gamified": "أسلوب تفاعلي (ألعاب)", + "caseBasedLearning": "تعلّم قائم على دراسة حالات" + }, + "languageOptions": { + "english": "الإنجليزية", + "hindi": "الهندية" + }, + "errors": { + "generateFailed": "تعذّر علينا إعداد خطة. هل تريد المحاولة مرة أخرى؟", + "taskFailed": "تعذّر علينا إنهاء هذه الخطة. هل تريد المحاولة مرة أخرى؟" + }, + "actions": { + "draftMyPlan": "أعدّ خطتي" + }, + "generating": { + "title": "جارٍ إعداد خطتك", + "subtitle": "يتم تنظيم المحاضرة إلى أجزاء واضحة. يستغرق ذلك عادةً حوالي 30 ثانية." + }, + "draftingDone": { + "title": "إليك خطة الدرس التي أعددناها", + "subtitle": "افتحها لمراجعة الجدول الزمني أو تعديل أي جزء أو تصديرها." + }, + "recentFiles": { + "title": "خططك الأخيرة", + "fallbackLabel": "خطة درس", + "emptyHint": "ستظهر خطط دروسك هنا. أعدّ خطتك الأولى أعلاه." + }, + "productName": "Vsmart Lecturer" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterPlanLecturePreview.json b/frontend-admin-dashboard/public/locales/ar/aiCenterPlanLecturePreview.json new file mode 100644 index 0000000000..1057d54030 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterPlanLecturePreview.json @@ -0,0 +1,19 @@ +{ + "header": { + "title": "معاينة محاضرة VSmart" + }, + "labels": { + "lectureTitle": "📘 عنوان المحاضرة: {{title}}", + "level": "المستوى:", + "modeOfTeaching": "أسلوب التدريس:", + "lectureLanguage": "لغة المحاضرة:", + "lectureDuration": "مدة المحاضرة:", + "topicCovered": "الموضوع المُغطّى:", + "content": "المحتوى:", + "inLectureQuestion": "سؤال أثناء المحاضرة:", + "activity": "النشاط:", + "assignment": "الواجب", + "notApplicable": "غير متاح", + "task": "المهمة:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterPlayWithPDF.json b/frontend-admin-dashboard/public/locales/ar/aiCenterPlayWithPDF.json new file mode 100644 index 0000000000..e347bde06f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterPlayWithPDF.json @@ -0,0 +1,45 @@ +{ + "header": { + "title": "الدردشة مع مستند", + "subtitle": "أضف ملف PDF، ثم اسأل عنه أي شيء — ملخصات، أسئلة، أو شروحات." + }, + "upload": { + "dropTitle": "أفلت مستندك هنا، أو انقر للاختيار", + "dropSubtitle": "PDF أو Word أو PowerPoint — أي شيء تريد الدردشة معه.", + "fallbackFileName": "الدردشة مع المستند", + "statusUploading": "جارٍ الرفع…", + "statusProcessing": "جارٍ القراءة…", + "statusReady": "جاهز للدردشة", + "workingUploading": "جارٍ قراءة ملفك…", + "workingProcessing": "جارٍ تجهيز مستندك للدردشة…", + "resetAriaLabel": "البدء من جديد بمستند آخر" + }, + "chat": { + "emptyTitle": "اسأل أي شيء عن هذا المستند", + "emptySubtitle": "استخدم الاقتراحات أدناه، أو اكتب سؤالك الخاص.", + "suggestions": { + "mainIdea": "ما الفكرة الرئيسية لهذا المستند؟", + "summarize": "لخّص النقاط الرئيسية.", + "generateQuestions": "أنشئ 5 أسئلة حول المفاهيم الرئيسية." + }, + "pending": "جارٍ قراءة المستند…", + "inputPlaceholder": "اسأل أي شيء عن هذا المستند…", + "send": "إرسال", + "historyTitle": "سجل الدردشة" + }, + "errors": { + "unsupportedFormat": "لا يمكننا قراءة ملفات .{{ext}}. جرّب PDF أو Word أو PowerPoint.", + "uploadIncomplete": "لم يكتمل الرفع. هل تريد المحاولة مرة أخرى؟", + "readFailed": "تعذّرت علينا قراءة هذا الملف. هل تجرّب ملفًا آخر؟", + "genericFailure": "حدث خطأ ما أثناء قراءة ملفك. حاول مرة أخرى؟", + "noResponse": "لا يوجد رد بعد. هل تحاول السؤال مرة أخرى؟" + }, + "recentFiles": { + "title": "محادثاتك الأخيرة", + "fallbackLabel": "محادثة مستند", + "emptyHint": "ستظهر جلسات الدردشة هنا. أضف مستندًا أعلاه لبدء واحدة." + }, + "tasksList": { + "heading": "Vsmart Chat" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterPromptDummyData.json b/frontend-admin-dashboard/public/locales/ar/aiCenterPromptDummyData.json new file mode 100644 index 0000000000..f66ae93c5f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterPromptDummyData.json @@ -0,0 +1,14 @@ +{ + "topic": { + "heading": "الموضوع", + "description": "على سبيل المثال، اختر مجموعة من الأسئلة التي تغطي المبادئ الأساسية لعملية البناء الضوئي، بما في ذلك العملية والعوامل المؤثرة فيها وأهميتها في النظام البيئي. ركّز على الفهم المفاهيمي والتطبيق" + }, + "pages": { + "heading": "الصفحات", + "description": "على سبيل المثال، اختر أسئلة من الصفحات 5-10 التي تتناول عملية البناء الضوئي والمفاهيم ذات الصلة." + }, + "questionNo": { + "heading": "أرقام الأسئلة", + "description": "على سبيل المثال، اختر 10 أسئلة تركّز على عملية البناء الضوئي." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterQuestionConfigPanel.json b/frontend-admin-dashboard/public/locales/ar/aiCenterQuestionConfigPanel.json new file mode 100644 index 0000000000..20d7aa61b6 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterQuestionConfigPanel.json @@ -0,0 +1,33 @@ +{ + "defaultTitle": "كيف تريد الأسئلة؟", + "defaultSubtitle": "عدّل هذه الإعدادات أو استخدم الإعدادات الافتراضية فقط.", + "defaultCtaLabel": "صياغة ورقتي", + "howManyLabel": "كم عدد الأسئلة؟", + "customPlaceholder": "مخصص", + "difficultyLabel": "مستوى الصعوبة", + "questionTypeLabel": "نوع السؤال", + "languageLabel": "اللغة", + "questionTypeMcq": "اختيار من متعدد", + "questionTypeTrueFalse": "صواب/خطأ", + "questionTypeNumeric": "رقمي", + "questionTypeShortAnswer": "إجابة قصيرة", + "questionTypeMixed": "مختلط", + "difficultyEasy": "سهل", + "difficultyMedium": "متوسط", + "difficultyHard": "صعب", + "languageEnglish": "الإنجليزية", + "languageHindi": "الهندية", + "promptVariousTypeQuestions_zero": "لا أسئلة من أنواع متنوعة", + "promptVariousTypeQuestions_one": "سؤال واحد من أنواع متنوعة", + "promptVariousTypeQuestions_two": "سؤالان من أنواع متنوعة", + "promptVariousTypeQuestions_few": "أسئلة من أنواع متنوعة", + "promptVariousTypeQuestions_many": "سؤالًا من أنواع متنوعة", + "promptVariousTypeQuestions_other": "سؤال من أنواع متنوعة", + "promptTypedQuestions_zero": "لا أسئلة {{type}}", + "promptTypedQuestions_one": "سؤال {{type}} واحد", + "promptTypedQuestions_two": "سؤالا {{type}}", + "promptTypedQuestions_few": "أسئلة {{type}}", + "promptTypedQuestions_many": "سؤالًا من نوع {{type}}", + "promptTypedQuestions_other": "سؤال {{type}}", + "promptTemplate": "أنشئ {{count}} {{typeText}} باللغة {{language}}. مستوى الصعوبة: {{difficulty}}." +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterQuestionsFromTextDialog.json b/frontend-admin-dashboard/public/locales/ar/aiCenterQuestionsFromTextDialog.json new file mode 100644 index 0000000000..f111b844a0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterQuestionsFromTextDialog.json @@ -0,0 +1,31 @@ +{ + "heading": "إنشاء أسئلة من المواضيع", + "fields": { + "topics": { + "label": "المواضيع", + "placeholder": "أدخل الموضوع الذي تريد إنشاء السؤال من أجله" + }, + "detailsOfTopics": { + "label": "تفاصيل المواضيع", + "placeholder": "على سبيل المثال، أنشئ مجموعة من الأسئلة تغطي المبادئ الأساسية لعملية التمثيل الضوئي، بما في ذلك العملية والعوامل المؤثرة فيها وأهميتها في النظام البيئي. ركّز على الفهم المفاهيمي والتطبيق" + }, + "numQuestions": { + "label": "عدد الأسئلة", + "placeholder": "على سبيل المثال، 10" + }, + "classLevel": { + "placeholder": "على سبيل المثال، الصف الثامن" + }, + "questionType": { + "label": "نوع السؤال", + "placeholder": "مثال: عددي، اختيار من متعدد، صح/خطأ، إلخ" + }, + "questionLanguage": { + "label": "لغة السؤال" + } + }, + "languages": { + "english": "الإنجليزية", + "hindi": "الهندية" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterRecentWorkDialog.json b/frontend-admin-dashboard/public/locales/ar/aiCenterRecentWorkDialog.json new file mode 100644 index 0000000000..1e1ed83679 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterRecentWorkDialog.json @@ -0,0 +1,41 @@ +{ + "dialog": { + "title": "أعمالك الأخيرة", + "close": "إغلاق", + "searchPlaceholder": "ابحث بالاسم أو اسم الملف…", + "clearSearch": "مسح البحث", + "itemCount_zero": "{{count}} عنصر عبر جميع الأدوات", + "itemCount_one": "{{count}} عنصر واحد عبر جميع الأدوات", + "itemCount_two": "{{count}} عنصران عبر جميع الأدوات", + "itemCount_few": "{{count}} عناصر عبر جميع الأدوات", + "itemCount_many": "{{count}} عنصرًا عبر جميع الأدوات", + "itemCount_other": "{{count}} عنصر عبر جميع الأدوات" + }, + "sourceFilters": { + "all": "كل المصادر", + "pdf": "ملفات PDF", + "audio": "صوت", + "image": "صور", + "doc": "مستندات", + "none": "حسب الموضوع" + }, + "dateBuckets": { + "today": "اليوم", + "yesterday": "أمس", + "earlierThisWeek": "في وقت سابق من هذا الأسبوع", + "older": "أقدم" + }, + "emptyState": { + "nothingHereYet": "لا يوجد شيء هنا بعد", + "nothingHereYetDescription": "سيظهر هنا كل ما تنشئه.", + "noMatches": "لا توجد نتائج مطابقة", + "noMatchesDescription": "جرّب مصطلح بحث أو عامل تصفية مختلفًا.", + "clearFilters": "مسح عوامل التصفية" + }, + "pagination": { + "showingRange": "عرض {{start}}–{{end}} من {{total}}", + "prev": "السابق", + "next": "التالي", + "pageOf": "صفحة {{current}} من {{total}}" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartAudio.json b/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartAudio.json new file mode 100644 index 0000000000..58d013dfc7 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartAudio.json @@ -0,0 +1,33 @@ +{ + "heading": "فيسمارت الصوت", + "fields": { + "text": { + "label": "النص", + "placeholder": "أدخل النص" + }, + "numQuestions": { + "label": "عدد الأسئلة", + "placeholder": "أدخل عدد الأسئلة" + }, + "classLevel": { + "label": "المستوى الدراسي", + "placeholder": "أدخل المستوى الدراسي" + }, + "topics": { + "label": "المواضيع", + "placeholder": "أدخل المواضيع" + }, + "questionType": { + "label": "نوع السؤال", + "placeholder": "أدخل نوع السؤال" + }, + "questionLanguage": { + "label": "لغة الأسئلة", + "placeholder": "أدخل لغة الأسئلة" + } + }, + "actions": { + "cancel": "إلغاء", + "regenerate": "إعادة إنشاء" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartExtract.json b/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartExtract.json new file mode 100644 index 0000000000..37d07293b1 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartExtract.json @@ -0,0 +1,15 @@ +{ + "dialog": { + "heading": "دي سمارت إكستراكت" + }, + "fields": { + "prompt": { + "label": "أدخل موجّهك", + "placeholder": "أدخل موجّهك هنا..." + } + }, + "actions": { + "cancel": "إلغاء", + "regenerate": "إعادة الإنشاء" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartImage.json b/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartImage.json new file mode 100644 index 0000000000..da705bd702 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartImage.json @@ -0,0 +1,15 @@ +{ + "dialog": { + "heading": "رفع Vsmart" + }, + "fields": { + "prompt": { + "label": "أدخل الطلب الخاص بك", + "placeholder": "أدخل الطلب الخاص بك هنا..." + } + }, + "actions": { + "cancel": "إلغاء", + "regenerate": "إعادة الإنشاء" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartOrganizer.json b/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartOrganizer.json new file mode 100644 index 0000000000..c38c25ceb9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartOrganizer.json @@ -0,0 +1,15 @@ +{ + "dialog": { + "heading": "دي سمارت أورجانايزر" + }, + "fields": { + "prompt": { + "label": "أدخل الطلب الخاص بك", + "placeholder": "أدخل الطلب هنا..." + } + }, + "actions": { + "cancel": "إلغاء", + "regenerate": "إعادة الإنشاء" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartPrompt.json b/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartPrompt.json new file mode 100644 index 0000000000..532704a86d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartPrompt.json @@ -0,0 +1,43 @@ +{ + "dialog": { + "heading": "دي سمارت برومبت" + }, + "fields": { + "text": { + "label": "النص", + "placeholder": "أدخل النص" + }, + "num": { + "label": "عدد الأسئلة", + "placeholder": "أدخل عدد الأسئلة" + }, + "classLevel": { + "label": "المستوى الدراسي", + "placeholder": "أدخل المستوى الدراسي" + }, + "topics": { + "label": "المواضيع", + "placeholder": "أدخل المواضيع" + }, + "questionType": { + "label": "نوع السؤال", + "placeholder": "أدخل نوع السؤال" + }, + "questionLanguage": { + "label": "لغة السؤال", + "placeholder": "أدخل لغة السؤال" + } + }, + "actions": { + "cancel": "إلغاء", + "regenerate": "إعادة الإنشاء" + }, + "validation": { + "textRequired": "النص مطلوب", + "numRequired": "يجب ألا يقل عدد الأسئلة عن 1", + "classLevelRequired": "المستوى الدراسي مطلوب", + "topicsRequired": "المواضيع مطلوبة", + "questionTypeRequired": "نوع السؤال مطلوب", + "questionLanguageRequired": "لغة السؤال مطلوبة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartSorter.json b/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartSorter.json new file mode 100644 index 0000000000..31881b89b0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartSorter.json @@ -0,0 +1,15 @@ +{ + "dialog": { + "heading": "رفع دي سمارت" + }, + "fields": { + "prompt": { + "label": "أدخل موجّهك", + "placeholder": "أدخل موجّهك هنا..." + } + }, + "actions": { + "cancel": "إلغاء", + "regenerate": "إعادة الإنشاء" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartUpload.json b/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartUpload.json new file mode 100644 index 0000000000..c5da7d0763 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterRegenerateVsmartUpload.json @@ -0,0 +1,15 @@ +{ + "dialog": { + "heading": "دي سمارت أبلود" + }, + "fields": { + "prompt": { + "label": "أدخل موجّهك", + "placeholder": "أدخل موجّهك هنا..." + } + }, + "actions": { + "cancel": "إلغاء", + "regenerate": "إعادة الإنشاء" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterResourcesCard.json b/frontend-admin-dashboard/public/locales/ar/aiCenterResourcesCard.json new file mode 100644 index 0000000000..a52b218ede --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterResourcesCard.json @@ -0,0 +1,20 @@ +{ + "emptyState": { + "title": "الموارد", + "description": "إدارة وعرض الملفات التي رفعتها.", + "heading": "لم يتم العثور على ملفات.", + "body": "لا توجد موارد متاحة حاليًا أو أن تفاصيل الملفات المرتبطة بها ناقصة." + }, + "title": "الموارد", + "description": "قائمة بالملفات الفريدة، مرتبة حسب الأحدث إنشاءً.", + "table": { + "fileName": "اسم الملف", + "type": "النوع", + "createdOn": "تاريخ الإنشاء", + "actions": "الإجراءات" + }, + "actions": { + "download": "تنزيل {{fileName}}", + "noFile": "-" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterSortAndSplitTopicQuestions.json b/frontend-admin-dashboard/public/locales/ar/aiCenterSortAndSplitTopicQuestions.json new file mode 100644 index 0000000000..eafe2f064b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterSortAndSplitTopicQuestions.json @@ -0,0 +1,64 @@ +{ + "header": { + "title": "استخراج أسئلة محددة", + "subtitle": "أخبرنا بما تريده، ثم أسقط ورقة الأسئلة. سنستخرج بالضبط تلك الأسئلة." + }, + "sections": { + "step1Title": "ما الذي تريد استخراجه؟", + "step2Title": "صِف ما تريده", + "step3Title": "أسقط ملفك" + }, + "filterOptions": { + "topic": { + "label": "مواضيع محددة", + "description": "استخرج الأسئلة المرتبطة بالمواضيع التي تحددها.", + "placeholder": "مثال: أسئلة تغطي التمثيل الضوئي، والعوامل المؤثرة فيه، وأهميته في النظام البيئي" + }, + "pages": { + "label": "صفحات محددة", + "description": "استخرج الأسئلة من نطاق صفحات تحدده.", + "placeholder": "مثال: أسئلة من الصفحات 5 إلى 10 حول التمثيل الضوئي" + }, + "questionNo": { + "label": "مجموعة من أرقام الأسئلة", + "description": "استخرج مجموعة محددة من الأسئلة حسب الموضوع أو العدد.", + "placeholder": "مثال: 10 أسئلة تركز على التمثيل الضوئي" + } + }, + "upload": { + "dropTitle": "أسقط ملفك هنا، أو انقر للاختيار", + "dropSubtitle": "ملف PDF أو Word أو PowerPoint يحتوي على أسئلة.", + "removeAriaLabel": "إزالة الملف", + "statusUploading": "جارٍ الرفع…", + "statusProcessing": "جارٍ القراءة…", + "statusReady": "جاهز للاستخراج", + "statusGenerating": "جارٍ الاستخراج…", + "statusDone": "تم", + "workingUploading": "جارٍ قراءة ملفك…", + "workingProcessing": "جارٍ تجهيز مستندك…", + "workingGenerating": "جارٍ استخراج الأسئلة التي طلبتها — عادةً حوالي 30 ثانية." + }, + "generate": { + "ctaLabel": "استخراج الأسئلة", + "doneHeading": "منظم Vsmart", + "generatingTitle": "جارٍ استخراج الأسئلة التي طلبتها", + "generatingSubtitle": "جارٍ قراءة الأسئلة المطابقة واختيارها. عادةً حوالي 30 ثانية." + }, + "recentFiles": { + "title": "عمليات الاستخراج الأخيرة", + "fallbackLabel": "مجموعة مستخرجة", + "emptyHint": "ستظهر مجموعات الأسئلة المستخرجة هنا. أخبرنا بما تريده أعلاه، ثم أسقط ملفًا." + }, + "tasksList": { + "heading": "منظم Vsmart" + }, + "errors": { + "unsupportedFormat": "لا يمكننا قراءة ملفات .{{ext}}. جرّب PDF أو Word أو PowerPoint.", + "missingPrompt": "أخبرنا أولاً بما تريد استخراجه، ثم أسقط ملفك.", + "uploadIncomplete": "لم يكتمل الرفع. هل تريد المحاولة مرة أخرى؟", + "readFailed": "تعذّرت علينا قراءة هذا الملف. هل تريد تجربة ملف آخر؟", + "genericFailure": "حدث خطأ ما أثناء قراءة ملفك. حاول مرة أخرى؟", + "extractFailed": "تعذّر علينا استخراج هذه الأسئلة. هل تريد تنقيح الوصف؟", + "pullFailed": "تعذّر علينا إنهاء هذا الاستخراج. هل تريد المحاولة مرة أخرى؟" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterSortTopicQuestions.json b/frontend-admin-dashboard/public/locales/ar/aiCenterSortTopicQuestions.json new file mode 100644 index 0000000000..b9108a1d6b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterSortTopicQuestions.json @@ -0,0 +1,47 @@ +{ + "header": { + "title": "الفرز التلقائي حسب الموضوع", + "subtitle": "أسقط ورقة أسئلة. سنقوم تلقائيًا بتجميع الأسئلة حسب الموضوع لتعيد استخدامها." + }, + "prompt": { + "label": "هل لديك مواضيع محددة في ذهنك؟", + "optional": "(اختياري)", + "placeholder": "مثال: ركّز على التمثيل الضوئي، والتنفس، وتغذية النبات", + "hint": "اتركه فارغًا وسنحدد المواضيع بأنفسنا." + }, + "upload": { + "dropTitle": "أسقط ملفك هنا، أو انقر للاختيار", + "dropSubtitle": "ملف PDF أو Word أو PowerPoint يحتوي على أسئلة.", + "removeAriaLabel": "إزالة الملف", + "statusUploading": "جارٍ الرفع…", + "statusProcessing": "جارٍ القراءة…", + "statusReady": "جاهز للفرز", + "statusGenerating": "جارٍ الفرز…", + "statusDone": "تم", + "workingUploading": "جارٍ قراءة ملفك…", + "workingProcessing": "جارٍ تجهيز مستندك…", + "workingGenerating": "جارٍ تجميع الأسئلة حسب الموضوع — عادةً حوالي 30 ثانية." + }, + "generate": { + "ctaLabel": "فرز وإعداد المسودة", + "doneHeading": "فارز Vsmart", + "generatingTitle": "جارٍ تجميع الأسئلة حسب الموضوع", + "generatingSubtitle": "جارٍ قراءة الورقة وتنظيمها حسب الموضوع. عادةً حوالي 30 ثانية." + }, + "recentFiles": { + "title": "عمليات الفرز الأخيرة", + "fallbackLabel": "مجموعة أسئلة مُفرزة", + "emptyHint": "ستظهر مجموعات الأسئلة المُفرزة هنا. أسقط ملفًا أعلاه للبدء." + }, + "tasksList": { + "heading": "فارز Vsmart" + }, + "errors": { + "unsupportedFormat": "لا يمكننا قراءة ملفات .{{ext}}. جرّب PDF أو Word أو PowerPoint.", + "uploadIncomplete": "لم يكتمل الرفع. هل تريد المحاولة مرة أخرى؟", + "readFailed": "تعذّرت علينا قراءة هذا الملف. هل تريد تجربة ملف آخر؟", + "genericFailure": "حدث خطأ ما أثناء قراءة ملفك. حاول مرة أخرى؟", + "taskFailed": "تعذّر علينا إنهاء عملية الفرز هذه. هل تريد المحاولة مرة أخرى؟", + "sortFailed": "تعذّر علينا فرز هذا الملف. هل تريد تجربة ملف آخر؟" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterTopicWiseQuestionsComponent.json b/frontend-admin-dashboard/public/locales/ar/aiCenterTopicWiseQuestionsComponent.json new file mode 100644 index 0000000000..b7e949a2fb --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterTopicWiseQuestionsComponent.json @@ -0,0 +1,17 @@ +{ + "trigger": { + "label": "فرز الأسئلة حسب الموضوع" + }, + "dialog": { + "title": "فرز الأسئلة حسب الموضوع", + "options": { + "topic": "اختر أي أسئلة يغطيها موضوع معين", + "pages": "اختر أسئلة من صفحات محددة", + "questionNo": "اختر مجموعة من الأسئلة" + }, + "extractButton": "استخراج" + }, + "tasksList": { + "heading": "Vsmart Organizer" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterUploadFileMyResourcesComponent.json b/frontend-admin-dashboard/public/locales/ar/aiCenterUploadFileMyResourcesComponent.json new file mode 100644 index 0000000000..127b7b6330 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterUploadFileMyResourcesComponent.json @@ -0,0 +1,12 @@ +{ + "trigger": { + "uploadButton": "رفع" + }, + "dialog": { + "title": "رفع ملف", + "uploadButton": "رفع PDF/DOCX/PPT" + }, + "toast": { + "success": "تم رفع الملف بنجاح!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterUseAIModels.json b/frontend-admin-dashboard/public/locales/ar/aiCenterUseAIModels.json new file mode 100644 index 0000000000..14f4bd1102 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterUseAIModels.json @@ -0,0 +1,4 @@ +{ + "providerModelFallback": "نموذج {{provider}}", + "defaultModelFallback": "النموذج الافتراضي" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartAudioIndex.json b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartAudioIndex.json new file mode 100644 index 0000000000..c1b8d146f5 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartAudioIndex.json @@ -0,0 +1,3 @@ +{ + "navHeading": "أسئلة من تسجيل صوتي" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartChatIndex.json b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartChatIndex.json new file mode 100644 index 0000000000..96dfa23f84 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartChatIndex.json @@ -0,0 +1,3 @@ +{ + "navHeading": "الدردشة مع مستند" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartExtractGenerateQuestionPaper.json b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartExtractGenerateQuestionPaper.json new file mode 100644 index 0000000000..36b70f4922 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartExtractGenerateQuestionPaper.json @@ -0,0 +1,46 @@ +{ + "header": { + "title": "إعادة استخدام أسئلة موجودة", + "subtitle": "أفلت ورقة أسئلة (PDF أو Word أو PowerPoint). سنستخرج الأسئلة ونحوّلها إلى مجموعة قابلة للتعديل." + }, + "dropzone": { + "instruction": "أفلت ورقة الأسئلة هنا، أو انقر للاختيار", + "hint": "ملف PDF أو Word أو PowerPoint يحتوي على أسئلة موجودة." + }, + "fileCard": { + "removeAriaLabel": "إزالة الملف", + "status": { + "uploading": "جارٍ الرفع…", + "processing": "جارٍ القراءة…", + "ready": "جاهز للاستخراج", + "generating": "جارٍ استخراج الأسئلة", + "done": "تم" + } + }, + "workingLabel": { + "uploading": "جارٍ قراءة ملفك…", + "processing": "جارٍ تجهيز مستندك…", + "generating": "جارٍ استخراج الأسئلة — يستغرق عادة نحو 30 ثانية." + }, + "generatingState": { + "title": "جارٍ استخراج الأسئلة", + "subtitle": "جارٍ قراءة الورقة ورقمنة كل سؤال. يستغرق عادة نحو 30 ثانية." + }, + "questionConfig": { + "ctaLabel": "استخراج الأسئلة" + }, + "recentFiles": { + "title": "عمليات الاستخراج الأخيرة", + "emptyHint": "ستظهر عمليات الاستخراج هنا. أفلت ورقة أسئلة أعلاه للبدء.", + "fallbackLabel": "استخراج بلا عنوان" + }, + "productName": "Vsmart Extract", + "errors": { + "unsupportedFormat": "لا يمكننا قراءة ملفات .{{ext}} حاليًا. جرّب PDF أو Word أو PowerPoint.", + "uploadFailed": "لم يكتمل الرفع. هل تريد المحاولة مرة أخرى؟", + "processFailed": "تعذّرت قراءة هذا الملف. جرّب ملفًا آخر؟", + "readFailed": "حدث خطأ ما أثناء قراءة ملفك. حاول مرة أخرى؟", + "extractionFailed": "تعذّر إنهاء هذا الاستخراج. هل تريد المحاولة مرة أخرى؟", + "generateFailed": "تعذّر استخراج الأسئلة من هذا الملف. جرّب ملفًا آخر؟" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartExtractIndex.json b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartExtractIndex.json new file mode 100644 index 0000000000..5232b181c2 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartExtractIndex.json @@ -0,0 +1,3 @@ +{ + "navHeading": "إعادة استخدام أسئلة موجودة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartFeedbackIndex.json b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartFeedbackIndex.json new file mode 100644 index 0000000000..c65df02df3 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartFeedbackIndex.json @@ -0,0 +1,3 @@ +{ + "navHeading": "مدرّب المحاضرات" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartImageGenerateQuestionPaper.json b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartImageGenerateQuestionPaper.json new file mode 100644 index 0000000000..9a5a8815ee --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartImageGenerateQuestionPaper.json @@ -0,0 +1,46 @@ +{ + "header": { + "title": "أسئلة من صورة", + "subtitle": "التقط أو ارفع صورة لورقة أسئلة مطبوعة. سنحوّلها إلى مجموعة أسئلة رقمية قابلة للتعديل." + }, + "dropzone": { + "instruction": "أفلت صورة هنا، أو انقر للاختيار", + "hint": "بصيغة JPG أو PNG — الصور الواضحة تعطي أفضل النتائج، حتى المكتوبة بخط اليد." + }, + "fileCard": { + "removeAriaLabel": "إزالة الملف", + "status": { + "uploading": "جارٍ الرفع…", + "processing": "جارٍ القراءة…", + "ready": "جاهز للاستخراج", + "generating": "جارٍ استخراج الأسئلة", + "done": "تم" + } + }, + "workingLabel": { + "uploading": "جارٍ قراءة صورتك…", + "processing": "جارٍ النظر في الأسئلة…", + "generating": "جارٍ استخراج الأسئلة — عادةً ما يستغرق ذلك نحو 30 ثانية." + }, + "generatingState": { + "title": "جارٍ استخراج الأسئلة", + "subtitle": "قراءة صورتك ورقمنة كل سؤال. يستغرق ذلك عادةً نحو 30 ثانية." + }, + "questionConfig": { + "ctaLabel": "استخراج الأسئلة" + }, + "recentFiles": { + "title": "عمليات الاستخراج الأخيرة", + "emptyHint": "ستظهر هنا مجموعات الأسئلة المستخرجة. أفلت صورة أعلاه للبدء.", + "fallbackLabel": "استخراج بلا عنوان" + }, + "productName": "Vsmart Image", + "errors": { + "unsupportedFormat": "لا يمكننا قراءة ملفات .{{ext}}. جرّب JPG أو PNG.", + "uploadFailed": "لم يكتمل الرفع. هل تريد المحاولة مرة أخرى؟", + "processFailed": "تعذّرت علينا قراءة هذه الصورة. جرّب صورة أوضح؟", + "readFailed": "حدث خطأ أثناء قراءة صورتك. حاول مرة أخرى؟", + "extractionFailed": "تعذّر علينا إنهاء هذا الاستخراج. هل تريد المحاولة مرة أخرى؟", + "generateFailed": "تعذّر علينا استخراج الأسئلة من هذه الصورة. جرّب صورة أوضح؟" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartImageIndex.json b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartImageIndex.json new file mode 100644 index 0000000000..7b543c2316 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartImageIndex.json @@ -0,0 +1,3 @@ +{ + "navHeading": "أسئلة من صورة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartLectureIndex.json b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartLectureIndex.json new file mode 100644 index 0000000000..26b361a2b7 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartLectureIndex.json @@ -0,0 +1,3 @@ +{ + "navHeading": "مخطط الدرس" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartOrganizerIndex.json b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartOrganizerIndex.json new file mode 100644 index 0000000000..3b9a75f57f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartOrganizerIndex.json @@ -0,0 +1,3 @@ +{ + "navHeading": "استخراج أسئلة محددة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartPromptIndex.json b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartPromptIndex.json new file mode 100644 index 0000000000..8410015551 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartPromptIndex.json @@ -0,0 +1,3 @@ +{ + "navHeading": "أسئلة من موضوع" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartSorterIndex.json b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartSorterIndex.json new file mode 100644 index 0000000000..068ea526d0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartSorterIndex.json @@ -0,0 +1,3 @@ +{ + "navHeading": "الفرز التلقائي حسب الموضوع" +} diff --git a/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartUploadIndex.json b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartUploadIndex.json new file mode 100644 index 0000000000..0e72ee5270 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/aiCenterVsmartUploadIndex.json @@ -0,0 +1,3 @@ +{ + "navHeading": "إنشاء ورقة أسئلة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentAccessControlTab.json b/frontend-admin-dashboard/public/locales/ar/assessmentAccessControlTab.json new file mode 100644 index 0000000000..8652a8fedb --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentAccessControlTab.json @@ -0,0 +1,8 @@ +{ + "sections": { + "creationAccessTitle": "صلاحية إنشاء التقييم", + "liveNotificationsTitle": "إشعارات التقييم المباشر", + "submissionReportsTitle": "صلاحية تسليم التقييم والتقارير", + "evaluationTitle": "صلاحية التقييم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentAddEditCriteriaDialog.json b/frontend-admin-dashboard/public/locales/ar/assessmentAddEditCriteriaDialog.json new file mode 100644 index 0000000000..714b63c703 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentAddEditCriteriaDialog.json @@ -0,0 +1,80 @@ +{ + "dialog": { + "headingEdit": "تعديل معايير التقييم", + "headingAdd": "إضافة معايير التقييم" + }, + "footer": { + "cancel": "إلغاء", + "save": "حفظ المعايير" + }, + "questionInfo": { + "typeLabel": "نوع السؤال:", + "maxMarksLabel": "الدرجة القصوى:" + }, + "tabs": { + "manual": "يدوي", + "ai": "توليد بالذكاء الاصطناعي", + "template": "استخدام نموذج" + }, + "manual": { + "criteriaHeading": "المعيار {{index}}", + "nameLabel": "اسم المعيار", + "namePlaceholder": "مثال: جودة المحتوى", + "descriptionLabel": "الوصف", + "descriptionPlaceholder": "مثال: عمق ودقة المحتوى", + "maxMarksLabel": "الدرجة القصوى", + "maxMarksPlaceholder": "0", + "addCriteria": "إضافة معيار", + "saveAsTemplateLabel_zero": "حفظ كنموذج لإعادة الاستخدام ({{questionType}} - {{count}} درجة)", + "saveAsTemplateLabel_one": "حفظ كنموذج لإعادة الاستخدام ({{questionType}} - {{count}} درجة واحدة)", + "saveAsTemplateLabel_two": "حفظ كنموذج لإعادة الاستخدام ({{questionType}} - {{count}} درجتان)", + "saveAsTemplateLabel_few": "حفظ كنموذج لإعادة الاستخدام ({{questionType}} - {{count}} درجات)", + "saveAsTemplateLabel_many": "حفظ كنموذج لإعادة الاستخدام ({{questionType}} - {{count}} درجة)", + "saveAsTemplateLabel_other": "حفظ كنموذج لإعادة الاستخدام ({{questionType}} - {{count}} درجة)", + "totalMarks_zero": "الإجمالي: {{achieved}} / {{count}} درجة", + "totalMarks_one": "الإجمالي: {{achieved}} / {{count}} درجة واحدة", + "totalMarks_two": "الإجمالي: {{achieved}} / {{count}} درجتان", + "totalMarks_few": "الإجمالي: {{achieved}} / {{count}} درجات", + "totalMarks_many": "الإجمالي: {{achieved}} / {{count}} درجة", + "totalMarks_other": "الإجمالي: {{achieved}} / {{count}} درجة", + "totalMarksValidSuffix": " ✓", + "totalMarksInvalidSuffix": " (يجب أن يطابق درجات السؤال)" + }, + "ai": { + "generatedBanner": "✓ تم توليد المعايير بنجاح!", + "marksBadge_zero": "{{count}} درجة", + "marksBadge_one": "{{count}} درجة واحدة", + "marksBadge_two": "{{count}} درجتان", + "marksBadge_few": "{{count}} درجات", + "marksBadge_many": "{{count}} درجة", + "marksBadge_other": "{{count}} درجة", + "regenerate": "إعادة التوليد", + "emptyPrompt": "دع الذكاء الاصطناعي يولّد معايير تقييم لهذا السؤال", + "generateButton": "توليد بالذكاء الاصطناعي" + }, + "template": { + "selectPrompt": "اختر نموذجًا لاستخدامه:", + "emptyState": "لم يتم العثور على نماذج لهذا النوع من الأسئلة", + "criteriaCount_zero": "{{count}} معايير", + "criteriaCount_one": "{{count}} معيار واحد", + "criteriaCount_two": "{{count}} معياران", + "criteriaCount_few": "{{count}} معايير", + "criteriaCount_many": "{{count}} معيارًا", + "criteriaCount_other": "{{count}} معيار", + "marksCount_zero": "{{count}} درجة", + "marksCount_one": "{{count}} درجة واحدة", + "marksCount_two": "{{count}} درجتان", + "marksCount_few": "{{count}} درجات", + "marksCount_many": "{{count}} درجة", + "marksCount_other": "{{count}} درجة" + }, + "toasts": { + "criteriaGenerated": "تم توليد المعايير بنجاح!", + "generateFailed": "فشل توليد المعايير. يرجى المحاولة مرة أخرى.", + "templateSaved": "تم حفظ المعايير كنموذج!", + "templateSaveFailed": "فشل حفظ النموذج", + "invalidMarksDistribution": "توزيع الدرجات غير صالح", + "criteriaSaved": "تم حفظ المعايير بنجاح!", + "incompleteCriteria": "يرجى إكمال المعايير قبل الحفظ" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentAddingParticipantsTab.json b/frontend-admin-dashboard/public/locales/ar/assessmentAddingParticipantsTab.json new file mode 100644 index 0000000000..b8e3695585 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentAddingParticipantsTab.json @@ -0,0 +1,19 @@ +{ + "header": { + "title": "اختيار المشاركين", + "description": "اختر دفعات كاملة أو حدد المتعلمين بشكل فردي." + }, + "tabs": { + "selectBatch": "اختيار الدفعة", + "selectIndividually": "اختيار فردي" + }, + "batchList": { + "sectionPlaceholder": "اختر القسم", + "searchPlaceholder": "البحث حسب الدورة أو المستوى", + "clearSearchAriaLabel": "مسح البحث", + "emptyState": { + "message": "لا توجد دورات أو مستويات مطابقة لـ \"{{query}}\"", + "clearSearch": "مسح البحث" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentAnnouncementComponent.json b/frontend-admin-dashboard/public/locales/ar/assessmentAnnouncementComponent.json new file mode 100644 index 0000000000..c65492ae2e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentAnnouncementComponent.json @@ -0,0 +1,16 @@ +{ + "trigger": { + "makeAnnouncement": "إنشاء إعلان" + }, + "dialog": { + "heading": "إنشاء إعلان", + "titleLabel": "عنوان الإعلان", + "titlePlaceholder": "اكتب عنوانًا موجزًا للإعلان (مثال: الوقت المتبقي، تم حل المشكلة التقنية)", + "instructionsHeading": "تعليمات التقييم", + "instructionsPlaceholder": "أضف التعليمات", + "publishButton": "نشر الإعلان" + }, + "validation": { + "titleRequired": "العنوان مطلوب" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentAnswerSpacingDialog.json b/frontend-admin-dashboard/public/locales/ar/assessmentAnswerSpacingDialog.json new file mode 100644 index 0000000000..3e97cc18fc --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentAnswerSpacingDialog.json @@ -0,0 +1,29 @@ +{ + "dialog": { + "title": "إدارة تباعد الإجابات" + }, + "info": { + "description": "حدد المسافة (بالملم) التي سيتم توفيرها بعد كل سؤال للإجابات. النطاق الصالح بين", + "and": "و", + "value": "{{value}} ملم" + }, + "table": { + "headers": { + "qNo": "رقم السؤال", + "section": "القسم", + "question": "السؤال", + "type": "النوع", + "space": "المسافة (ملم)" + }, + "emptyState": "لم يتم العثور على أسئلة مؤهلة. يمكن فقط لأسئلة الإجابة الطويلة والكلمة الواحدة أن يكون لها تباعد مخصص.", + "type": { + "longAnswer": "إجابة طويلة", + "oneWord": "كلمة واحدة" + }, + "spaceUnit": "ملم" + }, + "actions": { + "cancel": "إلغاء", + "save": "حفظ الإعدادات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentBasicInfoTab.json b/frontend-admin-dashboard/public/locales/ar/assessmentBasicInfoTab.json new file mode 100644 index 0000000000..72a1aef3e1 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentBasicInfoTab.json @@ -0,0 +1,32 @@ +{ + "basicInfo": { + "assessmentName": "اسم التقييم", + "untitled": "بدون عنوان", + "notAvailable": "غير متوفر" + }, + "instructions": { + "title": "تعليمات التقييم", + "description": "تظهر للمشاركين قبل بدء التقييم", + "empty": "لم يتم توفير أي تعليمات." + }, + "liveDateRange": { + "title": "نطاق تاريخ البث المباشر", + "startDateTime": "تاريخ ووقت البدء", + "endDateTime": "تاريخ ووقت الانتهاء" + }, + "attemptSettings": { + "title": "إعدادات المحاولة", + "description": "يتحكم في كيفية إجراء المشاركين للتقييم", + "reattemptCount": "عدد المحاولات المسموح بها", + "totalDuration": "المدة الإجمالية", + "durationNotSet": "—", + "durationMinutes_zero": "{{count}} دقيقة", + "durationMinutes_one": "{{count}} دقيقة", + "durationMinutes_two": "{{count}} دقيقتان", + "durationMinutes_few": "{{count}} دقائق", + "durationMinutes_many": "{{count}} دقيقة", + "durationMinutes_other": "{{count}} دقيقة", + "assessmentPreview": "معاينة التقييم", + "allowSwitchingSections": "السماح بالتنقل بين الأقسام" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentBulkActions.json b/frontend-admin-dashboard/public/locales/ar/assessmentBulkActions.json new file mode 100644 index 0000000000..8424c61289 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentBulkActions.json @@ -0,0 +1,10 @@ +{ + "selectedCount_zero": "[{{count}}] لا شيء محدد", + "selectedCount_one": "[{{count}}] محدد واحد", + "selectedCount_two": "[{{count}}] محددان", + "selectedCount_few": "[{{count}}] محددة", + "selectedCount_many": "[{{count}}] محددًا", + "selectedCount_other": "[{{count}}] محدد", + "reset": "إعادة تعيين", + "bulkActions": "إجراءات جماعية" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentBulkActionsMenuAttempted.json b/frontend-admin-dashboard/public/locales/ar/assessmentBulkActionsMenuAttempted.json new file mode 100644 index 0000000000..7dd6c77962 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentBulkActionsMenuAttempted.json @@ -0,0 +1,18 @@ +{ + "menu": { + "provideReattempt": "منح إعادة محاولة", + "revaluate": "إعادة التقييم", + "questionWise": "حسب السؤال", + "entireAssessment": "التقييم بالكامل", + "releaseResult": "نشر النتيجة", + "exportReports": "تصدير التقارير (ZIP)" + }, + "actionInfo": { + "selectedStudents_zero": "لا يوجد طلاب محددون ({{count}})", + "selectedStudents_one": "طالب واحد محدد ({{count}})", + "selectedStudents_two": "طالبان محددان ({{count}})", + "selectedStudents_few": "{{count}} طلاب محددون", + "selectedStudents_many": "{{count}} طالبًا محددًا", + "selectedStudents_other": "{{count}} طالب محدد" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentBulkActionsMenuOngoing.json b/frontend-admin-dashboard/public/locales/ar/assessmentBulkActionsMenuOngoing.json new file mode 100644 index 0000000000..e6000f84a7 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentBulkActionsMenuOngoing.json @@ -0,0 +1,14 @@ +{ + "menu": { + "increaseAssessmentTime": "زيادة وقت التقييم", + "closeSubmission": "إغلاق التسليم" + }, + "actionInfo": { + "selectedStudents_zero": "لا يوجد طلاب محددون ({{count}})", + "selectedStudents_one": "طالب واحد محدد ({{count}})", + "selectedStudents_two": "طالبان محددان ({{count}})", + "selectedStudents_few": "{{count}} طلاب محددون", + "selectedStudents_many": "{{count}} طالبًا محددًا", + "selectedStudents_other": "{{count}} طالب محدد" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentBulkActionsMenuPending.json b/frontend-admin-dashboard/public/locales/ar/assessmentBulkActionsMenuPending.json new file mode 100644 index 0000000000..459158b9d8 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentBulkActionsMenuPending.json @@ -0,0 +1,14 @@ +{ + "menu": { + "sendReminder": "إرسال تذكير", + "removeParticipants": "إزالة المشاركين" + }, + "actionInfo": { + "selectedStudents_zero": "لا يوجد طلاب محددون ({{count}})", + "selectedStudents_one": "طالب واحد محدد ({{count}})", + "selectedStudents_two": "طالبان محددان ({{count}})", + "selectedStudents_few": "{{count}} طلاب محددون", + "selectedStudents_many": "{{count}} طالبًا محددًا", + "selectedStudents_other": "{{count}} طالب محدد" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentCloseSubmissionComponent.json b/frontend-admin-dashboard/public/locales/ar/assessmentCloseSubmissionComponent.json new file mode 100644 index 0000000000..c60ed77801 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentCloseSubmissionComponent.json @@ -0,0 +1,7 @@ +{ + "dialog": { + "heading": "إغلاق التسليم", + "confirmMessagePrefix": "هل أنت متأكد من رغبتك في إغلاق التسليم لـ", + "doneButton": "تم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentCodingMainQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentCodingMainQP.json new file mode 100644 index 0000000000..32a1b25483 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentCodingMainQP.json @@ -0,0 +1,8 @@ +{ + "emptyState": { + "noQuestions": "الرجاء إضافة سؤال لعرض تفاصيل السؤال" + }, + "survey": { + "notSupported": "أسئلة البرمجة غير مدعومة في الاستطلاعات." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentCodingPPTList.json b/frontend-admin-dashboard/public/locales/ar/assessmentCodingPPTList.json new file mode 100644 index 0000000000..e3d63ef722 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentCodingPPTList.json @@ -0,0 +1,16 @@ +{ + "noProblemStatement": "(لا يوجد وصف للمشكلة)", + "languagesLabel": "اللغات: {{languages}}", + "maxPointsLabel": "الحد الأقصى للنقاط: {{points}}", + "testsLabel": "الاختبارات: {{count}}", + "sampleTestCasesHeading": "حالات اختبار نموذجية", + "sampleLabel": "نموذج {{number}}", + "inputLabel": "الإدخال", + "expectedLabel": "المتوقع", + "moreAccepted_zero": "+{{count}} مقبول إضافي", + "moreAccepted_one": "+{{count}} مقبول إضافي", + "moreAccepted_two": "+{{count}} مقبولان إضافيان", + "moreAccepted_few": "+{{count}} مقبولات إضافية", + "moreAccepted_many": "+{{count}} مقبولًا إضافيًا", + "moreAccepted_other": "+{{count}} مقبول إضافي" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentCodingPPTQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentCodingPPTQP.json new file mode 100644 index 0000000000..e3d63ef722 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentCodingPPTQP.json @@ -0,0 +1,16 @@ +{ + "noProblemStatement": "(لا يوجد وصف للمشكلة)", + "languagesLabel": "اللغات: {{languages}}", + "maxPointsLabel": "الحد الأقصى للنقاط: {{points}}", + "testsLabel": "الاختبارات: {{count}}", + "sampleTestCasesHeading": "حالات اختبار نموذجية", + "sampleLabel": "نموذج {{number}}", + "inputLabel": "الإدخال", + "expectedLabel": "المتوقع", + "moreAccepted_zero": "+{{count}} مقبول إضافي", + "moreAccepted_one": "+{{count}} مقبول إضافي", + "moreAccepted_two": "+{{count}} مقبولان إضافيان", + "moreAccepted_few": "+{{count}} مقبولات إضافية", + "moreAccepted_many": "+{{count}} مقبولًا إضافيًا", + "moreAccepted_other": "+{{count}} مقبول إضافي" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentCollapsibleQuillEditor.json b/frontend-admin-dashboard/public/locales/ar/assessmentCollapsibleQuillEditor.json new file mode 100644 index 0000000000..f6c41d5076 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentCollapsibleQuillEditor.json @@ -0,0 +1,6 @@ +{ + "actions": { + "showMore": "عرض المزيد", + "showLess": "عرض أقل" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentColumns.json b/frontend-admin-dashboard/public/locales/ar/assessmentColumns.json new file mode 100644 index 0000000000..5627576d90 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentColumns.json @@ -0,0 +1,12 @@ +{ + "columns": { + "name": "الاسم", + "enrollmentNumber": "رقم القيد", + "collegeSchool": "الكلية/المدرسة", + "gender": "الجنس", + "mobileNumber": "رقم الجوال", + "emailId": "البريد الإلكتروني", + "city": "المدينة", + "state": "الولاية" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveMultipleCorrectMainList.json b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveMultipleCorrectMainList.json new file mode 100644 index 0000000000..93b4bb8204 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveMultipleCorrectMainList.json @@ -0,0 +1,12 @@ +{ + "popover": { + "title": "إعدادات الأسئلة", + "questionTypeLabel": "نوع السؤال" + }, + "comprehensionText": "نص الفهم", + "questionLabel": "السؤال", + "answerLabel": "الإجابة:", + "removeOptionTitle": "إزالة الخيار", + "addOptionButton": "إضافة خيار", + "explanationLabel": "التوضيح:" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveMultipleCorrectMainQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveMultipleCorrectMainQP.json new file mode 100644 index 0000000000..164b470577 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveMultipleCorrectMainQP.json @@ -0,0 +1,22 @@ +{ + "emptyState": { + "message": "يرجى إضافة سؤال لعرض تفاصيل السؤال" + }, + "comprehension": { + "title": "نص الفهم", + "hint": "المقطع الذي يشير إليه كل سؤال في هذه المجموعة.", + "placeholder": "نص الفهم" + }, + "question": { + "title": "سؤال", + "placeholder": "اكتب السؤال" + }, + "explanation": { + "hint": "يُعرض للمتعلمين مع نتيجتهم.", + "placeholder": "التفسير" + }, + "defaults": { + "answersType": "الإجابة", + "explanationsType": "التفسير" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveMultipleCorrectPPTList.json b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveMultipleCorrectPPTList.json new file mode 100644 index 0000000000..71f5e32241 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveMultipleCorrectPPTList.json @@ -0,0 +1,12 @@ +{ + "optionLabels": { + "a": "(a.)", + "b": "(b.)", + "c": "(c.)", + "d": "(d.)" + }, + "menu": { + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveMultipleCorrectPPTQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveMultipleCorrectPPTQP.json new file mode 100644 index 0000000000..5d321f84d2 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveMultipleCorrectPPTQP.json @@ -0,0 +1,5 @@ +{ + "optionLabelFallback": "({{letter}}.)", + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveNumericPPTList.json b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveNumericPPTList.json new file mode 100644 index 0000000000..45f9a11e51 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveNumericPPTList.json @@ -0,0 +1,4 @@ +{ + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveNumericPPTQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveNumericPPTQP.json new file mode 100644 index 0000000000..45f9a11e51 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveNumericPPTQP.json @@ -0,0 +1,4 @@ +{ + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveNumericTemplateList.json b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveNumericTemplateList.json new file mode 100644 index 0000000000..44862b500e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveNumericTemplateList.json @@ -0,0 +1,29 @@ +{ + "emptyState": { + "message": "يرجى إضافة سؤال لعرض تفاصيل السؤال" + }, + "header": { + "questionsSettings": "إعدادات الأسئلة", + "questionTypeLabel": "نوع السؤال", + "numericalTypeLabel": "النوع العددي" + }, + "timeLimit": { + "title": "الحد الزمني", + "hrs": "ساعة", + "min": "دقيقة" + }, + "comprehensionText": "نص الفهم", + "question": { + "label": "السؤال" + }, + "answer": { + "label": "الإجابة:" + }, + "explanation": { + "label": "الشرح:" + }, + "editor": { + "showMore": "عرض المزيد", + "showLess": "عرض أقل" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveNumericTemplateQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveNumericTemplateQP.json new file mode 100644 index 0000000000..f729c31470 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveNumericTemplateQP.json @@ -0,0 +1,19 @@ +{ + "errors": { + "formNotInitialized": "النموذج غير مهيأ بشكل صحيح", + "noQuestions": "الرجاء إضافة سؤال لعرض تفاصيل السؤال" + }, + "settings": { + "title": "إعدادات الأسئلة", + "numericalType": "النوع العددي", + "decimalPrecisionPlaceholder": "دقة الأرقام العشرية" + }, + "comprehensionText": "نص الاستيعاب", + "question": { + "label": "السؤال" + }, + "defaults": { + "answer": "الإجابة:", + "explanation": "الشرح:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveSingleCorrectMainList.json b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveSingleCorrectMainList.json new file mode 100644 index 0000000000..aed0142927 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveSingleCorrectMainList.json @@ -0,0 +1,20 @@ +{ + "emptyState": "يرجى إضافة سؤال لعرض تفاصيل السؤال", + "header": { + "questionsSettings": "إعدادات الأسئلة", + "questionTypeLabel": "نوع السؤال" + }, + "comprehensionText": "نص الاستيعاب", + "question": { + "label": "السؤال" + }, + "answer": { + "label": "الإجابة:" + }, + "explanation": { + "label": "الشرح:" + }, + "optionLabelFormat": "({{letter}}.)", + "addOption": "إضافة خيار", + "removeOption": "إزالة الخيار" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveSingleCorrectMainQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveSingleCorrectMainQP.json new file mode 100644 index 0000000000..dfc4eb5255 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveSingleCorrectMainQP.json @@ -0,0 +1,21 @@ +{ + "emptyState": "يرجى إضافة سؤال لعرض تفاصيل السؤال", + "comprehension": { + "title": "نص الفهم", + "hint": "المقطع الذي يشير إليه كل سؤال في هذه المجموعة.", + "placeholder": "نص الفهم" + }, + "question": { + "title": "السؤال", + "titleNumbered": "السؤال {{number}}", + "placeholder": "اكتب السؤال" + }, + "answer": { + "typeFallback": "الإجابة" + }, + "explanation": { + "typeFallback": "الشرح", + "hint": "يُعرض للمتعلمين مع نتيجتهم.", + "placeholder": "الشرح" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveSingleCorrectPPTList.json b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveSingleCorrectPPTList.json new file mode 100644 index 0000000000..ebfa0b3648 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveSingleCorrectPPTList.json @@ -0,0 +1,29 @@ +{ + "header": { + "questionsSettings": "إعدادات الأسئلة", + "questionTypeLabel": "نوع السؤال", + "marksLabel": "الدرجات", + "negativeMarkingLabel": "الدرجات السالبة" + }, + "timeLimit": { + "title": "الحد الزمني", + "hrs": "ساعة", + "min": "دقيقة" + }, + "comprehensionText": "نص الاستيعاب", + "question": { + "label": "السؤال" + }, + "answer": { + "label": "الإجابة:" + }, + "explanation": { + "label": "الشرح:" + }, + "optionLabels": { + "a": "(a.)", + "b": "(b.)", + "c": "(c.)", + "d": "(d.)" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveSingleCorrectPPTQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveSingleCorrectPPTQP.json new file mode 100644 index 0000000000..2c7b617107 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentComprehensiveSingleCorrectPPTQP.json @@ -0,0 +1,6 @@ +{ + "comprehensionText": "نص الفهم", + "optionLabelFallback": "({{letter}}.)", + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentCreateAssessmentComponent.json b/frontend-admin-dashboard/public/locales/ar/assessmentCreateAssessmentComponent.json new file mode 100644 index 0000000000..19b60731f8 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentCreateAssessmentComponent.json @@ -0,0 +1,41 @@ +{ + "sidebar": { + "stepLabel": "الخطوة {{number}}", + "done": "· تم" + }, + "steps": { + "basicInfo": { + "label": "معلومات أساسية", + "description": "الاسم والجدولة والإعدادات" + }, + "addQuestions": { + "label": "إضافة أسئلة", + "description": "رفع أو إنشاء الأسئلة" + }, + "addParticipants": { + "label": "إضافة مشاركين", + "description": "اختر من يمكنه أداء هذا" + }, + "accessControl": { + "label": "التحكم في الوصول", + "description": "أذونات الإدارة" + } + }, + "examTypeLabel": { + "exam": "امتحان", + "mock": "اختبار تجريبي", + "practice": "اختبار تدريبي", + "survey": "استبيان", + "manualUploadExam": "امتحان برفع يدوي" + }, + "header": { + "stepOf": "الخطوة {{current}} من {{total}}", + "completed": "{{count}} / {{total}} مكتمل" + }, + "helmet": { + "titleSurvey": "إنشاء استبيان", + "titleAssessment": "إنشاء تقييم", + "descriptionSurvey": "هذه الصفحة مخصصة لإنشاء استبيان للطلاب عبر لوحة الإدارة.", + "descriptionAssessment": "هذه الصفحة مخصصة لإنشاء تقييم للطلاب عبر لوحة الإدارة." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentCriteriaPreviewDialog.json b/frontend-admin-dashboard/public/locales/ar/assessmentCriteriaPreviewDialog.json new file mode 100644 index 0000000000..63cdcc897e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentCriteriaPreviewDialog.json @@ -0,0 +1,21 @@ +{ + "heading": "معايير التقييم", + "invalidFormat": { + "title": "تنسيق معايير غير صالح", + "description": "بنية بيانات المعايير غير صالحة. يرجى إعادة التوليد أو إنشاء المعايير يدويًا." + }, + "totalMarksLabel": "إجمالي الدرجات:", + "totalMarksValue_zero": "{{count}} درجة", + "totalMarksValue_one": "{{count}} درجة واحدة", + "totalMarksValue_two": "{{count}} درجتان", + "totalMarksValue_few": "{{count}} درجات", + "totalMarksValue_many": "{{count}} درجة", + "totalMarksValue_other": "{{count}} درجة", + "breakdownHeading": "تفصيل المعايير:", + "itemMarks_zero": "{{count}} درجة", + "itemMarks_one": "{{count}} درجة واحدة", + "itemMarks_two": "{{count}} درجتان", + "itemMarks_few": "{{count}} درجات", + "itemMarks_many": "{{count}} درجة", + "itemMarks_other": "{{count}} درجة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentCriteriaStatusBadge.json b/frontend-admin-dashboard/public/locales/ar/assessmentCriteriaStatusBadge.json new file mode 100644 index 0000000000..b659e4927b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentCriteriaStatusBadge.json @@ -0,0 +1,9 @@ +{ + "labels": { + "aiGenerated": "تم إنشاؤه بالذكاء الاصطناعي", + "manual": "يدوي", + "template": "قالب", + "notAdded": "لم تتم الإضافة" + }, + "previewTooltip": "معاينة المعايير" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentEnhancedSurveyIndividualRespondentsTab.json b/frontend-admin-dashboard/public/locales/ar/assessmentEnhancedSurveyIndividualRespondentsTab.json new file mode 100644 index 0000000000..e62a4529e7 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentEnhancedSurveyIndividualRespondentsTab.json @@ -0,0 +1,42 @@ +{ + "questionType": { + "mcqs": "اختيار واحد", + "mcqm": "اختيار متعدد", + "trueFalse": "صح/خطأ", + "numeric": "رقمي", + "oneWord": "كلمة واحدة", + "longAnswer": "إجابة طويلة" + }, + "response": { + "noResponse": "لا توجد إجابة", + "unknownAnswerFormat": "تنسيق إجابة غير معروف", + "noCorrectAnswerAvailable": "لا توجد إجابة صحيحة متاحة", + "noCorrectOptionsAvailable": "لا توجد خيارات صحيحة متاحة", + "unknownCorrectAnswerFormat": "تنسيق إجابة صحيحة غير معروف" + }, + "states": { + "loading": "جارٍ تحميل بيانات الاستبيان المحسّن...", + "errorPrefix": "خطأ:", + "noRespondentsFound": "لم يتم العثور على مجيبين.", + "noResponsesAvailable": "لا توجد إجابات متاحة لهذا المجيب." + }, + "header": { + "batch": "الدفعة:", + "correctCount": "{{correct}}/{{total}} صحيحة", + "accuracy": "{{value}}% دقة" + }, + "navigation": { + "previous": "السابق", + "of": "من {{total}}", + "next": "التالي" + }, + "question": { + "numberedTitle": "س{{order}}. {{content}}", + "userAnswer": "إجابة المستخدم:", + "markedForReview": "مُعلّم للمراجعة", + "correctAnswer": "الإجابة الصحيحة:", + "allOptions": "جميع الخيارات:", + "timeTakenSeconds": "{{count}} ث", + "questionNumber": "السؤال رقم {{order}}" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentEvaluationAi.json b/frontend-admin-dashboard/public/locales/ar/assessmentEvaluationAi.json new file mode 100644 index 0000000000..3626e03727 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentEvaluationAi.json @@ -0,0 +1,112 @@ +{ + "status": { + "pending": "التقييم لم يبدأ بعد", + "inProgress": "التقييم قيد التنفيذ", + "initiated": "بدأ التقييم", + "processing": "جارٍ معالجة ورقة إجابة الطالب", + "extracting": "جارٍ استخراج إجابات الطالب", + "evaluating": "جارٍ تقييم إجابات الطالب", + "grading": "جارٍ تصحيح الإجابات المقيَّمة", + "completed": "اكتمل التقييم", + "failed": "فشل التقييم", + "cancelled": "تم إلغاء التقييم", + "unknown": "غير معروف" + }, + "common": { + "loading": "جارٍ التحميل..." + }, + "nav": { + "heading": "تقدم التقييم" + }, + "error": { + "title": "خطأ في تحميل التقدم", + "loadFailed": "فشل تحميل تقدم التقييم", + "goBack": "رجوع" + }, + "toasts": { + "evaluationStopped": "تم إيقاف التقييم بنجاح!", + "stopFailed": "فشل إيقاف التقييم", + "answerSheetUnavailable": "ورقة الإجابة غير متوفرة", + "answerSheetLoadFailed": "فشل تحميل ورقة الإجابة", + "evaluationCompleted": "اكتمل التقييم بنجاح!", + "evaluationFailedRetry": "فشل التقييم. يرجى المحاولة مرة أخرى.", + "marksUpdated": "تم تحديث الدرجات", + "marksUpdateFailed": "فشل تحديث الدرجات. يرجى المحاولة مرة أخرى." + }, + "header": { + "participant": "المشارك", + "assessment": "التقييم", + "status": "الحالة", + "duration": "المدة", + "progress": "التقدم", + "stop": "إيقاف", + "stopping": "جارٍ الإيقاف..." + }, + "summary": { + "totalScore": "الدرجة الإجمالية", + "percentage": "النسبة المئوية", + "completed": "مكتمل", + "pending": "قيد الانتظار" + }, + "filters": { + "all": "الكل", + "completed": "مكتمل", + "pending": "قيد الانتظار" + }, + "answerSheet": { + "loading": "جارٍ التحميل...", + "button": "ورقة الإجابة", + "panelTitle": "ورقة إجابة الطالب" + }, + "banners": { + "aiDrafted": "قام الذكاء الاصطناعي بصياغة هذا التقييم. راجع كل سؤال، وعدّل الدرجات أو الملاحظات إن لزم الأمر، ثم اعتمد النتيجة.", + "needsReview_zero": "لا توجد أسئلة ({{count}}) تحتاج إلى مراجعتك، ولم يتم تقييمها تلقائيًا.", + "needsReview_one": "سؤال واحد ({{count}}) لم يتم تقييمه تلقائيًا ويحتاج إلى مراجعتك. افتحه أدناه لإدخال الدرجات قبل اعتماد النتيجة.", + "needsReview_two": "سؤالان ({{count}}) لم يتم تقييمهما تلقائيًا ويحتاجان إلى مراجعتك. افتحهما أدناه لإدخال الدرجات قبل اعتماد النتيجة.", + "needsReview_few": "{{count}} أسئلة لم يتم تقييمها تلقائيًا وتحتاج إلى مراجعتك. افتحها أدناه لإدخال الدرجات قبل اعتماد النتيجة.", + "needsReview_many": "{{count}} سؤالًا لم يتم تقييمها تلقائيًا وتحتاج إلى مراجعتك. افتحها أدناه لإدخال الدرجات قبل اعتماد النتيجة.", + "needsReview_other": "{{count}} سؤال لم يتم تقييمها تلقائيًا وتحتاج إلى مراجعتك. افتحها أدناه لإدخال الدرجات قبل اعتماد النتيجة." + }, + "emptyState": { + "all": "لا توجد أسئلة لعرضها", + "completed": "لا توجد أسئلة مكتملة لعرضها", + "pending": "لا توجد أسئلة قيد الانتظار لعرضها" + }, + "question": { + "qLabel": "س{{number}}", + "title": "السؤال {{number}}", + "reviewedPrefix": "تمت مراجعته من قِبلك · ", + "completedAt": "اكتمل {{time}}", + "pendingStatus": "قيد الانتظار", + "failedGradeMessage": "تعذر على الذكاء الاصطناعي تقييم هذا السؤال — يحتاج إلى مراجعتك", + "editedBadge": "معدَّل", + "needsReviewBadge": "يحتاج إلى مراجعة" + }, + "teacherReview": { + "failedTitle": "لم يتم تقييم هذا السؤال تلقائيًا.", + "aiDraftedTitle": "درجات مقترحة من الذكاء الاصطناعي", + "failedHint": "أدخل الدرجات والملاحظات لتقييمه بنفسك.", + "aiDraftedHint": "عدّل الدرجات أو الملاحظات إذا كنت لا توافق على تقييم الذكاء الاصطناعي.", + "gradeManually": "التقييم يدويًا", + "edit": "تعديل", + "marksLabel": "الدرجات", + "feedbackLabel": "الملاحظات", + "feedbackPlaceholder": "ملاحظات لهذا السؤال", + "cancel": "إلغاء", + "save": "حفظ الدرجات", + "saving": "جارٍ الحفظ..." + }, + "details": { + "questionHeading": "السؤال", + "correctAnswerHeading": "الإجابة الصحيحة", + "studentAnswerHeading": "إجابة الطالب", + "feedbackHeading": "الملاحظات", + "gradingBreakdownHeading": "تفاصيل التصحيح" + }, + "table": { + "criteria": "المعيار", + "reason": "السبب", + "marks": "الدرجات", + "totalMarksAwarded": "إجمالي الدرجات الممنوحة:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentEvaluationAiIndex.json b/frontend-admin-dashboard/public/locales/ar/assessmentEvaluationAiIndex.json new file mode 100644 index 0000000000..f6fe1de98f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentEvaluationAiIndex.json @@ -0,0 +1,42 @@ +{ + "header": { + "title": "تقييمات الذكاء الاصطناعي", + "subtitle": "كل عملية تقييم بالذكاء الاصطناعي لهذا الاختبار. افتح عملية لمراجعة الدرجات وتعديلها قبل اعتماد النتيجة." + }, + "emptyStates": { + "noAssessment": "لم يتم اختيار اختبار. افتح هذه الصفحة من علامة تبويب التسليمات الخاصة بالاختبار.", + "error": "تعذّر تحميل التقييمات. يرجى المحاولة مرة أخرى.", + "noEvaluations": "لم يبدأ أي تقييم بالذكاء الاصطناعي لهذا الاختبار بعد." + }, + "table": { + "columns": { + "participant": "المشارك", + "status": "الحالة", + "progress": "التقدّم", + "started": "بدأ في", + "actions": "الإجراءات" + }, + "unknownParticipant": "غير معروف", + "notAvailable": "—" + }, + "status": { + "completed": "مكتمل", + "needsReview_zero": "لا حاجة للمراجعة ({{count}})", + "needsReview_one": "بحاجة إلى مراجعة ({{count}})", + "needsReview_two": "بحاجة إلى مراجعة ({{count}})", + "needsReview_few": "بحاجة إلى مراجعة ({{count}} أسئلة)", + "needsReview_many": "بحاجة إلى مراجعة ({{count}} سؤالًا)", + "needsReview_other": "بحاجة إلى مراجعة ({{count}})", + "failed": "فشل", + "cancelled": "ملغى", + "inProgress": "قيد التنفيذ" + }, + "actions": { + "open": "فتح", + "retry": "إعادة المحاولة" + }, + "toasts": { + "restartSuccess": "تمت إعادة تشغيل تقييم الذكاء الاصطناعي", + "restartError": "تعذّرت إعادة تشغيل التقييم. يرجى المحاولة مرة أخرى." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentEvaluationStatusCell.json b/frontend-admin-dashboard/public/locales/ar/assessmentEvaluationStatusCell.json new file mode 100644 index 0000000000..fa0debee53 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentEvaluationStatusCell.json @@ -0,0 +1,10 @@ +{ + "evaluated": "تم التقييم", + "evaluating": "جارٍ التقييم", + "pending": "قيد الانتظار", + "viewEvaluatedCopy": "عرض النسخة المقيَّمة", + "noEvaluatedCopyError": "لم يتم العثور على نسخة مقيَّمة لهذه المحاولة.", + "loadFailedError": "تعذر تحميل النسخة المقيَّمة. يرجى المحاولة مرة أخرى.", + "evaluatedCopyHeading": "النسخة المقيَّمة", + "fileNamePrefix": "نسخة-مقيّمة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentExportCsvDialog.json b/frontend-admin-dashboard/public/locales/ar/assessmentExportCsvDialog.json new file mode 100644 index 0000000000..e4f2c32a04 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentExportCsvDialog.json @@ -0,0 +1,28 @@ +{ + "trigger": { + "label": "تصدير" + }, + "dialog": { + "heading": "تصدير CSV" + }, + "errors": { + "columnsLoadFailed": "تعذّر تحميل قائمة الأعمدة. لا يزال بإمكانك تصدير أعمدة النتائج القياسية." + }, + "chooseColumns": "اختر الأعمدة المراد تضمينها في الملف.", + "selectAll": "تحديد الكل", + "clearAll": "مسح الكل", + "resultColumns": { + "title": "أعمدة النتائج" + }, + "registrationFields": { + "title": "حقول نموذج التسجيل", + "empty": "لا يجمع هذا الاختبار أي تفاصيل إضافية عند التسجيل." + }, + "exporting": "جارٍ التصدير…", + "exportCsvButton": "تصدير CSV", + "toasts": { + "noData": "لم يتم إرجاع أي بيانات. يرجى المحاولة مرة أخرى.", + "exportSuccess": "تم تصدير النتائج بنجاح.", + "exportFailed": "فشل تصدير ملف CSV. يرجى المحاولة مرة أخرى." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentExportHandlerQuestionPaper.json b/frontend-admin-dashboard/public/locales/ar/assessmentExportHandlerQuestionPaper.json new file mode 100644 index 0000000000..6a0d29a6e7 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentExportHandlerQuestionPaper.json @@ -0,0 +1,17 @@ +{ + "export": { + "exporting": "جارٍ التصدير...", + "button": "تصدير {{format}}" + }, + "setLabel": " (المجموعة {{letter}})", + "fileName": { + "base": "ورقة الأسئلة", + "withSet": "ورقة الأسئلة {{letter}}" + }, + "progress": { + "title": "جارٍ إنشاء ملف PDF", + "subtitle": "قد يستغرق هذا بعض الوقت", + "cancelAriaLabel": "إلغاء إنشاء ملف PDF", + "percentComplete": "اكتمل {{progress}}٪" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentExportIndex.json b/frontend-admin-dashboard/public/locales/ar/assessmentExportIndex.json new file mode 100644 index 0000000000..5ac37fe10c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentExportIndex.json @@ -0,0 +1,3 @@ +{ + "pageHeading": "معاينة وتصدير" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentExportQuestionPaper.json b/frontend-admin-dashboard/public/locales/ar/assessmentExportQuestionPaper.json new file mode 100644 index 0000000000..e826b06497 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentExportQuestionPaper.json @@ -0,0 +1,11 @@ +{ + "trigger": { + "label": "تصدير" + }, + "settingsButton": "إعدادات التصدير", + "close": "إغلاق", + "letterhead": { + "upload": "الترويسة", + "delete": "حذف الترويسة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentExportQuestionPaperSettingDialog.json b/frontend-admin-dashboard/public/locales/ar/assessmentExportQuestionPaperSettingDialog.json new file mode 100644 index 0000000000..314f84cbfa --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentExportQuestionPaperSettingDialog.json @@ -0,0 +1,84 @@ +{ + "dialog": { + "title": "إعدادات التصدير" + }, + "sections": { + "layout": { + "title": "إعدادات التخطيط", + "columnsPerPage": "عدد الأعمدة في الصفحة", + "roughWork": { + "label": "مساحة للمسودة", + "none": "بدون", + "bottom": "أسفل" + }, + "roughWorkSize": { + "label": "حجم مساحة المسودة", + "small": "صغير (50 مم)", + "medium": "متوسط (100 مم)", + "large": "كبير (150 مم)" + }, + "pagePadding": { + "label": "هامش الصفحة", + "low": "منخفض (10 مم)", + "medium": "متوسط (20 مم)", + "high": "مرتفع (30 مم)" + }, + "fontSize": { + "label": "حجم الخط", + "small": "صغير (10 نقطة)", + "medium": "متوسط (12 نقطة)", + "large": "كبير (14 نقطة)" + }, + "imageSize": { + "label": "حجم الصورة", + "maintainAspectRatio": "الحفاظ على نسبة أبعاد الصورة" + } + }, + "display": { + "title": "إعدادات العرض", + "options": { + "showInstitutionLetterhead": "إظهار ترويسة المؤسسة", + "showFirstPageInstructions": "إظهار التعليمات في الصفحة الأولى", + "showAdaptiveMarkingRules": "إظهار قواعد التصحيح التكيفي - التقييم بالكامل", + "showSectionInstructions": "إظهار تعليمات كل قسم", + "showSectionDuration": "إظهار مدة كل قسم", + "showMarksPerQuestion": "إظهار الدرجات لكل سؤال", + "showAdaptiveMarkingRulesSection": "إظهار قواعد التصحيح التكيفي - حسب القسم", + "showCheckboxesBeforeOptions": "إظهار مربعات الاختيار قبل الخيارات", + "showPageNumbers": "إظهار أرقام الصفحات" + } + }, + "paper": { + "title": "إعدادات ورقة الأسئلة", + "questionPaperSets": "إنشاء مجموعات من أوراق الأسئلة", + "includeQuestionSetCode": "تضمين رمز مجموعة الأسئلة", + "randomizeQuestions": "ترتيب الأسئلة عشوائيًا", + "randomizeOptions": "ترتيب الخيارات عشوائيًا" + }, + "customFields": { + "title": "إعدادات الحقول المخصصة", + "includeCustomInputFields": "تضمين حقول إدخال مخصصة", + "fieldTypePlaceholder": "اختر نوع الحقل", + "fieldTypes": { + "blank": "فارغ (افتراضي)", + "blocks": "كتل", + "input": "مربع إدخال", + "checkbox": "مربع اختيار" + }, + "newFieldPlaceholder": "أدخل تسمية الحقل الجديد", + "addButton": "إضافة" + }, + "advanced": { + "title": "الإعدادات المتقدمة", + "answerSpacing": { + "label": "تباعد الإجابات", + "customSpacingButton": "تباعد مخصص", + "description": "تكوين مساحة مخصصة للإجابات." + } + } + }, + "footer": { + "cancel": "إلغاء", + "save": "حفظ" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentFilePreviewDialog.json b/frontend-admin-dashboard/public/locales/ar/assessmentFilePreviewDialog.json new file mode 100644 index 0000000000..7ecaf73d47 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentFilePreviewDialog.json @@ -0,0 +1,8 @@ +{ + "download": "تنزيل", + "downloading": "جارٍ التنزيل...", + "previewError": { + "title": "تعذّرت معاينة هذا الملف هنا.", + "description": "استخدم زر التنزيل أدناه لحفظ الملف بالامتداد الصحيح وفتحه على جهازك." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentGlobalLevelReleaseResult.json b/frontend-admin-dashboard/public/locales/ar/assessmentGlobalLevelReleaseResult.json new file mode 100644 index 0000000000..c89e003868 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentGlobalLevelReleaseResult.json @@ -0,0 +1,14 @@ +{ + "trigger": { + "releaseResult": "إصدار النتيجة" + }, + "dialog": { + "title": "إصدار النتيجة لجميع {{learnerPlural}}", + "attention": "تنبيه", + "confirmText": "هل أنت متأكد أنك تريد إصدار النتيجة لجميع {{learnerLower}}؟", + "confirmButton": "نعم" + }, + "toasts": { + "releaseResultSuccess": "تم إصدار نتيجة هذا التقييم لجميع الطلاب. يجب على المشاركين التحقق من بريدهم الإلكتروني!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentGlobalLevelRevaluate.json b/frontend-admin-dashboard/public/locales/ar/assessmentGlobalLevelRevaluate.json new file mode 100644 index 0000000000..ba21b2bab5 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentGlobalLevelRevaluate.json @@ -0,0 +1,14 @@ +{ + "trigger": { + "entireAssessment": "التقييم بالكامل" + }, + "dialog": { + "title": "إعادة تقييم جميع {{learnerPlural}}", + "attention": "تنبيه", + "confirmText": "هل أنت متأكد أنك تريد إعادة التقييم لجميع {{learnerLower}}؟", + "confirmButton": "نعم" + }, + "toasts": { + "revaluateSuccess": "تمت إعادة تقييم محاولة هذا التقييم لجميع الطلاب. يجب على المشاركين التحقق من بريدهم الإلكتروني!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentGlobalRevaluateQuestionWise.json b/frontend-admin-dashboard/public/locales/ar/assessmentGlobalRevaluateQuestionWise.json new file mode 100644 index 0000000000..6b0bb77706 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentGlobalRevaluateQuestionWise.json @@ -0,0 +1,15 @@ +{ + "trigger": { + "questionWise": "حسب السؤال" + }, + "table": { + "questionNumber": "رقم السؤال", + "question": "السؤال" + }, + "toasts": { + "revaluateSuccess": "تمت إعادة تقييم محاولة هذا التقييم لجميع الطلاب. يجب على المشاركين التحقق من بريدهم الإلكتروني!" + }, + "actions": { + "revaluate": "إعادة التقييم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentIncreaseAssessmentTime.json b/frontend-admin-dashboard/public/locales/ar/assessmentIncreaseAssessmentTime.json new file mode 100644 index 0000000000..5b3b5bd980 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentIncreaseAssessmentTime.json @@ -0,0 +1,23 @@ +{ + "dialog": { + "heading": "زيادة وقت التقييم" + }, + "assessment": { + "title": "التقييم بالكامل", + "increaseByLabel": "زيادة بمقدار", + "selectSectionPlaceholder": "اختر القسم", + "doneButton": "تم" + }, + "section": { + "title": "القسم {{index}}", + "increaseByLabel": "زيادة بمقدار", + "selectSectionPlaceholder": "اختر القسم", + "doneButton": "تم" + }, + "question": { + "title": "السؤال {{index}}", + "increaseByLabel": "زيادة بمقدار", + "selectSectionPlaceholder": "اختر القسم", + "doneButton": "تم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentIndex.json b/frontend-admin-dashboard/public/locales/ar/assessmentIndex.json new file mode 100644 index 0000000000..e871d43c59 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentIndex.json @@ -0,0 +1,27 @@ +{ + "pageHeading": "التقييمات", + "metaDescription": "تعرض هذه الصفحة جميع أنواع التقييمات التي يمكنك إنشاؤها هنا.", + "createAssessmentHeading": "إنشاء تقييم", + "cards": { + "examination": { + "title": "امتحان", + "description": "تقييم بوقت محدد يُنشر في موعد مجدول، يحاكي ظروف الامتحان الحقيقي." + }, + "mock": { + "title": "امتحان تجريبي", + "description": "تقييم تدريبي متاح دائمًا، بمدة ثابتة لمحاكاة سيناريوهات الامتحان." + }, + "practice": { + "title": "تقييم تدريبي", + "description": "تقييم متاح عند الطلب بدون حدود زمنية، يتيح للطلاب حله في أي وقت." + }, + "survey": { + "title": "استبيان", + "description": "مجموعة من الأسئلة لجمع الآراء والملاحظات، دون إجابات صحيحة أو خاطئة." + }, + "manualUploadExam": { + "title": "امتحان برفع يدوي", + "description": "يقوم الطلاب بتنزيل ورقة الأسئلة وحلها دون اتصال ثم رفع أوراق الإجابة. يراجع المعلمون الإجابات ويرصدون الدرجات يدويًا." + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentLongAnswerPPTList.json b/frontend-admin-dashboard/public/locales/ar/assessmentLongAnswerPPTList.json new file mode 100644 index 0000000000..8a6acf50e6 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentLongAnswerPPTList.json @@ -0,0 +1,6 @@ +{ + "menu": { + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentLongAnswerPPTQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentLongAnswerPPTQP.json new file mode 100644 index 0000000000..8a6acf50e6 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentLongAnswerPPTQP.json @@ -0,0 +1,6 @@ +{ + "menu": { + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentLongAnswerTemplateList.json b/frontend-admin-dashboard/public/locales/ar/assessmentLongAnswerTemplateList.json new file mode 100644 index 0000000000..1b0ceeaec4 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentLongAnswerTemplateList.json @@ -0,0 +1,14 @@ +{ + "settings": { + "title": "إعدادات الأسئلة", + "questionType": "نوع السؤال" + }, + "comprehensionText": "نص الاستيعاب", + "question": { + "label": "السؤال" + }, + "answer": "الإجابة", + "defaults": { + "explanation": "الشرح:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentLongAnswerTemplateQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentLongAnswerTemplateQP.json new file mode 100644 index 0000000000..2b81620477 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentLongAnswerTemplateQP.json @@ -0,0 +1,13 @@ +{ + "emptyState": { + "noQuestions": "يرجى إضافة سؤال لعرض تفاصيل السؤال" + }, + "label": { + "comprehensionText": "نص الفهم", + "question": "السؤال", + "answer": "الإجابة" + }, + "explanation": { + "defaultLabel": "الشرح:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentMultipleCorrectMainList.json b/frontend-admin-dashboard/public/locales/ar/assessmentMultipleCorrectMainList.json new file mode 100644 index 0000000000..4cf382d3b2 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentMultipleCorrectMainList.json @@ -0,0 +1,13 @@ +{ + "emptyState": "الرجاء إضافة سؤال لعرض تفاصيل السؤال", + "popover": { + "title": "إعدادات الأسئلة", + "questionTypeLabel": "نوع السؤال" + }, + "comprehensionText": "نص الفهم", + "questionLabel": "السؤال {{number}}", + "answerLabel": "الإجابة:", + "removeOptionTitle": "إزالة الخيار", + "addOptionButton": "إضافة خيار", + "explanationLabel": "التوضيح:" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentMultipleCorrectMainQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentMultipleCorrectMainQP.json new file mode 100644 index 0000000000..5a43cfc121 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentMultipleCorrectMainQP.json @@ -0,0 +1,9 @@ +{ + "emptyState": "الرجاء إضافة سؤال لعرض تفاصيل السؤال", + "comprehensionText": "نص الفهم", + "questionLabel": "السؤال", + "questionLabelNumbered": "السؤال {{number}}", + "questionPlaceholder": "اكتب السؤال", + "explanationHint": "يظهر للمتعلمين مع نتيجتهم.", + "explanationPlaceholder": "التوضيح" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentMultipleCorrectPPTList.json b/frontend-admin-dashboard/public/locales/ar/assessmentMultipleCorrectPPTList.json new file mode 100644 index 0000000000..71f5e32241 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentMultipleCorrectPPTList.json @@ -0,0 +1,12 @@ +{ + "optionLabels": { + "a": "(a.)", + "b": "(b.)", + "c": "(c.)", + "d": "(d.)" + }, + "menu": { + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentMultipleCorrectPPTQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentMultipleCorrectPPTQP.json new file mode 100644 index 0000000000..71f5e32241 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentMultipleCorrectPPTQP.json @@ -0,0 +1,12 @@ +{ + "optionLabels": { + "a": "(a.)", + "b": "(b.)", + "c": "(c.)", + "d": "(d.)" + }, + "menu": { + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentNumericPPTList.json b/frontend-admin-dashboard/public/locales/ar/assessmentNumericPPTList.json new file mode 100644 index 0000000000..45f9a11e51 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentNumericPPTList.json @@ -0,0 +1,4 @@ +{ + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentNumericPPTQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentNumericPPTQP.json new file mode 100644 index 0000000000..45f9a11e51 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentNumericPPTQP.json @@ -0,0 +1,4 @@ +{ + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentNumericTemplateList.json b/frontend-admin-dashboard/public/locales/ar/assessmentNumericTemplateList.json new file mode 100644 index 0000000000..65ff2ea63b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentNumericTemplateList.json @@ -0,0 +1,16 @@ +{ + "settings": { + "title": "إعدادات الأسئلة", + "questionType": "نوع السؤال", + "numericalType": "النوع العددي", + "decimalPrecision": "دقة الأرقام العشرية" + }, + "comprehensionText": "نص الاستيعاب", + "question": { + "label": "السؤال" + }, + "defaults": { + "answer": "الإجابة:", + "explanation": "الشرح:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentNumericTemplateQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentNumericTemplateQP.json new file mode 100644 index 0000000000..0b7b3d2b3a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentNumericTemplateQP.json @@ -0,0 +1,18 @@ +{ + "errors": { + "formNotInitialized": "النموذج غير مهيأ بشكل صحيح" + }, + "settings": { + "title": "إعدادات الأسئلة", + "numericalType": "النوع العددي", + "decimalPrecisionPlaceholder": "دقة الأرقام العشرية" + }, + "comprehensionText": "نص الاستيعاب", + "question": { + "label": "السؤال" + }, + "defaults": { + "answer": "الإجابة:", + "explanation": "الشرح:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentOfflineAttachmentsPanel.json b/frontend-admin-dashboard/public/locales/ar/assessmentOfflineAttachmentsPanel.json new file mode 100644 index 0000000000..2c2a5c6947 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentOfflineAttachmentsPanel.json @@ -0,0 +1,35 @@ +{ + "header": { + "title": "المرفقات", + "optionalPdfOnly": "اختياري · PDF فقط", + "selectedCount_zero": "· لم يتم تحديد أي مرفق من 3 ({{count}})", + "selectedCount_one": "· تم تحديد مرفق واحد ({{count}}) من 3", + "selectedCount_two": "· تم تحديد مرفقين ({{count}}) من 3", + "selectedCount_few": "· تم تحديد {{count}} مرفقات من 3", + "selectedCount_many": "· تم تحديد {{count}} مرفقًا من 3", + "selectedCount_other": "· تم تحديد {{count}} من 3" + }, + "description": "يتم الرفع عند إرسال هذه المدخلة.", + "slots": { + "student": { + "label": "ورقة إجابة الطالب", + "hint": "مسح ضوئي لما كتبه الطالب. يُعرض كإجابته المُقدَّمة." + }, + "checked": { + "label": "ورقة الإجابة المُصححة", + "hint": "النسخة المُقيَّمة بدرجاتك وملاحظاتك. تُعرض للطالب مع نتيجته." + }, + "report": { + "label": "التقرير", + "hint": "تقرير نتيجة اختياري أعددته خارج المنصة." + } + }, + "removeAria": "إزالة {{label}}", + "dropzone": { + "uploadPrompt": "انقر للرفع أو اسحب وأفلت" + }, + "rejections": { + "notPdf": "\"{{fileName}}\" ليس ملف PDF. يجب رفع المسح الضوئي كملفات PDF.", + "emptyFile": "\"{{fileName}}\" فارغ (0 بايت). أعد تصديره وحاول مرة أخرى." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentOfflineBulkImportDialog.json b/frontend-admin-dashboard/public/locales/ar/assessmentOfflineBulkImportDialog.json new file mode 100644 index 0000000000..e3a871991b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentOfflineBulkImportDialog.json @@ -0,0 +1,188 @@ +{ + "dialog": { + "heading": "إدخال بيانات غير متصل بالجملة" + }, + "footer": { + "close": "إغلاق", + "cancel": "إلغاء", + "chooseAnotherFile": "اختيار ملف آخر", + "retryingText": "جارٍ إعادة المحاولة...", + "importingText": "جارٍ الاستيراد...", + "retryImport": "إعادة محاولة الاستيراد", + "importStudents_zero": "استيراد {{count}} طالب", + "importStudents_one": "استيراد طالب واحد ({{count}})", + "importStudents_two": "استيراد طالبين ({{count}})", + "importStudents_few": "استيراد {{count}} طلاب", + "importStudents_many": "استيراد {{count}} طالبًا", + "importStudents_other": "استيراد {{count}} طالب" + }, + "pick": { + "errors": { + "studentsLoadFailed": { + "title": "تعذّر تحميل قائمة الطلاب", + "detail": "لا يمكن مطابقة أسماء المستخدمين حتى يتم تحميلها. أغلق هذا الحوار وأعد فتحه للمحاولة مرة أخرى." + }, + "noBatchLearners": { + "title": "لا يوجد متعلمو دفعات في هذا التقييم", + "detail": "يطابق الاستيراد الجماعي الطلاب بواسطة اسم المستخدم من دفعات التقييم. أضف دفعات إلى التقييم، أو أدخل هؤلاء الطلاب واحدًا تلو الآخر." + }, + "zipInvalid": { + "title": "تعذّر استخدام ملف الضغط هذا" + } + }, + "template": { + "title": "1. ابدأ من قالب", + "loadingStudents": "جارٍ تحميل قائمة الطلاب…", + "description_zero": "التنزيلان معبّآن مسبقًا بـ {{count}} طالب في دفعات هذا التقييم، لذا عليك فقط ملء الدرجات وأسماء الملفات.", + "description_one": "التنزيلان معبّآن مسبقًا بـ {{count}} طالب واحد في دفعات هذا التقييم، لذا عليك فقط ملء الدرجات وأسماء الملفات.", + "description_two": "التنزيلان معبّآن مسبقًا بـ {{count}} طالبين في دفعات هذا التقييم، لذا عليك فقط ملء الدرجات وأسماء الملفات.", + "description_few": "التنزيلان معبّآن مسبقًا بـ {{count}} طلاب في دفعات هذا التقييم، لذا عليك فقط ملء الدرجات وأسماء الملفات.", + "description_many": "التنزيلان معبّآن مسبقًا بـ {{count}} طالبًا في دفعات هذا التقييم، لذا عليك فقط ملء الدرجات وأسماء الملفات.", + "description_other": "التنزيلان معبّآن مسبقًا بـ {{count}} طالب في دفعات هذا التقييم، لذا عليك فقط ملء الدرجات وأسماء الملفات.", + "downloadCsv": "تنزيل CSV", + "building": "جارٍ الإنشاء...", + "downloadSampleZip": "تنزيل ملف ضغط نموذجي" + }, + "upload": { + "title": "2. تحميل ملف الضغط المعبّأ", + "description": "يجب أن يحتوي ملف الضغط على manifest.csv بالإضافة إلى ملفات PDF الممسوحة ضوئيًا. يتم التقاط ملفات PDF الموجودة في answers/ أو checked/ أو reports/ والمسمّاة باسم المستخدم تلقائيًا. لا يُشترط أي عمود — فالملف المسمّى باسم الطالب يعرّفه بنفسه. لا يُحفظ شيء حتى تراجع المعاينة.", + "readingZip": "جارٍ قراءة ملف الضغط...", + "clickOrDrag": "انقر للتحميل أو اسحب وأفلت", + "zipOnly": "ملفات ‎.zip فقط" + } + }, + "preview": { + "readyCount": "{{count}} جاهز", + "pdfsToUpload_zero": "{{count}} ملف PDF ليُحمَّل", + "pdfsToUpload_one": "{{count}} ملف PDF واحد ليُحمَّل", + "pdfsToUpload_two": "{{count}} ملفا PDF ليُحمَّلا", + "pdfsToUpload_few": "{{count}} ملفات PDF ليُحمَّل", + "pdfsToUpload_many": "{{count}} ملف PDF ليُحمَّل", + "pdfsToUpload_other": "{{count}} ملف PDF ليُحمَّل", + "problemsCount": "{{count}} به مشاكل", + "downloadProblemsCsv": "تنزيل CSV للمشاكل", + "errors": { + "manifestInvalid": { + "title": "لا يمكن استيراد هذا البيان" + }, + "importFailed": { + "title": "فشل الاستيراد", + "retryNote": "ملفاتك مُحمَّلة بالفعل — \"إعادة محاولة الاستيراد\" تعيد إرسالها دون إعادة التحميل." + }, + "noRowsReady": { + "title": "لا توجد صفوف جاهزة للاستيراد", + "detail": "كل صف في manifest.csv به مشكلة. أصلح المشكلات المدرجة أدناه (أو نزّل CSV المشكلات) وحمّل ملف الضغط مرة أخرى." + } + }, + "uploadFailures": { + "header_zero": "تم تخطي {{count}} صف لأنه تعذّر تحميل ملفاته", + "header_one": "تم تخطي {{count}} صف واحد لأنه تعذّر تحميل ملفه", + "header_two": "تم تخطي {{count}} صفين لأنه تعذّر تحميل ملفيهما", + "header_few": "تم تخطي {{count}} صفوف لأنه تعذّر تحميل ملفاتها", + "header_many": "تم تخطي {{count}} صفًا لأنه تعذّر تحميل ملفاتها", + "header_other": "تم تخطي {{count}} صف لأنه تعذّر تحميل ملفاتها", + "lineDetail": "السطر {{line}} ({{username}}): {{errors}}", + "andMore_zero": "…و{{count}} أخرى — راجع CSV المشكلات.", + "andMore_one": "…و{{count}} واحد آخر — راجع CSV المشكلات.", + "andMore_two": "…و{{count}} اثنان آخران — راجع CSV المشكلات.", + "andMore_few": "…و{{count}} أخرى — راجع CSV المشكلات.", + "andMore_many": "…و{{count}} أخرى — راجع CSV المشكلات.", + "andMore_other": "…و{{count}} أخرى — راجع CSV المشكلات." + }, + "warnings": { + "marksReleased": "الصفوف التي تحتوي على قيمة total_marks سيُطلق نتيجتها للطالب.", + "newAttempt": "يضيف الاستيراد محاولة جديدة لكل طالب. لإصلاح الأخطاء، أعد تحميل الصفوف المتأثرة فقط — وليس الملف بأكمله." + }, + "ignoredColumns": { + "header": "الأعمدة المتجاهَلة: {{columns}}", + "hint": "تحقق من وجود خطأ إملائي إذا كنت تتوقع قراءة هذه الأعمدة. الأعمدة المعروفة: {{columns}}." + }, + "unreferencedFiles": { + "header_zero": "سيتم تجاهل {{count}} ملف في ملف الضغط", + "header_one": "سيتم تجاهل {{count}} ملف واحد في ملف الضغط", + "header_two": "سيتم تجاهل {{count}} ملفين في ملف الضغط", + "header_few": "سيتم تجاهل {{count}} ملفات في ملف الضغط", + "header_many": "سيتم تجاهل {{count}} ملفًا في ملف الضغط", + "header_other": "سيتم تجاهل {{count}} ملف في ملف الضغط", + "noFileAttachedSuffix": " — لا يحتوي أي صف على ملف مرفق، لذا ستُستورد الدرجات فقط", + "hint": "سمّها في manifest.csv، أو ضعها في answers/ أو checked/ أو reports/ باسم مسمّى باسم مستخدم الطالب." + }, + "progress": { + "uploading": "جارٍ تحميل {{done}} من {{total}} ملفات…" + } + }, + "table": { + "headers": { + "line": "السطر", + "student": "الطالب", + "marks": "الدرجات", + "answer": "الإجابة", + "checked": "المُصحَّح", + "report": "التقرير", + "status": "الحالة" + }, + "status": { + "ready": "جاهز" + }, + "footerNote_zero": "يتم عرض {{visible}} من {{total}} صف — سيُستورد {{hidden}} أخرى دون مشاكل.", + "footerNote_one": "يتم عرض {{visible}} من {{total}} صف — سيُستورد {{hidden}} صف واحد آخر دون مشاكل.", + "footerNote_two": "يتم عرض {{visible}} من {{total}} صف — سيُستورد {{hidden}} صفان آخران دون مشاكل.", + "footerNote_few": "يتم عرض {{visible}} من {{total}} صف — سيُستورد {{hidden}} صفوف أخرى دون مشاكل.", + "footerNote_many": "يتم عرض {{visible}} من {{total}} صف — سيُستورد {{hidden}} صفًا آخر دون مشاكل.", + "footerNote_other": "يتم عرض {{visible}} من {{total}} صف — سيُستورد {{hidden}} صف آخر دون مشاكل." + }, + "done": { + "errors": { + "somethingWrong": { + "title": "حدث خطأ ما" + } + }, + "importedCount": "{{count}} مستورد", + "notImportedCount": "{{count}} غير مستورد", + "resultLine": "{{rowLabel}} ({{username}}): {{message}}" + }, + "slotCell": { + "auto": "تلقائي" + }, + "common": { + "emptyValue": "—" + }, + "toasts": { + "importedWithFailures_zero": "تم استيراد {{success}} من {{count}} طالب — فشل {{failed}}.", + "importedWithFailures_one": "تم استيراد {{success}} من {{count}} طالب واحد — فشل {{failed}}.", + "importedWithFailures_two": "تم استيراد {{success}} من {{count}} طالبين — فشل {{failed}}.", + "importedWithFailures_few": "تم استيراد {{success}} من {{count}} طلاب — فشل {{failed}}.", + "importedWithFailures_many": "تم استيراد {{success}} من {{count}} طالبًا — فشل {{failed}}.", + "importedWithFailures_other": "تم استيراد {{success}} من {{count}} طالب — فشل {{failed}}.", + "importedSuccess_zero": "تم استيراد {{count}} طالب.", + "importedSuccess_one": "تم استيراد {{count}} طالب واحد.", + "importedSuccess_two": "تم استيراد {{count}} طالبين.", + "importedSuccess_few": "تم استيراد {{count}} طلاب.", + "importedSuccess_many": "تم استيراد {{count}} طالبًا.", + "importedSuccess_other": "تم استيراد {{count}} طالب.", + "noProblemsToDownload": "لا توجد مشاكل لتنزيلها." + }, + "errors": { + "couldNotBuildCsvTemplate": "تعذّر إنشاء قالب CSV.", + "couldNotBuildSampleZip": "تعذّر إنشاء ملف الضغط النموذجي.", + "couldNotReadZip": "تعذّر قراءة ملف الضغط هذا.", + "noRowsUploaded": "تعذّر تحميل أي من الصفوف، لذا لم يُستورد شيء. راجع المشكلات أدناه.", + "bulkImportFailed": "فشل الاستيراد الجماعي. يرجى المحاولة مرة أخرى.", + "couldNotBuildProblemsCsv": "تعذّر إنشاء CSV المشكلات.", + "uploadNoFileId": "تحميل \"{{name}}\" لم يُعِد معرّف ملف", + "fileUploadFailed": "فشل تحميل الملف", + "importFailedGeneric": "فشل الاستيراد" + }, + "validation": { + "notZip": "\"{{fileName}}\" ليس ملف ‎.zip. ضع manifest.csv وملفات PDF في أرشيف مضغوط وحمّله.", + "emptyZip": "\"{{fileName}}\" فارغ (0 بايت). أعد إنشاء ملف الضغط وحاول مرة أخرى.", + "studentsNotLoaded": "تعذّر تحميل قائمة الطلاب، لذا لا يمكن مطابقة أسماء المستخدمين. أغلق هذا الحوار وأعد فتحه للمحاولة مرة أخرى.", + "noBatchLearners": "لا يحتوي هذا التقييم على متعلمي دفعات للمطابقة معهم. يطابق الاستيراد الجماعي الطلاب بواسطة اسم المستخدم من دفعات التقييم.", + "noManifestFound": "لم يُعثر على manifest.csv في ملف الضغط هذا. نزّل ملف الضغط النموذجي أعلاه لمعرفة التنسيق المتوقع.", + "manifestEmpty": "{{manifestPath}} فارغ. املأ صفًا واحدًا على الأقل وأعد الضغط.", + "manifestNoDataRows": "{{manifestPath}} يحتوي على رأس دون صفوف بيانات. أضف صفًا لكل طالب وأعد الضغط." + }, + "network": { + "unreachable": "تعذّر الوصول إلى الخادم. تحقق من اتصالك وحاول مرة أخرى." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentOfflineEntryMainComponent.json b/frontend-admin-dashboard/public/locales/ar/assessmentOfflineEntryMainComponent.json new file mode 100644 index 0000000000..bad1e8c103 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentOfflineEntryMainComponent.json @@ -0,0 +1,37 @@ +{ + "navHeading": "إدخال البيانات دون اتصال", + "defaults": { + "assessmentName": "التقييم" + }, + "selectStudent": { + "subtitle": "إدخال البيانات دون اتصال — اختر طالبًا للبدء", + "bulkImport": "استيراد جماعي" + }, + "completed": { + "title": "اكتمل الإرسال", + "summaryPrefix": "تم إرسال الإجابات دون اتصال الخاصة بـ ", + "summarySuffix": " وتصحيحها.", + "enterAnotherStudent": "إدخال طالب آخر", + "backToAssessments": "العودة إلى التقييمات" + }, + "responses": { + "back": "رجوع", + "viewMode": { + "table": "جدول", + "preview": "معاينة" + }, + "scoringMode": { + "auto": "تلقائي", + "directMarks": "درجات مباشرة" + }, + "submitAll": "إرسال جميع الإجابات", + "noQuestionsInSection": "لا توجد أسئلة في هذا القسم." + }, + "toasts": { + "submitSuccess": "تم إرسال الإجابات دون اتصال بنجاح" + }, + "errors": { + "uploadFailed": "تعذّر رفع \"{{fileName}}\"", + "submitFailed": "فشل إرسال الإجابات دون اتصال" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentOfflineEntrySubmitDialog.json b/frontend-admin-dashboard/public/locales/ar/assessmentOfflineEntrySubmitDialog.json new file mode 100644 index 0000000000..e66b1c30ef --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentOfflineEntrySubmitDialog.json @@ -0,0 +1,24 @@ +{ + "title": "تأكيد الإرسال", + "studentLabel": "الطالب:", + "scoringModeLabel": "وضع التقييم:", + "scoringModeAutoCalculate": "حساب تلقائي", + "scoringModeDirectMarks": "درجات مباشرة", + "answeredCount_zero": "{{count}} تمت الإجابة عنها", + "answeredCount_one": "{{count}} سؤال واحد تمت الإجابة عنه", + "answeredCount_two": "{{count}} سؤالان تمت الإجابة عنهما", + "answeredCount_few": "{{count}} أسئلة تمت الإجابة عنها", + "answeredCount_many": "{{count}} سؤالًا تمت الإجابة عنها", + "answeredCount_other": "{{count}} سؤال تمت الإجابة عنه", + "unansweredCount_zero": "{{count}} بلا إجابة", + "unansweredCount_one": "{{count}} سؤال واحد بلا إجابة", + "unansweredCount_two": "{{count}} سؤالان بلا إجابة", + "unansweredCount_few": "{{count}} أسئلة بلا إجابة", + "unansweredCount_many": "{{count}} سؤالًا بلا إجابة", + "unansweredCount_other": "{{count}} سؤال بلا إجابة", + "unansweredWarning": "سيتم احتساب الأسئلة التي بلا إجابة بدرجة 0.", + "attachmentsHeading": "المرفقات ({{count}})", + "cancel": "إلغاء", + "submitting": "جارٍ الإرسال...", + "submit": "إرسال" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentOneWordPPTList.json b/frontend-admin-dashboard/public/locales/ar/assessmentOneWordPPTList.json new file mode 100644 index 0000000000..45f9a11e51 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentOneWordPPTList.json @@ -0,0 +1,4 @@ +{ + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentOneWordPPTQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentOneWordPPTQP.json new file mode 100644 index 0000000000..8a6acf50e6 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentOneWordPPTQP.json @@ -0,0 +1,6 @@ +{ + "menu": { + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentOneWordTemplateList.json b/frontend-admin-dashboard/public/locales/ar/assessmentOneWordTemplateList.json new file mode 100644 index 0000000000..30f2e4205e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentOneWordTemplateList.json @@ -0,0 +1,16 @@ +{ + "popover": { + "title": "إعدادات الأسئلة", + "questionTypeLabel": "نوع السؤال" + }, + "comprehensionText": "نص الفهم", + "question": { + "labelWithNumber": "السؤال {{number}}" + }, + "answer": { + "label": "الإجابة" + }, + "explanation": { + "label": "الشرح:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentOneWordTemplateQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentOneWordTemplateQP.json new file mode 100644 index 0000000000..3b6e706a83 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentOneWordTemplateQP.json @@ -0,0 +1,16 @@ +{ + "emptyState": { + "noQuestions": "يرجى إضافة سؤال لعرض تفاصيل السؤال" + }, + "label": { + "comprehensionText": "نص الفهم", + "question": "السؤال", + "answer": "الإجابة" + }, + "placeholder": { + "correctAnswer": "أدخل الإجابة الصحيحة" + }, + "explanation": { + "defaultLabel": "الشرح:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentOptionMappingDemo.json b/frontend-admin-dashboard/public/locales/ar/assessmentOptionMappingDemo.json new file mode 100644 index 0000000000..0ff1bca17e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentOptionMappingDemo.json @@ -0,0 +1,45 @@ +{ + "title": "عرض توضيحي لتعيين معرّفات الخيارات", + "subtitle": "كيف يتم تعيين معرّفات الخيارات إلى محتوى قابل للقراءة", + "common": { + "questionLabel": "السؤال:", + "responseLabel": "الاستجابة:" + }, + "before": { + "title": "قبل (معرّفات الخيارات الخام)", + "selectedOptions": "الخيارات المحددة: {{ids}}" + }, + "after": { + "title": "بعد (المحتوى المعيَّن)", + "selected": "المحدد: {{options}}" + }, + "allOptions": { + "title": "جميع الخيارات المتاحة", + "selectedBadge": "محدد" + }, + "process": { + "title": "عملية التعيين:", + "steps": { + "fetchQuestions": { + "label": "جلب واجهة برمجة الأسئلة:", + "description": "الحصول على الأسئلة مع خياراتها من الخادم الخلفي" + }, + "createOptionsMap": { + "label": "إنشاء خريطة الخيارات:", + "description": "تعيين معرّفات الخيارات إلى نص محتواها" + }, + "parseResponse": { + "label": "تحليل الاستجابة:", + "description": "استخراج معرّفات الخيارات المحددة من استجابة المستخدم" + }, + "mapIdsToContent": { + "label": "تعيين المعرّفات إلى المحتوى:", + "description": "استبدال معرّفات الخيارات بنص قابل للقراءة" + }, + "displayEnhancedUi": { + "label": "عرض واجهة مستخدم محسّنة:", + "description": "عرض محتوى السؤال الفعلي والخيارات المحددة" + } + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentPaperSetQuestions.json b/frontend-admin-dashboard/public/locales/ar/assessmentPaperSetQuestions.json new file mode 100644 index 0000000000..9792f74ee7 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentPaperSetQuestions.json @@ -0,0 +1,8 @@ +{ + "header": { + "letterheadAlt": "الترويسة" + }, + "roughWork": { + "heading": "مساحة للمسودة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentParticipantsIndividualList.json b/frontend-admin-dashboard/public/locales/ar/assessmentParticipantsIndividualList.json new file mode 100644 index 0000000000..801f476d7f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentParticipantsIndividualList.json @@ -0,0 +1,8 @@ +{ + "trigger": { + "viewList": "عرض القائمة" + }, + "dialog": { + "title": "قائمة المتعلمين الفردية" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentParticipantsList.json b/frontend-admin-dashboard/public/locales/ar/assessmentParticipantsList.json new file mode 100644 index 0000000000..4e56810d68 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentParticipantsList.json @@ -0,0 +1,15 @@ +{ + "trigger": { + "viewList": "عرض القائمة" + }, + "dialog": { + "title": "قائمة المشاركين" + }, + "filters": { + "genderLabel": "الجنس" + }, + "filterButtons": { + "filter": "تصفية", + "reset": "إعادة تعيين" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentParticipantsTab.json b/frontend-admin-dashboard/public/locales/ar/assessmentParticipantsTab.json new file mode 100644 index 0000000000..1bf3ea6235 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentParticipantsTab.json @@ -0,0 +1,32 @@ +{ + "header": { + "title": "مشاركو التقييم" + }, + "internalParticipants": { + "count_zero": "لا يوجد مشاركون (داخلي) ({{count}})", + "count_one": "مشارك واحد (داخلي) ({{count}})", + "count_two": "مشاركان (داخلي) ({{count}})", + "count_few": "{{count}} مشاركين (داخلي)", + "count_many": "{{count}} مشاركًا (داخلي)", + "count_other": "{{count}} مشارك (داخلي)" + }, + "joinLink": { + "title": "رابط الانضمام" + }, + "qrCode": { + "title": "رمز الاستجابة السريعة" + }, + "notifications": { + "title": "إخطار المشاركين عبر البريد الإلكتروني:", + "whenCreated": "عند إنشاء التقييم:", + "beforeLive": "قبل بدء التقييم مباشرة:", + "minutesBeforeLive_zero": "{{count}} دقيقة", + "minutesBeforeLive_one": "دقيقة واحدة ({{count}})", + "minutesBeforeLive_two": "دقيقتان ({{count}})", + "minutesBeforeLive_few": "{{count}} دقائق", + "minutesBeforeLive_many": "{{count}} دقيقة", + "minutesBeforeLive_other": "{{count}} دقيقة", + "whenLive": "عند بدء التقييم مباشرة:", + "whenReportsGenerated": "عند إنشاء تقارير التقييم:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentPreview.json b/frontend-admin-dashboard/public/locales/ar/assessmentPreview.json new file mode 100644 index 0000000000..6de95f7e2a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentPreview.json @@ -0,0 +1,26 @@ +{ + "toasts": { + "updateSuccess": "تم تحديث ورقة الأسئلة لهذا التقييم بنجاح!", + "incompleteQuestions": "بعض أسئلتك غير مكتملة أو تحتاج إلى انتباه!" + }, + "header": { + "joinLinkLabel": "رابط الانضمام:" + }, + "announcementDialog": { + "title": "إعلان التقييم المباشر", + "empty": "لا توجد إعلانات", + "timestampPlaceholder": "اليوم، 11:28 صباحًا" + }, + "actions": { + "save": "حفظ", + "exit": "خروج", + "addQuestion": "إضافة سؤال" + }, + "questionTypes": { + "mcqSingleCorrect": "اختيار من متعدد (إجابة واحدة صحيحة)", + "mcqMultipleCorrect": "اختيار من متعدد (إجابات متعددة صحيحة)" + }, + "emptyStates": { + "noQuestions": "لا يوجد أي سؤال." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentProvideReattemptDialog.json b/frontend-admin-dashboard/public/locales/ar/assessmentProvideReattemptDialog.json new file mode 100644 index 0000000000..eb0f492767 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentProvideReattemptDialog.json @@ -0,0 +1,17 @@ +{ + "dialogHeading": "منح إعادة المحاولة", + "confirmTitlePrefix": "هل أنت متأكد من رغبتك في منح إعادة محاولة إلى", + "confirmTitleSuffix": " المحدد؟", + "inputLabel": "عدد المحاولات الممنوحة", + "inputPlaceholder": "1", + "inputError": "أدخل رقمًا صحيحًا بين 1 و20", + "doneButton": "تم", + "toastSuccess_zero": "لم يتم منح أي محاولات ({{count}}) للمشاركين المحددين.", + "toastSuccess_one": "تم منح محاولة واحدة ({{count}}) للمشاركين المحددين.", + "toastSuccess_two": "تم منح محاولتين ({{count}}) للمشاركين المحددين.", + "toastSuccess_few": "تم منح {{count}} محاولات للمشاركين المحددين.", + "toastSuccess_many": "تم منح {{count}} محاولة للمشاركين المحددين.", + "toastSuccess_other": "تم منح {{count}} محاولة للمشاركين المحددين.", + "toastErrorBulk": "تعذر تحديد تسجيلات المشاركين المحددين.", + "toastErrorSingle": "تعذر تحديد تسجيل هذا المشارك." +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentProvideReleaseResult.json b/frontend-admin-dashboard/public/locales/ar/assessmentProvideReleaseResult.json new file mode 100644 index 0000000000..3bb38d5159 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentProvideReleaseResult.json @@ -0,0 +1,11 @@ +{ + "dialog": { + "heading": "نشر النتيجة", + "confirmTextPrefix": "هل أنت متأكد أنك تريد نشر النتيجة للمحدد", + "confirmSuffix": "؟" + }, + "doneButton": "تم", + "toasts": { + "releaseSuccess": "تم نشر نتيجة هذا الاختبار للطلاب المحددين. يرجى التحقق من بريدك الإلكتروني!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentProvideRevaluateDialog.json b/frontend-admin-dashboard/public/locales/ar/assessmentProvideRevaluateDialog.json new file mode 100644 index 0000000000..2ef5da4d79 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentProvideRevaluateDialog.json @@ -0,0 +1,11 @@ +{ + "dialog": { + "heading": "إعادة تقييم الاختبار", + "confirmTextPrefix": "هل أنت متأكد أنك تريد إعادة تقييم الاختبار للمحدد", + "confirmSuffix": "؟" + }, + "doneButton": "تم", + "toasts": { + "revaluateSuccess": "تمت إعادة تقييم محاولة هذا الاختبار للطلاب المحددين. يرجى التحقق من بريدك الإلكتروني!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentProvideRevaluateQuestionwiseDialog.json b/frontend-admin-dashboard/public/locales/ar/assessmentProvideRevaluateQuestionwiseDialog.json new file mode 100644 index 0000000000..e50c88a287 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentProvideRevaluateQuestionwiseDialog.json @@ -0,0 +1,13 @@ +{ + "dialog": { + "heading": "إعادة التقييم حسب السؤال" + }, + "table": { + "questionNo": "رقم السؤال", + "question": "السؤال" + }, + "revaluateButton": "إعادة التقييم", + "toasts": { + "revaluateSuccess": "تمت إعادة تقييم محاولة هذا التقييم للطلاب المحددين. يرجى التحقق من بريدك الإلكتروني!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionAnalysisChart.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionAnalysisChart.json new file mode 100644 index 0000000000..b39e67f6a2 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionAnalysisChart.json @@ -0,0 +1,17 @@ +{ + "legend": { + "correct": "صحيح", + "partiallyCorrect": "صحيح جزئيًا", + "wrongResponses": "إجابات خاطئة", + "skip": "تخطي" + }, + "checkInsightsButton": "عرض إحصاءات الأسئلة", + "dialogTitle": "إحصاءات الأسئلة", + "xAxisLabel": "السؤال", + "yAxisLabel": "عدد المشاركين", + "header": { + "title": "تحليل الأسئلة", + "selectSectionPlaceholder": "اختر القسم", + "exportButton": "تصدير" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionEditorParts.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionEditorParts.json new file mode 100644 index 0000000000..a406524ee8 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionEditorParts.json @@ -0,0 +1,21 @@ +{ + "answerOptions": { + "addOption": "إضافة خيار", + "hint": { + "survey": "يختار المستجيبون خيارًا واحدًا — أسئلة الاستطلاع ليس لها إجابة صحيحة.", + "single": "حدد خيارًا واحدًا فقط كإجابة صحيحة.", + "multiple": "حدد كل خيار يُعتبر صحيحًا." + }, + "ariaLabel": "خيارات الإجابة", + "markCorrect": { + "ariaLabel": "تحديد الخيار {{letter}} كإجابة صحيحة", + "titleCorrect": "إجابة صحيحة", + "titleMark": "تحديد كإجابة صحيحة" + }, + "optionPlaceholder": "الخيار {{letter}}", + "removeOption": { + "ariaLabel": "إزالة الخيار {{letter}}", + "title": "إزالة الخيار" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionInsights.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionInsights.json new file mode 100644 index 0000000000..f0ec58ac1f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionInsights.json @@ -0,0 +1,39 @@ +{ + "toasts": { + "pdfExportSuccess": "تم تصدير بيانات تحليل الأسئلة للطلاب بصيغة PDF بنجاح" + }, + "questionCard": { + "heading": "السؤال ({{number}}.)", + "correctAnswerLabel": "الإجابة الصحيحة:", + "explanationLabel": "الشرح:", + "topResponsesTitle": "أسرع 3 إجابات صحيحة", + "secondsSuffix_zero": "{{count}} ثانية", + "secondsSuffix_one": "ثانية واحدة ({{count}})", + "secondsSuffix_two": "ثانيتان ({{count}})", + "secondsSuffix_few": "{{count}} ثوانٍ", + "secondsSuffix_many": "{{count}} ثانية", + "secondsSuffix_other": "{{count}} ثانية" + }, + "summary": { + "totalAttempts_zero": "إجمالي المحاولات: {{count}} طالب", + "totalAttempts_one": "إجمالي المحاولات: طالب واحد ({{count}})", + "totalAttempts_two": "إجمالي المحاولات: طالبان ({{count}})", + "totalAttempts_few": "إجمالي المحاولات: {{count}} طلاب", + "totalAttempts_many": "إجمالي المحاولات: {{count}} طالبًا", + "totalAttempts_other": "إجمالي المحاولات: {{count}} طالب" + }, + "respondents": { + "correctLabel": "المجيبون بشكل صحيح: ", + "partialLabel": "المجيبون بشكل صحيح جزئيًا: ", + "wrongLabel": "المجيبون بشكل خاطئ: ", + "skippedLabel": "تم التخطي: ", + "viewList": "عرض القائمة", + "notAvailable": "غير متاح" + }, + "dialogs": { + "correctTitle": "المجيبون بشكل صحيح", + "partialTitle": "المجيبون بشكل صحيح جزئيًا", + "wrongTitle": "المجيبون بشكل خاطئ", + "skippedTitle": "من تخطى السؤال" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionInsightsAnalysisChart.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionInsightsAnalysisChart.json new file mode 100644 index 0000000000..e4276b6c6b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionInsightsAnalysisChart.json @@ -0,0 +1,8 @@ +{ + "legend": { + "correct": "صحيح", + "partiallyCorrect": "صحيح جزئيًا", + "wrongResponse": "إجابة خاطئة", + "skipped": "تم التخطي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionNavigator.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionNavigator.json new file mode 100644 index 0000000000..341f3b577c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionNavigator.json @@ -0,0 +1,7 @@ +{ + "legend": { + "current": "الحالي", + "answered": "تمت الإجابة", + "unanswered": "لم تتم الإجابة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPaperEditDialogue.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPaperEditDialogue.json new file mode 100644 index 0000000000..7afb692c3f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPaperEditDialogue.json @@ -0,0 +1,6 @@ +{ + "title": "تعديل", + "titleFieldLabel": "العنوان", + "titleFieldPlaceholder": "أدخل العنوان", + "saveButton": "حفظ" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPaperTemplate.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPaperTemplate.json new file mode 100644 index 0000000000..3d212a712a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPaperTemplate.json @@ -0,0 +1,50 @@ +{ + "common": { + "questionCount_zero": "{{count}} سؤال", + "questionCount_one": "سؤال واحد ({{count}})", + "questionCount_two": "سؤالان ({{count}})", + "questionCount_few": "{{count}} أسئلة", + "questionCount_many": "{{count}} سؤالًا", + "questionCount_other": "{{count}} سؤال", + "tagCount_zero": "{{count}} وسم", + "tagCount_one": "وسم واحد ({{count}})", + "tagCount_two": "وسمان ({{count}})", + "tagCount_few": "{{count}} أوسام", + "tagCount_many": "{{count}} وسمًا", + "tagCount_other": "{{count}} وسم" + }, + "header": { + "logoAlt": "الشعار", + "untitled": "بدون عنوان", + "exit": "خروج", + "save": "حفظ" + }, + "sidebar": { + "questionsLabel": "الأسئلة", + "questionIncompleteAriaLabel": "السؤال غير مكتمل", + "questionIncompleteTooltip": "السؤال غير مكتمل" + }, + "actions": { + "addQuestion": "إضافة سؤال" + }, + "dialogs": { + "addQuestion": { + "title": "إضافة سؤال" + } + }, + "emptyState": { + "title": "لا توجد أسئلة بعد", + "description": "أضف سؤالك الأول لبدء إنشاء هذه الورقة." + }, + "tags": { + "title": "وسوم الموضوع / الموضوع الفرعي", + "description": "تُستخدم لتجميع الأسئلة في التقارير وبحث الأسئلة.", + "applyToAll": "تطبيق على جميع الأسئلة" + }, + "toasts": { + "addTagFirst": "أضف وسمًا واحدًا على الأقل إلى هذا السؤال أولاً", + "tagsApplied": "تم تطبيق {{tags}} على جميع {{questions}}", + "updateSuccess": "تم تحديث ورقة الأسئلة بنجاح", + "incompleteQuestions": "بعض أسئلتك غير مكتملة أو تحتاج إلى انتباه!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPaperUpload.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPaperUpload.json new file mode 100644 index 0000000000..08a6d148e9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPaperUpload.json @@ -0,0 +1,55 @@ +{ + "formatGuide": { + "title": "تنسيق المستند المتوقع", + "description": "تتم قراءة كل سؤال من هذه العلامات، واحدة في كل سطر. الإجابة والشرح والوسوم اختيارية — يمكنك وسم كل سؤال بمادة/موضوع.", + "questionLabel": "السؤال:", + "optionsLabel": "الخيارات:", + "answerLabel": "الإجابة:", + "answerHint": "— حرف الخيار الصحيح (تعمل أيضًا Ans: (A))", + "explanationLabel": "الشرح:", + "tagsLabel": "الوسوم:", + "tagsHint": "— اختياري، مفصول بفواصل", + "variationsPart1": "تُقبل أيضًا اختلافات بسيطة — مثل", + "variationsPart2": "كخيارات، وسطر مفرد", + "variationsPart3": "لا تتطابق. يتم وضع علامة على الأسطر غير المطابقة بعد الرفع حتى تتمكن من إصلاحها أو تخطيها." + }, + "docxHelp": { + "part1": "إذا واجهت مشكلة أثناء رفع ملف docx، يرجى تحويل ملفك إلى html", + "linkText": "هنا", + "part2": "وحاول إعادة الرفع.", + "step1": "الخطوة 1 - انتقل إلى هذا الموقع", + "step2": "الخطوة 2 - فعّل تضمين الصور", + "step3": "الخطوة 3 - نزّل ملف html بعد التحويل", + "imageAlt": "شعار" + }, + "upload": { + "processing": "جارٍ المعالجة…", + "processingHint": "الخادم يقوم بتحليل مستندك. قد تستغرق الملفات الكبيرة حتى دقيقة." + }, + "fields": { + "titleLabel": "العنوان", + "titlePlaceholder": "أدخل العنوان" + }, + "buttons": { + "loading": "جارٍ التحميل...", + "preview": "معاينة", + "addQuestions": "إضافة أسئلة", + "done": "تم" + }, + "toasts": { + "questionPaperAdded": "تمت إضافة ورقة الأسئلة بنجاح", + "incompleteQuestions": "بعض أسئلتك غير مكتملة أو تحتاج إلى انتباه!", + "addedAndSkipped_zero": "تمت إضافة {{count}} سؤال؛ تم تخطي {{skipped}} بسبب مشكلات.", + "addedAndSkipped_one": "تمت إضافة {{count}} سؤال؛ تم تخطي {{skipped}} بسبب مشكلات.", + "addedAndSkipped_two": "تمت إضافة {{count}} سؤالين؛ تم تخطي {{skipped}} بسبب مشكلات.", + "addedAndSkipped_few": "تمت إضافة {{count}} أسئلة؛ تم تخطي {{skipped}} بسبب مشكلات.", + "addedAndSkipped_many": "تمت إضافة {{count}} سؤالًا؛ تم تخطي {{skipped}} بسبب مشكلات.", + "addedAndSkipped_other": "تمت إضافة {{count}} سؤال؛ تم تخطي {{skipped}} بسبب مشكلات.", + "loadedAll_zero": "تم تحميل {{count}} سؤال بالكامل. {{flagged}} يحتاج إلى تعديل قبل أن تتمكن من الحفظ.", + "loadedAll_one": "تم تحميل {{count}} سؤال بالكامل. {{flagged}} يحتاج إلى تعديل قبل أن تتمكن من الحفظ.", + "loadedAll_two": "تم تحميل {{count}} سؤالين بالكامل. {{flagged}} يحتاج إلى تعديل قبل أن تتمكن من الحفظ.", + "loadedAll_few": "تم تحميل {{count}} أسئلة بالكامل. {{flagged}} يحتاج إلى تعديل قبل أن تتمكن من الحفظ.", + "loadedAll_many": "تم تحميل {{count}} سؤالًا بالكامل. {{flagged}} يحتاج إلى تعديل قبل أن تتمكن من الحفظ.", + "loadedAll_other": "تم تحميل {{count}} سؤال بالكامل. {{flagged}} يحتاج إلى تعديل قبل أن تتمكن من الحفظ." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersComponent.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersComponent.json new file mode 100644 index 0000000000..23acad3e55 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersComponent.json @@ -0,0 +1,5 @@ +{ + "heading": "أوراق الأسئلة", + "pageTitle": "أوراق الأسئلة", + "pageDescription": "تعرض هذه الصفحة جميع أوراق الأسئلة المضافة، كما تتيح لك إضافة أوراق أسئلة جديدة من هنا." +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersDateRange.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersDateRange.json new file mode 100644 index 0000000000..6801f32582 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersDateRange.json @@ -0,0 +1,6 @@ +{ + "heading": "تحديد النطاق الزمني", + "startDateLabel": "أدخل تاريخ البدء", + "endDateLabel": "أدخل تاريخ الانتهاء", + "doneButton": "تم" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersFilter.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersFilter.json new file mode 100644 index 0000000000..b4bb44fd4d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersFilter.json @@ -0,0 +1,10 @@ +{ + "searchPlaceholder": "ابحث عن {{label}}...", + "noResults": "لم يتم العثور على نتائج.", + "selectedCount_zero": "لم يتم تحديد شيء ({{count}})", + "selectedCount_one": "تم تحديد عنصر واحد ({{count}})", + "selectedCount_two": "تم تحديد عنصرين ({{count}})", + "selectedCount_few": "تم تحديد {{count}} عناصر", + "selectedCount_many": "تم تحديد {{count}} عنصرًا", + "selectedCount_other": "تم تحديد {{count}} عنصر" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersHeading.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersHeading.json new file mode 100644 index 0000000000..97cde21432 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersHeading.json @@ -0,0 +1,9 @@ +{ + "heading": "الوصول إلى أوراق الأسئلة وإدارتها", + "description": "الوصول بسرعة إلى جميع أوراق الأسئلة وإدارتها عبر الصفوف والمواد الدراسية. تصفح الأوراق ونظّمها بسهولة لدعم التحضير السلس للامتحانات.", + "addQuestionPaper": "إضافة ورقة أسئلة", + "createManually": "إنشاء يدويًا", + "createQuestionPaperManually": "إنشاء ورقة أسئلة يدويًا", + "uploadFromDevice": "التحميل من الجهاز", + "uploadQuestionPaperFromDevice": "تحميل ورقة أسئلة من الجهاز" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersList.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersList.json new file mode 100644 index 0000000000..644fdfce98 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersList.json @@ -0,0 +1,44 @@ +{ + "config": { + "backToList": "العودة إلى القائمة", + "createdOn": "أُنشئ في {{date}}", + "selectQuestionCount": "كم عدد الأسئلة المراد تضمينها؟", + "modeAll": "جميع الأسئلة", + "modeRandom": "اختيار عشوائي", + "modeTags": "عشوائي حسب الوسم", + "modeManual": "اختيار يدوي", + "modeManualHint": "— اختر أسئلة محددة في الشاشة التالية", + "randomPlaceholder": "مثال: 20", + "questionsLabel": "أسئلة", + "tagAvailable_zero": "{{count}} متاح", + "tagAvailable_one": "{{count}} متاح", + "tagAvailable_two": "{{count}} متاحان", + "tagAvailable_few": "{{count}} متاحة", + "tagAvailable_many": "{{count}} متاحاً", + "tagAvailable_other": "{{count}} متاح", + "total": "الإجمالي", + "totalQuestions_zero": "{{count}} سؤال", + "totalQuestions_one": "سؤال واحد ({{count}})", + "totalQuestions_two": "سؤالان ({{count}})", + "totalQuestions_few": "{{count}} أسئلة", + "totalQuestions_many": "{{count}} سؤالاً", + "totalQuestions_other": "{{count}} سؤال", + "cancel": "إلغاء", + "chooseQuestions": "اختيار الأسئلة", + "addRandomCount_zero": "أضف {{count}} سؤال عشوائي", + "addRandomCount_one": "أضف سؤالاً عشوائياً واحداً ({{count}})", + "addRandomCount_two": "أضف سؤالين عشوائيين ({{count}})", + "addRandomCount_few": "أضف {{count}} أسئلة عشوائية", + "addRandomCount_many": "أضف {{count}} سؤالاً عشوائياً", + "addRandomCount_other": "أضف {{count}} سؤال عشوائي", + "addAllQuestions": "أضف جميع الأسئلة" + }, + "list": { + "viewButton": "عرض", + "deleteQuestionPaper": "حذف ورقة الأسئلة", + "createdOnLabel": "تاريخ الإنشاء:", + "notAvailable": "غير متاح", + "yearClassLabel": "السنة/الصف:", + "subjectLabel": "المادة:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersSearchComponent.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersSearchComponent.json new file mode 100644 index 0000000000..a18bcd5bf8 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersSearchComponent.json @@ -0,0 +1,3 @@ +{ + "searchQuestionPaperPlaceholder": "البحث عن ورقة الأسئلة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersTabs.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersTabs.json new file mode 100644 index 0000000000..14cebdef04 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionPapersTabs.json @@ -0,0 +1,15 @@ +{ + "filters": { + "yearClass": "السنة/الصف", + "tags": "الوسوم" + }, + "actions": { + "filter": "تصفية", + "reset": "إعادة تعيين", + "tryAgain": "إعادة المحاولة" + }, + "emptyStates": { + "noQuestionPapers": "لا توجد أوراق أسئلة متاحة", + "noFavouriteQuestionPapers": "لم يتم تمييز أي ورقة أسئلة كمفضلة بعد" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionResponseForm.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionResponseForm.json new file mode 100644 index 0000000000..9cfcc5d3b9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionResponseForm.json @@ -0,0 +1,28 @@ +{ + "header": { + "questionOf": "السؤال {{current}} من {{total}}", + "typeAndMaxMarks": "{{type}} | أقصى الدرجات: {{maxMarks}}" + }, + "question": { + "noContent": "لا يوجد نص للسؤال" + }, + "options": { + "selectOne": "اختر خيارًا واحدًا:", + "selectAll": "اختر كل ما ينطبق:", + "optionFallback": "الخيار {{number}}" + }, + "directMarks": { + "marksLabel": "الدرجات", + "statusLabel": "الحالة", + "statusSelect": "اختر", + "statusCorrect": "صحيح", + "statusIncorrect": "غير صحيح", + "statusPartialCorrect": "صحيح جزئيًا" + }, + "navigation": { + "clearResponse": "مسح الإجابة", + "previous": "السابق", + "submit": "إرسال", + "next": "التالي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionSelectorDialog.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionSelectorDialog.json new file mode 100644 index 0000000000..90162a11c5 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionSelectorDialog.json @@ -0,0 +1,48 @@ +{ + "dialog": { + "heading_zero": "اختر الأسئلة ({{count}} سؤال)", + "heading_one": "اختر الأسئلة ({{count}} سؤال واحد)", + "heading_two": "اختر الأسئلة ({{count}} سؤالان)", + "heading_few": "اختر الأسئلة ({{count}} أسئلة)", + "heading_many": "اختر الأسئلة ({{count}} سؤالاً)", + "heading_other": "اختر الأسئلة ({{count}} سؤال)" + }, + "footer": { + "selectedCount_zero": "تم تحديد {{count}} سؤال", + "selectedCount_one": "تم تحديد سؤال واحد ({{count}})", + "selectedCount_two": "تم تحديد سؤالان ({{count}})", + "selectedCount_few": "تم تحديد {{count}} أسئلة", + "selectedCount_many": "تم تحديد {{count}} سؤالاً", + "selectedCount_other": "تم تحديد {{count}} سؤال" + }, + "actions": { + "cancel": "إلغاء", + "addQuestions_zero": "إضافة {{count}} سؤال", + "addQuestions_one": "إضافة سؤال واحد ({{count}})", + "addQuestions_two": "إضافة سؤالين ({{count}})", + "addQuestions_few": "إضافة {{count}} أسئلة", + "addQuestions_many": "إضافة {{count}} سؤالاً", + "addQuestions_other": "إضافة {{count}} سؤال" + }, + "search": { + "placeholder": "ابحث في الأسئلة..." + }, + "tags": { + "filterLabel": "تصفية حسب الوسم:" + }, + "list": { + "selectAll_zero": "تحديد الكل ({{count}})", + "selectAll_one": "تحديد الكل (سؤال واحد - {{count}})", + "selectAll_two": "تحديد الكل (سؤالان - {{count}})", + "selectAll_few": "تحديد الكل ({{count}} أسئلة)", + "selectAll_many": "تحديد الكل ({{count}} سؤالاً)", + "selectAll_other": "تحديد الكل ({{count}} سؤال)", + "matchingCount_zero": "{{count}} مطابق", + "matchingCount_one": "سؤال واحد مطابق ({{count}})", + "matchingCount_two": "سؤالان مطابقان ({{count}})", + "matchingCount_few": "{{count}} أسئلة مطابقة", + "matchingCount_many": "{{count}} سؤالاً مطابقاً", + "matchingCount_other": "{{count}} سؤال مطابق", + "emptyState": "لا توجد أسئلة تطابق بحثك." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionStatus.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionStatus.json new file mode 100644 index 0000000000..01cfa561a1 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionStatus.json @@ -0,0 +1,10 @@ +{ + "tabs": { + "internalParticipants": "المشاركون الداخليون", + "externalParticipants": "المشاركون الخارجيون" + }, + "toasts": { + "pdfExportSuccess": "تم تصدير بيانات قائمة المستجيبين للطلاب بصيغة PDF بنجاح", + "csvExportSuccess": "تم تصدير بيانات قائمة المستجيبين للطلاب بصيغة CSV بنجاح" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionTypeSelection.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionTypeSelection.json new file mode 100644 index 0000000000..f21e2e16d7 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionTypeSelection.json @@ -0,0 +1,25 @@ +{ + "sections": { + "quickAccess": "الوصول السريع", + "programming": "البرمجة", + "optionBased": "قائم على الخيارات", + "mathBased": "قائم على الرياضيات", + "writingSkills": "مهارات الكتابة", + "readingSkills": "مهارات القراءة" + }, + "questionTypes": { + "mcqSingle": "أسئلة اختيار من متعدد (إجابة صحيحة واحدة)", + "mcqMultiple": "أسئلة اختيار من متعدد (إجابات صحيحة متعددة)", + "numerical": "عددي", + "trueFalse": "صح خطأ", + "coding": "سؤال برمجي", + "longAnswer": "إجابة طويلة", + "singleWord": "كلمة واحدة", + "comprehensionMcqSingle": "أسئلة اختيار من متعدد للفهم (إجابة صحيحة واحدة)", + "comprehensionMcqMultiple": "أسئلة اختيار من متعدد للفهم (إجابات صحيحة متعددة)", + "comprehensionNumeric": "سؤال عددي للفهم" + }, + "dialog": { + "title": "إنشاء ورقة أسئلة يدويًا" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionsMarkRankGraph.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionsMarkRankGraph.json new file mode 100644 index 0000000000..1b11f4c882 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionsMarkRankGraph.json @@ -0,0 +1,14 @@ +{ + "chart": { + "markLabel": "الدرجة", + "rankAxis": "الترتيب", + "marksObtainedAxis": "الدرجات المحصلة" + }, + "header": { + "title": "الرسم البياني للدرجات والترتيب" + }, + "toasts": { + "pdfExportSuccess": "تم تصدير بيانات ترتيب ودرجات الطلاب بصيغة PDF بنجاح", + "csvExportSuccess": "تم تصدير بيانات ترتيب ودرجات الطلاب بصيغة CSV بنجاح" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionsPieChart.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionsPieChart.json new file mode 100644 index 0000000000..5df6a2738b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionsPieChart.json @@ -0,0 +1,34 @@ +{ + "chart": { + "ongoing": "قيد التقدم", + "pending": "قيد الانتظار", + "attempted": "تمت المحاولة" + }, + "info": { + "createdOn": "تاريخ الإنشاء", + "startDateTime": "تاريخ ووقت البدء", + "endDateTime": "تاريخ ووقت الانتهاء", + "duration": "المدة", + "totalParticipants": "إجمالي المشاركين", + "subjectFallback": "غير متاح" + }, + "durationLabel": { + "hoursMinutes": "{{hours}} ساعة {{minutes}} دقيقة", + "hoursOnly": "{{hours}} ساعة", + "minutesOnly": "{{minutes}} دقيقة" + }, + "stats": { + "avgDuration": "متوسط المدة", + "avgDurationUnit": "دقيقة", + "avgMarks": "متوسط الدرجات" + }, + "breakdown": { + "title": "تفصيل المشاركة", + "total_zero": "{{count}} إجمالاً", + "total_one": "{{count}} إجمالاً", + "total_two": "{{count}} إجمالاً", + "total_few": "{{count}} إجمالاً", + "total_many": "{{count}} إجمالاً", + "total_other": "{{count}} إجمالاً" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionsRankMarkTable.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionsRankMarkTable.json new file mode 100644 index 0000000000..5d2d88054d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionsRankMarkTable.json @@ -0,0 +1,8 @@ +{ + "columns": { + "rank": "الترتيب", + "marks": "الدرجات", + "percentile": "المئين", + "participants": "عدد المشاركين" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentQuestionsSection.json b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionsSection.json new file mode 100644 index 0000000000..694a64a7ee --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentQuestionsSection.json @@ -0,0 +1,50 @@ +{ + "sectionInfo": { + "descriptionTitle": "وصف القسم" + }, + "stats": { + "sectionDuration": { + "label": "مدة القسم", + "value": "{{duration}} دقيقة" + }, + "avgMarksPerQuestion": "متوسط الدرجات / سؤال", + "totalMarks": "إجمالي الدرجات", + "negativeMarking": "الدرجات السلبية" + }, + "badges": { + "partialMarking": "تقدير جزئي", + "cutoffMarks": "درجة القطع", + "problemRandomization": "عشوائية الأسئلة" + }, + "table": { + "heading": { + "survey": "أسئلة الاستبيان", + "adaptiveMarking": "قواعد التقدير التكيفي" + }, + "columns": { + "index": "#", + "question": "السؤال", + "type": "النوع", + "marks": "الدرجات", + "penalty": "الخصم", + "criteria": "المعايير", + "time": "الوقت" + }, + "criteriaNotSet": "غير محدد" + }, + "toasts": { + "invalidCriteriaFormat": "تعذر معاينة المعايير. تنسيق البيانات غير صالح." + }, + "accordion": { + "questionCount_zero": "لا أسئلة ({{count}})", + "questionCount_one": "سؤال واحد ({{count}})", + "questionCount_two": "سؤالان ({{count}})", + "questionCount_few": "{{count}} أسئلة", + "questionCount_many": "{{count}} سؤالًا", + "questionCount_other": "{{count}} سؤال", + "mcqSingle": "اختيار من متعدد · فردي {{count}}", + "mcqMultiple": "اختيار من متعدد · متعدد {{count}}", + "total": "الإجمالي {{count}}" + }, + "sectionTotal": "إجمالي القسم" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentReattemptRequestsTab.json b/frontend-admin-dashboard/public/locales/ar/assessmentReattemptRequestsTab.json new file mode 100644 index 0000000000..54206f9203 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentReattemptRequestsTab.json @@ -0,0 +1,57 @@ +{ + "filters": { + "pending": "قيد الانتظار", + "approved": "تمت الموافقة", + "rejected": "مرفوض", + "all": "الكل" + }, + "status": { + "approved": "تمت الموافقة", + "rejected": "مرفوض", + "pending": "قيد الانتظار" + }, + "requestType": { + "timeIncrease": "زيادة الوقت", + "reattempt": "إعادة المحاولة" + }, + "summary": { + "currentlyAllowed": "المسموح به حاليًا: {{value}}", + "used": "المستخدم: {{value}}", + "requested": "تم الطلب في {{date}}", + "reviewedOn": "في {{date}}", + "grantedCount_zero": "لم يتم منح أي محاولات ({{count}})", + "grantedCount_one": "تم منح محاولة واحدة ({{count}})", + "grantedCount_two": "تم منح محاولتين ({{count}})", + "grantedCount_few": "تم منح {{count}} محاولات", + "grantedCount_many": "تم منح {{count}} محاولة", + "grantedCount_other": "تم منح {{count}} محاولة" + }, + "form": { + "attemptsToGrantLabel": "عدد المحاولات الممنوحة", + "attemptsRangeError": "من 1 إلى 20" + }, + "actions": { + "approve": "الموافقة", + "reject": "الرفض" + }, + "toasts": { + "granted_zero": "لم يتم منح أي محاولات ({{count}}) إلى {{name}}.", + "granted_one": "تم منح محاولة واحدة ({{count}}) إلى {{name}}.", + "granted_two": "تم منح محاولتين ({{count}}) إلى {{name}}.", + "granted_few": "تم منح {{count}} محاولات إلى {{name}}.", + "granted_many": "تم منح {{count}} محاولة إلى {{name}}.", + "granted_other": "تم منح {{count}} محاولة إلى {{name}}.", + "theLearner": "المتعلم", + "rejected": "تم رفض الطلب.", + "updateFailed": "تعذر تحديث هذا الطلب. يرجى المحاولة مرة أخرى." + }, + "error": { + "title": "تعذر تحميل الطلبات", + "tryAgain": "أعد المحاولة" + }, + "empty": { + "titleAll": "لا توجد طلبات", + "titleFiltered": "لا توجد طلبات {{status}}", + "description": "يمكن للمتعلمين طلب محاولة أخرى أو وقت إضافي من داخل الاختبار. تظهر طلباتهم هنا." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentRemoveParticipantsComponent.json b/frontend-admin-dashboard/public/locales/ar/assessmentRemoveParticipantsComponent.json new file mode 100644 index 0000000000..d97f7fa9ad --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentRemoveParticipantsComponent.json @@ -0,0 +1,8 @@ +{ + "dialog": { + "heading": "إزالة المشاركين", + "confirmMessagePrefix": "هل أنت متأكد أنك تريد إزالة المشاركين لـ", + "confirmMessageSuffix": "؟", + "done": "تم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentReportZipExportDialog.json b/frontend-admin-dashboard/public/locales/ar/assessmentReportZipExportDialog.json new file mode 100644 index 0000000000..9b06f6f57a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentReportZipExportDialog.json @@ -0,0 +1,88 @@ +{ + "trigger": { + "label": "تصدير التقارير (ZIP)" + }, + "dialog": { + "heading": "تصدير التقارير (ZIP)" + }, + "description": { + "bulk_zero": "ينشئ ملف ZIP يحتوي على تقارير PDF لـ {{count}} تقديم محدد.", + "bulk_one": "ينشئ ملف ZIP يحتوي على تقارير PDF لـ {{count}} تقديم محدد.", + "bulk_two": "ينشئ ملف ZIP يحتوي على تقارير PDF لـ {{count}} تقديمين محددين.", + "bulk_few": "ينشئ ملف ZIP يحتوي على تقارير PDF لـ {{count}} تقديمات محددة.", + "bulk_many": "ينشئ ملف ZIP يحتوي على تقارير PDF لـ {{count}} تقديمًا محددًا.", + "bulk_other": "ينشئ ملف ZIP يحتوي على تقارير PDF لـ {{count}} تقديم محدد.", + "all": "ينشئ ملف ZIP يحتوي على تقارير PDF لجميع التقديمات المطابقة للمرشح الحالي." + }, + "regenerateLabel": "إعادة إنشاء التقارير حتى إذا كانت موجودة بالفعل لأحد الطلاب", + "startButton": "بدء التصدير", + "starting": "جارٍ البدء…", + "toasts": { + "alreadyRunning": "يوجد بالفعل تصدير قيد التشغيل لهذا الاختبار — يتم عرض تقدمه.", + "exportStarted_zero": "تم بدء التصدير لـ {{count}} تقديم.", + "exportStarted_one": "تم بدء التصدير لـ {{count}} تقديم.", + "exportStarted_two": "تم بدء التصدير لـ {{count}} تقديمين.", + "exportStarted_few": "تم بدء التصدير لـ {{count}} تقديمات.", + "exportStarted_many": "تم بدء التصدير لـ {{count}} تقديمًا.", + "exportStarted_other": "تم بدء التصدير لـ {{count}} تقديم.", + "startFailed": "فشل بدء التصدير. يرجى المحاولة مرة أخرى.", + "resuming": "جارٍ استئناف التصدير…", + "resumeFailed": "فشل استئناف التصدير.", + "cancelled": "تم إلغاء التصدير.", + "cancelFailed": "فشل إلغاء التصدير.", + "downloadNotReady": "رابط التنزيل غير جاهز بعد. يرجى المحاولة مرة أخرى بعد قليل.", + "downloadFailed": "فشل تجهيز التنزيل." + }, + "defaultFileName": "تقارير الاختبار", + "progress": { + "summary": "{{completed}} من {{total}} مكتمل", + "failedCount": "{{count}} فشل", + "skippedCount": "{{count}} تم تخطيه" + }, + "warnings": { + "contextDrift": "تمت إعادة حساب إحصاءات الفصل أثناء هذا التصدير — قد تعرض بعض التقارير أرقامًا مختلفة قليلًا عن غيرها في هذا الملف.", + "staleItems_zero": "تم إنشاء {{count}} تقرير قبل تحديث محاولة الطالب (مثل إعادة التقييم). أعد إنشاء التصدير لتحديثها.", + "staleItems_one": "تم إنشاء {{count}} تقرير قبل تحديث محاولة الطالب (مثل إعادة التقييم). أعد إنشاء التصدير لتحديثه.", + "staleItems_two": "تم إنشاء {{count}} تقريرين قبل تحديث محاولة الطالب (مثل إعادة التقييم). أعد إنشاء التصدير لتحديثهما.", + "staleItems_few": "تم إنشاء {{count}} تقارير قبل تحديث محاولة الطالب (مثل إعادة التقييم). أعد إنشاء التصدير لتحديثها.", + "staleItems_many": "تم إنشاء {{count}} تقريرًا قبل تحديث محاولة الطالب (مثل إعادة التقييم). أعد إنشاء التصدير لتحديثها.", + "staleItems_other": "تم إنشاء {{count}} تقرير قبل تحديث محاولة الطالب (مثل إعادة التقييم). أعد إنشاء التصدير لتحديثها." + }, + "failures": { + "count_zero": "{{count}} حالة فشل", + "count_one": "{{count}} حالة فشل", + "count_two": "{{count}} حالتا فشل", + "count_few": "{{count}} حالات فشل", + "count_many": "{{count}} حالة فشل", + "count_other": "{{count}} حالة فشل", + "detail_zero": "{{reason}} ({{count}} محاولة إعادة)", + "detail_one": "{{reason}} ({{count}} محاولة إعادة)", + "detail_two": "{{reason}} ({{count}} محاولتا إعادة)", + "detail_few": "{{reason}} ({{count}} محاولات إعادة)", + "detail_many": "{{reason}} ({{count}} محاولة إعادة)", + "detail_other": "{{reason}} ({{count}} محاولة إعادة)" + }, + "downloadZip": "تنزيل ملف ZIP", + "continueButton": "متابعة ({{count}} متبقٍ)", + "downloadCompleted": "تنزيل {{count}} مكتمل", + "cancelButton": "إلغاء", + "startNewExport": "بدء تصدير جديد", + "zipContentsNote": { + "prefix": "بعد استخراج ملف ZIP، افتح", + "suffix": "لعرض وفتح تقرير كل طالب.", + "csvNote": "(يحتوي index.csv على نفس القائمة كبيانات جدول بيانات.)" + }, + "recentExports": { + "title": "التصديرات الأخيرة", + "completedCount": "{{completed}}/{{total}} مكتمل", + "view": "عرض" + }, + "statusChips": { + "completed": "مكتمل", + "partial": "جزئي", + "failed": "فشل", + "cancelled": "ملغى", + "inProgress": "قيد التنفيذ", + "queued": "في الانتظار" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentResponseParsingDemo.json b/frontend-admin-dashboard/public/locales/ar/assessmentResponseParsingDemo.json new file mode 100644 index 0000000000..2e3611d761 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentResponseParsingDemo.json @@ -0,0 +1,53 @@ +{ + "header": { + "title": "عرض توضيحي لتحليل الإجابات", + "subtitle": "قبل مقابل بعد: كيف تُعرض الإجابات الآن" + }, + "card": { + "title": "س{{number}}. سؤال الاستبيان {{number}}", + "beforeLabel": "❌ قبل (JSON الخام):", + "afterLabel": "✅ بعد (تم التحليل):", + "visited": "تمت الزيارة", + "markedForReview": "مُعلَّم للمراجعة", + "timeTaken": "الوقت المستغرق: {{seconds}} ث", + "durationLeft": "الوقت المتبقي: {{seconds}} ث", + "questionId": "معرّف السؤال: {{id}}" + }, + "improvements": { + "title": "✨ التحسينات الرئيسية:", + "humanReadable": { + "bold": "إجابات قابلة للقراءة البشرية", + "text": "بدلاً من JSON الخام" + }, + "questionTypeBadges": { + "bold": "شارات نوع السؤال", + "text": "لسهولة التعرف عليه" + }, + "statusIndicators": { + "bold": "مؤشرات حالة الإجابة", + "text": "(تمت الزيارة، مُعلَّم للمراجعة)" + }, + "timeTracking": { + "bold": "تتبع الوقت", + "text": "(الوقت المستغرق، الوقت المتبقي)" + }, + "questionIdReference": { + "bold": "مرجع معرّف السؤال", + "text": "لأغراض التصحيح" + }, + "surveySpecific": { + "bold": "معالجة خاصة بالاستبيانات", + "text": "(لا حاجة لإجابات صحيحة)" + } + }, + "response": { + "unknown": "غير معروف", + "unknownType": "نوع إجابة غير معروف", + "parseError": "خطأ في تحليل بيانات الإجابة", + "selectedOptions": "الخيارات المحددة: {{options}}", + "noOptionsSelected": "لم يتم تحديد أي خيارات", + "answerValue": "الإجابة: {{value}}", + "noNumericAnswer": "لم يتم تقديم إجابة رقمية", + "noTextAnswer": "لم يتم تقديم إجابة نصية" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentRubricChangedBadge.json b/frontend-admin-dashboard/public/locales/ar/assessmentRubricChangedBadge.json new file mode 100644 index 0000000000..42334cf37b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentRubricChangedBadge.json @@ -0,0 +1,4 @@ +{ + "tooltip": "تم تحديث معايير التقييم إلى الإصدار v{{currentVersion}} منذ هذا التقييم (v{{evaluationVersion}})", + "badgeLabel": "تم تحديث معايير التقييم منذ هذا التقييم" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestDetails.json b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestDetails.json new file mode 100644 index 0000000000..5ee10db8c0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestDetails.json @@ -0,0 +1,41 @@ +{ + "batchDialog": { + "moreLink_zero": "+{{count}} إضافية", + "moreLink_one": "+{{count}} إضافي", + "moreLink_two": "+{{count}} إضافيان", + "moreLink_few": "+{{count}} إضافية", + "moreLink_many": "+{{count}} إضافيًا", + "moreLink_other": "+{{count}} إضافية", + "title": "دفعات التقييم" + }, + "info": { + "createdOn": "تم الإنشاء في: {{date}}", + "subject": "{{label}}: {{name}}", + "startDateTime": "تاريخ ووقت البدء: {{date}}", + "endDateTime": "تاريخ ووقت الانتهاء: {{date}}", + "duration": { + "label": "المدة: ", + "hours_zero": "{{count}} ساعة", + "hours_one": "{{count}} ساعة واحدة", + "hours_two": "{{count}} ساعتان", + "hours_few": "{{count}} ساعات", + "hours_many": "{{count}} ساعة", + "hours_other": "{{count}} ساعة", + "minutes_zero": "{{count}} دقيقة", + "minutes_one": "{{count}} دقيقة واحدة", + "minutes_two": "{{count}} دقيقتان", + "minutes_few": "{{count}} دقائق", + "minutes_many": "{{count}} دقيقة", + "minutes_other": "{{count}} دقيقة" + }, + "totalParticipants_zero": "إجمالي المشاركين: {{count}}", + "totalParticipants_one": "إجمالي المشاركين: {{count}}", + "totalParticipants_two": "إجمالي المشاركين: {{count}}", + "totalParticipants_few": "إجمالي المشاركين: {{count}}", + "totalParticipants_many": "إجمالي المشاركين: {{count}}", + "totalParticipants_other": "إجمالي المشاركين: {{count}}" + }, + "joinLink": { + "label": "رابط الانضمام:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestDetailsDropdownMenu.json b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestDetailsDropdownMenu.json new file mode 100644 index 0000000000..d0c72f52c4 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestDetailsDropdownMenu.json @@ -0,0 +1,60 @@ +{ + "menu": { + "viewDetails": "عرض تفاصيل الاختبار", + "deleteAssessment": "حذف الاختبار" + }, + "common": { + "open": "فتح", + "attention": "تنبيه" + }, + "reminderMessage": { + "prefix": "سيتم إرسال تذكير بالاختبار إلى جميع", + "participantsCount_zero": "{{count}} مشارك", + "participantsCount_one": "{{count}} مشارك واحد", + "participantsCount_two": "{{count}} مشاركَين", + "participantsCount_few": "{{count}} مشاركين", + "participantsCount_many": "{{count}} مشاركًا", + "participantsCount_other": "{{count}} مشارك", + "suffix": "الذين لم يظهروا بعد من الدفعات المخصصة." + }, + "dialogs": { + "reminder": { + "title": "إرسال تذكير", + "send": "إرسال" + }, + "delete": { + "title": "حذف الاختبار", + "confirmPrefix": "هل أنت متأكد أنك تريد حذف", + "confirmSuffix": "؟", + "delete": "حذف" + }, + "pause": { + "title": "إيقاف الحالة المباشرة مؤقتًا", + "dateLabel": "التاريخ", + "datePlaceholder": "التاريخ", + "pauseUntilLabel": "إيقاف حتى", + "timePlaceholder": "الوقت", + "pause": "إيقاف مؤقت" + }, + "resume": { + "title": "استئناف الحالة المباشرة", + "confirmPrefix": "هل تريد استئناف اختبارك المباشر", + "sampleAssessmentName": "العين البشرية والعالم الملون", + "confirmSuffix": "؟", + "resume": "استئناف" + }, + "reopen": { + "title": "إعادة فتح الاختبار", + "selectDateTime": "حدد تاريخ ووقت إعادة فتح الاختبار", + "startDateTimeLabel": "تاريخ ووقت البدء", + "endDateTimeLabel": "تاريخ ووقت الانتهاء", + "datePlaceholder": "التاريخ", + "timePlaceholder": "الوقت", + "reopen": "إعادة فتح" + } + }, + "toasts": { + "deleteSuccess": "تم حذف الاختبار بنجاح!", + "slidesDeleteFailed": "تم حذف الاختبار، ولكن تعذّرت إزالة شرائح الدورة الخاصة به." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestFilterButtons.json b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestFilterButtons.json new file mode 100644 index 0000000000..f40c89ecd7 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestFilterButtons.json @@ -0,0 +1,4 @@ +{ + "apply": "تطبيق", + "clearAll": "مسح الكل" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestFilters.json b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestFilters.json new file mode 100644 index 0000000000..4631d8cdce --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestFilters.json @@ -0,0 +1,11 @@ +{ + "trigger": { + "ariaLabelSelected": "{{label}}: {{selection}}", + "ariaLabelEmpty": "تصفية حسب {{label}}", + "clearAriaLabel": "مسح عامل التصفية {{label}}" + }, + "search": { + "placeholder": "البحث في {{label}}...", + "noResults": "لم يتم العثور على نتائج." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestHeaderDescription.json b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestHeaderDescription.json new file mode 100644 index 0000000000..2d5b1979b0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestHeaderDescription.json @@ -0,0 +1,25 @@ +{ + "heading": "إدارة شاملة للاختبارات", + "description": "راقب وأدر جميع الاختبارات بسهولة من خلال رؤية شاملة للاختبارات الجارية والقادمة والسابقة. احصل على وصول سريع لتفاصيل كل اختبار وجدوله وحالته، لضمان إشراف منظم على عملية الاختبار بأكملها من البداية إلى النهاية.", + "assessmentSettingsLabel": "إعدادات الاختبار", + "createAssessmentButton": "إنشاء اختبار", + "createAssessmentDialogTitle": "إنشاء اختبار", + "examTypes": { + "examination": { + "title": "امتحان", + "description": "اختبار محدد الوقت يُفعَّل في موعد محدد، مما يحاكي ظروف الامتحان الحقيقية." + }, + "mock": { + "title": "اختبار تجريبي", + "description": "اختبار تدريبي متاح دائمًا بمدة زمنية ثابتة لمحاكاة سيناريوهات الامتحان." + }, + "practice": { + "title": "اختبار تدريب", + "description": "اختبار متاح عند الطلب دون حدود زمنية، يتيح للطلاب المحاولة في أي وقت." + }, + "survey": { + "title": "استبيان", + "description": "مجموعة من الأسئلة لجمع الآراء والملاحظات، دون إجابات صحيحة أو خاطئة." + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestMainComponent.json b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestMainComponent.json new file mode 100644 index 0000000000..d5c07b870f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestMainComponent.json @@ -0,0 +1,45 @@ +{ + "meta": { + "helmetTitle": "جدولة الاختبارات", + "helmetDescription": "تعرض هذه الصفحة قائمة بجميع الاختبارات المجدولة، كما يمكن جدولة تقييم من هنا." + }, + "header": { + "navHeading": "قائمة التقييمات" + }, + "tabs": { + "liveTests": { + "emptyMessage": "لا توجد اختبارات مباشرة حاليًا." + }, + "upcomingTests": { + "emptyMessage": "لا توجد اختبارات قادمة مجدولة." + }, + "previousTests": { + "emptyMessage": "لا توجد اختبارات سابقة متاحة." + }, + "draftTests": { + "emptyMessage": "لا توجد اختبارات مسودة متاحة." + } + }, + "filters": { + "filterBy": "تصفية حسب", + "mode": "الوضع", + "type": "النوع", + "evaluation": "التقييم", + "unapplied": { + "countLabel_zero": "تم تحديد {{count}} عامل تصفية", + "countLabel_one": "تم تحديد {{count}} عامل تصفية", + "countLabel_two": "تم تحديد {{count}} عامل تصفية", + "countLabel_few": "تم تحديد {{count}} عوامل تصفية", + "countLabel_many": "تم تحديد {{count}} عامل تصفية", + "countLabel_other": "تم تحديد {{count}} عامل تصفية", + "instructionPrefix": "— اضغط", + "applyLabel": "تطبيق", + "instructionSuffix": "لتحديث القائمة." + } + }, + "dialogs": { + "noCourse": { + "type": "إنشاء تقييم" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestSearchComponent.json b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestSearchComponent.json new file mode 100644 index 0000000000..502d893ae0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestSearchComponent.json @@ -0,0 +1,3 @@ +{ + "searchPlaceholder": "ابحث عن ورقة الأسئلة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestTabList.json b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestTabList.json new file mode 100644 index 0000000000..8afa9425bf --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentScheduleTestTabList.json @@ -0,0 +1,8 @@ +{ + "tabs": { + "live": "مباشر", + "upcoming": "قادم", + "previous": "سابق", + "drafts": "المسودات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentSearchComponent.json b/frontend-admin-dashboard/public/locales/ar/assessmentSearchComponent.json new file mode 100644 index 0000000000..b5fd89827e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentSearchComponent.json @@ -0,0 +1,3 @@ +{ + "searchByNamePlaceholder": "البحث بالاسم" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentSendReminderComponent.json b/frontend-admin-dashboard/public/locales/ar/assessmentSendReminderComponent.json new file mode 100644 index 0000000000..ae2e1c45b9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentSendReminderComponent.json @@ -0,0 +1,6 @@ +{ + "heading": "إرسال تذكير", + "confirmPrefix": "هل أنت متأكد أنك تريد إرسال تذكير إلى", + "confirmSuffix": "؟", + "done": "تم" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentSingleCorrectMainList.json b/frontend-admin-dashboard/public/locales/ar/assessmentSingleCorrectMainList.json new file mode 100644 index 0000000000..fcfe2faf95 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentSingleCorrectMainList.json @@ -0,0 +1,14 @@ +{ + "questionsSettings": "إعدادات الأسئلة", + "questionType": "نوع السؤال", + "comprehensionText": "نص الفهم", + "comprehensionTextPlaceholder": "أدخل نص الفهم", + "questionNumber": "السؤال {{number}}", + "writeTheQuestion": "اكتب السؤال", + "answer": "الإجابة:", + "optionPlaceholder": "الخيار {{letter}}", + "removeOption": "إزالة الخيار", + "addOption": "إضافة خيار", + "explanation": "الشرح:", + "explanationPlaceholder": "الشرح" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentSingleCorrectMainQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentSingleCorrectMainQP.json new file mode 100644 index 0000000000..d127185e2c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentSingleCorrectMainQP.json @@ -0,0 +1,13 @@ +{ + "emptyState": "يرجى إضافة سؤال لعرض تفاصيل السؤال", + "comprehensionText": "نص الفهم", + "question": "سؤال", + "questionLabel": "سؤال {{number}}", + "writeQuestionPlaceholder": "اكتب السؤال", + "explanationHint": "يُعرض للمتعلمين مع نتيجتهم.", + "explanationPlaceholder": "الشرح", + "defaults": { + "answer": "الإجابة", + "explanation": "الشرح" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentSingleCorrectPPTList.json b/frontend-admin-dashboard/public/locales/ar/assessmentSingleCorrectPPTList.json new file mode 100644 index 0000000000..71f5e32241 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentSingleCorrectPPTList.json @@ -0,0 +1,12 @@ +{ + "optionLabels": { + "a": "(a.)", + "b": "(b.)", + "c": "(c.)", + "d": "(d.)" + }, + "menu": { + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentSingleCorrectPPTQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentSingleCorrectPPTQP.json new file mode 100644 index 0000000000..11244ed563 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentSingleCorrectPPTQP.json @@ -0,0 +1,9 @@ +{ + "optionMarker": { + "fallback": "({{label}}.)" + }, + "dropdownMenu": { + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStep1BasicInfo.json b/frontend-admin-dashboard/public/locales/ar/assessmentStep1BasicInfo.json new file mode 100644 index 0000000000..34b08b09a4 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStep1BasicInfo.json @@ -0,0 +1,137 @@ +{ + "navigation": { + "updateSurvey": "تحديث الاستبيان", + "updateAssessment": "تحديث التقييم", + "createSurvey": "إنشاء استبيان", + "createAssessment": "إنشاء تقييم" + }, + "toasts": { + "updateSuccess": "تم تحديث بيانات الخطوة 1 بنجاح!", + "saveSuccess": "تم حفظ بيانات الخطوة 1 بنجاح!" + }, + "errors": { + "saveFailed": "فشل حفظ المعلومات الأساسية." + }, + "header": { + "title": "المعلومات الأساسية", + "description": "قم بتكوين التفاصيل الأساسية لتقييمك، بما في ذلك الاسم والجدول الزمني وطريقة التقييم وتفضيلات إصدار النتائج.", + "updateButton": "تحديث", + "nextButton": "التالي" + }, + "basicInfoSection": { + "titleSurvey": "معلومات الاستبيان", + "titleAssessment": "المعلومات الأساسية", + "description": "الاسم والمادة والتعليمات المعروضة للمشاركين.", + "titlePlaceholder": "أضف عنوانًا", + "nameLabelSurvey": "اسم الاستبيان", + "nameLabelAssessment": "اسم التقييم", + "instructionsLabelSurvey": "تعليمات الاستبيان", + "instructionsLabelAssessment": "تعليمات التقييم", + "instructionsPlaceholderSurvey": "اكتب تعليمات الاستبيان", + "instructionsPlaceholderAssessment": "اكتب تعليمات التقييم" + }, + "liveDateRangeSection": { + "title": "النطاق الزمني المباشر", + "description": "متى يمكن للمشاركين البدء ومتى يجب عليهم إنهاء التقييم.", + "startDateLabel": "تاريخ ووقت البدء", + "endDateLabel": "تاريخ ووقت الانتهاء" + }, + "attemptSettingsSection": { + "title": "إعدادات المحاولة", + "description": "تحكم في كيفية أداء المشاركين لهذا التقييم.", + "reattemptCountPlaceholder": "عدد المحاولات", + "reattemptCountLabel": "عدد المحاولات", + "resultEvaluationType": { + "label": "نوع النتيجة والتقييم", + "options": { + "autoAfterSubmission": { + "label": "تلقائي — الإصدار بعد التسليم", + "help": "يقوم النظام بالتصحيح تلقائيًا. تكون النتائج مرئية للمتعلم فور تسليمه مباشرة." + }, + "autoAfterAssessmentEnd": { + "label": "تلقائي — الإصدار بعد انتهاء الاختبار", + "help": "يقوم النظام بالتصحيح تلقائيًا. لا تصبح النتائج مرئية إلا بعد انقضاء وقت انتهاء التقييم." + }, + "noAutoRelease": { + "label": "تلقائي — الإصدار يدويًا", + "help": "يقوم النظام بالتصحيح تلقائيًا. تبقى النتائج مخفية حتى تنقر على \"إصدار النتيجة\" في علامة تبويب التسليمات." + }, + "manual": { + "label": "التقييم والإصدار اليدوي", + "help": "يقوم المعلم بتصحيح كل تسليم يدويًا. تبقى النتائج مخفية حتى تقوم بتقييمها وإصدارها يدويًا." + } + } + }, + "submissionType": { + "label": "نوع التسليم", + "helper": "كيفية تسليم الطلاب لإجاباتهم (مثل رفع ملف أو PDF)." + }, + "assessmentPreview": { + "labelSurvey": "السماح بمعاينة الاستبيان", + "labelAssessment": "السماح بمعاينة التقييم", + "description": "السماح للمشاركين بمعاينة التقييم قبل البدء.", + "timeLimitLabel": "حد وقت المعاينة" + }, + "switchSections": { + "label": "السماح لـ {{role}} بالتنقل بين الأقسام", + "description": "يمكن للمشاركين التنقل ذهابًا وإيابًا بين الأقسام." + } + }, + "unusedFields": { + "testCreation": { + "titlePlaceholder": "أضف عنوانًا", + "nameLabelSurvey": "اسم الاستبيان", + "nameLabelAssessment": "اسم التقييم" + }, + "liveDateRange": { + "startDateLabel": "تاريخ ووقت البدء", + "endDateLabel": "تاريخ ووقت الانتهاء" + }, + "assessmentInstructions": { + "labelSurvey": "تعليمات الاستبيان", + "labelAssessment": "تعليمات التقييم", + "placeholderSurvey": "أضف تعليمات الاستبيان", + "placeholderAssessment": "أضف تعليمات التقييم" + }, + "assessmentPreview": { + "enableLabel": "تمكين معاينة التقييم", + "timeLimitLabel": "حد وقت المعاينة" + }, + "reattemptCount": { + "placeholder": "أدخل عدد المحاولات", + "label": "عدد المحاولات" + }, + "submissionType": { + "label": "نوع التسليم", + "options": { + "autoSubmit": "تسليم تلقائي", + "manualSubmit": "تسليم يدوي" + } + }, + "durationDistribution": { + "label": "توزيع المدة", + "helper": "اختر ما إذا كانت حدود الوقت تنطبق على الاختبار بأكمله، أو لكل قسم، أو لكل سؤال.", + "options": { + "entireAssessment": "التقييم بأكمله", + "sectionWise": "حسب القسم", + "questionWise": "حسب السؤال" + } + }, + "evaluationType": { + "label": "نوع التقييم", + "options": { + "auto": "تقييم تلقائي", + "manual": "تقييم يدوي" + } + }, + "switchSections": { + "label": "السماح بالتنقل بين الأقسام" + }, + "reattemptRequest": { + "label": "السماح للطلاب بتقديم طلب إعادة محاولة" + }, + "timeIncreaseRequest": { + "label": "السماح للطلاب بتقديم طلب زيادة الوقت" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStep2AddingQuestions.json b/frontend-admin-dashboard/public/locales/ar/assessmentStep2AddingQuestions.json new file mode 100644 index 0000000000..ef8b7daee2 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStep2AddingQuestions.json @@ -0,0 +1,39 @@ +{ + "header": { + "title": "إضافة أسئلة", + "subtitle": "أضف أقسامًا وأسئلة إلى تقييمك. قم بتحميل ورقة أسئلة أو أنشئها يدويًا أو أنشئها باستخدام الذكاء الاصطناعي.", + "update": "تحديث", + "next": "التالي" + }, + "sectionDefaultName": "القسم {{number}}", + "durationSettings": { + "title": "إعدادات المدة", + "description": "اختر كيفية توزيع الوقت في تقييمك.", + "options": { + "entireSurvey": "الاستبيان بالكامل", + "entireAssessment": "التقييم بالكامل", + "sectionWise": "حسب القسم", + "questionWise": "حسب السؤال" + }, + "info": { + "entireTestSurvey": "حدد وقتًا واحدًا للاستبيان بالكامل.", + "entireTestAssessment": "حدد وقتًا واحدًا للتقييم بالكامل.", + "sectionWise": "خصص وقتًا محددًا لكل قسم. ستكون المدة الإجمالية للتقييم مجموع أوقات جميع الأقسام.", + "questionWise": "حدد حدود وقت فردية لكل سؤال في علامة التبويب الأقسام." + }, + "entireTestDuration": { + "label": "مدة الاختبار بالكامل", + "placeholderZero": "00", + "hrsUnit": "س", + "minUnit": "د" + } + }, + "addSectionButton": "إضافة قسم", + "toasts": { + "updateSuccess": "تم تحديث بيانات الخطوة 2 بنجاح!", + "saveSuccess": "تم حفظ بيانات الخطوة 2 بنجاح!" + }, + "errors": { + "saveFailed": "فشل حفظ الأسئلة." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStep2CreateFromKnowledgeBase.json b/frontend-admin-dashboard/public/locales/ar/assessmentStep2CreateFromKnowledgeBase.json new file mode 100644 index 0000000000..ed0f5c79f2 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStep2CreateFromKnowledgeBase.json @@ -0,0 +1,82 @@ +{ + "defaultSectionName": "القسم {{number}}", + "trigger": { + "title": "إنشاء من قاعدة المعرفة", + "subtitle": "أسئلة من كتبك وملاحظاتك الخاصة" + }, + "dialog": { + "headingChooseKb": "اختر قاعدة معرفة", + "headingKbSection": "{{kbName}} — {{sectionName}}" + }, + "kbStep": { + "intro": "سيتم كتابة الأسئلة فقط من المحتوى الموجود في قاعدة المعرفة التي تختارها، وسيُظهر كل سؤال الصفحة التي أُخذ منها.", + "emptyTitle": "لا توجد قواعد معرفة بعد", + "emptyDescription": "أضف كتبك أو ملاحظاتك ضمن قاعدة المعرفة أولاً، ثم عد إلى هنا.", + "sourcesCount_zero": "{{count}} مصدر", + "sourcesCount_one": "{{count}} مصدر واحد", + "sourcesCount_two": "{{count}} مصدران", + "sourcesCount_few": "{{count}} مصادر", + "sourcesCount_many": "{{count}} مصدرًا", + "sourcesCount_other": "{{count}} مصدر" + }, + "setup": { + "numberOfQuestions": "عدد الأسئلة", + "questionType": "نوع السؤال", + "difficulty": "مستوى الصعوبة", + "noTopicsWarning": "لا تحتوي قاعدة المعرفة هذه على مواضيع بعد. سيتم استخلاص الأسئلة من كل محتواها.", + "questionTypeOptions": { + "mcqs": "اختيار من متعدد (إجابة واحدة)", + "mcqm": "اختيار من متعدد (عدة إجابات)", + "trueFalse": "صواب / خطأ", + "oneWord": "كلمة واحدة", + "longAnswer": "إجابة طويلة", + "numeric": "رقمي" + }, + "difficultyOptions": { + "easy": "سهل", + "medium": "متوسط", + "hard": "صعب" + }, + "validation": { + "minCount": "سؤال واحد على الأقل", + "maxCount": "60 سؤالًا كحد أقصى في المرة الواحدة" + } + }, + "generating": { + "writingFrom": "جارٍ كتابة الأسئلة من {{kbName}}", + "hint": "تتم كتابة الأسئلة بضعة في كل مرة، لذا يستغرق القسم الأكبر وقتًا أطول. تبقى هذه العملية محفوظة في سجل قاعدة المعرفة حتى لو أغلقت هذه النافذة." + }, + "review": { + "partialDelivery": "تم كتابة {{delivered}} من أصل {{planned}} سؤالًا من المواضيع التي اخترتها. أضف مزيدًا من المحتوى أو وسّع التحديد لإكمال الباقي." + }, + "insert": { + "paperTitle": "{{kbName}} — {{sectionName}}", + "successToast_zero": "تمت إضافة {{count}} سؤال إلى {{sectionName}}", + "successToast_one": "تمت إضافة {{count}} سؤال واحد إلى {{sectionName}}", + "successToast_two": "تمت إضافة {{count}} سؤالين إلى {{sectionName}}", + "successToast_few": "تمت إضافة {{count}} أسئلة إلى {{sectionName}}", + "successToast_many": "تمت إضافة {{count}} سؤالًا إلى {{sectionName}}", + "successToast_other": "تمت إضافة {{count}} سؤال إلى {{sectionName}}" + }, + "footer": { + "cancel": "إلغاء", + "back": "رجوع", + "generateQuestions": "إنشاء الأسئلة", + "changeSelection": "تغيير التحديد", + "adding": "جارٍ الإضافة…", + "addQuestions_zero": "إضافة {{count}} سؤال إلى هذا القسم", + "addQuestions_one": "إضافة {{count}} سؤال واحد إلى هذا القسم", + "addQuestions_two": "إضافة {{count}} سؤالين إلى هذا القسم", + "addQuestions_few": "إضافة {{count}} أسئلة إلى هذا القسم", + "addQuestions_many": "إضافة {{count}} سؤالًا إلى هذا القسم", + "addQuestions_other": "إضافة {{count}} سؤال إلى هذا القسم" + }, + "errors": { + "loadTopics": "تعذّرت قراءة المواضيع في قاعدة المعرفة هذه", + "startGenerating": "تعذّر بدء الإنشاء", + "generationFailed": "فشل الإنشاء", + "regenerateQuestion": "تعذّرت إعادة كتابة هذا السؤال", + "paperNotSaved": "لم يتم حفظ ورقة الأسئلة", + "insertQuestions": "تعذّرت إضافة هذه الأسئلة إلى القسم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStep2GenerateQuestionsFromAI.json b/frontend-admin-dashboard/public/locales/ar/assessmentStep2GenerateQuestionsFromAI.json new file mode 100644 index 0000000000..f88b50ff6a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStep2GenerateQuestionsFromAI.json @@ -0,0 +1,45 @@ +{ + "trigger": { + "title": "إنشاء بالذكاء الاصطناعي", + "badge": "Vacademy AI", + "subtitle": "إنشاء الأسئلة تلقائيًا" + }, + "dialog": { + "title": "إنشاء أسئلة بالذكاء الاصطناعي" + }, + "generateSection": { + "cardTitle": "إنشاء أسئلة", + "cardDescription": "اطلب من الذكاء الاصطناعي استخدام ملف PDF أو صورة أو أي موضوع لإنشاء أسئلة جديدة", + "dialogTitle": "إنشاء أسئلة", + "vsmartUpload": { + "title": "VSmart Upload", + "subtitle": "(إنشاء الأسئلة عن طريق تحميل ملفات pdf و doc و ppt)", + "description": "أنشئ أوراق الأسئلة فورًا عن طريق تحميل مواد الدراسة بصيغة PDF أو Word أو PowerPoint. تستخدم أداة Vsmart Upload الذكاء الاصطناعي لتحليل ملفك بالكامل وإنشاء أسئلة ذات صلة ومنظمة جيدًا — أو تتيح لك لصق المحتوى يدويًا إذا فضّلت ذلك. لا حاجة إلى أي تنسيق، فقط أضف الملف وابدأ. مثالية للمعلمين ومُعدّي الامتحانات والمدربين في الشركات الذين يعملون بمحتوى موجود مثل شرائح المحاضرات أو أدلة الدراسة أو نشرات الدورات. سواء كنت تُعدّ تقييمات لفصل دراسي أو مجموعة تدريبية أو جلسة تدريبية — توفّر Vsmart Upload ساعات من الجهد بتحويل موادك إلى أوراق أسئلة جاهزة للاستخدام." + }, + "vsmartAudio": { + "title": "Vsmart Audio", + "subtitle": "(إنشاء الأسئلة عن طريق تحميل ملفات صوتية)", + "description": "حوّل أي محاضرة أو اجتماع أو تسجيل صوتي إلى ورقة أسئلة كاملة. ما عليك سوى تحميل ملفات بصيغة MP3 أو WAV أو صيغ صوتية أخرى — يقوم Vsmart Audio بتفريغ المحتوى واستخدام الذكاء الاصطناعي لإنشاء أسئلة منظمة ومراعية للسياق. لا حاجة إلى تفريغ أو تحرير يدوي. مثالي لمعامل اللغات ووحدات التعلم القائمة على البودكاست وجلسات التدريب في الشركات والمحاضرات المسجلة في الجامعات أو مراكز التدريب. يمكن للمدربين والمعلمين إعادة استخدام الموارد الصوتية الموجودة لإنشاء اختبارات قصيرة أو اختبارات فهم أو مواضيع نقاش ببضع نقرات فقط، مما يعزز التفاعل ويرسّخ نتائج التعلم." + }, + "vsmartTopics": { + "title": "Vsmart Topics", + "subtitle": "(إنشاء الأسئلة عن طريق تقديم مواضيع)", + "description": "أنشئ أوراق أسئلة مخصصة في ثوانٍ بمجرد كتابة موضوع أو مفهوم أو تعليمات. تستخدم أداة Vsmart Prompt الذكاء الاصطناعي المتقدم لفهم مدخلاتك وإنشاء مجموعة أسئلة مخصصة — تغطي مستويات صعوبة وصيغ ومهارات معرفية مختلفة، بما يتوافق مع احتياجاتك. مثالية للمعلمين والمدربين ورؤساء الأقسام الأكاديمية الذين يريدون تقييمات سريعة حول مواضيع محددة دون تحميل أي مواد. سواء كان ذلك لاختبار مفاجئ أو مراجعة مفاهيم أو جلسة سريعة، تقدّم Vsmart Prompt أسئلة دقيقة ومتنوعة بأقل قدر من المدخلات." + } + }, + "extractSection": { + "cardTitle": "استخراج أسئلة", + "cardDescription": "اطلب من الذكاء الاصطناعي استخراج الأسئلة من أي ملف PDF أو صورة أو محاضرة صوتية", + "dialogTitle": "استخراج أسئلة", + "vsmartExtract": { + "title": "Vsmart Extract", + "subtitle": "(استخراج الأسئلة عن طريق تحميل ملفات pdf و doc و ppt)", + "description": "استخرج بسهولة جميع الأسئلة الموجودة من أي مستند PDF — سواء كان ورقة امتحان سابقة أو ورقة عمل تدريبية أو بنك أسئلة. يقوم Vsmart Extract بمسح الملف بأكمله وتحديد أنماط الأسئلة وتنظيمها بدقة لإعادة الاستخدام أو التحرير أو التصدير بسهولة. مثالي للمعلمين والفرق الأكاديمية الذين يعملون مع ملفات PDF قديمة أو موارد مشتركة أو أوراق ممسوحة ضوئيًا. وفّر الوقت بدلاً من نسخ الأسئلة أو إعادة كتابتها يدويًا — يساعد Vsmart Extract المدارس ومراكز التدريب وأقسام التدريب في الشركات على بناء أرشيفات رقمية بسرعة أو إنشاء تقييمات محدثة من المواد القديمة." + }, + "vsmartImage": { + "title": "Vsmart Image", + "subtitle": "(استخراج الأسئلة عن طريق تحميل صور)", + "description": "حوّل الصور إلى أسئلة بكل سهولة. تستخدم أداة Vsmart Image تقنية التعرف الضوئي على الحروف (OCR) المتقدمة والذكاء الاصطناعي لمسح الصور الفوتوغرافية والصفحات الممسوحة ضوئيًا والملاحظات المكتوبة بخط اليد أو لقطات الشاشة — واستخراج أسئلة منظمة منها. ما عليك سوى تحميل صورتك، ودع الذكاء الاصطناعي يقوم بالباقي. مثالية للمعلمين ومعاهد التدريب والمدربين الذين غالبًا ما يتلقون محتوى في شكل ملاحظات مكتوبة بخط اليد أو لقطات من الكتب المدرسية أو صور السبورة. سواء كنت تُحوّل أوراق اختبار قديمة إلى صيغة رقمية أو تستخرج أسئلة من مواد مطبوعة — تُدخل Vsmart Image المحتوى التناظري إلى سير عملك الرقمي في ثوانٍ." + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStep2SectionInfo.json b/frontend-admin-dashboard/public/locales/ar/assessmentStep2SectionInfo.json new file mode 100644 index 0000000000..306d7bd445 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStep2SectionInfo.json @@ -0,0 +1,104 @@ +{ + "header": { + "deleteSection": "حذف القسم", + "mcqSingle": "اختيار واحد", + "mcqMulti": "اختيار متعدد", + "total": "الإجمالي" + }, + "uploadSection": { + "title": "تحميل ورقة الأسئلة", + "subtitle": "اختر الطريقة التي تريد بها ملء هذا القسم بالأسئلة.", + "manualCard": { + "title": "الإنشاء يدويًا", + "subtitle": "اكتب الأسئلة واحدًا تلو الآخر" + }, + "manualDialog": { + "title": "إنشاء ورقة أسئلة يدويًا" + }, + "savedCard": { + "title": "اختيار ورقة محفوظة", + "subtitle": "إعادة استخدام ورقة موجودة" + }, + "savedDialog": { + "title": "اختيار ورقة أسئلة محفوظة من القائمة", + "description": "اختر ورقة أسئلة محفوظة مسبقًا لإضافتها إلى هذا القسم" + } + }, + "criteria": { + "title": "معايير التقييم", + "subtitle": "دع الذكاء الاصطناعي يصيغ معايير تقييم لكل سؤال.", + "generateAll": "إنشاء جميع المعايير", + "stopProgress": "إيقاف ({{current}}/{{total}})" + }, + "dialogs": { + "confirmBulkGenerate_zero": "إنشاء معايير الذكاء الاصطناعي لجميع أسئلة هذا القسم ({{count}})؟", + "confirmBulkGenerate_one": "إنشاء معايير الذكاء الاصطناعي لسؤال واحد ({{count}}) في هذا القسم؟", + "confirmBulkGenerate_two": "إنشاء معايير الذكاء الاصطناعي للسؤالين ({{count}}) في هذا القسم؟", + "confirmBulkGenerate_few": "إنشاء معايير الذكاء الاصطناعي لجميع الأسئلة الـ{{count}} في هذا القسم؟", + "confirmBulkGenerate_many": "إنشاء معايير الذكاء الاصطناعي لجميع الأسئلة الـ{{count}} في هذا القسم؟", + "confirmBulkGenerate_other": "إنشاء معايير الذكاء الاصطناعي لجميع الأسئلة الـ{{count}} في هذا القسم؟" + }, + "toasts": { + "noQuestionsInSection": "لا توجد أسئلة في هذا القسم", + "generationCancelled": "تم إلغاء الإنشاء بواسطة المستخدم", + "questionAlreadyHasCriteria": "السؤال {{number}} لديه بالفعل معايير", + "questionZeroMarksSkip": "السؤال {{number}} بدون درجات، سيتم تخطيه", + "failedGenerateCriteriaForQuestion": "فشل إنشاء المعايير للسؤال {{number}}: {{error}}", + "generatedCriteriaSuccess_zero": "تم إنشاء المعايير لـ {{count}} سؤال!", + "generatedCriteriaSuccess_one": "تم إنشاء المعايير لسؤال واحد ({{count}})!", + "generatedCriteriaSuccess_two": "تم إنشاء المعايير للسؤالين ({{count}})!", + "generatedCriteriaSuccess_few": "تم إنشاء المعايير لـ {{count}} أسئلة!", + "generatedCriteriaSuccess_many": "تم إنشاء المعايير لـ {{count}} سؤالاً!", + "generatedCriteriaSuccess_other": "تم إنشاء المعايير لـ {{count}} سؤال!", + "failedGenerateCriteriaCount_zero": "فشل إنشاء المعايير لـ {{count}} سؤال", + "failedGenerateCriteriaCount_one": "فشل إنشاء المعايير لسؤال واحد ({{count}})", + "failedGenerateCriteriaCount_two": "فشل إنشاء المعايير للسؤالين ({{count}})", + "failedGenerateCriteriaCount_few": "فشل إنشاء المعايير لـ {{count}} أسئلة", + "failedGenerateCriteriaCount_many": "فشل إنشاء المعايير لـ {{count}} سؤالاً", + "failedGenerateCriteriaCount_other": "فشل إنشاء المعايير لـ {{count}} سؤال" + }, + "sectionDescription": { + "title": "وصف القسم", + "placeholder": "صف هذا القسم" + }, + "duration": { + "questionDuration": "مدة السؤال", + "sectionDuration": "مدة القسم", + "hrs": "ساعات", + "minutes": "دقائق" + }, + "markingScheme": { + "marksPerQuestion": { + "title": "الدرجات لكل سؤال", + "subtitle": "الدرجات الافتراضية الممنوحة للإجابات الصحيحة" + }, + "negativeMarking": "الدرجات السالبة", + "partialMarking": "التقييم الجزئي" + }, + "randomization": { + "problemRandomization": "عشوائية الأسئلة" + }, + "table": { + "surveyQuestionsTitle": "أسئلة الاستبيان", + "adaptiveMarkingRulesTitle": "قواعد التقييم التكيفي", + "questionCount_zero": "{{count}} سؤال", + "questionCount_one": "سؤال واحد ({{count}})", + "questionCount_two": "سؤالان ({{count}})", + "questionCount_few": "{{count}} أسئلة", + "questionCount_many": "{{count}} سؤالاً", + "questionCount_other": "{{count}} سؤال", + "headers": { + "qno": "رقم السؤال", + "question": "السؤال", + "surveyQuestion": "سؤال الاستبيان", + "questionType": "نوع السؤال", + "marks": "الدرجات", + "penalty": "الخصم", + "time": "الوقت", + "criteria": "المعايير" + } + }, + "totalMarks": { + "label": "إجمالي الدرجات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStep3AddingParticipants.json b/frontend-admin-dashboard/public/locales/ar/assessmentStep3AddingParticipants.json new file mode 100644 index 0000000000..bebd1f2d35 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStep3AddingParticipants.json @@ -0,0 +1,82 @@ +{ + "toasts": { + "updateSuccess": "تم تحديث بيانات الخطوة 3 بنجاح!", + "createSuccess": "تم حفظ بيانات الخطوة 3 بنجاح!", + "saveFailed": "فشل حفظ المشاركين." + }, + "header": { + "title": "إضافة مشاركين", + "description": "حدد من يمكنه المشاركة في هذا التقييم. أضف الطلاب من الدفعات، بشكل فردي، أو عبر التسجيل المفتوح.", + "updateButton": "تحديث", + "nextButton": "التالي" + }, + "accessSettings": { + "cardTitle": "إعدادات وصول المشاركين", + "cardDescription": "اختر كيف يحصل المتعلمون على إمكانية الوصول إلى هذا التقييم.", + "closedTest": { + "label": "اختبار مغلق", + "descriptionSurvey": "قصر الاستبيان على مشاركين محددين بتعيينه لدفعات المؤسسة أو باختيار {{term}} بشكل فردي.", + "descriptionAssessment": "قصر التقييم على مشاركين محددين بتعيينه لدفعات المؤسسة أو باختيار {{term}} بشكل فردي." + }, + "openTest": { + "label": "اختبار مفتوح", + "descriptionSurvey": "اسمح لأي شخص بالتسجيل في هذا الاستبيان عبر رابط مشترك. يمكن أيضًا تسجيل {{term}} المؤسسة مسبقًا.", + "descriptionAssessment": "اسمح لأي شخص بالتسجيل في هذا التقييم عبر رابط مشترك. يمكن أيضًا تسجيل {{term}} المؤسسة مسبقًا." + } + }, + "registration": { + "titleSurvey": "تسجيل الاستبيان", + "titleAssessment": "تسجيل التقييم", + "aboutTitleSurvey": "حول تسجيل الاستبيان", + "aboutTitleAssessment": "حول تسجيل التقييم", + "instructionsPlaceholder": "تعليمات التسجيل", + "startDateLabel": "تاريخ ووقت البدء", + "endDateLabel": "تاريخ ووقت الانتهاء", + "formFieldsTitle": "حقول نموذج التسجيل", + "formFieldsSubtitle": "اسحب لإعادة ترتيب الحقول وتخصيصها", + "addFieldHint": "استخدم الأزرار أدناه لإضافة حقل", + "addGender": "إضافة الجنس", + "addState": "إضافة الولاية/المحافظة", + "addCity": "إضافة المدينة", + "addSchoolCollege": "إضافة المدرسة/الكلية", + "addCustomField": "إضافة حقل مخصص", + "previewButton": "معاينة نموذج التسجيل", + "previewDialogTitle": "معاينة نموذج التسجيل", + "registerNowButton": "سجّل الآن" + }, + "shareAccess": { + "cardTitle": "مشاركة الوصول", + "cardDescription": "شارك رابط الانضمام أو رمز QR مع المشاركين.", + "joinLinkLabel": "رابط الانضمام", + "joinLinkPlaceholder": "رابط الانضمام", + "copyJoinLinkAriaLabel": "نسخ رابط الانضمام", + "qrCodeLabel": "رمز QR", + "downloadQrAriaLabel": "تنزيل رمز QR" + }, + "notifications": { + "cardTitle": "إشعارات البريد الإلكتروني", + "cardDescription": "اختر الأحداث التي تُطلق تنبيهات البريد الإلكتروني التلقائية.", + "notifyBeforeLabel": "الإشعار قبل", + "items": { + "whenCreatedSurvey": "عند إنشاء الاستبيان", + "whenCreatedAssessment": "عند إنشاء التقييم", + "beforeLiveSurvey": "قبل انطلاق الاستبيان", + "beforeLiveAssessment": "قبل انطلاق التقييم", + "whenLiveSurvey": "عند انطلاق الاستبيان", + "whenLiveAssessment": "عند انطلاق التقييم", + "reportGeneratedSurvey": "عند إنشاء تقارير الاستبيان", + "reportGeneratedAssessment": "عند إنشاء تقارير التقييم" + }, + "participants": { + "sectionTitle": "إشعار المشاركين" + }, + "parents": { + "sectionTitleSurvey": "إشعار المشاركين", + "sectionTitleAssessment": "إشعار أولياء الأمور", + "whenStudentAppearsSurvey": "عندما يحضر الطلاب الاستبيان", + "whenStudentAppearsAssessment": "عندما يحضر الطلاب التقييم", + "whenStudentFinishesSurvey": "عندما ينهي الطلاب الاستبيان", + "whenStudentFinishesAssessment": "عندما ينهي الطلاب التقييم" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStep4AccessControl.json b/frontend-admin-dashboard/public/locales/ar/assessmentStep4AccessControl.json new file mode 100644 index 0000000000..526cc0c58b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStep4AccessControl.json @@ -0,0 +1,43 @@ +{ + "header": { + "title": "التحكم في الوصول", + "description": "تحكم في من يمكنه إدارة هذا التقييم وعرض نتائجه وتقييمه.", + "update": "تحديث", + "save": "حفظ", + "publish": "نشر" + }, + "sections": { + "creationAccess": "صلاحية إنشاء التقييم", + "creationAccessSurvey": "صلاحية إنشاء الاستبيان", + "liveNotification": "إشعار التقييم المباشر", + "liveNotificationSurvey": "إشعار الاستبيان المباشر", + "submissionReportAccess": "صلاحية تقديم التقييم وتقاريره", + "submissionReportAccessSurvey": "صلاحية تقديم الاستبيان وتقاريره", + "evaluationProcess": "عملية التقييم" + }, + "toasts": { + "updateSuccess": "تم تحديث تقييمك بنجاح!", + "saveSuccess": "تم حفظ تقييمك بنجاح!", + "updatePublishSuccess": "تم تحديث تقييمك ونشره بنجاح!", + "publishSuccess": "تم نشر تقييمك بنجاح!", + "cancelInviteSuccess": "تم إلغاء دعوة هذا المستخدم بنجاح!" + }, + "errors": { + "saveFailed": "فشل حفظ التحكم في الوصول.", + "publishFailed": "فشل نشر التقييم." + }, + "card": { + "addButton": "إضافة", + "addUserTitle": "إضافة مستخدم", + "roleTypeLabel": "نوع الدور", + "selectAll": "تحديد الكل", + "cancelInvitation": "إلغاء الدعوة", + "attention": "تنبيه", + "confirmCancel": { + "prefix": "هل أنت متأكد أنك تريد إلغاء الدعوة لـ", + "suffix": "؟" + }, + "yes": "نعم", + "done": "تم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStep4InviteUsers.json b/frontend-admin-dashboard/public/locales/ar/assessmentStep4InviteUsers.json new file mode 100644 index 0000000000..f651d5a99a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStep4InviteUsers.json @@ -0,0 +1,26 @@ +{ + "trigger": "دعوة مستخدمين", + "dialog": { + "title": "دعوة مستخدم" + }, + "form": { + "name": { + "label": "الاسم الكامل", + "placeholder": "الاسم الكامل (الأول والأخير)" + }, + "email": { + "label": "البريد الإلكتروني", + "placeholder": "أدخل البريد الإلكتروني" + }, + "roleType": { + "label": "نوع الدور" + } + }, + "submit": "دعوة مستخدم", + "validation": { + "nameRequired": "الاسم الكامل مطلوب", + "emailRequired": "البريد الإلكتروني مطلوب", + "emailInvalid": "صيغة البريد الإلكتروني غير صحيحة", + "roleTypeRequired": "مطلوب نوع دور واحد على الأقل" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStudentAttemptDropdown.json b/frontend-admin-dashboard/public/locales/ar/assessmentStudentAttemptDropdown.json new file mode 100644 index 0000000000..7c9129add3 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStudentAttemptDropdown.json @@ -0,0 +1,89 @@ +{ + "dialogs": { + "attentionLabel": "تنبيه", + "yes": "نعم", + "cancel": "إلغاء", + "continue": "متابعة", + "provideReattempt": { + "title": "منح محاولة إعادة", + "confirmMessagePrefix": "هل أنت متأكد أنك تريد منح فرصة إعادة المحاولة لـ", + "providing": "جارٍ المنح..." + }, + "releaseResult": { + "title": "نشر النتيجة", + "confirmMessagePrefix": "هل أنت متأكد أنك تريد نشر النتيجة لـ" + }, + "manualReEvaluate": { + "title": "إعادة تقييم المحاولة", + "evaluatingLabel": "قيد التقييم", + "messagePart1": "لديه محاولة تم تقييمها بالفعل. ستؤدي إعادة التقييم إلى إعادتها إلى حالة", + "messagePart2": "حتى تقوم بإرسال درجات جديدة. هل تريد المتابعة؟" + }, + "evaluateWithAI": { + "title": "تقييم الاختبار بالذكاء الاصطناعي", + "evaluatingLabel": "قيد التقييم", + "reEvaluationWarningPart1": "تم تقييم هذه المحاولة بالفعل. ستؤدي إعادة التقييم إلى إعادتها إلى حالة", + "reEvaluationWarningPart2": "حتى تصبح النتيجة الجديدة جاهزة.", + "selectModelLabel": "اختر نموذج الذكاء الاصطناعي", + "selectModelPlaceholder": "اختر نموذجًا", + "chooseModelPrefix": "اختر نموذج الذكاء الاصطناعي لتقييم مشاركة", + "chooseModelSuffix": "", + "rubricInfo": "بالنسبة لأي سؤال بدون معايير تقييم، يقوم الذكاء الاصطناعي بإنشاء معايير التصحيح في المرة الأولى ويعيد استخدامها لكل طالب، بحيث يتم تقييم الجميع بنفس الطريقة. حدد معايير التقييم على الاختبار للتحكم الكامل في كيفية منح الدرجات.", + "estimatedCostLabel": "التكلفة المقدّرة:", + "creditsCount_zero": "{{count}} رصيد", + "creditsCount_one": "{{count}} رصيد واحد", + "creditsCount_two": "{{count}} رصيدان", + "creditsCount_few": "{{count}} أرصدة", + "creditsCount_many": "{{count}} رصيدًا", + "creditsCount_other": "{{count}} رصيد", + "balance_zero": "الرصيد: {{count}}", + "balance_one": "الرصيد: {{count}}", + "balance_two": "الرصيد: {{count}}", + "balance_few": "الرصيد: {{count}}", + "balance_many": "الرصيد: {{count}}", + "balance_other": "الرصيد: {{count}}", + "insufficientCredits": "لا يوجد رصيد كافٍ لهذا التقييم. أضف رصيدًا إلى مؤسستك للمتابعة.", + "starting": "جارٍ البدء...", + "start": "ابدأ" + }, + "revaluateEntireAssessment": { + "title": "إعادة تقييم الاختبار بالكامل", + "confirmMessagePrefix": "هل أنت متأكد أنك تريد إعادة التقييم لـ", + "confirmMessageSuffix": "بالنسبة للاختبار بالكامل؟" + } + }, + "dropdown": { + "checkingSubmission": "جارٍ التحقق من المشاركة...", + "viewSubmission": "عرض المشاركة", + "loadingSubmission": "جارٍ تحميل المشاركة...", + "uploadSubmission": "رفع المشاركة", + "provideReattempt": "منح محاولة إعادة", + "evaluate": "تقييم", + "revaluate": "إعادة تقييم", + "questionWise": "حسب السؤال", + "entireAssessment": "الاختبار بالكامل", + "manual": "يدوي", + "evaluateWithAI": "تقييم بالذكاء الاصطناعي", + "checkingEvaluatedCopy": "جارٍ التحقق من النسخة المقيَّمة...", + "viewEvaluatedCopy": "عرض النسخة المقيَّمة", + "loadingEvaluatedCopy": "جارٍ تحميل النسخة المقيَّمة...", + "generatingReport": "جارٍ إنشاء التقرير...", + "downloadReport": "تنزيل التقرير", + "releaseResult": "نشر النتيجة" + }, + "toasts": { + "reattemptProvided": "تم منح {{name}} فرصة إعادة المحاولة.", + "reattemptError": "فشل منح فرصة إعادة المحاولة. يرجى المحاولة مرة أخرى.", + "reattemptRegistrationError": "تعذر تحديد تسجيل هذا المشارك. يرجى المحاولة مرة أخرى.", + "resultReleased": "تم نشر النتيجة بنجاح. تم إشعار المتعلم عبر البريد الإلكتروني.", + "revaluateSuccess": "تمت إعادة تقييم محاولتك لهذا الاختبار. يرجى التحقق من بريدك الإلكتروني!", + "evaluationStarted": "بدأ التقييم بالذكاء الاصطناعي بنجاح!", + "evaluationError": "فشل بدء التقييم بالذكاء الاصطناعي. يرجى المحاولة مرة أخرى.", + "noEvaluatedCopy": "لم يتم العثور على نسخة مقيَّمة لهذه المحاولة.", + "evaluatedCopyLoadError": "فشل تحميل النسخة المقيَّمة. يرجى المحاولة مرة أخرى.", + "noSubmissionFile": "لم يتم العثور على ملف مشاركة لهذه المحاولة.", + "popupBlocked": "يرجى السماح بالنوافذ المنبثقة لعرض المشاركة.", + "submissionLoadError": "فشل تحميل المشاركة. يرجى المحاولة مرة أخرى.", + "reportGenerateError": "فشل إنشاء التقرير. يرجى المحاولة مرة أخرى." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStudentColumns.json b/frontend-admin-dashboard/public/locales/ar/assessmentStudentColumns.json new file mode 100644 index 0000000000..9b3ccac8be --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStudentColumns.json @@ -0,0 +1,29 @@ +{ + "columns": { + "details": "التفاصيل", + "name": "الاسم", + "attemptDate": "تاريخ المحاولة", + "startTime": "وقت البدء", + "endTime": "وقت الانتهاء", + "duration": "المدة", + "score": "الدرجة", + "evaluationStatus": "حالة التقييم", + "resultStatus": "حالة النتيجة", + "enrollmentNumber": "رقم التسجيل", + "gender": "الجنس", + "responseTime": "وقت الاستجابة", + "phoneNumber": "رقم الهاتف", + "emailId": "معرف البريد الإلكتروني", + "city": "المدينة", + "state": "المنطقة", + "submission": "الإرسال" + }, + "status": { + "released": "تم الإصدار", + "pending": "قيد الانتظار", + "notAvailable": "غير متاح" + }, + "tooltip": { + "instantSubmit": "إرسال فوري — لم يُقَس أي وقت للمحاولة. قد يكون عدم محاولة فعلية أو إرسالاً تلقائياً." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStudentLeaderboard.json b/frontend-admin-dashboard/public/locales/ar/assessmentStudentLeaderboard.json new file mode 100644 index 0000000000..1b0a8f7855 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStudentLeaderboard.json @@ -0,0 +1,14 @@ +{ + "header": { + "title": "لوحة المتصدرين" + }, + "toasts": { + "pdfExportSuccess": "تم تصدير بيانات لوحة المتصدرين بصيغة PDF بنجاح", + "csvExportSuccess": "تم تصدير بيانات لوحة المتصدرين بصيغة CSV بنجاح" + }, + "studentCard": { + "duration": "{{minutes}} دقيقة {{seconds}} ثانية", + "percentile": "المئين", + "marks": "الدرجات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStudentOngoingDropdown.json b/frontend-admin-dashboard/public/locales/ar/assessmentStudentOngoingDropdown.json new file mode 100644 index 0000000000..f08bb14bce --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStudentOngoingDropdown.json @@ -0,0 +1,22 @@ +{ + "dialogs": { + "attentionLabel": "تنبيه", + "close": "إغلاق", + "closeSubmission": { + "title": "إغلاق المشاركة", + "confirmMessagePrefix": "هل أنت متأكد أنك تريد إغلاق مشاركة الاختبار الخاصة بـ" + }, + "increaseTime": { + "title": "زيادة وقت الاختبار", + "increaseByLabel": "الزيادة بمقدار", + "selectSectionPlaceholder": "اختر القسم", + "entireAssessment": "الاختبار بالكامل", + "section": "القسم {{number}}", + "question": "السؤال {{number}}" + } + }, + "dropdown": { + "increaseSubmissionTime": "زيادة وقت المشاركة", + "closeSubmission": "إغلاق المشاركة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStudentPendingDropdown.json b/frontend-admin-dashboard/public/locales/ar/assessmentStudentPendingDropdown.json new file mode 100644 index 0000000000..7fe4718df0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStudentPendingDropdown.json @@ -0,0 +1,21 @@ +{ + "dialogs": { + "attentionLabel": "تنبيه", + "sendReminder": { + "title": "إرسال تذكير", + "confirmMessagePrefix": "سيتم إرسال تذكير إلى", + "confirmMessageSuffix": "الذي لم يحضر بعد للتقييم", + "send": "إرسال" + }, + "removeParticipant": { + "title": "إزالة المشارك", + "confirmMessagePrefix": "هل أنت متأكد أنك تريد إزالة", + "confirmMessageSuffix": "من هذا التقييم؟", + "remove": "إزالة" + } + }, + "dropdown": { + "sendReminder": "إرسال تذكير", + "removeParticipants": "إزالة المشاركين" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStudentQuestionwiseFilterButtons.json b/frontend-admin-dashboard/public/locales/ar/assessmentStudentQuestionwiseFilterButtons.json new file mode 100644 index 0000000000..cbfc607e87 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStudentQuestionwiseFilterButtons.json @@ -0,0 +1,4 @@ +{ + "filter": "تصفية", + "reset": "إعادة تعيين" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStudentRevaluateQuestionWise.json b/frontend-admin-dashboard/public/locales/ar/assessmentStudentRevaluateQuestionWise.json new file mode 100644 index 0000000000..0503b08608 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStudentRevaluateQuestionWise.json @@ -0,0 +1,15 @@ +{ + "header": { + "title": "إعادة التقييم حسب السؤال" + }, + "table": { + "columnQNo": "رقم السؤال", + "columnQuestion": "السؤال" + }, + "actions": { + "revaluate": "إعادة التقييم" + }, + "toasts": { + "revaluateSuccess": "تمت إعادة تقييم محاولة هذا الطالب لهذا الاختبار. يرجى التحقق من بريدك الإلكتروني!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentStudentSelector.json b/frontend-admin-dashboard/public/locales/ar/assessmentStudentSelector.json new file mode 100644 index 0000000000..953a6eff4e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentStudentSelector.json @@ -0,0 +1,29 @@ +{ + "tabs": { + "batchSelection": "اختيار الدفعة", + "individualSelection": "اختيار فردي" + }, + "studentCount_zero": "لا يوجد طلاب ({{count}})", + "studentCount_one": "طالب واحد ({{count}})", + "studentCount_two": "طالبان ({{count}})", + "studentCount_few": "{{count}} طلاب", + "studentCount_many": "{{count}} طالبًا", + "studentCount_other": "{{count}} طالب", + "clickRowHint": "انقر فوق صف لبدء إدخال البيانات", + "search": { + "placeholder": "ابحث بالاسم..." + }, + "table": { + "headers": { + "studentName": "اسم الطالب", + "status": "الحالة", + "score": "الدرجة" + }, + "empty": "لم يتم العثور على طلاب" + }, + "status": { + "attempted": "تمت المحاولة", + "pending": "قيد الانتظار", + "registered": "مسجَّل" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentSubjectTagInput.json b/frontend-admin-dashboard/public/locales/ar/assessmentSubjectTagInput.json new file mode 100644 index 0000000000..902ec2cfce --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentSubjectTagInput.json @@ -0,0 +1,7 @@ +{ + "placeholder": "أضف وسم مادة/موضوع", + "removeTag": "إزالة {{tag}}", + "noTagsFound": "لم يتم العثور على وسوم.", + "createTag": "إنشاء ”{{value}}“", + "existingTags": "الوسوم الحالية" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionFileCell.json b/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionFileCell.json new file mode 100644 index 0000000000..a5ac4cfa47 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionFileCell.json @@ -0,0 +1,10 @@ +{ + "checking": "جارٍ التحقق...", + "submitted": "تم التسليم", + "notSubmitted": "لم يتم التسليم", + "viewSubmission": "عرض التسليم", + "upload": "رفع", + "submissionHeading": "التسليم", + "fileNamePrefix": "التسليم", + "loadFailedError": "تعذر تحميل التسليم. يرجى المحاولة مرة أخرى." +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionsFilterButtons.json b/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionsFilterButtons.json new file mode 100644 index 0000000000..cbfc607e87 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionsFilterButtons.json @@ -0,0 +1,4 @@ +{ + "filter": "تصفية", + "reset": "إعادة تعيين" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionsStudentTable.json b/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionsStudentTable.json new file mode 100644 index 0000000000..951b1bd3bd --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionsStudentTable.json @@ -0,0 +1,6 @@ +{ + "states": { + "loading": "جارٍ التحميل...", + "error": "حدث خطأ أثناء تحميل البيانات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionsSummaryStrip.json b/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionsSummaryStrip.json new file mode 100644 index 0000000000..69899392d9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionsSummaryStrip.json @@ -0,0 +1,24 @@ +{ + "tiles": { + "submissionsAttempts": { + "label": "التسليمات / المحاولات" + }, + "submitted": { + "label": "مُسلَّم" + }, + "evaluated": { + "label": "تم التقييم" + }, + "pendingEvaluation": { + "label": "بانتظار التقييم" + }, + "resultsReleased": { + "label": "تم نشر النتائج" + }, + "avgHighLow": { + "label": "المتوسط / الأعلى / الأدنى (/ {{totalMarks}})", + "value": "{{avg}} / {{high}} / {{low}}" + }, + "ratio": "{{numerator}} / {{denominator}}" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionsTab.json b/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionsTab.json new file mode 100644 index 0000000000..fa7a5b2a61 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentSubmissionsTab.json @@ -0,0 +1,42 @@ +{ + "tabs": { + "attempted": "تمت المحاولة", + "ongoing": "جارٍ", + "pending": "معلّق" + }, + "participantType": { + "internal": "داخلي", + "external": "خارجي" + }, + "selectionMode": { + "batch": "اختيار الدفعة", + "individual": "اختيار فردي" + }, + "filters": { + "evaluationStatus": { + "label": "حالة التقييم", + "options": { + "pending": "معلّق", + "evaluating": "قيد التقييم", + "evaluated": "تم التقييم" + } + }, + "submissionStatus": { + "label": "الاستجابة", + "options": { + "submitted": "تم الإرسال", + "notSubmitted": "لم يُرسل" + } + } + }, + "buttons": { + "offlineEntry": "إدخال دون اتصال", + "revaluate": "إعادة التقييم", + "aiEvaluations": "تقييمات الذكاء الاصطناعي" + }, + "dialogs": { + "revaluate": { + "title": "إعادة تقييم النتيجة" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentSurveyIndividualRespondentsTab.json b/frontend-admin-dashboard/public/locales/ar/assessmentSurveyIndividualRespondentsTab.json new file mode 100644 index 0000000000..759eec7b7a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentSurveyIndividualRespondentsTab.json @@ -0,0 +1,38 @@ +{ + "loading": { + "respondents": "جارٍ تحميل المستجيبين للاستبيان...", + "questions": "جارٍ تحميل بيانات الأسئلة..." + }, + "error": { + "title": "خطأ في تحميل المستجيبين", + "tryAgain": "أعد المحاولة" + }, + "empty": { + "title": "لم يتم العثور على مستجيبين", + "description": "لم يتم العثور على ردود على الاستبيان لهذا التقييم." + }, + "navigation": { + "previous": "السابق", + "next": "التالي", + "respondentLabel": "المستجيب", + "ofTotal": "من {{count}}" + }, + "respondent": { + "unknownName": "غير معروف", + "noEmail": "لا يوجد بريد إلكتروني", + "batch": "الدفعة: {{name}}" + }, + "response": { + "noResponse": "لا يوجد رد", + "unknownType": "نوع رد غير معروف", + "unknownQuestionId": "غير معروف", + "unknownQuestionType": "غير معروف", + "defaultQuestionContent": "سؤال الاستبيان", + "parseError": "خطأ في تحليل بيانات الرد", + "questionPrefix": "س{{number}}.", + "label": "الرد:", + "visited": "تمت الزيارة", + "markedForReview": "معلّم للمراجعة", + "noneAvailable": "لا توجد ردود متاحة لهذا المستجيب." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentSurveyMainOverviewTab.json b/frontend-admin-dashboard/public/locales/ar/assessmentSurveyMainOverviewTab.json new file mode 100644 index 0000000000..08e96b93aa --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentSurveyMainOverviewTab.json @@ -0,0 +1,94 @@ +{ + "overview": { + "loading": "جارٍ تحميل بيانات الاستبيان...", + "errorTitle": "خطأ في تحميل بيانات الاستبيان", + "tryAgain": "أعد المحاولة", + "emptyTitle": "لا توجد بيانات استبيان متاحة", + "emptyDescription": "لم يتم العثور على ردود على الاستبيان لهذا التقييم.", + "totalParticipants": "إجمالي المشاركين", + "responded": "استجاب", + "outOf": "من أصل", + "completionRate": "معدل الإكمال", + "individualResponsesTitle": "الردود الفردية", + "loadingResponses": "جارٍ تحميل الردود...", + "loadingQuestionsData": "جارٍ تحميل بيانات الأسئلة...", + "loadingBatchData": "جارٍ تحميل بيانات الدفعة...", + "noResponsesForQuestion": "لم يتم العثور على ردود لهذا السؤال.", + "questionIdLabel": "معرف السؤال: {{id}}", + "tableName": "الاسم", + "tableEmail": "البريد الإلكتروني", + "tableBatch": "الدفعة", + "tableResponse": "الرد", + "notApplicable": "غير متاح" + }, + "question": { + "number": "س{{number}}", + "viewIndividualResponses": "عرض الردود الفردية", + "unsupportedType": "نوع سؤال غير مدعوم" + }, + "common": { + "responseCount_zero": "{{count}} استجابة", + "responseCount_one": "{{count}} استجابة واحدة", + "responseCount_two": "{{count}} استجابتان", + "responseCount_few": "{{count}} استجابات", + "responseCount_many": "{{count}} استجابة", + "responseCount_other": "{{count}} استجابة" + }, + "charts": { + "tooltipResponsesPercent_zero": "{{count}} استجابة ({{percentage}}%)", + "tooltipResponsesPercent_one": "{{count}} استجابة واحدة ({{percentage}}%)", + "tooltipResponsesPercent_two": "{{count}} استجابتان ({{percentage}}%)", + "tooltipResponsesPercent_few": "{{count}} استجابات ({{percentage}}%)", + "tooltipResponsesPercent_many": "{{count}} استجابة ({{percentage}}%)", + "tooltipResponsesPercent_other": "{{count}} استجابة ({{percentage}}%)", + "countLabel": "العدد" + }, + "legend": { + "percentResponses_zero": "{{percentage}}% ({{count}} استجابة)", + "percentResponses_one": "{{percentage}}% ({{count}} استجابة واحدة)", + "percentResponses_two": "{{percentage}}% ({{count}} استجابتان)", + "percentResponses_few": "{{percentage}}% ({{count}} استجابات)", + "percentResponses_many": "{{percentage}}% ({{count}} استجابة)", + "percentResponses_other": "{{percentage}}% ({{count}} استجابة)" + }, + "trueFalse": { + "true": "صحيح", + "false": "خطأ", + "tooltipPercent_zero": "{{percentage}}% ({{count}} استجابة)", + "tooltipPercent_one": "{{percentage}}% ({{count}} استجابة واحدة)", + "tooltipPercent_two": "{{percentage}}% ({{count}} استجابتان)", + "tooltipPercent_few": "{{percentage}}% ({{count}} استجابات)", + "tooltipPercent_many": "{{percentage}}% ({{count}} استجابة)", + "tooltipPercent_other": "{{percentage}}% ({{count}} استجابة)" + }, + "textQuestion": { + "recentResponses_zero": "الردود الأخيرة ({{count}} استجابة إجمالاً):", + "recentResponses_one": "الردود الأخيرة ({{count}} استجابة واحدة إجمالاً):", + "recentResponses_two": "الردود الأخيرة ({{count}} استجابتان إجمالاً):", + "recentResponses_few": "الردود الأخيرة ({{count}} استجابات إجمالاً):", + "recentResponses_many": "الردود الأخيرة ({{count}} استجابة إجمالاً):", + "recentResponses_other": "الردود الأخيرة ({{count}} استجابة إجمالاً):", + "similarResponses_zero": "{{count}} استجابة مشابهة", + "similarResponses_one": "{{count}} استجابة مشابهة واحدة", + "similarResponses_two": "{{count}} استجابتان مشابهتان", + "similarResponses_few": "{{count}} استجابات مشابهة", + "similarResponses_many": "{{count}} استجابة مشابهة", + "similarResponses_other": "{{count}} استجابة مشابهة" + }, + "numerical": { + "responseDistribution": "توزيع الردود:", + "responsesPercent_zero": "{{count}} استجابة ({{percentage}}%)", + "responsesPercent_one": "{{count}} استجابة واحدة ({{percentage}}%)", + "responsesPercent_two": "{{count}} استجابتان ({{percentage}}%)", + "responsesPercent_few": "{{count}} استجابات ({{percentage}}%)", + "responsesPercent_many": "{{count}} استجابة ({{percentage}}%)", + "responsesPercent_other": "{{count}} استجابة ({{percentage}}%)" + }, + "responses": { + "noOptionsSelected": "لم يتم تحديد أي خيارات", + "noNumericAnswer": "لم يتم تقديم إجابة رقمية", + "noTextAnswer": "لم يتم تقديم إجابة نصية", + "unknownType": "نوع رد غير معروف", + "errorParsing": "خطأ في تحليل بيانات الرد" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentTabIndex.json b/frontend-admin-dashboard/public/locales/ar/assessmentTabIndex.json new file mode 100644 index 0000000000..7adea18f7f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentTabIndex.json @@ -0,0 +1,24 @@ +{ + "heading": { + "title": "تفاصيل التقييم" + }, + "helmet": { + "title": "تفاصيل التقييم", + "description": "تعرض هذه الصفحة جميع التفاصيل المتعلقة بالتقييم." + }, + "actions": { + "previewAssessment": "معاينة التقييم", + "exportOffline": "تصدير دون اتصال", + "noSectionsError": "لم تتم إضافة أي أقسام لهذا التقييم." + }, + "tabs": { + "overview": "نظرة عامة", + "submissions": "التقديمات", + "individualRespondents": "المستجيبون الأفراد", + "basicInfo": "المعلومات الأساسية", + "questions": "الأسئلة", + "participants": "المشاركون", + "accessControl": "التحكم في الوصول", + "reattemptRequests": "طلبات إعادة المحاولة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentTabListComponent.json b/frontend-admin-dashboard/public/locales/ar/assessmentTabListComponent.json new file mode 100644 index 0000000000..cca1e6cbd8 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentTabListComponent.json @@ -0,0 +1,4 @@ +{ + "all": "الكل", + "favourites": "المفضلة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentTableViewResponseForm.json b/frontend-admin-dashboard/public/locales/ar/assessmentTableViewResponseForm.json new file mode 100644 index 0000000000..348c161d63 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentTableViewResponseForm.json @@ -0,0 +1,17 @@ +{ + "table": { + "headers": { + "number": "#", + "view": "عرض", + "options": "الخيارات", + "marks": "الدرجات" + }, + "questionNumber": "س{{number}}.", + "viewButton": "عرض", + "optionFallback": "الخيار {{label}}", + "marksPlaceholder": "-" + }, + "actions": { + "submit": "إرسال" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentTrueFalsePPTViewList.json b/frontend-admin-dashboard/public/locales/ar/assessmentTrueFalsePPTViewList.json new file mode 100644 index 0000000000..11244ed563 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentTrueFalsePPTViewList.json @@ -0,0 +1,9 @@ +{ + "optionMarker": { + "fallback": "({{label}}.)" + }, + "dropdownMenu": { + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentTrueFalsePPTViewQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentTrueFalsePPTViewQP.json new file mode 100644 index 0000000000..11244ed563 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentTrueFalsePPTViewQP.json @@ -0,0 +1,9 @@ +{ + "optionMarker": { + "fallback": "({{label}}.)" + }, + "dropdownMenu": { + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentTrueFalseTemplateList.json b/frontend-admin-dashboard/public/locales/ar/assessmentTrueFalseTemplateList.json new file mode 100644 index 0000000000..965401263c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentTrueFalseTemplateList.json @@ -0,0 +1,20 @@ +{ + "settingsPopover": { + "title": "إعدادات الأسئلة", + "questionTypeLabel": "نوع السؤال" + }, + "questionHeader": { + "questionLabel": "سؤال" + }, + "trueFalseOptions": { + "answerLabel": "الإجابة:" + }, + "trueFalseOption": { + "markerFallback": "({{label}}.)", + "trueLabel": "صحيح", + "falseLabel": "خطأ" + }, + "explanationSection": { + "label": "الشرح:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentTrueFalseTemplateQP.json b/frontend-admin-dashboard/public/locales/ar/assessmentTrueFalseTemplateQP.json new file mode 100644 index 0000000000..b2ec50a42f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentTrueFalseTemplateQP.json @@ -0,0 +1,23 @@ +{ + "settingsPopover": { + "title": "إعدادات الأسئلة", + "questionTypeLabel": "نوع السؤال" + }, + "questionHeader": { + "questionLabel": "سؤال" + }, + "trueFalseOption": { + "markerFallback": "({{label}}.)", + "trueLabel": "صحيح", + "falseLabel": "خطأ" + }, + "trueFalseOptions": { + "answerDefaultLabel": "الإجابة:" + }, + "explanationSection": { + "defaultLabel": "الشرح:" + }, + "emptyState": { + "message": "يرجى إضافة سؤال لعرض تفاصيل السؤال" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentUploadDiagnosticsDialog.json b/frontend-admin-dashboard/public/locales/ar/assessmentUploadDiagnosticsDialog.json new file mode 100644 index 0000000000..56d134efa5 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentUploadDiagnosticsDialog.json @@ -0,0 +1,43 @@ +{ + "header": { + "title": "مراجعة الأسئلة التي تم رفعها", + "summary_zero": "{{grouped}} من أصل {{count}} سؤال بحاجة إلى انتباه", + "summary_one": "{{grouped}} من أصل {{count}} سؤال واحد بحاجة إلى انتباه", + "summary_two": "{{grouped}} من أصل {{count}} سؤالين بحاجة إلى انتباه", + "summary_few": "{{grouped}} من أصل {{count}} أسئلة بحاجة إلى انتباه", + "summary_many": "{{grouped}} من أصل {{count}} سؤالًا بحاجة إلى انتباه", + "summary_other": "{{grouped}} من أصل {{count}} سؤال بحاجة إلى انتباه", + "errorsCount_zero": "— {{count}} بها أخطاء", + "errorsCount_one": "— سؤال واحد ({{count}}) به أخطاء", + "errorsCount_two": "— سؤالان ({{count}}) بهما أخطاء", + "errorsCount_few": "— {{count}} بها أخطاء", + "errorsCount_many": "— {{count}} بها أخطاء", + "errorsCount_other": "— {{count}} بها أخطاء", + "warningsCount_zero": ", {{count}} بها تحذيرات", + "warningsCount_one": ", سؤال واحد ({{count}}) به تحذيرات", + "warningsCount_two": ", سؤالان ({{count}}) بهما تحذيرات", + "warningsCount_few": ", {{count}} بها تحذيرات", + "warningsCount_many": ", {{count}} بها تحذيرات", + "warningsCount_other": ", {{count}} بها تحذيرات" + }, + "actionsBar": { + "summary": "{{skipCount}} محددة للتخطي · {{proceedCount}} ستُضاف إلى النموذج", + "skipAll": "تخطي الكل", + "skipNone": "عدم تخطي أي منها" + }, + "listItem": { + "questionNumber": "س{{number}}", + "errorBadge": "خطأ", + "warningBadge": "تحذير" + }, + "footer": { + "copyDiagnostics": "نسخ JSON التشخيصي", + "cancel": "إلغاء", + "keepAllAndEdit": "الاحتفاظ بالكل والتعديل", + "skipAndProceed": "تخطي والمتابعة ({{count}})" + }, + "toasts": { + "copySuccess": "تم نسخ معلومات التشخيص إلى الحافظة", + "copyFailed": "تعذر النسخ إلى الحافظة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/assessmentViewQuestionPaper.json b/frontend-admin-dashboard/public/locales/ar/assessmentViewQuestionPaper.json new file mode 100644 index 0000000000..cbf43e272c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/assessmentViewQuestionPaper.json @@ -0,0 +1,6 @@ +{ + "trigger": { + "view": "عرض", + "viewQuestionPaper": "عرض ورقة الأسئلة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerActivityTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerActivityTab.json new file mode 100644 index 0000000000..df3941f715 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerActivityTab.json @@ -0,0 +1,45 @@ +{ + "dailyActivity": { + "title": "النشاط اليومي", + "summary_zero": "لا يوجد نشاط ({{count}}) · توقيت المؤسسة", + "summary_one": "نشاط واحد ({{count}}) · توقيت المؤسسة", + "summary_two": "نشاطان ({{count}}) · توقيت المؤسسة", + "summary_few": "{{count}} أنشطة · توقيت المؤسسة", + "summary_many": "{{count}} نشاطًا · توقيت المؤسسة", + "summary_other": "{{count}} نشاط · توقيت المؤسسة", + "empty": "لا يوجد نشاط في هذه الفترة.", + "barTooltip_zero": "{{date}} — لا يوجد نشاط ({{count}})", + "barTooltip_one": "{{date}} — نشاط واحد ({{count}})", + "barTooltip_two": "{{date}} — نشاطان ({{count}})", + "barTooltip_few": "{{date}} — {{count}} أنشطة", + "barTooltip_many": "{{date}} — {{count}} نشاطًا", + "barTooltip_other": "{{date}} — {{count}} نشاط" + }, + "counsellorActivity": { + "title": "نشاط المستشارين", + "empty": "لا يوجد نشاط للمستشارين في هذه الفترة.", + "unknownCounsellor": "مستشار غير معروف" + }, + "csv": { + "headers": { + "counsellor": "المستشار", + "notes": "الملاحظات", + "calls": "المكالمات", + "statusChanges": "تغييرات الحالة", + "followupsCreated": "المتابعات المُنشأة", + "followupsClosed": "المتابعات المغلقة", + "total": "الإجمالي" + } + }, + "table": { + "headers": { + "counsellor": "المستشار", + "notes": "الملاحظات", + "calls": "المكالمات", + "statusChanges": "تغييرات الحالة", + "followupsCreated": "المتابعات المُنشأة", + "followupsClosed": "المتابعات المغلقة", + "total": "الإجمالي" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerAiIntelligencePage.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerAiIntelligencePage.json new file mode 100644 index 0000000000..2149392b56 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerAiIntelligencePage.json @@ -0,0 +1,93 @@ +{ + "header": { + "title": "الذكاء الاصطناعي", + "subtitle": "جودة المكالمات والنشاط اليومي عبر الزمن — الفريق أولاً، ثم كل مستشار، مع التغيّر مقارنة بالفترة السابقة حتى ترى ما تحسّن." + }, + "loadingInstitute": "جارٍ تحميل المؤسسة…", + "emptyState": { + "title": "الذكاء الاصطناعي معطّل", + "description": "فعّل ذكاء المكالمات من الإعدادات لنسخ المكالمات وتحليلها، وستعرض هذه الصفحة عندها اتجاهات الفريق وكل مستشار." + }, + "banner": { + "offWithData": "ذكاء المكالمات معطّل — لا يتم تحليل مكالمات جديدة. تُعرض بيانات محلَّلة سابقًا. فعّله من الإعدادات لاستئناف التحليل." + }, + "comparing": { + "label": "مقارنة", + "against": "مقابل الفترة السابقة" + }, + "period": { + "days_zero": "{{count}} يوم", + "days_one": "{{count}} يوم", + "days_two": "{{count}} يومان", + "days_few": "{{count}} أيام", + "days_many": "{{count}} يومًا", + "days_other": "{{count}} يوم", + "custom": "مخصّص", + "startDateLabel": "تاريخ البداية", + "endDateLabel": "تاريخ النهاية", + "to": "إلى" + }, + "months": { + "jan": "يناير", + "feb": "فبراير", + "mar": "مارس", + "apr": "أبريل", + "may": "مايو", + "jun": "يونيو", + "jul": "يوليو", + "aug": "أغسطس", + "sep": "سبتمبر", + "oct": "أكتوبر", + "nov": "نوفمبر", + "dec": "ديسمبر" + }, + "delta": { + "noChange": "لا تغيير" + }, + "teamInsights": { + "heading": "رؤى الفريق", + "groupCallQuality": "الاتصال وجودة المكالمات", + "groupActivity": "النشاط اليومي", + "metrics": { + "callsAnalyzed": "المكالمات المحلَّلة", + "avgCallerRating": "متوسط تقييم المتصل", + "avgCallOutput": "متوسط نتيجة المكالمة", + "positiveSentiment": "المشاعر الإيجابية", + "leadsDispositioned": "العملاء المحتملون الذين تم تصنيفهم", + "callsMade": "المكالمات المُجراة", + "teamReach": "نسبة وصول الفريق" + }, + "summary": { + "noChangeYet": "لا يوجد تغيّر ملموس مقارنة بالفترة السابقة حتى الآن.", + "aiSummaryLabel": "ملخّص الذكاء الاصطناعي —", + "whatImproved": "ما الذي تحسّن", + "needsAttention": "يحتاج إلى انتباه" + }, + "aiSummary": { + "improved": "تحسّن: {{list}}.", + "watch": "انتبه: {{list}}.", + "coachingFocus": "محور تدريب الذكاء الاصطناعي: {{tip}}", + "mostHitObjection": "الاعتراض الأكثر تكرارًا: {{objection}}." + } + }, + "counsellorBreakdown": { + "heading": "تفصيل المستشارين", + "subtitle": "المقاييس مقابل الفترة المماثلة السابقة", + "columns": { + "counsellor": "المستشار", + "analyzed": "محلَّلة", + "avgCaller": "متوسط المتصل", + "avgOutput": "متوسط النتيجة", + "dispositioned": "مصنَّفة", + "reach": "الوصول" + }, + "noActivity": "لا يوجد نشاط للمستشارين في هذه الفترة.", + "whatTheyCanImprove": "ما يمكنهم تحسينه", + "coaching": { + "loading": "جارٍ تحميل التدريب…", + "noCoaching": "لا توجد مكالمات محلَّلة في هذا النطاق — لا يوجد تدريب لهذا المستشار بعد.", + "weakestSkills": "أضعف المهارات:", + "notEnoughSignal": "لا توجد بيانات كافية للتدريب في هذا النطاق." + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerApiIntegrationDialog.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerApiIntegrationDialog.json new file mode 100644 index 0000000000..21f0fb9407 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerApiIntegrationDialog.json @@ -0,0 +1,89 @@ +{ + "dialogTitle": "تكامل API - {{campaignName}}", + "dialogDescription": "استخدم هذه التفاصيل للتكامل مع أدوات الأتمتة مثل Zapier وMake أو التطبيقات المخصصة.", + "tabs": { + "curl": "أمر cURL", + "docs": "التوثيق" + }, + "copyButton": { + "copy": "نسخ", + "copied": "تم النسخ", + "copyMarkdown": "نسخ Markdown" + }, + "toasts": { + "copiedToClipboard": "تم النسخ إلى الحافظة!", + "copyFailed": "فشل النسخ" + }, + "curlTab": { + "replaceHintPrefix": "استبدل القيم النائبة (مثال:", + "replaceHintSuffix": ") بالبيانات الفعلية قبل إرسال الطلب." + }, + "common": { + "yes": "نعم", + "no": "لا" + }, + "docsPanel": { + "heading": "دليل تكامل API", + "endpointHeading": "نقطة النهاية", + "headersHeading": "الترويسات", + "headersTable": { + "header": "الترويسة", + "value": "القيمة" + }, + "customFieldsHeading": "الحقول المخصصة", + "customFieldsTable": { + "fieldId": "معرّف الحقل", + "name": "الاسم", + "type": "النوع", + "required": "مطلوب" + }, + "integrationExamplesHeading": "أمثلة التكامل", + "zapierHeading": "تكامل Zapier", + "zapierSteps": { + "createZap": "أنشئ عملية Zap جديدة", + "chooseTrigger": "اختر تطبيق المشغّل الخاص بك", + "addWebhooks": "أضف Webhooks by Zapier كإجراء", + "selectPostMethod": "اختر طريقة \"POST\"", + "pasteUrl": "الصق عنوان URL لنقطة النهاية", + "mapFields": "اربط حقولك بمحتوى الطلب" + } + }, + "markdown": { + "guideHeading": "دليل تكامل API", + "endpointHeading": "نقطة النهاية", + "headersHeading": "الترويسات", + "headersTable": { + "header": "الترويسة", + "value": "القيمة" + }, + "requestBodyHeading": "بنية محتوى الطلب", + "customFieldsHeading": "مرجع الحقول المخصصة", + "customFieldsTable": { + "fieldId": "معرّف الحقل", + "fieldName": "اسم الحقل", + "type": "النوع", + "required": "مطلوب" + }, + "responseHeading": "الاستجابة", + "successLabel": "نجاح", + "errorLabel": "خطأ", + "integrationExamplesHeading": "أمثلة التكامل", + "zapierHeading": "تكامل Zapier", + "zapierSteps": { + "createZap": "أنشئ عملية Zap جديدة", + "chooseTrigger": "اختر تطبيق المشغّل الخاص بك (مثل Google Forms أو Typeform)", + "addWebhooks": "أضف Webhooks by Zapier كإجراء", + "selectPostMethod": "اختر طريقة \"POST\"", + "pasteUrl": "الصق عنوان URL لنقطة النهاية", + "setContentType": "اضبط Content-Type على application/json", + "mapFields": "اربط حقول المشغّل بمحتوى الطلب" + }, + "makeHeading": "Make (Integromat)", + "makeSteps": { + "createScenario": "أنشئ سيناريو جديدًا", + "addTrigger": "أضف وحدة المشغّل الخاصة بك", + "addHttpModule": "أضف وحدة HTTP > Make a request", + "configure": "اضبط الإعدادات باستخدام نقطة النهاية وبنية المحتوى أعلاه" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerAudienceCampaignCardMenuOptions.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerAudienceCampaignCardMenuOptions.json new file mode 100644 index 0000000000..22f850a49f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerAudienceCampaignCardMenuOptions.json @@ -0,0 +1,32 @@ +{ + "menu": { + "openMenu": "فتح القائمة", + "edit": "تعديل", + "addResponse": "إضافة رد", + "bulkImportCsv": "استيراد جماعي (CSV)", + "sendMessage": "إرسال رسالة", + "configureWorkflow": "إعداد سير العمل", + "viewLinkedWorkflows": "عرض مسارات العمل المرتبطة", + "bookingSettings": "إعدادات الحجز", + "apiIntegration": "تكامل API", + "getEmbedCode": "الحصول على كود التضمين", + "delete": "حذف" + }, + "deleteDialog": { + "title": "حذف {{term}}", + "description": "هل أنت متأكد أنك تريد حذف الحملة \"{{campaignName}}\"؟ لا يمكن التراجع عن هذا الإجراء.", + "cancel": "إلغاء", + "deleting": "جارٍ الحذف...", + "confirm": "حذف" + }, + "toast": { + "deleteSuccess": "تم حذف الحملة بنجاح", + "deleteError": "فشل حذف الحملة", + "editComingSoon": "ميزة تعديل الحملة قادمة قريبًا", + "campaignIdMissing": "معرّف الحملة مفقود" + }, + "defaults": { + "campaignName": "حملة", + "thisCampaign": "هذه الحملة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerAudienceCampaignSchema.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerAudienceCampaignSchema.json new file mode 100644 index 0000000000..0a16d15dc5 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerAudienceCampaignSchema.json @@ -0,0 +1,11 @@ +{ + "validation": { + "campaignNameRequired": "اسم الحملة مطلوب", + "campaignNameMinLength": "يجب أن يتكون الاسم من 3 أحرف على الأقل", + "campaignTypeRequired": "نوع الحملة مطلوب", + "startDateRequired": "تاريخ البدء مطلوب", + "endDateRequired": "تاريخ الانتهاء مطلوب", + "initialScoreMin": "يجب ألا تقل نقاط العميل المحتمل الأولية عن 0", + "initialScoreMax": "يجب ألا تتجاوز نقاط العميل المحتمل الأولية 50" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerAudienceInvite.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerAudienceInvite.json new file mode 100644 index 0000000000..6adc36e229 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerAudienceInvite.json @@ -0,0 +1,50 @@ +{ + "heading": { + "titleLoading": "{{term}}", + "titleWithCount": "{{formattedCount}} {{term}}", + "subtitle": "إدارة ومشاركة {{term}} عبر الحملات." + }, + "settingsButton": "إعدادات الجمهور", + "addButton": "إضافة {{term}}", + "status": { + "active": "نشط", + "draft": "مسودة", + "inactive": "غير نشط" + }, + "statusFilter": { + "allStatus": "كل الحالات", + "placeholder": "تصفية حسب الحالة" + }, + "toolbar": { + "searchPlaceholder": "البحث في {{term}}", + "searchAriaLabel": "البحث في {{term}}", + "subOrgPlaceholder": "تصفية حسب المؤسسة الفرعية", + "allSubOrgs": "كل المؤسسات الفرعية", + "subOrgFallback": "مؤسسة فرعية" + }, + "errorState": { + "title": "تعذر تحميل {{term}}", + "subtitle": "حدث خطأ ما. حاول مرة أخرى." + }, + "emptyState": { + "noMatches": "لا توجد {{term}} مطابقة لعوامل التصفية.", + "noneYet": "لم تُنشئ أي {{term}} بعد.", + "clearFilters": "مسح عوامل التصفية", + "createFirst": "أنشئ أول {{term}}" + }, + "toast": { + "missingCampaignId": "تعذر فتح تفاصيل الحملة. معرّف الحملة مفقود." + }, + "card": { + "openAriaLabel": "فتح {{campaignName}}", + "activateHint": "فعّل هذا {{term}} لإنشاء رابط قابل للمشاركة." + }, + "actions": { + "addResponse": "إضافة رد", + "addResponseTooltip": "إضافة رد نيابةً عن المستجيب", + "api": "واجهة برمجة التطبيقات", + "apiTooltip": "الحصول على تفاصيل تكامل واجهة البرمجة للأتمتة", + "embed": "تضمين", + "embedTooltip": "الحصول على كود التضمين لموقعك" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerBookingSettingsDialog.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerBookingSettingsDialog.json new file mode 100644 index 0000000000..ef91c552d4 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerBookingSettingsDialog.json @@ -0,0 +1,39 @@ +{ + "dialog": { + "heading": "إعدادات الحجز" + }, + "states": { + "loadError": "تعذّر تحميل إعدادات الحجز. حاول مرة أخرى." + }, + "empty": { + "description": "فعّل الحجوزات لـ \"{{audienceName}}\" — يحصل العملاء المحتملون في هذه القائمة على صفحة عامة يمكنهم من خلالها اختيار موعد في تقويم المضيف.", + "defaultTitle": "اجتماع {{audienceName}}" + }, + "active": { + "linkLabel": "رابط الحجز العام", + "statusActive": "نشط", + "statusInactive": "غير نشط", + "linkPlaceholder": "سيتوفر الرابط بمجرد أن يحصل على معرّف رابط", + "copy": "نسخ", + "activate": "تفعيل", + "deactivate": "تعطيل", + "delete": "حذف" + }, + "deleteDialog": { + "title": "حذف صفحة الحجز", + "description": "هل أنت متأكد أنك تريد حذف صفحة الحجز الخاصة بـ \"{{audienceName}}\"؟ سيتوقف رابطها العام عن العمل. لا يمكن التراجع عن هذا الإجراء.", + "cancel": "إلغاء", + "deleting": "جارٍ الحذف...", + "confirm": "حذف" + }, + "toast": { + "copySuccess": "تم نسخ رابط الحجز", + "copyError": "تعذّر نسخ الرابط", + "deactivateSuccess": "تم تعطيل صفحة الحجز", + "activateSuccess": "تم تفعيل صفحة الحجز", + "statusUpdateError": "فشل تحديث حالة صفحة الحجز", + "deleteSuccess": "تم حذف صفحة الحجز", + "deleteError": "فشل حذف صفحة الحجز", + "enableSuccess": "تم تفعيل الحجوزات الآن لهذه القائمة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerBulkSubmitAudienceLead.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerBulkSubmitAudienceLead.json new file mode 100644 index 0000000000..d2adc104d5 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerBulkSubmitAudienceLead.json @@ -0,0 +1,5 @@ +{ + "errors": { + "submitFailed": "تعذر إرسال العملاء المحتملين" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCallAllAiButton.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCallAllAiButton.json new file mode 100644 index 0000000000..0711cb0239 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCallAllAiButton.json @@ -0,0 +1,66 @@ +{ + "button": { + "label": "الاتصال بالجميع عبر الذكاء الاصطناعي" + }, + "dialog": { + "heading": "الاتصال بجميع العملاء المحتملين عبر الذكاء الاصطناعي" + }, + "footer": { + "cancel": "إلغاء", + "starting": "جارٍ البدء…", + "callLeads_zero": "الاتصال بـ {{count}} عميل محتمل", + "callLeads_one": "الاتصال بـ {{count}} عميل محتمل", + "callLeads_two": "الاتصال بـ {{count}} عميلين محتملين", + "callLeads_few": "الاتصال بـ {{count}} عملاء محتملين", + "callLeads_many": "الاتصال بـ {{count}} عميلاً محتملاً", + "callLeads_other": "الاتصال بـ {{count}} عميل محتمل", + "callLeadsGeneric": "الاتصال بالعملاء المحتملين" + }, + "body": { + "checkingEligibility": "جارٍ التحقق من عدد العملاء المحتملين الذين يمكن الاتصال بهم…", + "eligibleOfTotal_zero": "{{eligible}} من أصل {{count}} عميل محتمل في هذه القائمة يمكن الاتصال بهم (لديهم رقم اتصال محفوظ).", + "eligibleOfTotal_one": "{{eligible}} من أصل {{count}} عميل محتمل في هذه القائمة يمكن الاتصال به (لديه رقم اتصال محفوظ).", + "eligibleOfTotal_two": "{{eligible}} من أصل {{count}} عميلين محتملين في هذه القائمة يمكن الاتصال بهما (لديهما رقم اتصال محفوظ).", + "eligibleOfTotal_few": "{{eligible}} من أصل {{count}} عملاء محتملين في هذه القائمة يمكن الاتصال بهم (لديهم رقم اتصال محفوظ).", + "eligibleOfTotal_many": "{{eligible}} من أصل {{count}} عميلاً محتملاً في هذه القائمة يمكن الاتصال بهم (لديهم رقم اتصال محفوظ).", + "eligibleOfTotal_other": "{{eligible}} من أصل {{count}} عميل محتمل في هذه القائمة يمكن الاتصال بهم (لديهم رقم اتصال محفوظ).", + "disclaimer": "يتصل وكيل الذكاء الاصطناعي بكل عميل محتمل، بوتيرة منظمة في الخلفية، ويستهلك أرصدة اتصال. تتم نتيجة كل مكالمة وتعيين المستشار المسؤول تلقائيًا بعد انتهاء المكالمة. لا يمكن التراجع عن هذا بعد بدء التشغيل.", + "noneEligible": "لا يوجد لدى أي عميل محتمل في هذه القائمة رقم اتصال يمكن الاتصال به.", + "scope": { + "label": "من يتم الاتصال به", + "selectedOption_zero": "{{count}} من العملاء المحتملين المحددين فقط", + "selectedOption_one": "العميل المحتمل المحدد فقط ({{count}})", + "selectedOption_two": "العميلان المحتملان المحددان فقط ({{count}})", + "selectedOption_few": "{{count}} من العملاء المحتملين المحددين فقط", + "selectedOption_many": "{{count}} من العملاء المحتملين المحددين فقط", + "selectedOption_other": "{{count}} من العملاء المحتملين المحددين فقط", + "allOption_zero": "جميع العملاء المحتملين المؤهلين ({{count}}) في هذه القائمة", + "allOption_one": "العميل المحتمل المؤهل الوحيد ({{count}}) في هذه القائمة", + "allOption_two": "العميلان المحتملان المؤهلان ({{count}}) في هذه القائمة", + "allOption_few": "جميع العملاء المحتملين المؤهلين ({{count}}) في هذه القائمة", + "allOption_many": "جميع العملاء المحتملين المؤهلين ({{count}}) في هذه القائمة", + "allOption_other": "جميع العملاء المحتملين المؤهلين ({{count}}) في هذه القائمة" + }, + "parallel": { + "label": "عدد المكالمات في وقت واحد", + "options": { + "one": "1 — مكالمة واحدة في كل مرة", + "two": "2 بالتوازي", + "three": "3 بالتوازي" + }, + "caption": "تبدأ المكالمة التالية بمجرد انتهاء مكالمة أخرى، بحيث لا يتجاوز عدد المكالمات النشطة هذا الحد في أي وقت." + } + }, + "toast": { + "noneDispatched": "لم يتم إجراء أي مكالمات بالذكاء الاصطناعي", + "queued_zero": "تم جدولة {{count}} مكالمة بالذكاء الاصطناعي", + "queued_one": "تم جدولة مكالمة واحدة بالذكاء الاصطناعي ({{count}})", + "queued_two": "تم جدولة مكالمتين بالذكاء الاصطناعي ({{count}})", + "queued_few": "تم جدولة {{count}} مكالمات بالذكاء الاصطناعي", + "queued_many": "تم جدولة {{count}} مكالمة بالذكاء الاصطناعي", + "queued_other": "تم جدولة {{count}} مكالمة بالذكاء الاصطناعي" + }, + "errors": { + "startFailed": "تعذر بدء حملة الاتصال بالذكاء الاصطناعي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCallHealth.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCallHealth.json new file mode 100644 index 0000000000..a253cf369b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCallHealth.json @@ -0,0 +1,251 @@ +{ + "notReportedHint": "لم يتم الإبلاغ عن سلامة المكالمة — إما أن هذه المكالمة جرت قبل إطلاق التشخيصات، أو أن البوت لم يرسل أي حكم.", + "health": { + "healthy": "سليمة", + "degraded": "متدهورة", + "broken": "معطلة", + "notReported": "غير مُبلَّغ عنها" + }, + "headline": { + "crash": "توقف خط المعالجة أثناء المكالمة", + "ttsWedge": "تعطّل توليف الصوت — سمع المتصل صمتًا", + "replyUnplayed": "لم يتم تشغيل أحد الردود للمتصل أبدًا", + "answerDeleted": "تم تجاهل إجابات المتصل قبل أن يراها الوكيل", + "botSilent": "لم يتحدث الوكيل مطلقًا — لم يسمع المتصل شيئًا", + "replyLoop": "ظل الوكيل يعيد بدء نفس الرد", + "handbackLoop": "لم يكن لدى الوكيل ما يقوله وظل يطلب من المتصل التحدث", + "deadAir": "صمت طويل أثناء المكالمة", + "falseReask": "أعاد الوكيل طلب إجابات كان قد سمعها بالفعل", + "likelyMachine": "على الأرجح جهاز رد آلي، وليس شخصًا", + "sttDeaf": "أعاد التعرف على الكلام الاتصال في منتصف المكالمة", + "slowTts": "توليف صوت بطيء", + "slowLlm": "استجابات بطيئة من الوكيل", + "transferFailed": "طُلب التحويل إلى إنسان لكنه فشل", + "promptUnfilled": "يحتوي موجّه الوكيل على عناصر نائبة غير محلولة" + }, + "faultLabel": { + "crash": "تعطّل", + "ttsWedge": "تعليق توليف الصوت", + "replyUnplayed": "رد لم يُشغَّل", + "answerDeleted": "إجابات محذوفة", + "botSilent": "الوكيل صامت", + "replyLoop": "حلقة رد", + "handbackLoop": "لا شيء ليقال", + "deadAir": "صمت", + "falseReask": "إعادة سؤال خاطئة", + "likelyMachine": "جهاز رد على الأرجح", + "sttDeaf": "تعطّل التعرف على الكلام", + "slowTts": "توليف صوت بطيء", + "slowLlm": "نموذج لغوي بطيء", + "transferFailed": "فشل التحويل", + "promptUnfilled": "موجّه غير مكتمل" + }, + "cell": { + "onlyAiTitle": "مكالمات الذكاء الاصطناعي فقط تُبلغ عن السلامة التقنية", + "checkHealth": "تحقق من السلامة", + "reportNotLoaded": "لم يتم تحميل التقرير التقني لهذا الصف — انقر لجلب سلامة هذه المكالمة.", + "titleWithHeadline": "{{label}} — {{headline}}. انقر للاطلاع على التفاصيل التقنية.", + "titleNoFaults": "{{label}} — لم يتم اكتشاف أي أعطال. انقر للاطلاع على التفاصيل التقنية.", + "ariaLabel": "سلامة المكالمة: {{label}}" + }, + "inferredTag": { + "label": "مُستنتج", + "tooltip": "مُستنتج من قاعدة تقريبية وليس مقاسًا. تعامل معه كدليل لا كحقيقة." + }, + "evidence": { + "crashError": "خطأ: {{error}}", + "stall_zero": "{{count}} توقف", + "stall_one": "{{count}} توقف", + "stall_two": "{{count}} توقفان", + "stall_few": "{{count}} توقفات", + "stall_many": "{{count}} توقفًا", + "stall_other": "{{count}} توقف", + "wedge_zero": "{{count}} تعليق", + "wedge_one": "{{count}} تعليق", + "wedge_two": "{{count}} تعليقان", + "wedge_few": "{{count}} تعليقات", + "wedge_many": "{{count}} تعليقًا", + "wedge_other": "{{count}} تعليق", + "socketRebuild_zero": "{{count}} عملية إعادة بناء للمقبس", + "socketRebuild_one": "{{count}} عملية إعادة بناء للمقبس", + "socketRebuild_two": "{{count}} عمليتا إعادة بناء للمقبس", + "socketRebuild_few": "{{count}} عمليات إعادة بناء للمقبس", + "socketRebuild_many": "{{count}} عملية إعادة بناء للمقبس", + "socketRebuild_other": "{{count}} عملية إعادة بناء للمقبس", + "silentGeneration_zero": "{{count}} عملية توليد صامتة", + "silentGeneration_one": "{{count}} عملية توليد صامتة", + "silentGeneration_two": "{{count}} عمليتا توليد صامتتان", + "silentGeneration_few": "{{count}} عمليات توليد صامتة", + "silentGeneration_many": "{{count}} عملية توليد صامتة", + "silentGeneration_other": "{{count}} عملية توليد صامتة", + "letterlessChunkSkipped_zero": "{{count}} جزء غير حرفي تم تخطيه", + "letterlessChunkSkipped_one": "{{count}} جزء غير حرفي تم تخطيه", + "letterlessChunkSkipped_two": "{{count}} جزآن غير حرفيين تم تخطيهما", + "letterlessChunkSkipped_few": "{{count}} أجزاء غير حرفية تم تخطيها", + "letterlessChunkSkipped_many": "{{count}} جزءًا غير حرفي تم تخطيه", + "letterlessChunkSkipped_other": "{{count}} جزء غير حرفي تم تخطيه", + "stallCapHit": "تم بلوغ الحد الأقصى للتوقفات — صمت منذ ذلك الحين", + "worstDeadAir": "أطول صمت {{secs}}", + "repliesNeverPlayed": "{{count}} من أصل {{total}} ردًا لم يصل إلى المتصل أبدًا", + "notMeasured": "غير مقاس في هذه المكالمة", + "answerDiscarded_zero": "{{count}} إجابة من المتصل تم تجاهلها", + "answerDiscarded_one": "{{count}} إجابة من المتصل تم تجاهلها", + "answerDiscarded_two": "{{count}} إجابتان من المتصل تم تجاهلهما", + "answerDiscarded_few": "{{count}} إجابات من المتصل تم تجاهلها", + "answerDiscarded_many": "{{count}} إجابة من المتصل تم تجاهلها", + "answerDiscarded_other": "{{count}} إجابة من المتصل تم تجاهلها", + "capturedVerbatim": "تم تسجيل {{count}} حرفيًا (أدناه)", + "handbackTurn_zero": "{{count}} دور أُجيب فيه بـ«تحدث أنت»", + "handbackTurn_one": "{{count}} دور أُجيب فيه بـ«تحدث أنت»", + "handbackTurn_two": "{{count}} دوران أُجيب فيهما بـ«تحدث أنت»", + "handbackTurn_few": "{{count}} أدوار أُجيب فيها بـ«تحدث أنت»", + "handbackTurn_many": "{{count}} دورًا أُجيب فيه بـ«تحدث أنت»", + "handbackTurn_other": "{{count}} دور أُجيب فيه بـ«تحدث أنت»", + "sentenceSuppressed_zero": "{{count}} جملة تم كبتها لأنها قيلت مسبقًا", + "sentenceSuppressed_one": "{{count}} جملة تم كبتها لأنها قيلت مسبقًا", + "sentenceSuppressed_two": "{{count}} جملتان تم كبتهما لأنهما قيلتا مسبقًا", + "sentenceSuppressed_few": "{{count}} جمل تم كبتها لأنها قيلت مسبقًا", + "sentenceSuppressed_many": "{{count}} جملة تم كبتها لأنها قيلت مسبقًا", + "sentenceSuppressed_other": "{{count}} جملة تم كبتها لأنها قيلت مسبقًا", + "saidAnyway": "قيل {{count}} على أي حال لكسر الحلقة", + "replyRestarted": "أُعيد بدء نفس الرد {{count}} مرة متتالية", + "repeatedLineSuppressed_zero": "{{count}} سطر متكرر تم كبته", + "repeatedLineSuppressed_one": "{{count}} سطر متكرر تم كبته", + "repeatedLineSuppressed_two": "{{count}} سطران متكرران تم كبتهما", + "repeatedLineSuppressed_few": "{{count}} أسطر متكررة تم كبتها", + "repeatedLineSuppressed_many": "{{count}} سطرًا متكررًا تم كبته", + "repeatedLineSuppressed_other": "{{count}} سطر متكرر تم كبته", + "botSilent": "لم يُصدر الوكيل أي صوت على الإطلاق", + "worstGap": "أسوأ فجوة {{secs}}", + "p95": "p95 {{secs}}", + "p50": "p50 {{secs}}", + "max": "الحد الأقصى {{secs}}", + "falseReask": "{{count}} من أصل {{total}} إعادة سؤال أُطلقت بعد وصول الإجابة بالفعل", + "machineScore": "النتيجة {{score}}", + "machineMarkers": "المؤشرات: {{markers}}", + "firstCallerAudio": "أول صوت للمتصل {{secs}}", + "longestCallerTurn": "أطول دور للمتصل {{secs}}", + "callerTurn_zero": "{{count}} دور للمتصل", + "callerTurn_one": "{{count}} دور للمتصل", + "callerTurn_two": "{{count}} دوران للمتصل", + "callerTurn_few": "{{count}} أدوار للمتصل", + "callerTurn_many": "{{count}} دورًا للمتصل", + "callerTurn_other": "{{count}} دور للمتصل", + "noBargeIns": "لا مقاطعات", + "bargeIn_zero": "{{count}} مقاطعة", + "bargeIn_one": "{{count}} مقاطعة", + "bargeIn_two": "{{count}} مقاطعتان", + "bargeIn_few": "{{count}} مقاطعات", + "bargeIn_many": "{{count}} مقاطعة", + "bargeIn_other": "{{count}} مقاطعة", + "sttReconnect_zero": "{{count}} إعادة اتصال للتعرف على الكلام", + "sttReconnect_one": "{{count}} إعادة اتصال للتعرف على الكلام", + "sttReconnect_two": "{{count}} إعادتا اتصال للتعرف على الكلام", + "sttReconnect_few": "{{count}} عمليات إعادة اتصال للتعرف على الكلام", + "sttReconnect_many": "{{count}} إعادة اتصال للتعرف على الكلام", + "sttReconnect_other": "{{count}} إعادة اتصال للتعرف على الكلام", + "transferFailed": "طُلب التحويل، ولم يُسجَّل مع المزوّد أبدًا", + "promptUnresolved": "غير محلول: {{fields}}" + }, + "faultBlock": { + "unrecognisedCode": "كود عطل غير معروف — تم الإبلاغ عن هذه المكالمة من قِبل بوت أحدث من هذه اللوحة." + }, + "deletedAnswers": { + "title": "إجابات المتصل المُتجاهَلة", + "notMeasured": "غير مقاس في هذه المكالمة", + "notMeasuredHint": "(لم يقارن البوت ما سمعه بما استلمه الوكيل)", + "none": "لا شيء — وصلت كل إجابة من المتصل إلى الوكيل.", + "reachedTranscript_zero": "وصلت {{count}} إجابة إلى النص لكنها لم تصل إلى الوكيل أبدًا، فلم يستطع أي شيء في المكالمة الرد عليها.", + "reachedTranscript_one": "وصلت {{count}} إجابة إلى النص لكنها لم تصل إلى الوكيل أبدًا، فلم يستطع أي شيء في المكالمة الرد عليها.", + "reachedTranscript_two": "وصلت {{count}} إجابتان إلى النص لكنهما لم تصلا إلى الوكيل أبدًا، فلم يستطع أي شيء في المكالمة الرد عليهما.", + "reachedTranscript_few": "وصلت {{count}} إجابات إلى النص لكنها لم تصل إلى الوكيل أبدًا، فلم يستطع أي شيء في المكالمة الرد عليها.", + "reachedTranscript_many": "وصلت {{count}} إجابة إلى النص لكنها لم تصل إلى الوكيل أبدًا، فلم يستطع أي شيء في المكالمة الرد عليها.", + "reachedTranscript_other": "وصلت {{count}} إجابة إلى النص لكنها لم تصل إلى الوكيل أبدًا، فلم يستطع أي شيء في المكالمة الرد عليها." + }, + "lostFragments": { + "summary_zero": "فُقد أيضًا {{count}} جزء من كلمة كان أصغر من أن يحمل إجابة (مقطع مما قاله المتصل ولم يُكمله). لم يُحتسب كإجابة محذوفة.", + "summary_one": "فُقد أيضًا {{count}} جزء من كلمة كان أصغر من أن يحمل إجابة (مقطع مما قاله المتصل ولم يُكمله). لم يُحتسب كإجابة محذوفة.", + "summary_two": "فُقد أيضًا {{count}} جزآن من كلمة كانا أصغر من أن يحملا إجابة (مقطعان مما قاله المتصل ولم يُكملهما). لم يُحتسبا كإجابة محذوفة.", + "summary_few": "فُقدت أيضًا {{count}} أجزاء من كلمات كانت أصغر من أن تحمل إجابة (مقاطع مما قاله المتصل ولم يُكملها). لم تُحتسب كإجابات محذوفة.", + "summary_many": "فُقد أيضًا {{count}} جزءًا من كلمة كان أصغر من أن يحمل إجابة (مقاطع مما قاله المتصل ولم يُكملها). لم يُحتسب كإجابة محذوفة.", + "summary_other": "فُقد أيضًا {{count}} جزء من كلمة كان أصغر من أن يحمل إجابة (مقاطع مما قاله المتصل ولم يُكملها). لم يُحتسب كإجابة محذوفة." + }, + "rawJson": { + "summary": "JSON خام للتشخيصات", + "copyButton": "نسخ للمهندس", + "copiedToast": "تم نسخ JSON التشخيصات", + "copyErrorToast": "تعذر النسخ — حدد النص وانسخه يدويًا." + }, + "actionStatus": { + "queued": "في قائمة الانتظار", + "sending": "جارٍ الإرسال", + "sent": "تم الإرسال", + "failed": "فشل", + "expiredUnsent": "انتهت المهلة دون إرسال", + "unknown": "غير معروف" + }, + "sheet": { + "title": "سلامة المكالمة", + "description": "التشريح التقني لهذه المكالمة. تفاصيل تصحيح داخلية — لا تُعرض على العميل المحتمل.", + "promisedTitle": "ما وعدت به هذه المكالمة", + "bookMeeting": "حجز اجتماع", + "email": "البريد الإلكتروني", + "whatsapp": "واتساب", + "queuedHint": "تخرج عمليات الإرسال المُعلَّقة خلال دقائق قليلة. أي فشل هنا هو سبب المزوّد نفسه، دون تغيير.", + "checking": "جارٍ الفحص…", + "loadingDiagnostics": "جارٍ تحميل التشخيصات…", + "leadFallback": "العميل المحتمل", + "callIdLine": "{{lead}} · مكالمة {{id}}", + "rulesVersionSuffix": " · القواعد v{{version}}", + "whatHappenedTitle": "ماذا حدث", + "withheldHint": "الأرقام وراء هذا الحكم — الأزمنة والعدادات وإجابات المتصل المُتجاهَلة — محجوبة عن دورك: فهي تحتوي على كلام المتصل الحرفي. تخضع لنفس إعداد أرقام الهاتف غير المُخفاة، ضمن الإعدادات ← إعدادات العرض ← أرقام هاتف سجل المكالمات.", + "noReportTitle": "لا يوجد تقرير تقني لهذه المكالمة", + "noReportErrorHint": "تعذر تحميل تفاصيل المكالمة، لذا لا توجد تشخيصات متاحة.", + "noReportEmptyHint": "تُسجَّل التشخيصات بواسطة وكيل الصوت بالذكاء الاصطناعي ابتداءً من القواعد v1. المكالمات التي جرت قبل إطلاقها، والمكالمات البشرية، لا تحتوي على شيء لعرضه هنا.", + "noFaultsDetected": "لم يتم اكتشاف أي أعطال", + "noFaultsFired": "لم يُطلَق أي عطل. بقيت كل قاعدة في الإصدار v{{version}} ضمن الحد المسموح.", + "diagBuildFailed": "فشل بناء تشخيصات البوت نفسه ({{error}}) — قد تكون الأرقام أدناه غير مكتملة.", + "timingsTitle": "الأزمنة", + "llmTtfbP50": "زمن أول استجابة للنموذج اللغوي p50", + "llmTtfbP95": "زمن أول استجابة للنموذج اللغوي p95", + "ttsTtfbP50": "زمن أول استجابة لتوليف الصوت p50", + "ttsTtfbP95": "زمن أول استجابة لتوليف الصوت p95", + "sttTtfbP50": "زمن أول استجابة للتعرف على الكلام p50", + "sttTtfbP95": "زمن أول استجابة للتعرف على الكلام p95", + "deadAirP95": "الصمت p95", + "worstDeadAir": "أطول صمت", + "greetPath": "مسار الترحيب", + "greetDelay": "تأخير الترحيب", + "setup": "الإعداد", + "notReportedEmpty": "غير مُبلَّغ عنه", + "speechCacheTitle": "ذاكرة تخزين الصوت المؤقتة", + "servedFromCache": "قُدِّم من الذاكرة المؤقتة", + "synthesized": "تم توليفه", + "hitRate": "معدل الإصابة", + "charactersSaved": "الأحرف الموفَّرة", + "audioReplayed": "الصوت المُعاد تشغيله", + "savedOnThisCall": "الموفَّر في هذه المكالمة", + "notPriced": "غير مُسعَّر", + "signalsTitle": "الإشارات", + "callerTurns": "أدوار المتصل", + "agentTurns": "أدوار الوكيل", + "bargeIns": "المقاطعات", + "nudges": "التنبيهات", + "repliesPlayed": "الردود المُشغَّلة", + "repliesPlayedValue": "{{played}} من {{total}}", + "ttsStalls": "توقفات توليف الصوت", + "ttsWedges": "تعليقات توليف الصوت", + "sttReconnects": "إعادات اتصال التعرف على الكلام", + "endedBy": "انتهت بسبب", + "machineScore": "درجة جهاز الرد الآلي {{score}}", + "machineMarkersSuffix": "· المؤشرات: {{markers}}" + }, + "endedBy": { + "idleHangup": "إنهاء بسبب الخمول", + "turnCap": "حد الأدوار", + "normalEnd": "نهاية طبيعية" + }, + "stat": { + "notMeasured": "غير مقاس" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCallIntelligenceTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCallIntelligenceTab.json new file mode 100644 index 0000000000..958e17f702 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCallIntelligenceTab.json @@ -0,0 +1,56 @@ +{ + "kpi": { + "callsAnalyzed": { + "label": "المكالمات التي تم تحليلها" + }, + "avgCallerRating": { + "label": "متوسط تقييم المتصل", + "sub": "مدى نجاح الفريق في تحقيق هدفه" + }, + "avgOutcomeRating": { + "label": "متوسط تقييم النتيجة", + "sub": "كيف كانت المكالمة بالنسبة للعميل المحتمل" + }, + "positiveSentiment": { + "label": "الشعور الإيجابي", + "sub_zero": "{{positive}} من {{count}} عميل محتمل", + "sub_one": "{{positive}} من {{count}} عميل محتمل واحد", + "sub_two": "{{positive}} من {{count}} عميلين محتملين", + "sub_few": "{{positive}} من {{count}} عملاء محتملين", + "sub_many": "{{positive}} من {{count}} عميلاً محتملاً", + "sub_other": "{{positive}} من {{count}} عميل محتمل" + } + }, + "breakdown": { + "callOutcomesTitle": "نتائج المكالمات", + "leadSentimentTitle": "شعور العملاء المحتملين" + }, + "section": { + "teamCallQualityTitle": "الفريق — جودة المكالمات" + }, + "table": { + "counsellor": "المستشار", + "calls": "المكالمات", + "avgCaller": "متوسط المتصل", + "avgOutcome": "متوسط النتيجة" + }, + "emptyHint": { + "noAnalyzedCalls": "لا توجد مكالمات تم تحليلها ضمن هذا النطاق. فعّل ذكاء إدارة علاقات العملاء (CRM Intelligence) وحلّل المكالمات لملء هذا التقرير.", + "noPerCounsellorData": "لا توجد بيانات لكل مستشار ضمن هذا النطاق." + }, + "status": { + "connectedPositive": "إيجابية", + "connectedNeutral": "محايدة", + "connectedNegative": "سلبية", + "callbackRequested": "طُلب معاودة الاتصال", + "notInterested": "غير مهتم", + "informationOnly": "استعلام فقط", + "noClearOutcome": "لا نتيجة واضحة", + "wrongNumber": "رقم خاطئ" + }, + "sentiment": { + "positive": "إيجابي", + "neutral": "محايد", + "negative": "سلبي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCallLogPage.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCallLogPage.json new file mode 100644 index 0000000000..70ad254d37 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCallLogPage.json @@ -0,0 +1,24 @@ +{ + "header": { + "navTitle": "سجل المكالمات", + "title": "سجل المكالمات", + "subtitle": "كل مكالمة عبر فريقك — بالذكاء الاصطناعي والبشري، واردة وصادرة، لكل المزوّدين. صفِّ، صنِّف وصدِّر.", + "callingSettings": "إعدادات المكالمات", + "refresh": "تحديث" + }, + "filters": { + "presetLabel": "{{days}} يوم", + "from": "من", + "to": "إلى", + "apply": "تطبيق", + "reset": "إعادة تعيين", + "noInstitute": "اختر مؤسسة لعرض سجل المكالمات." + }, + "counsellorPicker": { + "ariaLabel": "التصفية حسب المرشد", + "allCounsellors": "كل المرشدين" + }, + "callIntelligence": { + "heading": "ذكاء المكالمات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCallLogTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCallLogTab.json new file mode 100644 index 0000000000..db00736a78 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCallLogTab.json @@ -0,0 +1,126 @@ +{ + "kpi": { + "totalCalls": "إجمالي المكالمات", + "connected": "متصلة", + "talkTime": "مدة المكالمة", + "talkTimeUnit": "س : د", + "uniqueLeads": "العملاء المحتملون الفريدون", + "aiVsHuman": "الذكاء الاصطناعي مقابل البشر", + "aiVsHumanUnit": "الذكاء الاصطناعي / البشر" + }, + "chips": { + "allCalls": "كل المكالمات", + "missedInbound": "مكالمات واردة فائتة", + "callbacksDue": "معاودة اتصال مستحقة" + }, + "filters": { + "leadName": "اسم العميل المحتمل", + "leadNamePlaceholder": "البحث بالاسم", + "number": "الرقم", + "numberPlaceholder": "أرقام الهاتف", + "direction": "الاتجاه", + "directionOutbound": "صادرة", + "directionInbound": "واردة", + "type": "النوع", + "typeHuman": "بشري", + "typeAi": "ذكاء اصطناعي", + "provider": "المزوّد", + "status": "الحالة", + "disposition": "النتيجة", + "all": "الكل", + "searchPlaceholder": "البحث عن {{label}}…" + }, + "providers": { + "exotel": "Exotel", + "aavtaarAi": "الذكاء الاصطناعي (Aavtaar)", + "airtel": "Airtel" + }, + "table": { + "heading": "المكالمات", + "empty": "لا توجد مكالمات مطابقة لهذه الفلاتر.", + "columns": { + "time": "الوقت", + "lead": "العميل المحتمل", + "direction": "الاتجاه", + "type": "النوع", + "status": "الحالة", + "health": "الحالة الفنية", + "healthTooltip": "التقييم الفني من وكيل المكالمات الذكي", + "duration": "المدة", + "counsellor": "المستشار", + "disposition": "النتيجة", + "recording": "التسجيل", + "ai": "ذكاء اصطناعي" + }, + "openLeadProfile": "فتح الملف الشخصي للعميل المحتمل", + "viewLead": "عرض العميل المحتمل", + "ivrOptionChosen": "خيار الرد الآلي المُختار" + }, + "directionBadge": { + "in": "وارد", + "out": "صادر" + }, + "typeBadge": { + "ai": "ذكاء اصطناعي", + "human": "بشري" + }, + "statusDetail": { + "whyEnded": "لماذا انتهت هذه المكالمة على هذا النحو؟", + "loading": "جارٍ تحميل التفاصيل…", + "reasonPrefix": "السبب: {{reason}}", + "noDetail": "لا تتوفر تفاصيل إضافية.", + "reason": "السبب", + "provider": "المزوّد", + "attempted": "وقت المحاولة", + "answered": "وقت الرد", + "duration": "المدة", + "cost": "التكلفة", + "viewRawResponse": "عرض استجابة المزوّد الخام" + }, + "disposition": { + "edit": "تعديل", + "set": "تحديد" + }, + "recording": { + "loading": "جارٍ التحميل…", + "play": "تشغيل" + }, + "export": { + "csv": "CSV", + "excel": "Excel", + "successToast": "تم تصدير {{format}}", + "errorToast": "فشل التصدير. يُرجى المحاولة مرة أخرى." + }, + "dispositionDialog": { + "heading": "تحديد نتيجة المكالمة", + "save": "حفظ", + "outcome": "النتيجة", + "noOutcomes": "لا توجد نتائج مُهيَّأة.", + "mapsToStatus": "→ الحالة", + "callbackAt": "موعد المعاودة", + "notes": "ملاحظات (اختياري)", + "notesPlaceholder": "أضف ملاحظة", + "savedStatusUpdated": "تم الحفظ — تم تحديث حالة العميل المحتمل", + "saved": "تم حفظ النتيجة", + "saveError": "تعذّر حفظ النتيجة." + }, + "intelligenceDialog": { + "heading": "نص المكالمة وذكاء المكالمة الاصطناعي", + "analyzeHeading": "هل تريد تحليل هذه المكالمة؟", + "analyzeConfirm": "تحليل" + }, + "empty": { + "pickInstitute": "اختر مؤسسة لعرض سجل المكالمات." + }, + "deployPending": { + "title": "لوحة المكالمات غير متاحة على هذا الخادم بعد", + "description": "لم يتم نشر نقاط نهاية لوحة الاتصالات في هذه البيئة بعد. يُرجى التحقق لاحقًا بعد الإصدار التالي للخادم." + }, + "error": { + "loadFailed": "تعذّر تحميل سجل المكالمات." + }, + "common": { + "retry": "إعادة المحاولة", + "leadFallback": "عميل محتمل" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCallingTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCallingTab.json new file mode 100644 index 0000000000..57994dbbeb --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCallingTab.json @@ -0,0 +1,104 @@ +{ + "empty": { + "pickInstitute": "اختر مؤسسة لعرض تقارير المكالمات.", + "noCallsInRange": "لا توجد مكالمات في هذا النطاق الزمني.", + "noCounsellorActivity": "لا يوجد نشاط مكالمات للمستشارين في هذا النطاق الزمني." + }, + "deployPending": { + "title": "تقارير المكالمات غير متاحة على هذا الخادم بعد", + "description": "لم يتم نشر نقاط نهاية إعداد التقارير في هذه البيئة بعد. يُرجى التحقق لاحقًا بعد الإصدار التالي للخادم." + }, + "error": { + "loadFailed": "تعذّر تحميل هذا التقرير.", + "retry": "إعادة المحاولة" + }, + "kpi": { + "totalDials": { + "label": "إجمالي محاولات الاتصال", + "sub_zero": "خلال {{count}} أيام", + "sub_one": "خلال {{count}} يوم واحد", + "sub_two": "خلال {{count}} يومين", + "sub_few": "خلال {{count}} أيام", + "sub_many": "خلال {{count}} يومًا", + "sub_other": "خلال {{count}} يوم" + }, + "connected": { + "label": "متصلة", + "sub_zero": "من أصل {{dials}} اتصالات", + "sub_one": "من أصل {{dials}} اتصال واحد", + "sub_two": "من أصل {{dials}} اتصالين", + "sub_few": "من أصل {{dials}} اتصالات", + "sub_many": "من أصل {{dials}} اتصالًا", + "sub_other": "من أصل {{dials}} اتصال" + }, + "connectRate": { + "label": "معدل الاتصال", + "sub": "متصلة ÷ محاولات الاتصال" + }, + "talkTime": { + "label": "مدة المكالمة", + "sub": "ساعات : دقائق" + } + }, + "dailyChart": { + "heading": "نشاط المكالمات اليومي", + "legendDials": "الاتصالات", + "legendConnected": "متصلة", + "ariaLabel": "الاتصالات والمكالمات المتصلة يوميًا", + "exportCsv": "تصدير CSV" + }, + "counsellorTable": { + "heading": "أداء مكالمات المستشارين", + "exportCsv": "تصدير CSV", + "headers": { + "counsellor": "المستشار", + "dials": "الاتصالات", + "connected": "متصلة", + "connectRate": "نسبة الاتصال %", + "talkTime": "مدة المكالمة", + "avgCall": "متوسط المكالمة", + "outcomes": "النتائج" + } + }, + "heatmap": { + "heading": "خريطة أوقات الاتصال الحرارية", + "timezoneNote": "اليوم × الساعة، حسب المنطقة الزمنية لتقارير مؤسستك", + "legendFewer": "أقل", + "legendMore": "أكثر", + "days": { + "mon": "إثنين", + "tue": "ثلاثاء", + "wed": "أربعاء", + "thu": "خميس", + "fri": "جمعة", + "sat": "سبت", + "sun": "أحد" + }, + "tooltip": { + "noDials": "{{day}} {{hour}} — لا اتصالات", + "withDials_zero": "{{day}} {{hour}} — {{count}} اتصالات · {{pct}}٪ متصلة", + "withDials_one": "{{day}} {{hour}} — {{count}} اتصال واحد · {{pct}}٪ متصلة", + "withDials_two": "{{day}} {{hour}} — {{count}} اتصالين · {{pct}}٪ متصلة", + "withDials_few": "{{day}} {{hour}} — {{count}} اتصالات · {{pct}}٪ متصلة", + "withDials_many": "{{day}} {{hour}} — {{count}} اتصالًا · {{pct}}٪ متصلة", + "withDials_other": "{{day}} {{hour}} — {{count}} اتصال · {{pct}}٪ متصلة" + } + }, + "csv": { + "daily": { + "date": "التاريخ", + "dials": "الاتصالات", + "connected": "متصلة", + "connectRate": "معدل الاتصال (%)", + "talkTime": "مدة المكالمة (ثوانٍ)" + }, + "counsellor": { + "counsellor": "المستشار", + "dials": "الاتصالات", + "connected": "متصلة", + "connectRate": "معدل الاتصال (%)", + "talkTime": "مدة المكالمة (ثوانٍ)", + "avgCall": "متوسط المكالمة (ثوانٍ)" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignCustomFieldsCard.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignCustomFieldsCard.json new file mode 100644 index 0000000000..7243b0df39 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignCustomFieldsCard.json @@ -0,0 +1,17 @@ +{ + "header": { + "title": "تخصيص نموذج الحملة", + "description": "قم بتكوين الحقول التي سيملؤها الطلاب. يتم تحميل الحقول من الإعدادات تلقائيًا." + }, + "sharedFieldNotice_zero": "هذا الحقل غير مُشترك مع أي نموذج آخر{{names}} ({{count}}). يتم تخزين نوعه وتسميته وخياراته مرة واحدة فقط، لذا فإن تغييرها هنا يغيّرها هناك أيضًا.", + "sharedFieldNotice_one": "هذا الحقل مُشترك مع نموذج آخر واحد{{names}} ({{count}}). يتم تخزين نوعه وتسميته وخياراته مرة واحدة فقط، لذا فإن تغييرها هنا يغيّرها هناك أيضًا.", + "sharedFieldNotice_two": "هذا الحقل مُشترك مع نموذجين آخرين{{names}} ({{count}}). يتم تخزين نوعه وتسميته وخياراته مرة واحدة فقط، لذا فإن تغييرها هنا يغيّرها هناك أيضًا.", + "sharedFieldNotice_few": "هذا الحقل مُشترك مع {{count}} نماذج أخرى{{names}}. يتم تخزين نوعه وتسميته وخياراته مرة واحدة فقط، لذا فإن تغييرها هنا يغيّرها هناك أيضًا.", + "sharedFieldNotice_many": "هذا الحقل مُشترك مع {{count}} نموذجًا آخر{{names}}. يتم تخزين نوعه وتسميته وخياراته مرة واحدة فقط، لذا فإن تغييرها هنا يغيّرها هناك أيضًا.", + "sharedFieldNotice_other": "هذا الحقل مُشترك مع {{count}} نموذج آخر{{names}}. يتم تخزين نوعه وتسميته وخياراته مرة واحدة فقط، لذا فإن تغييرها هنا يغيّرها هناك أيضًا.", + "actions": { + "addPhoneNumber": "إضافة رقم الهاتف", + "addCustomField": "إضافة حقل مخصص", + "previewForm": "معاينة نموذج التسجيل" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignLink.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignLink.json new file mode 100644 index 0000000000..ab42cee70e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignLink.json @@ -0,0 +1,6 @@ +{ + "copy": "نسخ", + "copied": "تم النسخ", + "copyLinkAriaLabel": "نسخ الرابط", + "copiedAriaLabel": "تم النسخ" +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignProgressDialog.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignProgressDialog.json new file mode 100644 index 0000000000..adbdf17d0f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignProgressDialog.json @@ -0,0 +1,47 @@ +{ + "dialog": { + "heading": "مكالمات الذكاء الاصطناعي قيد التنفيذ" + }, + "status": { + "completed": "مكتملة", + "noAnswer": "لا يوجد رد", + "busy": "مشغول", + "failed": "فشلت", + "cancelled": "أُلغيت", + "onCall": "قيد المكالمة", + "ringing": "يرن الهاتف", + "dialing": "جارٍ الاتصال" + }, + "header": { + "allFinished_zero": "انتهت جميع المكالمات ({{count}})", + "allFinished_one": "انتهت المكالمة الوحيدة ({{count}})", + "allFinished_two": "انتهت المكالمتان ({{count}})", + "allFinished_few": "انتهت جميع المكالمات الـ {{count}}", + "allFinished_many": "انتهت جميع المكالمات الـ {{count}}", + "allFinished_other": "انتهت جميع المكالمات الـ {{count}}", + "progress": "اكتمل {{done}} من أصل {{total}}", + "liveSuffix_zero": " · {{count}} مكالمة جارية", + "liveSuffix_one": " · مكالمة {{count}} جارية", + "liveSuffix_two": " · مكالمتان ({{count}}) جاريتان", + "liveSuffix_few": " · {{count}} مكالمات جارية", + "liveSuffix_many": " · {{count}} مكالمة جارية", + "liveSuffix_other": " · {{count}} مكالمة جارية", + "parallelLabel_zero": "{{count}} في المرة الواحدة", + "parallelLabel_one": "مكالمة {{count}} في المرة", + "parallelLabel_two": "مكالمتان ({{count}}) في المرة", + "parallelLabel_few": "{{count}} في المرة الواحدة", + "parallelLabel_many": "{{count}} في المرة الواحدة", + "parallelLabel_other": "{{count}} في المرة الواحدة" + }, + "list": { + "dialingFirst": "جارٍ الاتصال بالعميل المحتمل الأول…", + "defaultLeadName": "عميل محتمل", + "duration": "{{minutes}} د {{seconds}} ث" + }, + "error": { + "pollPaused": "تم إيقاف التحديثات المباشرة مؤقتًا (تتم إعادة المحاولة) — المكالمات مستمرة في العمل من جهة الخادم." + }, + "footer": { + "backgroundNotice": "يعمل هذا في الخلفية — إغلاق هذه النافذة لا يوقف المكالمات. تتم نتيجة كل مكالمة وتعيين المستشار تلقائيًا بعد كل مكالمة." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignTypeDropdown.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignTypeDropdown.json new file mode 100644 index 0000000000..fbed23a27f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignTypeDropdown.json @@ -0,0 +1,10 @@ +{ + "placeholder": "اختر نوع الحملة", + "optionWebsite": "الموقع الإلكتروني", + "optionGoogleAds": "إعلانات جوجل", + "optionSocialMedia": "وسائل التواصل الاجتماعي", + "addCustomType": "إضافة نوع استفسار مخصص", + "customInputPlaceholder": "أدخل نوع الحملة", + "cancel": "إلغاء", + "save": "حفظ" +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignUsersAdd.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignUsersAdd.json new file mode 100644 index 0000000000..1875dbd4f4 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignUsersAdd.json @@ -0,0 +1,36 @@ +{ + "schema": { + "campaignIdRequired": "معرّف الحملة مطلوب" + }, + "navHeading": "إضافة رد", + "helmet": { + "title": "إضافة رد - {{name}}", + "description": "أضف ردًا نيابةً عن أحد المستجيبين." + }, + "backButton": "العودة إلى مستخدمي {{audienceTerm}}", + "header": { + "description": "املأ التفاصيل أدناه لإرسال رد نيابةً عن أحد المستجيبين." + }, + "emptyState": { + "title": "لا توجد حقول نموذج مُهيّأة لهذه {{audienceTerm}}.", + "hint": "يرجى إضافة حقول مخصصة أولاً لبدء جمع الردود." + }, + "form": { + "sectionTitle": "تفاصيل المستجيب", + "requiredFields": "الحقول المطلوبة", + "cancel": "إلغاء", + "submitting": "جارٍ الإرسال...", + "submit": "إرسال الرد" + }, + "validation": { + "missingFields": "يرجى ملء الحقول المطلوبة: {{fields}}" + }, + "toast": { + "success": "تم إرسال الرد بنجاح!", + "genericError": "فشل إرسال الرد. يرجى المحاولة مرة أخرى." + }, + "audienceFallbackName": "هذه {{audienceTerm}}", + "footer": { + "responsesAppearUnder": "تظهر الردود ضمن {{audienceName}} في قائمة {{audienceTerm}} الخاصة بك." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignUsersColumns.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignUsersColumns.json new file mode 100644 index 0000000000..c6a9fa8381 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignUsersColumns.json @@ -0,0 +1,17 @@ +{ + "columns": { + "serialNumber": "الرقم التسلسلي", + "details": "التفاصيل", + "status": "الحالة", + "reachOutIn": "التواصل خلال", + "followUpAt": "المتابعة في", + "counsellor": "المستشار", + "activityNotes": "النشاط والملاحظات", + "submittedOn": "تاريخ الإرسال" + }, + "actions": { + "reassign": "إعادة التعيين", + "assign": "تعيين", + "deleteLead": "حذف العميل المحتمل" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignUsersIndex.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignUsersIndex.json new file mode 100644 index 0000000000..055c5945d2 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignUsersIndex.json @@ -0,0 +1,12 @@ +{ + "schema": { + "campaignIdRequired": "معرّف الحملة مطلوب" + }, + "navHeading": "مستخدمو {{audienceTerm}}", + "helmet": { + "title": "مستخدمو {{audienceTerm}}", + "description": "عرض المستخدمين المسجلين في {{audienceTerm}}." + }, + "backButton": "العودة إلى {{audienceTermPlural}}", + "missingCampaignId": "معرّف الحملة مطلوب" +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignUsersTable.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignUsersTable.json new file mode 100644 index 0000000000..21ddd62aec --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCampaignUsersTable.json @@ -0,0 +1,152 @@ +{ + "heading": { + "fallbackTitle": "قائمة الجمهور", + "addResponse": "إضافة استجابة", + "loadingRespondents": "جارٍ تحميل المستجيبين…", + "respondentsCount_zero": "{{formattedCount}} مستجيب", + "respondentsCount_one": "{{formattedCount}} مستجيب", + "respondentsCount_two": "{{formattedCount}} مستجيبان", + "respondentsCount_few": "{{formattedCount}} مستجيبين", + "respondentsCount_many": "{{formattedCount}} مستجيبًا", + "respondentsCount_other": "{{formattedCount}} مستجيب" + }, + "filters": { + "tiers": { + "label": "كل المستويات", + "hot": "ساخن", + "warm": "دافئ", + "cold": "بارد" + }, + "leadStatus": { + "label": "كل العملاء المحتملين", + "active": "نشط (غير مسجَّل)", + "converted": "مسجَّل / تم التحويل" + }, + "sla": { + "label": "كل حالات اتفاقية مستوى الخدمة", + "anyOverdue": "أي حالة متأخرة", + "reachOutOverdue": "التواصل متأخر", + "reachOutDueSoon": "التواصل مستحق قريبًا", + "followUpDue": "المتابعة مستحقة", + "followUpOverdue": "المتابعة متأخرة" + }, + "callHistory": { + "placeholder": "سجل المكالمات", + "notCalled": "لم يتم الاتصال", + "called": "تم الاتصال (أي عدد)", + "calledOnce": "تم الاتصال مرة واحدة", + "calledTwicePlus": "تم الاتصال مرتين أو أكثر", + "aiCalled": "تم الاتصال بواسطة الذكاء الاصطناعي", + "manualCalled": "تم الاتصال يدويًا" + }, + "moreFilters": "المزيد من الفلاتر", + "dateFrom": "من", + "dateTo": "إلى", + "applyDates": "تطبيق التواريخ", + "clearAll": "مسح الكل" + }, + "chips": { + "search": "بحث: {{query}}", + "datePlaceholder": "…", + "date": "التاريخ: {{from}} ← {{to}}", + "tier": "المستوى: {{values}}", + "status": "الحالة: {{values}}", + "statusActive": "نشط", + "statusConverted": "تم التحويل", + "sla": "اتفاقية مستوى الخدمة: {{values}}", + "counsellor": "المستشار: {{values}}", + "counsellorUnassigned": "غير معيَّن", + "counsellorSelected": "محدَّد", + "customField": "{{fieldName}}: {{value}}", + "customFieldFallback": "حقل", + "removeAriaLabel": "إزالة {{label}}" + }, + "toolbar": { + "sendMessage": "إرسال رسالة", + "importCsv": "استيراد CSV", + "exporting": "جارٍ التصدير…", + "export": "تصدير" + }, + "search": { + "placeholder": "البحث عن العملاء المحتملين", + "ariaLabel": "البحث عن العملاء المحتملين", + "loadingResults": "جارٍ تحميل النتائج…", + "resultsCount_zero": "عرض {{shown}} من أصل {{total}} نتيجة", + "resultsCount_one": "عرض {{shown}} من أصل {{total}} نتيجة", + "resultsCount_two": "عرض {{shown}} من أصل {{total}} نتيجتين", + "resultsCount_few": "عرض {{shown}} من أصل {{total}} نتائج", + "resultsCount_many": "عرض {{shown}} من أصل {{total}} نتيجة", + "resultsCount_other": "عرض {{shown}} من أصل {{total}} نتيجة" + }, + "bulkToolbar": { + "selectedCount_zero": "{{count}} محدد", + "selectedCount_one": "{{count}} محدد", + "selectedCount_two": "{{count}} محدَّدان", + "selectedCount_few": "{{count}} محددة", + "selectedCount_many": "{{count}} محددًا", + "selectedCount_other": "{{count}} محدد", + "selecting": "جارٍ التحديد…", + "selectAll": "تحديد الكل ({{count}})", + "clear": "مسح", + "bulkActions": "إجراءات جماعية", + "assignLeads": "تعيين العملاء المحتملين", + "unassignLeads": "إلغاء تعيين العملاء المحتملين", + "deleteLeads": "حذف العملاء المحتملين" + }, + "emptyState": { + "errorTitle": "تعذَّر تحميل المستجيبين", + "errorDescription": "حدث خطأ ما أثناء جلب مستخدمي الحملة. حاول مرة أخرى.", + "noResultsTitle": "لا يوجد مستجيبون يطابقون هذه الفلاتر", + "noResultsDescription": "جرِّب مسح الفلاتر لرؤية المزيد من النتائج.", + "noDataTitle": "لا يوجد مستجيبون حتى الآن", + "noDataDescription": "عندما يملأ الأشخاص نموذج الجمهور هذا، سيظهرون هنا." + }, + "exportColumns": { + "leadId": "معرّف العميل المحتمل", + "submittedAt": "تاريخ الإرسال", + "name": "الاسم", + "email": "البريد الإلكتروني", + "mobile": "رقم الجوال", + "leadStatus": "حالة العميل المحتمل", + "counsellor": "المستشار", + "activityNotes": "النشاط والملاحظات", + "notesCount": "عدد الملاحظات", + "leadJourney": "رحلة العميل المحتمل (التصرف والملاحظات)" + }, + "csv": { + "headers": { + "leadId": "معرّف العميل المحتمل", + "submittedAt": "تاريخ الإرسال", + "name": "الاسم", + "email": "البريد الإلكتروني", + "mobile": "رقم الجوال", + "leadStatus": "حالة العميل المحتمل", + "counsellor": "المستشار", + "activityNotes": "النشاط والملاحظات", + "notesCount": "عدد الملاحظات", + "leadJourney": "رحلة العميل المحتمل (التصرف والملاحظات)" + }, + "noteFallbackLabel": "ملاحظة", + "updatedByLabel": "تم التحديث بواسطة", + "dateLabel": "التاريخ" + }, + "toasts": { + "selectAllFailed": "فشل تحديد جميع العملاء المحتملين", + "noLeadsToExport": "لا يوجد عملاء محتملون للتصدير", + "exportStarting": "جارٍ بدء التصدير…", + "noDataToExport": "لا توجد بيانات للتصدير", + "exportSuccess_zero": "تم تصدير {{count}} عميل محتمل", + "exportSuccess_one": "تم تصدير {{count}} عميل محتمل", + "exportSuccess_two": "تم تصدير {{count}} عميلين محتملين", + "exportSuccess_few": "تم تصدير {{count}} عملاء محتملين", + "exportSuccess_many": "تم تصدير {{count}} عميلًا محتملًا", + "exportSuccess_other": "تم تصدير {{count}} عميل محتمل", + "exportFailed": "فشل تصدير العملاء المحتملين" + }, + "campaignFields": { + "optedOutFrom": "استبعد نفسه من" + }, + "dialogs": { + "campaignFallbackName": "الحملة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCohortTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCohortTab.json new file mode 100644 index 0000000000..d49e149cef --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCohortTab.json @@ -0,0 +1,31 @@ +{ + "section": { + "title": "تحليل الأفواج" + }, + "emptyHint": "لا توجد أفواج اكتساب في هذا النطاق. جرّب نطاق تاريخ أوسع.", + "csv": { + "headers": { + "cohort": "الفوج", + "leads": "العملاء المحتملون", + "converted": "تم التحويل", + "convPct": "نسبة التحويل %", + "revenue": "الإيرادات", + "avgDealValue": "متوسط قيمة الصفقة", + "revenuePerLead": "الإيرادات / عميل محتمل", + "medianDaysToConvert": "متوسط الأيام حتى التحويل" + } + }, + "table": { + "headers": { + "cohort": "الفوج", + "leads": "العملاء المحتملون", + "converted": "تم التحويل", + "convPct": "نسبة التحويل %", + "revenue": "الإيرادات", + "avgDeal": "متوسط الصفقة", + "revPerLead": "الإيراد / عميل محتمل", + "medianDays": "متوسط الأيام" + } + }, + "footnote": "كل فوج هو العملاء المحتملون المكتسَبون في ذلك الشهر؛ الإيرادات هي إجمالي الإيرادات المُحصَّلة على مدى العمر من أولئك الذين تم تحويلهم." +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCommunicationHistory.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCommunicationHistory.json new file mode 100644 index 0000000000..7cd6eef791 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCommunicationHistory.json @@ -0,0 +1,34 @@ +{ + "loading": "جارٍ تحميل سجل التواصل...", + "emptyState": "لم يتم إرسال أي رسائل إلى هذا الجمهور بعد.", + "heading": "سجل التواصل", + "noValuePlaceholder": "—", + "failedCount_zero": "لم تفشل أي رسالة ({{count}})", + "failedCount_one": "فشلت رسالة واحدة ({{count}})", + "failedCount_two": "فشلت رسالتان ({{count}})", + "failedCount_few": "فشلت {{count}} رسائل", + "failedCount_many": "فشلت {{count}} رسالة", + "failedCount_other": "فشلت {{count}} رسالة", + "pageOf": "الصفحة {{current}} من {{total}}", + "table": { + "channel": "القناة", + "templateSubject": "القالب / الموضوع", + "recipients": "المستلمون", + "result": "النتيجة", + "status": "الحالة", + "sentAt": "أُرسل في" + }, + "channels": { + "whatsapp": "واتساب", + "email": "البريد الإلكتروني", + "push": "إشعار", + "systemAlert": "تنبيه النظام" + }, + "statuses": { + "completed": "مكتمل", + "processing": "قيد المعالجة", + "partial": "جزئي", + "failed": "فشل", + "pending": "قيد الانتظار" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerConfigureAudienceWorkflowDialog.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerConfigureAudienceWorkflowDialog.json new file mode 100644 index 0000000000..f02d1da54d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerConfigureAudienceWorkflowDialog.json @@ -0,0 +1,73 @@ +{ + "dialog": { + "title": "تكوين سير العمل", + "description": "قم بإعداد بريد إلكتروني تلقائي لـ “{{audienceName}}”. للتدفقات الأكثر تعقيدًا (التأخيرات، الشروط، خطوات متعددة)، استخدم أداة إنشاء سير العمل الكاملة." + }, + "kind": { + "label": "نوع سير العمل", + "confirmation": { + "title": "تأكيد", + "description": "يُرسل فورًا عند تقديم عميل محتمل للنموذج." + }, + "followup": { + "title": "متابعة", + "description": "يُرسل بعد N يوم من تقديم عميل محتمل للنموذج." + } + }, + "fields": { + "followupDays": { + "label": "كم عدد الأيام بعد التقديم لإرسال المتابعة؟", + "helper": "يعمل سير العمل يوميًا في الساعة 9:00 صباحًا بتوقيت الهند ويرسل بريدًا إلكترونيًا للعملاء المحتملين الذين يكون تاريخ تقديمهم قبل هذا العدد من الأيام بالضبط. لإرسال البريد في عدة أيام (مثل اليوم 3 واليوم 7)، أنشئ سير عمل منفصلًا لكل منها." + }, + "name": { + "label": "اسم سير العمل", + "placeholder": "مثال: بريد ترحيبي وتأكيد للعملاء المحتملين الجدد" + }, + "description": { + "label": "الوصف", + "optional": "(اختياري)", + "placeholder": "ما الذي يقوم به سير العمل هذا؟" + }, + "template": { + "label": "قالب البريد الإلكتروني", + "loading": "جارٍ تحميل القوالب...", + "placeholder": "-- اختر قالبًا --", + "noneFound": "لم يتم العثور على قوالب بريد إلكتروني. أنشئ قالبًا في قسم الاتصالات أولاً.", + "untitledFallback": "بدون عنوان" + } + }, + "nameSuggestion": { + "confirmation": "{{audienceName}} — بريد التأكيد", + "followup_zero": "{{audienceName}} — متابعة بعد {{count}} يوم", + "followup_one": "{{audienceName}} — متابعة بعد {{count}} يوم واحد", + "followup_two": "{{audienceName}} — متابعة بعد {{count}} يومين", + "followup_few": "{{audienceName}} — متابعة بعد {{count}} أيام", + "followup_many": "{{audienceName}} — متابعة بعد {{count}} يومًا", + "followup_other": "{{audienceName}} — متابعة بعد {{count}} يوم" + }, + "actions": { + "cancel": "إلغاء", + "submit": "إنشاء سير العمل", + "submitting": "جارٍ الإنشاء..." + }, + "toast": { + "success": "تم إنشاء سير العمل", + "errorFallback": "فشل إنشاء سير العمل" + }, + "dto": { + "confirmation": { + "descriptionFallback": "إرسال بريد إلكتروني للتأكيد عند تقديم عميل محتمل لـ \"{{audienceName}}\"", + "triggerNodeName": "المشغّل: تم تقديم نموذج الجمهور" + }, + "followup": { + "descriptionFallback_zero": "إرسال بريد إلكتروني للمتابعة إلى العملاء المحتملين الذين قدموا \"{{audienceName}}\" قبل {{count}} يوم بالضبط", + "descriptionFallback_one": "إرسال بريد إلكتروني للمتابعة إلى العملاء المحتملين الذين قدموا \"{{audienceName}}\" قبل {{count}} يوم واحد بالضبط", + "descriptionFallback_two": "إرسال بريد إلكتروني للمتابعة إلى العملاء المحتملين الذين قدموا \"{{audienceName}}\" قبل {{count}} يومين بالضبط", + "descriptionFallback_few": "إرسال بريد إلكتروني للمتابعة إلى العملاء المحتملين الذين قدموا \"{{audienceName}}\" قبل {{count}} أيام بالضبط", + "descriptionFallback_many": "إرسال بريد إلكتروني للمتابعة إلى العملاء المحتملين الذين قدموا \"{{audienceName}}\" قبل {{count}} يومًا بالضبط", + "descriptionFallback_other": "إرسال بريد إلكتروني للمتابعة إلى العملاء المحتملين الذين قدموا \"{{audienceName}}\" قبل {{count}} يوم بالضبط", + "queryNodeName": "جلب العملاء المحتملين الأخيرين" + }, + "sendNodeName": "إرسال: {{templateName}}" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCounsellorsTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCounsellorsTab.json new file mode 100644 index 0000000000..765eb85a5e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCounsellorsTab.json @@ -0,0 +1,42 @@ +{ + "title": "أداء المستشارين", + "summary": { + "counsellorCount_zero": "لا يوجد مستشارون ({{count}})", + "counsellorCount_one": "مستشار واحد ({{count}})", + "counsellorCount_two": "مستشاران ({{count}})", + "counsellorCount_few": "{{count}} مستشارين", + "counsellorCount_many": "{{count}} مستشارًا", + "counsellorCount_other": "{{count}} مستشار", + "avgResponse": "متوسط الاستجابة", + "avgConversion": "متوسط التحويل" + }, + "csv": { + "headers": { + "counsellor": "المستشار", + "assigned": "المُسندة", + "responded": "تم الرد عليها", + "conversions": "التحويلات", + "convRate": "معدل التحويل %", + "avgResponse": "متوسط الاستجابة (دقيقة)", + "tatMet": "الالتزام بوقت الاستجابة %", + "open": "مفتوحة", + "overdue": "متأخرة" + } + }, + "table": { + "loading": "جارٍ تحميل المستشارين…", + "empty": "لا يوجد نشاط للمستشارين في هذه الفترة.", + "topPerformer": "الأفضل أداءً", + "headers": { + "counsellor": "المستشار", + "assigned": "المُسندة", + "responded": "تم الرد عليها", + "conversions": "التحويلات", + "convRate": "معدل التحويل", + "avgResponse": "متوسط الاستجابة", + "tatMet": "الالتزام بالوقت", + "open": "مفتوحة", + "overdue": "متأخرة" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCreateCampaignDialog.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCreateCampaignDialog.json new file mode 100644 index 0000000000..6f9b020594 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCreateCampaignDialog.json @@ -0,0 +1,4 @@ +{ + "editHeading": "تعديل {{label}}", + "createHeading": "إنشاء {{label}}" +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCreateCampaignForm.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCreateCampaignForm.json new file mode 100644 index 0000000000..9c889dca7c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCreateCampaignForm.json @@ -0,0 +1,72 @@ +{ + "shareLink": { + "ready": "رابط الحملة جاهز للمشاركة" + }, + "campaignName": { + "label": "اسم الحملة", + "placeholder": "أدخل اسم الحملة" + }, + "campaignType": { + "label": "نوع الحملة" + }, + "campaignObjective": { + "label": "هدف الحملة", + "placeholder": "مثال: التفاعل، الاحتفاظ بالعملاء" + }, + "subOrg": { + "label": "المؤسسة الفرعية", + "placeholder": "اختر مؤسسة فرعية (اختياري)", + "noneOption": "بلا" + }, + "teamNotifications": { + "label": "إشعارات الفريق", + "infoAriaLabel": "معلومات حول مشاركة تحليلات الحملة", + "tooltip": "أدخل عناوين البريد الإلكتروني لأعضاء الفريق الذين يجب أن يتلقوا تحديثات الحملة", + "placeholder": "أدخل عناوين البريد الإلكتروني" + }, + "shareAnalytics": { + "label": "مشاركة تحليلات الحملة مع أعضاء الفريق", + "infoAriaLabel": "معلومات حول مشاركة تحليلات الحملة", + "tooltip": "السماح لأعضاء الفريق بعرض مقاييس أداء الحملة وتقاريرها" + }, + "description": { + "label": "وصف الحملة", + "placeholder": "صف أهداف الحملة والجمهور المستهدف والرسائل الرئيسية" + }, + "startDate": { + "label": "تاريخ البدء" + }, + "endDate": { + "label": "تاريخ الانتهاء" + }, + "status": { + "label": "الحالة", + "infoAriaLabel": "معلومات حول مشاركة تحليلات الحملة", + "tooltip": "لمشاركة رابط الحملة مع المتعلمين، يُرجى التأكد من ضبط حالة الحملة على نشطة." + }, + "initialLeadScore": { + "label": "درجة العميل المحتمل الأولية", + "infoAriaLabel": "معلومات عن درجة العميل المحتمل الأولية", + "tooltip": "درجة أساسية تُضاف إلى كل عميل محتمل يتم اكتسابه من خلال هذه الحملة. الدرجة النهائية = الدرجة الأولية + الدرجة المحسوبة (بحد أقصى 100)." + }, + "customField": { + "newOptionDefault": "خيار {{number}}" + }, + "postSubmit": { + "previewCampaignNameFallback": "حملتك", + "description": "ما يراه المُجيب فور إرسال هذا النموذج. يتم تعبئته مسبقًا من الإعدادات ← إعدادات العملاء المحتملين ← النماذج؛ التغييرات هنا تنطبق على هذه الحملة فقط." + }, + "errors": { + "instituteContextUnavailable": "سياق المؤسسة غير متاح. يُرجى تحديث الصفحة والمحاولة مرة أخرى.", + "uploadImageFailed": "فشل تحميل صورة الحملة", + "customFieldsMustBeArray": "يجب أن تكون بيانات JSON للحقول المخصصة عبارة عن مصفوفة.", + "customFieldsInvalidJson": "يجب أن تكون الحقول المخصصة بصيغة JSON صالحة." + }, + "actions": { + "reset": "إعادة تعيين", + "save": "حفظ التغييرات", + "saving": "جارٍ الحفظ...", + "create": "إنشاء {{term}}", + "creating": "جارٍ الإنشاء..." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCreateFlowDialog.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCreateFlowDialog.json new file mode 100644 index 0000000000..b5aaa72de9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCreateFlowDialog.json @@ -0,0 +1,27 @@ +{ + "heading": "إنشاء تدفق إعداد", + "fields": { + "name": { + "label": "اسم التدفق", + "placeholder": "مثال: إعداد الطلاب الجدد" + }, + "description": { + "label": "الوصف", + "placeholder": "الغرض من هذا التدفق (اختياري)" + } + }, + "actions": { + "cancel": "إلغاء", + "createFlow": "إنشاء تدفق", + "creating": "جارٍ الإنشاء…" + }, + "validation": { + "nameRequired": "اسم التدفق مطلوب", + "nameMaxLength": "يجب ألا يتجاوز 150 حرفًا", + "descriptionMaxLength": "يجب ألا يتجاوز 1000 حرف" + }, + "toasts": { + "created": "تم إنشاء تدفق الإعداد", + "createError": "تعذّر إنشاء التدفق. يرجى المحاولة مرة أخرى." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCrmIntelligenceReportTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCrmIntelligenceReportTab.json new file mode 100644 index 0000000000..083d4ed193 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCrmIntelligenceReportTab.json @@ -0,0 +1,20 @@ +{ + "coachingDrillIn": { + "loading": "جارٍ تحميل التوجيه…", + "noAnalyzedCalls": "لا توجد مكالمات محلَّلة بعد — لا يوجد توجيه لهذا الموظف.", + "weakestLabel": "الأضعف:", + "scoreOutOfTen": "{{score}}/10" + }, + "workPatterns": { + "sectionTitle": "أنماط عمل المستشارين", + "emptyMessage": "لا يوجد نشاط للمستشارين في هذه الفترة.", + "columns": { + "counsellor": "المستشار", + "leadsDispositioned": "العملاء المحتملون المعالجون", + "calls": "المكالمات", + "connected": "المتصلة", + "reach": "معدل الوصول" + }, + "improveHeading": "ما يمكنهم تحسينه" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerCustomReportTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerCustomReportTab.json new file mode 100644 index 0000000000..363a4a1bd2 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerCustomReportTab.json @@ -0,0 +1,29 @@ +{ + "buildSection": { + "title": "إنشاء تقرير", + "groupByLabel": "التجميع حسب (الأبعاد)", + "measuresLabel": "المقاييس", + "filtersHeading": "عوامل التصفية", + "runButton": "تشغيل التقرير", + "pickHint": "اختر بُعدًا واحدًا ومقياسًا واحدًا على الأقل." + }, + "resultSection": { + "title": "النتيجة", + "matrixViewLabel": "عرض المصفوفة", + "emptyHint": "لا توجد صفوف مطابقة لهذا المواصفات.", + "totalColumn": "الإجمالي", + "matrixCaption": "{{measure}} حسب {{rowDim}} (الصفوف) × {{colDim}} (الأعمدة).", + "truncatedMatrix_zero": "لم يُبنَ من {{count}} صفوف — نقّح عوامل التصفية لتضييق النتيجة.", + "truncatedMatrix_one": "مبني من أول {{count}} صف — نقّح عوامل التصفية لتضييق النتيجة.", + "truncatedMatrix_two": "مبني من أول {{count}} صفّين — نقّح عوامل التصفية لتضييق النتيجة.", + "truncatedMatrix_few": "مبني من أول {{count}} صفوف — نقّح عوامل التصفية لتضييق النتيجة.", + "truncatedMatrix_many": "مبني من أول {{count}} صفًا — نقّح عوامل التصفية لتضييق النتيجة.", + "truncatedMatrix_other": "مبني من أول {{count}} صف — نقّح عوامل التصفية لتضييق النتيجة.", + "truncatedTable_zero": "لا تُعرض {{count}} صفوف — نقّح عوامل التصفية لتضييق النتيجة.", + "truncatedTable_one": "يُعرض أول {{count}} صف — نقّح عوامل التصفية لتضييق النتيجة.", + "truncatedTable_two": "يُعرض أول {{count}} صفّين — نقّح عوامل التصفية لتضييق النتيجة.", + "truncatedTable_few": "تُعرض أول {{count}} صفوف — نقّح عوامل التصفية لتضييق النتيجة.", + "truncatedTable_many": "تُعرض أول {{count}} صفًا — نقّح عوامل التصفية لتضييق النتيجة.", + "truncatedTable_other": "تُعرض أول {{count}} صف — نقّح عوامل التصفية لتضييق النتيجة." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerDispositionsTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerDispositionsTab.json new file mode 100644 index 0000000000..58d80f5af2 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerDispositionsTab.json @@ -0,0 +1,27 @@ +{ + "sections": { + "currentStatus": { + "title": "الحالة الحالية حسب المستشار", + "emptyHint": "لا توجد عملاء محتملون في هذا النطاق.", + "description": "العملاء المحتملون المستلمون في النطاق الزمني المحدد، حسب الحالة التي يحملونها الآن. الإجمالي = جميع عملاء المستشار المحتملين من هذا النطاق؛ بلا حالة = عملاء محتملون لم تُحدَّد لهم حالة قط." + }, + "statusChanges": { + "title": "تغييرات الحالة حسب المستشار", + "emptyHint": "لا توجد تغييرات حالة في هذا النطاق.", + "description": "تُحصي كل خلية عمليات الانتقال إلى تلك الحالة التي أجراها المستشار في هذا النطاق. قيد الانتظار = عملاء محتملون مُسندون دون أي تغيير حالة مُسجَّل في أي وقت (لم يُعمل عليهم قط)." + }, + "callOutcomes": { + "title": "نتائج المكالمات حسب المستشار", + "emptyHint": "لا توجد نتائج مكالمات مُسجَّلة في هذا النطاق.", + "description": "أعداد نتائج المكالمات المُسجَّلة لكل مستشار، حسب حالات مكالمات الاتصالات لديك." + } + }, + "table": { + "counsellor": "المستشار", + "noStatus": "بلا حالة", + "total": "الإجمالي", + "totalLeads": "إجمالي العملاء المحتملين", + "pending": "قيد الانتظار", + "totalRowLabel": "الإجمالي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerEmbedCodeDialog.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerEmbedCodeDialog.json new file mode 100644 index 0000000000..c7df313307 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerEmbedCodeDialog.json @@ -0,0 +1,38 @@ +{ + "dialogTitle": "كود التضمين - {{campaignName}}", + "dialogDescription": "ضمّن هذا النموذج في موقعك الإلكتروني أو أي صفحة ويب لجمع الردود.", + "tabs": { + "buttonPopup": "زر + نافذة منبثقة", + "directEmbed": "تضمين مباشر" + }, + "defaults": { + "buttonText": "سجّل الآن", + "popupTitle": "نموذج التسجيل" + }, + "buttonPanel": { + "heading": "تخصيص الزر", + "buttonTextLabel": "نص الزر", + "bgColorLabel": "لون الخلفية", + "textColorLabel": "لون النص", + "borderRadiusLabel": "استدارة الحواف (بكسل)", + "popupTitleLabel": "عنوان النافذة المنبثقة", + "previewLabel": "معاينة" + }, + "iframePanel": { + "heading": "تخصيص الإطار", + "widthLabel": "العرض", + "heightLabel": "الارتفاع (بكسل)", + "previewScaledLabel": "معاينة (مصغّرة)", + "formPreviewTitle": "معاينة النموذج", + "tipPrefix": "نصيحة: استخدم", + "tipSuffix": "لعرض متجاوب، أو حدد بكسلات مثل" + }, + "copyButton": { + "copy": "نسخ", + "copied": "تم النسخ" + }, + "toasts": { + "copiedToClipboard": "تم النسخ إلى الحافظة!", + "copyFailed": "فشل النسخ" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerFollowUpStatTiles.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerFollowUpStatTiles.json new file mode 100644 index 0000000000..c5c2d2a06f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerFollowUpStatTiles.json @@ -0,0 +1,20 @@ +{ + "tiles": { + "overdue": { + "label": "معلّق", + "caption": "متأخر" + }, + "today": { + "label": "اليوم", + "caption": "مستحق اليوم" + }, + "upcoming": { + "label": "القادمة", + "caption": "خلال 7 أيام" + }, + "all": { + "label": "الكل", + "caption": "جميع المتابعات" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerFollowUpsCalendarView.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerFollowUpsCalendarView.json new file mode 100644 index 0000000000..d894cd893e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerFollowUpsCalendarView.json @@ -0,0 +1,61 @@ +{ + "dayLabels": { + "sun": "أحد", + "mon": "إثنين", + "tue": "ثلاثاء", + "wed": "أربعاء", + "thu": "خميس", + "fri": "جمعة", + "sat": "سبت" + }, + "nav": { + "previousMonth": "الشهر السابق", + "nextMonth": "الشهر التالي", + "today": "اليوم" + }, + "error": { + "title": "تعذر تحميل المتابعات", + "description": "حدث خطأ أثناء تحميل التقويم. حاول مرة أخرى." + }, + "overflowMore_zero": "+{{count}} إضافية", + "overflowMore_one": "+مهمة واحدة إضافية ({{count}})", + "overflowMore_two": "+مهمتان إضافيتان ({{count}})", + "overflowMore_few": "+{{count}} إضافية", + "overflowMore_many": "+{{count}} إضافية", + "overflowMore_other": "+{{count}} إضافية", + "agenda": { + "emptyTitle": "لا متابعات هذا الشهر", + "emptyDescription": "غيّر الشهر باستخدام الأسهم أعلاه أو اختر مستشارًا.", + "tasks_zero": "لا مهام ({{count}})", + "tasks_one": "مهمة واحدة ({{count}})", + "tasks_two": "مهمتان ({{count}})", + "tasks_few": "{{count}} مهام", + "tasks_many": "{{count}} مهمة", + "tasks_other": "{{count}} مهمة" + }, + "dayCounts": { + "overdue_zero": "لا مهام متأخرة ({{count}})", + "overdue_one": "مهمة واحدة متأخرة ({{count}})", + "overdue_two": "مهمتان متأخرتان ({{count}})", + "overdue_few": "{{count}} مهام متأخرة", + "overdue_many": "{{count}} مهمة متأخرة", + "overdue_other": "{{count}} مهمة متأخرة", + "today_zero": "لا مهام اليوم ({{count}})", + "today_one": "مهمة واحدة اليوم ({{count}})", + "today_two": "مهمتان اليوم ({{count}})", + "today_few": "{{count}} مهام اليوم", + "today_many": "{{count}} مهمة اليوم", + "today_other": "{{count}} مهمة اليوم", + "upcoming_zero": "لا مهام قادمة ({{count}})", + "upcoming_one": "مهمة واحدة قادمة ({{count}})", + "upcoming_two": "مهمتان قادمتان ({{count}})", + "upcoming_few": "{{count}} مهام قادمة", + "upcoming_many": "{{count}} مهمة قادمة", + "upcoming_other": "{{count}} مهمة قادمة", + "none": "لا متابعات" + }, + "dayPanel": { + "emptyTitle": "لا شيء في هذا اليوم", + "emptyDescription": "اختر يومًا آخر من التقويم أعلاه." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerFollowUpsPage.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerFollowUpsPage.json new file mode 100644 index 0000000000..245dde9577 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerFollowUpsPage.json @@ -0,0 +1,93 @@ +{ + "navHeading": "المتابعات", + "heading": { + "team": "المتابعات", + "mine": "متابعاتي" + }, + "subline": { + "teamAllCaughtUp": "الفريق أنجز كل شيء", + "selfAllCaughtUp": "أنجزتَ كل شيء", + "tasksDueToday_admin_zero": "لا توجد لدى الفريق مهام مستحقة اليوم ({{count}})", + "tasksDueToday_admin_one": "لدى الفريق مهمة واحدة مستحقة اليوم ({{count}})", + "tasksDueToday_admin_two": "لدى الفريق مهمتان مستحقتان اليوم ({{count}})", + "tasksDueToday_admin_few": "لدى الفريق {{count}} مهام مستحقة اليوم", + "tasksDueToday_admin_many": "لدى الفريق {{count}} مهمة مستحقة اليوم", + "tasksDueToday_admin_other": "لدى الفريق {{count}} مهمة مستحقة اليوم", + "tasksDueToday_user_zero": "ليس لديك مهام مستحقة اليوم ({{count}})", + "tasksDueToday_user_one": "لديك مهمة واحدة مستحقة اليوم ({{count}})", + "tasksDueToday_user_two": "لديك مهمتان مستحقتان اليوم ({{count}})", + "tasksDueToday_user_few": "لديك {{count}} مهام مستحقة اليوم", + "tasksDueToday_user_many": "لديك {{count}} مهمة مستحقة اليوم", + "tasksDueToday_user_other": "لديك {{count}} مهمة مستحقة اليوم", + "overdueSuffix_zero": " · لا يوجد متأخر ({{count}})", + "overdueSuffix_one": " · مهمة واحدة متأخرة ({{count}})", + "overdueSuffix_two": " · مهمتان متأخرتان ({{count}})", + "overdueSuffix_few": " · {{count}} مهام متأخرة", + "overdueSuffix_many": " · {{count}} مهمة متأخرة", + "overdueSuffix_other": " · {{count}} مهمة متأخرة" + }, + "tabs": { + "list": "قائمة", + "calendar": "تقويم" + }, + "showing": { + "prefix": "عرض", + "suffix_zero": "متابعة", + "suffix_one": "متابعة", + "suffix_two": "متابعتان", + "suffix_few": "متابعات", + "suffix_many": "متابعة", + "suffix_other": "متابعة", + "suffix_overdue_zero": "متابعة متأخرة", + "suffix_overdue_one": "متابعة متأخرة", + "suffix_overdue_two": "متابعتان متأخرتان", + "suffix_overdue_few": "متابعات متأخرة", + "suffix_overdue_many": "متابعة متأخرة", + "suffix_overdue_other": "متابعة متأخرة", + "suffix_today_zero": "متابعة مستحقة اليوم", + "suffix_today_one": "متابعة مستحقة اليوم", + "suffix_today_two": "متابعتان مستحقتان اليوم", + "suffix_today_few": "متابعات مستحقة اليوم", + "suffix_today_many": "متابعة مستحقة اليوم", + "suffix_today_other": "متابعة مستحقة اليوم", + "suffix_upcoming_zero": "متابعة قادمة", + "suffix_upcoming_one": "متابعة قادمة", + "suffix_upcoming_two": "متابعتان قادمتان", + "suffix_upcoming_few": "متابعات قادمة", + "suffix_upcoming_many": "متابعة قادمة", + "suffix_upcoming_other": "متابعة قادمة" + }, + "table": { + "actionHeader": "إجراء" + }, + "callReasons": { + "noSubmissionId": "لا يوجد معرّف تقديم لهذا العميل المحتمل", + "noPhone": "لا يوجد رقم هاتف مسجل لهذا العميل المحتمل", + "callInProgress": "هناك مكالمة أخرى قيد البدء…" + }, + "errors": { + "loadFailedTitle": "تعذّر تحميل المتابعات", + "loadFailedDescription": "حدث خطأ ما أثناء جلب المتابعات. حاول مرة أخرى." + }, + "empty": { + "title": { + "teamAllCaughtUp": "لا توجد متابعات معلّقة على مستوى الفريق", + "selfAllCaughtUp": "أنجزتَ كل شيء", + "noOverdue": "لا توجد متابعات متأخرة", + "nothingToday": "لا شيء مستحق اليوم", + "noUpcoming": "لا توجد متابعات قادمة خلال الأيام السبعة القادمة", + "default": "لا توجد متابعات" + }, + "description": { + "overdueNudge_zero": "لا لديك مهام متأخرة ({{count}}) — بدّل إلى \"المعلّقة\" أعلاه.", + "overdueNudge_one": "لديك مهمة واحدة متأخرة ({{count}}) — بدّل إلى \"المعلّقة\" أعلاه.", + "overdueNudge_two": "لديك مهمتان متأخرتان ({{count}}) — بدّل إلى \"المعلّقة\" أعلاه.", + "overdueNudge_few": "لديك {{count}} مهام متأخرة — بدّل إلى \"المعلّقة\" أعلاه.", + "overdueNudge_many": "لديك {{count}} مهمة متأخرة — بدّل إلى \"المعلّقة\" أعلاه.", + "overdueNudge_other": "لديك {{count}} مهمة متأخرة — بدّل إلى \"المعلّقة\" أعلاه.", + "upcomingNudge": "بدّل إلى \"القادمة\" أعلاه لترى ما هو مستحق تاليًا.", + "teamAllCaughtUp": "أنجز الفريق كل المتابعات في هذا العرض.", + "selfAllCaughtUp": "لا توجد لديك متابعات معلّقة في هذا العرض." + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerFollowupsTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerFollowupsTab.json new file mode 100644 index 0000000000..556a28705c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerFollowupsTab.json @@ -0,0 +1,40 @@ +{ + "buckets": { + "dueToday": "مستحق اليوم", + "overdue1to3": "متأخر 1–3 أيام", + "overdue3to7": "متأخر 3–7 أيام", + "overdue7plus": "متأخر 7+ أيام", + "upcoming": "قادم" + }, + "section": { + "agingByCounsellor": "التقادم حسب المستشار", + "closureReasons": "أسباب الإغلاق (آخر 30 يومًا)" + }, + "table": { + "headers": { + "counsellor": "المستشار", + "dueToday": "مستحق اليوم", + "days1to3": "1–3 أيام", + "days3to7": "3–7 أيام", + "days7plus": "7+ أيام", + "upcoming": "قادم", + "oldestOverdue": "أقدم تأخير" + }, + "oldestOverdueValue": "{{count}} يوم" + }, + "csv": { + "headers": { + "counsellor": "المستشار", + "dueToday": "مستحق اليوم", + "overdue1to3": "متأخر 1-3 أيام", + "overdue3to7": "متأخر 3-7 أيام", + "overdue7plus": "متأخر 7+ أيام", + "upcoming": "قادم", + "oldestOverdueDays": "أقدم تأخير (أيام)" + } + }, + "empty": { + "noOpenFollowups": "لا توجد متابعات مفتوحة الآن.", + "noClosures": "لم يتم إغلاق أي متابعة خلال آخر 30 يومًا." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerForecastTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerForecastTab.json new file mode 100644 index 0000000000..f4f9d10b2a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerForecastTab.json @@ -0,0 +1,28 @@ +{ + "title": "توقع الإيرادات", + "calculationTitle": "كيف يتم احتساب هذا", + "empty": { + "noHistory": "لا يوجد سجل كافٍ للتوقع بعد." + }, + "horizon": { + "label_zero": "{{count}} أيام قادمة", + "label_one": "{{count}} يوم قادم", + "label_two": "{{count}} يومان قادمان", + "label_few": "{{count}} أيام قادمة", + "label_many": "{{count}} يومًا قادمًا", + "label_other": "{{count}} يوم قادم", + "blendedEstimate": "التقدير المُجمّع", + "runRate": "معدل التشغيل", + "pipelineWeighted": "المرجّح حسب خط الأنابيب" + }, + "assumptions": { + "revenueLastDays": "الإيرادات (آخر {{days}} يوم)", + "avgDailyRevenue": "متوسط الإيرادات اليومية", + "leadsLastDays": "العملاء المحتملون (آخر {{days}} يوم)", + "conversionsLastDays": "التحويلات (آخر {{days}} يوم)", + "historicalConvRate": "معدل التحويل التاريخي", + "avgDealValue": "متوسط قيمة الصفقة", + "openPipelineLeads": "العملاء المحتملون في خط الأنابيب المفتوح", + "footnote": "معدل التشغيل = متوسط الإيرادات اليومية × الفترة الزمنية. التقدير المرجّح حسب خط الأنابيب = العملاء المحتملون المفتوحون × معدل التحويل التاريخي × متوسط قيمة الصفقة، يُدرَج تدريجيًا حسب الفترة الزمنية. التقدير المُجمّع هو متوسط الاثنين. يستخدم التوقع نافذة تاريخية ثابتة، وليس عامل التصفية الزمني للصفحة." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerFormatCustomFieldValue.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerFormatCustomFieldValue.json new file mode 100644 index 0000000000..80a5c2403f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerFormatCustomFieldValue.json @@ -0,0 +1,6 @@ +{ + "checkbox": { + "yes": "نعم", + "no": "لا" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerFunnelTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerFunnelTab.json new file mode 100644 index 0000000000..64f70faac7 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerFunnelTab.json @@ -0,0 +1,41 @@ +{ + "kpi": { + "medianDaysToConvert": { + "label": "متوسط الأيام حتى التحويل", + "sub": "من أول نشاط حتى التحويل، للعملاء المحتملين الذين تم كسبهم خلال هذه الفترة" + }, + "conversionRate": { + "label": "معدل التحويل", + "sub": "المكسوبون خلال الفترة ÷ المُقدَّمون خلال الفترة" + } + }, + "section": { + "title": "قمع المراحل" + }, + "emptyHint": "لا يوجد نشاط في المراحل خلال هذه الفترة.", + "csv": { + "headers": { + "stage": "المرحلة", + "entered": "دخل", + "inStageNow": "في المرحلة الآن", + "medianDaysInStage": "متوسط الأيام في المرحلة", + "advanced": "تقدّم", + "advancedPct": "نسبة التقدّم %", + "regressed": "تراجع" + } + }, + "table": { + "headers": { + "stage": "المرحلة", + "entered": "دخل", + "inStageNow": "في المرحلة الآن", + "medianDays": "متوسط الأيام", + "advancedPct": "نسبة التقدّم %", + "regressed": "تراجع" + } + }, + "stageBar": { + "nowSuffix": "· {{value}} الآن" + }, + "footnote": "متوسط الأيام = الوقت المُستغرَق في المرحلة للفترات التي بدأت خلال هذا النطاق. تقدّم / تراجع = الانتقال إلى مرحلة لاحقة / سابقة في ترتيب الحالات لديك." +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadBoardPage.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadBoardPage.json new file mode 100644 index 0000000000..a29bc22342 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadBoardPage.json @@ -0,0 +1,89 @@ +{ + "heading": { + "title": "لوحة العملاء المحتملين", + "subtitle": "اسحب عميلاً محتملاً إلى عمود آخر لتغيير حالته." + }, + "slaOptions": { + "anyOverdue": "فات موعد نهائي", + "firstContactMissed": "فات موعد أول تواصل", + "firstContactComingUp": "أول تواصل قادم", + "followUpComingUp": "متابعة قادمة", + "followUpMissed": "فاتت المتابعة" + }, + "dateRangeOptions": { + "last24Hours": "آخر 24 ساعة", + "last7Days": "آخر 7 أيام", + "last15Days": "آخر 15 يومًا", + "last30Days": "آخر 30 يومًا", + "allTime": "كل الأوقات", + "customRange": "نطاق مخصص" + }, + "filters": { + "tier": { + "label": "جميع الفئات", + "hot": "ساخن", + "warm": "دافئ", + "cold": "بارد" + }, + "sla": { + "label": "جميع حالات اتفاقية مستوى الخدمة" + }, + "audience": { + "label": "جميع الجماهير", + "untitled": "جمهور بلا عنوان" + }, + "callHistory": { + "any": "سجل المكالمات", + "notCalled": "لم يتم الاتصال", + "calledAny": "تم الاتصال (أي عدد)", + "calledOnce": "تم الاتصال مرة واحدة", + "calledTwicePlus": "تم الاتصال مرتين أو أكثر", + "aiCalled": "تم الاتصال بواسطة الذكاء الاصطناعي", + "manualCalled": "تم الاتصال يدويًا" + }, + "customDate": { + "setDates": "تحديد التواريخ", + "rangeDisplay": "{{from}} → {{to}}", + "done": "تم", + "from": "من", + "to": "إلى" + } + }, + "toolbar": { + "leadSettings": "إعدادات العملاء المحتملين" + }, + "chips": { + "search": "بحث: {{query}}", + "audience": "الجمهور: {{names}}", + "tier": "الفئة: {{tiers}}", + "sla": "اتفاقية مستوى الخدمة: {{states}}", + "counsellor": "المستشار: {{names}}", + "source": "المصدر: {{source}}", + "customField": "{{field}}: {{value}}", + "dateRangeCustomWithDates": "التاريخ: {{from}} → {{to}}", + "dateRangeCustomFallback": "التاريخ: نطاق مخصص", + "dateRangeFallback": "نطاق التاريخ", + "fallbackSelected": "محدد", + "fallbackField": "حقل", + "unassigned": "غير مُسند", + "removeAriaLabel": "إزالة {{label}}", + "clearAll": "مسح الكل" + }, + "search": { + "placeholder": "ابحث عن العملاء المحتملين في جميع الأعمدة", + "ariaLabel": "بحث عن العملاء المحتملين" + }, + "board": { + "loading": "جارٍ تحميل اللوحة…", + "noStatusColumnLabel": "بدون حالة", + "noStatusesTitle": "لم يتم تكوين حالات العملاء المحتملين", + "noStatusesDescription": "قم بإعداد حالات مسار العمل في إعدادات العملاء المحتملين لاستخدام اللوحة.", + "allHiddenTitle": "جميع الأعمدة مخفية", + "allHiddenDescription": "كل عمود حالة مخفي — استخدم \"إدارة الأعمدة\" لإظهار البعض منها." + }, + "callReasons": { + "noSubmissionId": "لا يوجد معرّف تقديم لهذا العميل المحتمل", + "noPhone": "لا يوجد رقم هاتف مسجل لهذا العميل المحتمل", + "callInProgress": "هناك مكالمة أخرى قيد البدء…" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadBulkImportDialog.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadBulkImportDialog.json new file mode 100644 index 0000000000..6a13528e0d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadBulkImportDialog.json @@ -0,0 +1,143 @@ +{ + "dialog": { + "title": "استيراد جماعي لملف CSV — {{campaignName}}", + "description": "قم برفع ملف CSV لاستيراد عدة عملاء محتملين دفعة واحدة." + }, + "steps": { + "upload": "رفع", + "preview": "معاينة", + "results": "النتائج" + }, + "loading": { + "campaignFields": "جارٍ تحميل حقول الحملة..." + }, + "noCustomFields": { + "title": "لم يتم تكوين أي حقول مخصصة", + "description": "أضف حقولاً مخصصة إلى هذه الحملة قبل استيراد العملاء المحتملين." + }, + "upload": { + "downloadTemplateButton": "تنزيل القالب", + "fieldsCount_zero": "لا يحتوي ملف CSV على أي حقول ({{count}})، بالإضافة إلى صف نموذجي", + "fieldsCount_one": "يحتوي ملف CSV على حقل واحد ({{count}})، بالإضافة إلى صف نموذجي", + "fieldsCount_two": "يحتوي ملف CSV على حقلين ({{count}})، بالإضافة إلى صف نموذجي", + "fieldsCount_few": "يحتوي ملف CSV على {{count}} حقول، بالإضافة إلى صف نموذجي", + "fieldsCount_many": "يحتوي ملف CSV على {{count}} حقلاً، بالإضافة إلى صف نموذجي", + "fieldsCount_other": "يحتوي ملف CSV على {{count}} حقل، بالإضافة إلى صف نموذجي", + "dropzone": { + "cta": "انقر لرفع ملف CSV الخاص بك", + "maxRows": "الحد الأقصى {{count}} صف" + }, + "expectedColumns": { + "title": "الأعمدة المتوقعة", + "leadOwnerChip": "مالك العميل المحتمل (بريد المستشار)", + "leadStatusChip": "حالة العميل المحتمل", + "leadOwnerLabel": "مالك العميل المحتمل", + "leadStatusLabel": "حالة العميل المحتمل", + "andConnector": "و", + "hintSuffix": "اختياريان. يقبل حقل المالك بريد المستشار الإلكتروني (أو اسمًا فريدًا)؛ ويقبل حقل الحالة تسمية مثل {{statusExamples}}. تُستخدم {{defaultStatus}} افتراضيًا عند ترك الحالة فارغة.", + "defaultStatusExamples": "جديد، تم التواصل، مهتم", + "defaultStatusFallback": "حالتك الافتراضية" + } + }, + "preview": { + "statTiles": { + "totalRows": "إجمالي الصفوف", + "valid": "صالح", + "errors": "الأخطاء", + "duplicates": "التكرارات" + }, + "blocked": { + "reason_zero": "لا توجد أعمدة إلزامية مفقودة: {{columns}}", + "reason_one": "عمود إلزامي مفقود: {{columns}}", + "reason_two": "عمودان إلزاميان مفقودان: {{columns}}", + "reason_few": "أعمدة إلزامية مفقودة: {{columns}}", + "reason_many": "عمودًا إلزاميًا مفقودًا: {{columns}}", + "reason_other": "عمود إلزامي مفقود: {{columns}}", + "title": "تم حظر الاستيراد — {{reason}}", + "descriptionPrefix": "لا يحتوي ملفك على عمود مطابق لـ", + "descriptionSuffix_zero": ". يؤثر هذا على كل صف، لذا لا يمكن استيراد أي شيء — سيتم حفظ كل عميل محتمل مع ترك تلك الحقول فارغة. أضف العمود إلى ملف CSV (يجب أن يطابق العنوان اسم الحقل) وأعد الرفع.", + "descriptionSuffix_one": ". يؤثر هذا على كل صف، لذا لا يمكن استيراد أي شيء — سيتم حفظ كل عميل محتمل مع ترك ذلك الحقل فارغًا. أضف العمود إلى ملف CSV (يجب أن يطابق العنوان اسم الحقل) وأعد الرفع.", + "descriptionSuffix_two": ". يؤثر هذا على كل صف، لذا لا يمكن استيراد أي شيء — سيتم حفظ كل عميل محتمل مع ترك هذين الحقلين فارغين. أضف العمود إلى ملف CSV (يجب أن يطابق العنوان اسم الحقل) وأعد الرفع.", + "descriptionSuffix_few": ". يؤثر هذا على كل صف، لذا لا يمكن استيراد أي شيء — سيتم حفظ كل عميل محتمل مع ترك تلك الحقول فارغة. أضف العمود إلى ملف CSV (يجب أن يطابق العنوان اسم الحقل) وأعد الرفع.", + "descriptionSuffix_many": ". يؤثر هذا على كل صف، لذا لا يمكن استيراد أي شيء — سيتم حفظ كل عميل محتمل مع ترك تلك الحقول فارغة. أضف العمود إلى ملف CSV (يجب أن يطابق العنوان اسم الحقل) وأعد الرفع.", + "descriptionSuffix_other": ". يؤثر هذا على كل صف، لذا لا يمكن استيراد أي شيء — سيتم حفظ كل عميل محتمل مع ترك تلك الحقول فارغة. أضف العمود إلى ملف CSV (يجب أن يطابق العنوان اسم الحقل) وأعد الرفع." + }, + "invalidRows": { + "count_zero": "{{formatted}} صف لن يتم استيراده", + "count_one": "صف واحد ({{formatted}}) لن يتم استيراده", + "count_two": "صفان ({{formatted}}) لن يتم استيرادهما", + "count_few": "{{formatted}} صفوف لن يتم استيرادها", + "count_many": "{{formatted}} صفًا لن يتم استيراده", + "count_other": "{{formatted}} صف لن يتم استيراده", + "downloadReportButton": "تنزيل تقرير الأخطاء", + "note": "يتم تخطي هذه الصفوف — بينما تُستورد بقية الصفوف الصالحة بشكل طبيعي. يسرد التقرير كل صف تم تخطيه مع سببه، حتى تتمكن من إصلاح تلك الصفوف فقط ورفعها بشكل منفصل.", + "duplicateReasonLabel": "مكرر (نفس البريد الإلكتروني ظهر سابقًا في الملف)", + "reportColumnHeader": "خطأ الاستيراد" + }, + "columnMapping": { + "title": "تخطيط الأعمدة", + "mappedCount": "({{mapped}}/{{total}} تمت مطابقتها)" + }, + "table": { + "rowNumberHeader": "#", + "statusHeader": "الحالة", + "ownerColumnHeader": "المالك ←", + "statusColumnHeader": "الحالة ←", + "duplicateEmailTooltip": "بريد إلكتروني مكرر", + "showingFirst100_zero": "عرض أول {{shown}} من أصل {{formatted}} صف", + "showingFirst100_one": "عرض أول {{shown}} من أصل صف واحد ({{formatted}})", + "showingFirst100_two": "عرض أول {{shown}} من أصل صفين ({{formatted}})", + "showingFirst100_few": "عرض أول {{shown}} من أصل {{formatted}} صفوف", + "showingFirst100_many": "عرض أول {{shown}} من أصل {{formatted}} صفًا", + "showingFirst100_other": "عرض أول {{shown}} من أصل {{formatted}} صف" + }, + "footer": { + "backButton": "رجوع", + "submittingBatch": "جارٍ الإرسال… الدفعة {{done}}/{{total}}", + "submittingRows_zero": "جارٍ إرسال {{count}} صف...", + "submittingRows_one": "جارٍ إرسال صف واحد ({{count}})...", + "submittingRows_two": "جارٍ إرسال صفين ({{count}})...", + "submittingRows_few": "جارٍ إرسال {{count}} صفوف...", + "submittingRows_many": "جارٍ إرسال {{count}} صفًا...", + "submittingRows_other": "جارٍ إرسال {{count}} صف...", + "submitButton_zero": "إرسال {{count}} صف صالح", + "submitButton_one": "إرسال صف صالح واحد ({{count}})", + "submitButton_two": "إرسال صفين صالحين ({{count}})", + "submitButton_few": "إرسال {{count}} صفوف صالحة", + "submitButton_many": "إرسال {{count}} صفًا صالحًا", + "submitButton_other": "إرسال {{count}} صف صالح" + } + }, + "results": { + "summary": { + "total": "الإجمالي", + "success": "نجاح", + "failed": "فشل", + "skipped": "تم التخطي" + }, + "showDetailsButton": "إظهار التفاصيل", + "hideDetailsButton": "إخفاء التفاصيل", + "rowLabel": "الصف {{number}}", + "statusLabel": { + "FAILED": "فشل", + "SKIPPED": "تم التخطي" + }, + "doneButton": "تم" + }, + "toasts": { + "csvEmpty": "ملف CSV فارغ", + "tooManyRows": "يحتوي ملف CSV على {{count}} صف. الحد الأقصى المسموح به هو {{max}}.", + "missingMandatoryColumns": "أعمدة إلزامية مفقودة: {{columns}}. أضفها إلى الملف وأعد الرفع.", + "parseFailed": "فشل تحليل ملف CSV: {{message}}", + "cannotImport": "تعذر الاستيراد — {{reason}}. أصلح الملف وأعد رفعه.", + "noValidRows": "لا توجد صفوف صالحة لإرسالها", + "allImportedSuccess_zero": "تم استيراد جميع الـ {{count}} عميل محتمل بنجاح!", + "allImportedSuccess_one": "تم استيراد العميل المحتمل الوحيد ({{count}}) بنجاح!", + "allImportedSuccess_two": "تم استيراد العميلين المحتملين ({{count}}) بنجاح!", + "allImportedSuccess_few": "تم استيراد جميع الـ {{count}} عملاء محتملين بنجاح!", + "allImportedSuccess_many": "تم استيراد جميع الـ {{count}} عميلاً محتملاً بنجاح!", + "allImportedSuccess_other": "تم استيراد جميع الـ {{count}} عميل محتمل بنجاح!", + "importComplete": "اكتمل الاستيراد: {{success}} ناجح، {{failed}} فاشل، {{skipped}} تم تخطيه", + "submitFailed": "فشل إرسال العملاء المحتملين" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadBulkImportUtils.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadBulkImportUtils.json new file mode 100644 index 0000000000..5d81d8f36b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadBulkImportUtils.json @@ -0,0 +1,17 @@ +{ + "errors": { + "unknownCounsellorEmail": "بريد إلكتروني غير معروف للمرشد: {{email}}", + "ambiguousCounsellorName": "يوجد أكثر من مرشد باسم \"{{name}}\" — استخدم بريده الإلكتروني بدلاً من ذلك", + "unknownCounsellor": "مرشد غير معروف: {{name}}", + "unknownLeadStatus": "حالة عميل محتمل غير معروفة: {{status}}.", + "validStatuses": "الحالات الصالحة: {{statuses}}", + "fieldRequired": "{{field}} مطلوب", + "invalidEmail": "بريد إلكتروني غير صالح: {{email}}" + }, + "csvTemplate": { + "leadOwnerColumn": "مالك العميل المحتمل (بريد المرشد الإلكتروني)", + "leadStatusColumn": "حالة العميل المحتمل", + "sampleName": "جون دو", + "sampleStatus": "جديد" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadCallsTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadCallsTab.json new file mode 100644 index 0000000000..a017b645a5 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadCallsTab.json @@ -0,0 +1,79 @@ +{ + "kpi": { + "leadsCalled": { + "label": "العملاء المحتملون الذين تم الاتصال بهم", + "sub": "عملاء محتملون فريدون بمحاولة اتصال واحدة على الأقل" + }, + "totalDials": { + "label": "إجمالي محاولات الاتصال", + "sub": "جميع محاولات الاتصال خلال هذا النطاق الزمني" + }, + "connected": { + "label": "متصلة", + "sub": "عملاء محتملون تم الوصول إليهم مرة واحدة على الأقل" + }, + "callbackAsked": { + "label": "طلب معاودة الاتصال", + "sub": "عملاء محتملون تم تسجيل طلب معاودة اتصال لهم" + }, + "neverConnected": { + "label": "لم يتم الاتصال بهم مطلقًا", + "sub": "تمت المحاولة لكن لم يتم الوصول إليهم أبدًا" + }, + "newLeadsNotCalled": { + "label": "عملاء محتملون جدد لم يتم الاتصال بهم", + "sub": "لا توجد أي محاولة اتصال على الإطلاق" + } + }, + "section": { + "titleCalled": "محاولات الاتصال حسب العميل المحتمل", + "titleUncalled": "عملاء محتملون جدد لم تتم مكالمتهم قط" + }, + "search": { + "placeholder": "ابحث بالاسم / الهاتف", + "ariaLabel": "البحث عن العملاء المحتملين بالاسم أو الهاتف" + }, + "view": { + "called": "تم الاتصال بهم ({{count}})", + "uncalled": "لم يتم الاتصال بهم ({{count}})" + }, + "empty": { + "noCalls": "لا توجد مكالمات مع العملاء المحتملين في هذا النطاق الزمني.", + "allCalled": "تم الاتصال بجميع العملاء المحتملين الجدد في هذا النطاق مرة واحدة على الأقل. 🎉" + }, + "pager": { + "summary_zero": "{{formattedCount}} عميل محتمل · صفحة {{page}} من {{pageCount}}", + "summary_one": "{{formattedCount}} عميل محتمل واحد · صفحة {{page}} من {{pageCount}}", + "summary_two": "{{formattedCount}} عميلان محتملان · صفحة {{page}} من {{pageCount}}", + "summary_few": "{{formattedCount}} عملاء محتملين · صفحة {{page}} من {{pageCount}}", + "summary_many": "{{formattedCount}} عميلاً محتملاً · صفحة {{page}} من {{pageCount}}", + "summary_other": "{{formattedCount}} عميل محتمل · صفحة {{page}} من {{pageCount}}", + "prev": "السابق", + "next": "التالي" + }, + "footnote": { + "called": "يتم احتساب متصلة / معاودة الاتصال / لم يتم الرد لكل مكالمة على حدة، وقد تتداخل (فقد تُسجَّل مكالمة متصلة أيضًا كطلب معاودة اتصال)، لذا فهي لا تساوي مجموع المحاولات.", + "uncalled": "العملاء المحتملون الذين قُدِّموا في هذا النطاق ولم تتم مكالمتهم مطلقًا — ولو مرة واحدة، في أي وقت." + }, + "field": { + "lead": "العميل المحتمل", + "phone": "الهاتف", + "status": "الحالة", + "counsellor": "المستشار", + "attempts": "المحاولات", + "connected": "متصلة", + "callback": "معاودة الاتصال", + "notPickedUp": "لم يتم الرد", + "failed": "فشلت", + "lastCall": "آخر مكالمة", + "lastOutcome": "آخر نتيجة", + "nextCallback": "معاودة الاتصال التالية", + "source": "المصدر", + "assignedCounsellor": "المستشار المعيّن", + "submitted": "تاريخ التقديم" + }, + "row": { + "unknownLead": "عميل محتمل غير معروف", + "unassigned": "غير معيّن" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadFormResponseCard.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadFormResponseCard.json new file mode 100644 index 0000000000..a7b51532a6 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadFormResponseCard.json @@ -0,0 +1,27 @@ +{ + "card": { + "title": "رد النموذج" + }, + "row": { + "notProvided": "غير متوفر", + "viewAttachment": "عرض المرفق" + }, + "callButton": { + "title": "اتصل بهذا العميل المحتمل", + "ariaLabel": "الاتصال بالعميل المحتمل", + "connecting": "جارٍ الاتصال…", + "callNow": "اتصل الآن" + }, + "aiCallButton": { + "title": "إجراء مكالمة بوكيل الذكاء الاصطناعي الصوتي لهذا العميل المحتمل", + "ariaLabel": "اتصال ذكاء اصطناعي بالعميل المحتمل", + "calling": "جارٍ الاتصال…", + "aiCall": "مكالمة ذكاء اصطناعي", + "optionsHeading": "خيارات مكالمة الذكاء الاصطناعي", + "placeAiCall": "إجراء مكالمة ذكاء اصطناعي" + }, + "errors": { + "noResponseForCall": "لا يوجد رد حملة مرتبط — لا يمكن إجراء مكالمة من هنا.", + "noResponseForAiCall": "لا يوجد رد حملة مرتبط — لا يمكن إجراء مكالمة ذكاء اصطناعي من هنا." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadReportsPage.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadReportsPage.json new file mode 100644 index 0000000000..0bd631ae29 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerLeadReportsPage.json @@ -0,0 +1,42 @@ +{ + "header": { + "navTitle": "مركز التقارير", + "title": "مركز التقارير", + "subtitle": "صحة قنوات المبيعات والمصادر ونشاط الاتصال وسرعة القمع ونظافة المتابعة — في مكان واحد.", + "refresh": "تحديث" + }, + "filters": { + "presetLabel": "{{days}} يوم", + "from": "من", + "to": "إلى", + "apply": "تطبيق", + "reset": "إعادة تعيين", + "noInstitute": "اختر معهدًا لعرض التقارير." + }, + "tabs": { + "overview": "نظرة عامة", + "sources": "المصادر", + "funnel": "قمع المبيعات", + "dispositions": "التصرفات", + "calling": "الاتصال", + "leadCalls": "مكالمات العملاء المحتملين", + "callIntelligence": "ذكاء إدارة علاقات العملاء", + "activity": "النشاط", + "followups": "المتابعات", + "counsellors": "المستشارون", + "manager": "المدير", + "revenue": "الإيرادات", + "cohort": "الفوج", + "forecast": "التوقعات", + "custom": "المُنشئ" + }, + "counsellorPicker": { + "ariaLabel": "تصفية حسب المستشار", + "allCounsellors": "جميع المستشارين" + }, + "campaignPicker": { + "ariaLabel": "تصفية حسب الحملة", + "allCampaigns": "جميع الحملات", + "untitledCampaign": "حملة بدون عنوان" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerLinkedWorkflowsDialog.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerLinkedWorkflowsDialog.json new file mode 100644 index 0000000000..1a22324508 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerLinkedWorkflowsDialog.json @@ -0,0 +1,42 @@ +{ + "dialog": { + "titlePrefix": "سير العمل المرتبط بـ", + "description": "سير العمل الذي يعمل لهذه الحملة — سواء كان يستهدفها تحديدًا أو يعمل بشكل عام عبر جميع الحملات. يشمل ذلك سير العمل المُشغَّل بالأحداث (عند تقديم عميل محتمل) وسير العمل المجدوَل (مثل رسائل المتابعة)." + }, + "status": { + "refreshing": "جارٍ التحديث…", + "foundCount_zero": "تم العثور على {{count}} سير عمل", + "foundCount_one": "تم العثور على سير عمل واحد ({{count}})", + "foundCount_two": "تم العثور على سيرَي عمل ({{count}})", + "foundCount_few": "تم العثور على {{count}} سيرورات عمل", + "foundCount_many": "تم العثور على {{count}} سيرورة عمل", + "foundCount_other": "تم العثور على {{count}} سير عمل", + "loading": "جارٍ تحميل سير العمل…", + "error": "فشل تحميل سير العمل. انقر على «تحديث» لإعادة المحاولة." + }, + "actions": { + "refresh": "تحديث", + "open": "فتح", + "close": "إغلاق" + }, + "empty": { + "title": "لا يوجد سير عمل مرتبط بهذه الحملة بعد.", + "hint": "استخدم «تكوين سير العمل» من القائمة لإنشاء واحد." + }, + "badge": { + "specific": "هذه الحملة", + "global": "جميع الحملات", + "matchReason": { + "eventDriven": "عند التقديم", + "scheduled": "مجدوَل" + } + }, + "workflowStatus": { + "active": "نشط", + "draft": "مسودة", + "inactive": "غير نشط" + }, + "workflow": { + "triggerLabel": "المشغّل: {{name}}" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerListIndexLazy.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerListIndexLazy.json new file mode 100644 index 0000000000..9b5d927c7d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerListIndexLazy.json @@ -0,0 +1,7 @@ +{ + "navHeading": "إدارة {{term}}", + "performanceOverview": { + "title": "نظرة عامة على الأداء", + "description": "من أين تأتي التحويلات وعدد المكالمات التي يجريها فريقك يوميًا" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerManagerTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerManagerTab.json new file mode 100644 index 0000000000..266cfc333f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerManagerTab.json @@ -0,0 +1,42 @@ +{ + "sectionTitle": "أداء الفريق", + "teamsBadge_zero": "{{count}} فريق", + "teamsBadge_one": "{{count}} فريق واحد", + "teamsBadge_two": "فريقان ({{count}})", + "teamsBadge_few": "{{count}} فرق", + "teamsBadge_many": "{{count}} فريقًا", + "teamsBadge_other": "{{count}} فريق", + "unassigned": "غير معيَّن", + "total": "الإجمالي", + "emptyHint": "لا يوجد نشاط للفرق في هذا النطاق الزمني.", + "breakdownTitle": "معدل التحويل حسب الفريق", + "footnote": "المستجيبون = العملاء المحتملون الذين تلقّوا استجابة واحدة على الأقل. نسبة التحويل % = التحويلات ÷ العملاء المحتملون. متوسط الاستجابة = متوسط الوقت حتى أول استجابة. نسبة الإنجاز % = التحويلات ÷ الهدف.", + "table": { + "team": "الفريق", + "head": "الرئيس", + "counsellors": "المستشارون", + "leads": "العملاء المحتملون", + "responded": "استجابوا", + "conversions": "التحويلات", + "convRate": "نسبة التحويل %", + "open": "مفتوح", + "overdue": "متأخر", + "avgResponse": "متوسط الاستجابة", + "target": "الهدف", + "attainmentPct": "نسبة الإنجاز %" + }, + "csv": { + "team": "الفريق", + "head": "الرئيس", + "counsellors": "المستشارون", + "leads": "العملاء المحتملون", + "responded": "استجابوا", + "conversions": "التحويلات", + "conversionRatePct": "نسبة التحويل (%)", + "open": "مفتوح", + "overdue": "متأخر", + "avgResponseMinutes": "متوسط الاستجابة (بالدقائق)", + "target": "الهدف", + "attainmentPct": "نسبة الإنجاز (%)" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerMultiEmailInput.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerMultiEmailInput.json new file mode 100644 index 0000000000..23405c544f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerMultiEmailInput.json @@ -0,0 +1,3 @@ +{ + "placeholder": "أدخل البريد الإلكتروني واضغط على Enter" +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingDashboardPage.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingDashboardPage.json new file mode 100644 index 0000000000..6c23e92a4a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingDashboardPage.json @@ -0,0 +1,34 @@ +{ + "page": { + "title": "لوحة تأهيل المستخدمين", + "subtitle": "كل مثيل تأهيل عبر كل عميل محتمل/طالب — اطّلع على المعلّقين، وعلى أي مسار، وعند أي خطوة، دون فتح كل ملف شخصي." + }, + "filters": { + "flow": "المسار", + "status": "الحالة", + "allFlows": "كل المسارات", + "allStatuses": "كل الحالات" + }, + "columns": { + "person": "الشخص", + "flow": "المسار", + "currentStep": "الخطوة الحالية", + "status": "الحالة", + "startedBy": "بدأ بواسطة", + "started": "تاريخ البدء" + }, + "statusLabels": { + "inProgress": "قيد التنفيذ", + "completed": "مكتمل", + "abandoned": "متروك", + "cancelled": "ملغى" + }, + "resolvedStudentPrefix": "→ الطالب: {{name}}", + "unknownStep": "غير معروف", + "states": { + "noInstitute": "اختر معهدًا لعرض لوحة تأهيل المستخدمين.", + "loadError": "تعذّر تحميل لوحة تأهيل المستخدمين.", + "emptyTitle": "لم يُعثر على أي مثيلات تأهيل", + "emptyDescription": "لا أحد يطابق هذه المرشّحات بعد — ابدأ مسار تأهيل من العرض الجانبي لطالب أو عميل محتمل ليظهر هنا." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingFlowBuilderPage.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingFlowBuilderPage.json new file mode 100644 index 0000000000..9c29aa134c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingFlowBuilderPage.json @@ -0,0 +1,48 @@ +{ + "navHeading": { + "fallback": "مسار التهيئة" + }, + "actions": { + "backToFlows": "العودة إلى مسارات التهيئة", + "retry": "إعادة المحاولة", + "activating": "جارٍ التفعيل…", + "activateFlow": "تفعيل المسار", + "addStep": "إضافة خطوة", + "edit": "تعديل", + "cancel": "إلغاء", + "removing": "جارٍ الإزالة…", + "remove": "إزالة" + }, + "states": { + "loadingFlow": "جارٍ تحميل المسار…", + "loadFlowError": "تعذّر تحميل مسار التهيئة هذا.", + "addStepBeforeActivating": "أضف خطوة واحدة على الأقل قبل تفعيل هذا المسار.", + "loadingSteps": "جارٍ تحميل الخطوات…", + "noStepsTitle": "لا توجد خطوات بعد", + "noStepsDescription": "أضف الخطوة الأولى (مثل نموذج تسجيل) للبدء في بناء هذا المسار." + }, + "tags": { + "optional": "اختياري", + "grantsStudentRole": "يمنح دور الطالب", + "sendsCredentials": "يرسل بيانات الدخول" + }, + "statusLabels": { + "draft": "مسودة", + "active": "نشِط", + "archived": "مؤرشف" + }, + "stepTypeLabels": { + "FORM": "نموذج" + }, + "deleteDialog": { + "title": "هل تريد إزالة هذه الخطوة؟", + "description": "سيتم أرشفة \"{{stepName}}\". يحتفظ المتعلمون الذين وصلوا بالفعل إلى هذه الخطوة بتقدمهم، لكن الخطوة لن تظهر لأي شخص يبدأ المسار بعد ذلك." + }, + "toasts": { + "reorderError": "تعذّر حفظ الترتيب الجديد. جارٍ التراجع.", + "stepRemoved": "تمت إزالة الخطوة", + "removeError": "تعذّرت إزالة الخطوة.", + "flowActivated": "تم تفعيل المسار", + "activateError": "تعذّر تفعيل المسار." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingFlowsPage.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingFlowsPage.json new file mode 100644 index 0000000000..7c44791951 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingFlowsPage.json @@ -0,0 +1,50 @@ +{ + "navHeading": { + "onboarding": "الإعداد" + }, + "loading": "جارٍ التحميل…", + "notEnabled": { + "title": "تدفقات الإعداد غير مفعّلة", + "prefix": "يمكن لأحد المسؤولين تفعيل ذلك ضمن", + "settingsPath": "الإعدادات ← إعدادات الإعداد" + }, + "page": { + "title": "تدفقات الإعداد", + "subtitle": "قوائم مرتبة يمر بها العميل المحتمل/الطالب بين الموافقة على الانضمام واكتمال التسجيل." + }, + "actions": { + "createFlow": "إنشاء تدفق", + "manage": "إدارة", + "retry": "إعادة المحاولة", + "cancel": "إلغاء", + "deleting": "جارٍ الحذف…", + "deleteFlow": "حذف التدفق" + }, + "columns": { + "name": "الاسم", + "description": "الوصف", + "status": "الحالة", + "steps": "الخطوات", + "actions": "الإجراءات" + }, + "statusLabels": { + "draft": "مسودة", + "active": "نشط", + "archived": "مؤرشف" + }, + "states": { + "noInstitute": "اختر مؤسسة لعرض تدفقات الإعداد.", + "loadError": "تعذّر تحميل تدفقات الإعداد.", + "emptyTitle": "لا توجد تدفقات إعداد بعد", + "emptyDescription": "أنشئ أول تدفق لتحديد الخطوات التي يكملها العميل المحتمل أو الطالب في طريقه إلى اكتمال التسجيل." + }, + "deleteDialog": { + "heading": "حذف تدفق الإعداد", + "confirmPrefix": "حذف", + "confirmSuffix": "؟ سيؤدي هذا إلى أرشفة التدفق — لا يمكن بدء مثيلات جديدة منه بعد الآن، ويُزال من هذه القائمة، لكن أي مثيلات إعداد قيد التقدم عليه بالفعل تبقى دون تغيير." + }, + "toasts": { + "deleted": "تم حذف تدفق الإعداد", + "deleteError": "تعذّر حذف التدفق. يرجى المحاولة مرة أخرى." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingIndexLazy.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingIndexLazy.json new file mode 100644 index 0000000000..d0ec227c07 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingIndexLazy.json @@ -0,0 +1,7 @@ +{ + "tablistAriaLabel": "أقسام التأهيل", + "tabs": { + "flows": "المسارات", + "dashboard": "لوحة المعلومات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingService.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingService.json new file mode 100644 index 0000000000..8a25cc88bc --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerOnboardingService.json @@ -0,0 +1,7 @@ +{ + "triggerEvents": { + "entered": "عندما يدخل الموضوع في هذه الخطوة", + "completed": "عندما تكتمل هذه الخطوة", + "skipped": "عندما يتم تخطي هذه الخطوة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerOverviewTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerOverviewTab.json new file mode 100644 index 0000000000..48474dc62a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerOverviewTab.json @@ -0,0 +1,47 @@ +{ + "kpi": { + "totalLeads": { + "label": "إجمالي العملاء المحتملين", + "sub": "{{active}} نشط · {{lost}} مفقود" + }, + "conversionRate": { + "label": "معدل التحويل", + "sub": "{{converted}} من أصل {{total}} تم تحويلهم" + }, + "avgResponseTime": { + "label": "متوسط وقت الاستجابة", + "sub_zero": "لم يستجب {{count}} عميل محتمل", + "sub_one": "استجاب {{count}} عميل محتمل واحد", + "sub_two": "استجاب {{count}} عميلين محتملين", + "sub_few": "استجاب {{count}} عملاء محتملين", + "sub_many": "استجاب {{count}} عميلاً محتملاً", + "sub_other": "استجاب {{count}} عميل محتمل" + }, + "tatMet": { + "label": "الالتزام بوقت الاستجابة المتفق عليه", + "subCount": "{{count}} ضمن الوقت المتفق عليه", + "subDisabled": "الوقت المتفق عليه غير مفعّل في الإعدادات" + } + }, + "status": { + "title": "توزيع الحالات", + "donutAriaLabel": "مخطط دائري لتوزيع الحالات", + "totalUnit": "عميل محتمل" + }, + "trend": { + "title": "الاتجاه اليومي", + "submittedLegend": "مُرسَل", + "convertedLegend": "محوَّل", + "noData": "لا توجد بيانات", + "ariaLabel": "العملاء المحتملون المُرسَلون والمحوَّلون يوميًا" + }, + "breakdown": { + "bySource": "حسب المصدر", + "byTier": "حسب الفئة" + }, + "tier": { + "hot": "ساخن", + "warm": "دافئ", + "cold": "بارد" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerRecentLeadsPage.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerRecentLeadsPage.json new file mode 100644 index 0000000000..2a52318d4a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerRecentLeadsPage.json @@ -0,0 +1,165 @@ +{ + "nav": { + "title": "العملاء المحتملون الجدد" + }, + "heading": { + "loading": "جارٍ تحميل العملاء المحتملين…", + "leadsCount_zero": "{{count}} عميل محتمل", + "leadsCount_one": "عميل محتمل واحد ({{count}})", + "leadsCount_two": "عميلان محتملان ({{count}})", + "leadsCount_few": "{{count}} عملاء محتملين", + "leadsCount_many": "{{count}} عميلاً محتملاً", + "leadsCount_other": "{{count}} عميل محتمل" + }, + "slaOptions": { + "all": "جميع حالات الإجراءات", + "anyOverdueLabel": "فات موعد نهائي", + "anyOverdueHelper": "أول تواصل أو متابعة — أيهما تجاوز موعده", + "firstContactMissed": "فات موعد أول تواصل", + "firstContactComingUp": "أول تواصل قادم", + "followUpComingUp": "متابعة قادمة", + "followUpMissed": "فاتت المتابعة" + }, + "dateRangeOptions": { + "last24Hours": "آخر 24 ساعة", + "last7Days": "آخر 7 أيام", + "last15Days": "آخر 15 يومًا", + "last30Days": "آخر 30 يومًا", + "allTime": "كل الأوقات", + "customRange": "نطاق مخصص" + }, + "filters": { + "tier": { + "label": "جميع المستويات", + "hot": "ساخن", + "warm": "دافئ", + "cold": "بارد" + }, + "leadStatus": { + "label": "جميع العملاء المحتملين", + "active": "نشط (غير مسجَّل)", + "converted": "مسجَّل / محوَّل" + }, + "sla": { + "label": "جميع حالات SLA" + }, + "audience": { + "label": "جميع الجماهير", + "untitled": "جمهور بلا عنوان" + }, + "customDate": { + "setDates": "تحديد التواريخ", + "done": "تم", + "from": "من", + "to": "إلى" + } + }, + "toolbar": { + "leadSettings": "إعدادات العملاء المحتملين", + "showDeletedTitle": "إظهار العملاء المحتملين المحذوفين", + "backToActiveTitle": "العودة إلى العملاء المحتملين النشطين", + "deletedLeadsButton": "العملاء المحتملون المحذوفون", + "viewingDeletedButton": "عرض المحذوفين", + "exporting": "جارٍ التصدير…", + "export": "تصدير" + }, + "chips": { + "search": "بحث: {{query}}", + "audience": "الجمهور: {{names}}", + "tier": "المستوى: {{tiers}}", + "status": "الحالة: {{statuses}}", + "sla": "SLA: {{states}}", + "counsellor": "المستشار: {{names}}", + "source": "المصدر: {{source}}", + "customField": "{{field}}: {{value}}", + "dateRangeCustomWithDates": "التاريخ: {{from}} → {{to}}", + "dateRangeCustomFallback": "التاريخ: نطاق مخصص", + "dateRangeFallback": "نطاق التاريخ", + "fallbackSelected": "محدَّد", + "fallbackField": "الحقل", + "statusActive": "نشط", + "statusConverted": "محوَّل", + "unassigned": "غير مُسنَد", + "removeAriaLabel": "إزالة {{label}}", + "clearAll": "مسح الكل" + }, + "search": { + "placeholder": "البحث عن العملاء المحتملين", + "showing": "عرض", + "resultsPending": "من … نتائج", + "resultsCount_zero": "من {{count}} نتيجة", + "resultsCount_one": "من نتيجة واحدة ({{count}})", + "resultsCount_two": "من نتيجتين ({{count}})", + "resultsCount_few": "من {{count}} نتائج", + "resultsCount_many": "من {{count}} نتيجة", + "resultsCount_other": "من {{count}} نتيجة" + }, + "bulk": { + "selectedCount_zero": "{{count}} محدَّد", + "selectedCount_one": "محدَّد واحد ({{count}})", + "selectedCount_two": "محدَّدان ({{count}})", + "selectedCount_few": "{{count}} محدَّدة", + "selectedCount_many": "{{count}} محدَّدًا", + "selectedCount_other": "{{count}} محدَّد", + "selecting": "جارٍ التحديد…", + "selectAll_zero": "تحديد الكل ({{count}})", + "selectAll_one": "تحديد الكل ({{count}})", + "selectAll_two": "تحديد الكل ({{count}})", + "selectAll_few": "تحديد الكل ({{count}})", + "selectAll_many": "تحديد الكل ({{count}})", + "selectAll_other": "تحديد الكل ({{count}})", + "clear": "مسح", + "actionsButton": "إجراءات جماعية", + "assignLeads": "إسناد العملاء المحتملين", + "unassignLeads": "إلغاء إسناد العملاء المحتملين", + "restoreLeads": "استعادة العملاء المحتملين", + "deleteLeads": "حذف العملاء المحتملين" + }, + "emptyState": { + "errorTitle": "تعذّر تحميل العملاء المحتملين", + "errorDescription": "حدث خطأ أثناء جلب العملاء المحتملين. حاول مرة أخرى." + }, + "callReasons": { + "noSubmissionId": "لا يوجد معرّف إرسال لهذا العميل المحتمل", + "noPhone": "لا يوجد رقم هاتف مسجَّل لهذا العميل المحتمل", + "callInProgress": "جارٍ بدء مكالمة أخرى…" + }, + "export": { + "columns": { + "leadId": "معرّف العميل المحتمل", + "submittedAt": "تاريخ الإرسال", + "name": "الاسم", + "email": "البريد الإلكتروني", + "mobile": "الجوال", + "audience": "الجمهور", + "leadStatus": "حالة العميل المحتمل", + "counsellor": "المستشار", + "activityNotes": "النشاط والملاحظات", + "notesCount": "عدد الملاحظات", + "leadJourney": "رحلة العميل المحتمل (التصرف والملاحظات)" + }, + "notes": { + "defaultTitle": "ملاحظة", + "updatedBy": "حدَّثها - {{name}}", + "date": "التاريخ - {{date}}" + }, + "noLeadsToExport": "لا يوجد عملاء محتملون للتصدير", + "exportedLeads_zero": "تم تصدير {{count}} عميل محتمل", + "exportedLeads_one": "تم تصدير عميل محتمل واحد ({{count}})", + "exportedLeads_two": "تم تصدير عميلين محتملين ({{count}})", + "exportedLeads_few": "تم تصدير {{count}} عملاء محتملين", + "exportedLeads_many": "تم تصدير {{count}} عميلاً محتملاً", + "exportedLeads_other": "تم تصدير {{count}} عميل محتمل", + "exportFailed": "فشل تصدير العملاء المحتملين الجدد" + }, + "toasts": { + "leadRestored_zero": "تمت استعادة {{count}} عميل محتمل", + "leadRestored_one": "تمت استعادة عميل محتمل واحد ({{count}})", + "leadRestored_two": "تمت استعادة عميلين محتملين ({{count}})", + "leadRestored_few": "تمت استعادة {{count}} عملاء محتملين", + "leadRestored_many": "تمت استعادة {{count}} عميلاً محتملاً", + "leadRestored_other": "تمت استعادة {{count}} عميل محتمل", + "restoreFailed": "فشلت الاستعادة. حاول مرة أخرى.", + "selectAllFailed": "فشل تحديد جميع العملاء المحتملين" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerReportShared.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerReportShared.json new file mode 100644 index 0000000000..6eb96cfc21 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerReportShared.json @@ -0,0 +1,32 @@ +{ + "emptyHint": { + "default": "لا توجد بيانات في هذا النطاق الزمني." + }, + "skeleton": { + "ariaLabel": "جارٍ تحميل التقرير" + }, + "errorState": { + "deployPendingTitle": "يحتاج هذا التقرير إلى أحدث نسخة من الخادم الخلفي — النشر معلّق", + "genericTitle": "تعذّر تحميل هذا التقرير", + "deployPendingBody": "نقطة النهاية التي تشغّل هذا التبويب غير متوفرة بعد في هذه البيئة. ستعمل تلقائيًا بعد عملية النشر التالية للخادم الخلفي.", + "genericBody": "حدث خطأ أثناء جلب البيانات. تحقق من اتصالك وحاول مرة أخرى.", + "retry": "إعادة المحاولة" + }, + "export": { + "defaultLabel": "تصدير CSV", + "dialogTitle": "اختر أعمدة التصدير", + "selectAll": "تحديد الكل", + "deselectAll": "إلغاء تحديد الكل", + "columnsSelected": "{{selected}} / {{total}} عمود", + "cancel": "إلغاء", + "export": "تصدير" + }, + "breakdownBar": { + "converted_zero": "({{count}} تحويل)", + "converted_one": "({{count}} تحويل)", + "converted_two": "({{count}} تحويلان)", + "converted_few": "({{count}} تحويلات)", + "converted_many": "({{count}} تحويلًا)", + "converted_other": "({{count}} تحويل)" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerRevenueTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerRevenueTab.json new file mode 100644 index 0000000000..520cea7e6d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerRevenueTab.json @@ -0,0 +1,33 @@ +{ + "column": { + "revenue": "الإيرادات", + "payingLeads": "العملاء المحتملون الدافعون", + "payments": "المدفوعات", + "avgDealValue": "متوسط قيمة الصفقة", + "source": "المصدر", + "counsellor": "المستشار" + }, + "kpi": { + "revenueSub": "محصّلة من العملاء المحتملين المحوّلين" + }, + "dailyRevenue": { + "title": "الإيرادات اليومية", + "barTooltip_zero": "{{date}}: {{amount}} ({{count}} دفعة)", + "barTooltip_one": "{{date}}: {{amount}} ({{count}} دفعة)", + "barTooltip_two": "{{date}}: {{amount}} ({{count}} دفعتان)", + "barTooltip_few": "{{date}}: {{amount}} ({{count}} دفعات)", + "barTooltip_many": "{{date}}: {{amount}} ({{count}} دفعة)", + "barTooltip_other": "{{date}}: {{amount}} ({{count}} دفعة)", + "footnote": "يُسجَّل الإيراد بتاريخ الدفع للعملاء المحتملين الذين تم تحويل ملفاتهم." + }, + "bySource": { + "title": "الإيرادات حسب المصدر" + }, + "byCounsellor": { + "title": "الإيرادات حسب المستشار" + }, + "empty": { + "noRevenue": "لا توجد إيرادات في هذا النطاق الزمني.", + "noCounsellorRevenue": "لا توجد إيرادات منسوبة إلى مستشار في هذا النطاق الزمني." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerRoleAccessGrid.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerRoleAccessGrid.json new file mode 100644 index 0000000000..3be7c92b09 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerRoleAccessGrid.json @@ -0,0 +1,11 @@ +{ + "roleLabels": { + "admin": "مسؤول", + "student": "طالب", + "parent": "ولي أمر" + }, + "actions": { + "view": "عرض", + "edit": "تعديل" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerSendMessageDialog.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerSendMessageDialog.json new file mode 100644 index 0000000000..f2a8ede346 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerSendMessageDialog.json @@ -0,0 +1,144 @@ +{ + "dialogTitle": "إرسال رسالة", + "dialogDescription": "أرسل رسالة إلى العملاء المحتملين في «{{campaignName}}»", + "stepTitles": { + "selectChannel": "اختيار القناة", + "composeContent": "إنشاء المحتوى", + "mapVariables": "ربط المتغيرات", + "reviewSend": "مراجعة وإرسال" + }, + "systemFields": { + "fullName": "الاسم الكامل", + "email": "البريد الإلكتروني", + "mobileNumber": "رقم الجوال", + "city": "المدينة", + "region": "المنطقة", + "campaignName": "اسم الحملة", + "submittedAt": "تاريخ الإرسال", + "sourceType": "نوع المصدر" + }, + "channels": { + "whatsapp": { + "label": "واتساب", + "description": "إرسال رسائل بقوالب عبر واجهة واتساب بيزنس" + }, + "email": { + "label": "البريد الإلكتروني", + "description": "إرسال رسائل بريد إلكتروني بموضوع ونص HTML قابلين للتخصيص" + }, + "push": { + "label": "إشعار فوري", + "description": "إرسال إشعارات فورية إلى مستخدمي الجوال والويب" + }, + "systemAlert": { + "label": "تنبيه داخل النظام", + "description": "إرسال تنبيهات داخل التطبيق تظهر في مركز الإشعارات" + } + }, + "toasts": { + "loadWhatsappTemplatesFailed": "فشل تحميل قوالب واتساب", + "loadEmailTemplatesFailed": "فشل تحميل قوالب البريد الإلكتروني", + "loadTemplateContentFailed": "فشل تحميل محتوى القالب", + "noneSent": "تعذّر إرسال أي رسالة. راجع التفاصيل أدناه.", + "partialSuccess": "تم القبول لـ {{accepted}} من {{total}} — فشل {{failed}}. راجع التفاصيل أدناه.", + "sendSuccess_zero": "لم تُقبل الرسالة لأي مستلم ({{count}})", + "sendSuccess_one": "تم قبول الرسالة لمستلم واحد ({{count}})", + "sendSuccess_two": "تم قبول الرسالة لمستلمين اثنين ({{count}})", + "sendSuccess_few": "تم قبول الرسالة لـ {{count}} مستلمين", + "sendSuccess_many": "تم قبول الرسالة لـ {{count}} مستلمًا", + "sendSuccess_other": "تم قبول الرسالة لـ {{count}} مستلم", + "sendFailed": "فشل إرسال الرسالة" + }, + "whatsappStep": { + "templateLabel": "القالب", + "loadingTemplates": "جارٍ تحميل القوالب...", + "selectPlaceholder": "اختر قالبًا معتمدًا", + "emptyText": "لا يوجد قالب معتمد مطابق لبحثك.", + "languageCodeLabel": "رمز اللغة", + "templatePreviewLabel": "معاينة القالب" + }, + "emailStep": { + "templateLabel": "القالب", + "loadingTemplates": "جارٍ تحميل القوالب...", + "selectPlaceholder": "اختر قالبًا", + "emptyText": "لا يوجد قالب مطابق لبحثك.", + "customOption": "مخصص — الكتابة من الصفر", + "loadingTemplateContent": "جارٍ تحميل محتوى القالب...", + "emailTypeLabel": "نوع البريد الإلكتروني", + "utilityEmail": "بريد وظيفي", + "promotionalEmail": "بريد ترويجي", + "subjectLabel": "الموضوع", + "subjectPlaceholder": "أدخل موضوع البريد الإلكتروني...", + "bodyLabel": "النص", + "previewTab": "معاينة", + "editTab": "تحرير HTML", + "previewEmptyState": "اختر قالبًا أو انتقل إلى تحرير HTML لكتابة المحتوى.", + "bodyPlaceholder": "أدخل نص HTML للبريد الإلكتروني... استخدم {{token}} للمتغيرات", + "placeholderHintPrefix": "المتغيرات مثل", + "placeholderHintSuffix": "يتم استبدالها لكل مستلم عند الإرسال. اربطها في الخطوة التالية." + }, + "pushStep": { + "titleLabel": "العنوان", + "titlePlaceholder": "عنوان الإشعار...", + "bodyLabel": "النص", + "bodyPlaceholder": "نص الإشعار..." + }, + "headerMedia": { + "label": "رابط رأس {{kind}}", + "hint": "يحتوي هذا القالب على رأس من نوع {{kind}}، ويشترطه واتساب في كل إرسال — بدونه يفشل الإرسال لجميع المستلمين. تم تعبئته مسبقًا بالوسائط المعتمدة مع القالب.", + "imagePreviewAlt": "معاينة الرأس", + "kindLabels": { + "image": "صورة", + "video": "فيديو", + "document": "مستند" + } + }, + "variableMapping": { + "noVariables": "لا توجد متغيرات لربطها. يمكنك المتابعة.", + "mapHint": "اربط كل متغير في القالب بحقل بيانات العميل المحتمل.", + "columnVariable": "المتغير", + "columnMappedField": "الحقل المرتبط", + "selectFieldPlaceholder": "اختر حقلاً...", + "staticValueOption": "قيمة ثابتة…", + "staticValuePlaceholder": "مثال: \"طالب\"" + }, + "review": { + "messageSent": "تم إرسال الرسالة", + "sendCompleted": "اكتمل الإرسال", + "statusLabel": "الحالة", + "recipientsLabel": "المستلمون", + "acceptedLabel": "مقبول", + "failedLabel": "فشل", + "processingNotice": "قيد المعالجة في الخلفية (الدفعة: {{batchId}})", + "close": "إغلاق", + "channelHeading": "القناة", + "whatsappContentLabel": "القالب", + "otherContentLabel": "الموضوع / العنوان", + "contentFallback": "-", + "recipientsAllKnown_zero": "لا يوجد أعضاء في هذه الجمهور ({{count}})", + "recipientsAllKnown_one": "جميع أعضاء هذه الجمهور (عضو واحد) ({{count}})", + "recipientsAllKnown_two": "جميع أعضاء هذه الجمهور (عضوان) ({{count}})", + "recipientsAllKnown_few": "جميع أعضاء هذه الجمهور ({{count}} أعضاء)", + "recipientsAllKnown_many": "جميع أعضاء هذه الجمهور ({{count}} عضوًا)", + "recipientsAllKnown_other": "جميع أعضاء هذه الجمهور ({{count}} عضو)", + "recipientsAllUnknown": "جميع أعضاء هذه الجمهور", + "variableMappingsLabel": "ربط المتغيرات", + "staticValueLabel": "ثابت: \"{{value}}\"", + "sending": "جارٍ الإرسال...", + "sendButtonKnown_zero": "إرسال إلى لا أحد ({{count}})", + "sendButtonKnown_one": "إرسال إلى عضو واحد ({{count}})", + "sendButtonKnown_two": "إرسال إلى عضوين ({{count}})", + "sendButtonKnown_few": "إرسال إلى {{count}} أعضاء", + "sendButtonKnown_many": "إرسال إلى {{count}} عضوًا", + "sendButtonKnown_other": "إرسال إلى {{count}} عضو", + "sendButtonUnknown": "إرسال إلى جميع الأعضاء", + "statusValues": { + "success": "ناجح", + "failed": "فشل" + } + }, + "footer": { + "back": "رجوع", + "next": "التالي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerSourcesTab.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerSourcesTab.json new file mode 100644 index 0000000000..780c69f07c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerSourcesTab.json @@ -0,0 +1,26 @@ +{ + "title": "أداء المصادر", + "columns": { + "source": "المصدر", + "leads": "العملاء المحتملون", + "connected": "المتصلون", + "interested": "المهتمون", + "won": "المكتسبون", + "convRate": "نسبة التحويل %", + "revenue": "الإيرادات", + "spend": "الإنفاق", + "cpl": "تكلفة العميل المحتمل", + "roi": "العائد على الاستثمار" + }, + "csvTotalRow": "الإجمالي", + "footerTotal": "الإجمالي", + "unknownSource": "غير معروف", + "emptyMessage": "لا توجد عملاء محتملون في هذا النطاق الزمني.", + "waveTwoCell": { + "comingSoon": "متاح قريبًا مع تتبع الإنفاق" + }, + "footnote": { + "connected": "المتصلون = العملاء المحتملون الذين لديهم مكالمة متصلة واحدة على الأقل (وفق إعدادات الاتصالات لديك).", + "interested": "المهتمون = العملاء المحتملون الذين دخلوا في حالة الاهتمام خلال هذا النطاق الزمني." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerStatusDropdown.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerStatusDropdown.json new file mode 100644 index 0000000000..ffdfb4438f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerStatusDropdown.json @@ -0,0 +1,6 @@ +{ + "placeholder": "اختر الحالة", + "optionActive": "نشط", + "optionInactive": "غير نشط", + "optionDraft": "مسودة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerStepDialog.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerStepDialog.json new file mode 100644 index 0000000000..b93b8eec1d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerStepDialog.json @@ -0,0 +1,46 @@ +{ + "heading": { + "add": "إضافة خطوة", + "edit": "تعديل الخطوة" + }, + "fields": { + "stepName": { + "label": "اسم الخطوة", + "placeholder": "مثال: تعبئة نموذج التسجيل" + }, + "coursePool": { + "label": "الدورة (الدورات) التي يمكن لهذه الخطوة التسجيل فيها", + "loadingPlaceholder": "جارٍ تحميل الدورات…", + "emptyPlaceholder": "اتركه فارغًا للسماح للمسؤول باختيار أي دورة…", + "helperText": "اتركه فارغًا ليختار المسؤول المُنفِّذ أي دورة عند التسجيل — لا تحتاج الرحلة لإعادة بناء عند إضافة دورة جديدة. اختر دورة (دورات) محددة لحصر الاختيار على تلك فقط." + } + }, + "switches": { + "optional": "خطوة اختيارية (يمكن تخطيها)", + "grantsStudentRole": "منح دور الطالب عند الإتمام", + "sendsLoginCredentials": "إرسال بيانات تسجيل الدخول عند الإتمام", + "createStudent": "إنشاء طالب من هذا النموذج عند الإتمام", + "skipIfAlreadyEnrolled": "إكمال هذه الخطوة تلقائيًا إذا كان الطالب مسجلاً بالفعل في دورة" + }, + "sections": { + "stepAccess": "صلاحية الوصول للخطوة (من يمكنه عرض/تعديل هذه الخطوة)", + "formFields": "حقول النموذج", + "loadingFields": "جارٍ تحميل الحقول الحالية…" + }, + "actions": { + "cancel": "إلغاء", + "saving": "جارٍ الحفظ…", + "saveStep": "حفظ الخطوة", + "addStep": "إضافة خطوة" + }, + "toasts": { + "stepUpdated": "تم تحديث الخطوة", + "stepAdded": "تمت إضافة الخطوة", + "saveError": "تعذّر حفظ الخطوة. يُرجى المحاولة مرة أخرى.", + "pendingFieldSelection": "لقد اخترت حقلاً ولكنك لم تنقر بعد على \"إرفاق\" — قم بإرفاقه (أو امسح التحديد) قبل الحفظ، وإلا سيُفقد." + }, + "schema": { + "stepNameRequired": "اسم الخطوة مطلوب", + "stepNameMaxLength": "يجب ألا يتجاوز 150 حرفًا" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerStepFieldConfigEditor.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerStepFieldConfigEditor.json new file mode 100644 index 0000000000..c5b0284f5f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerStepFieldConfigEditor.json @@ -0,0 +1,13 @@ +{ + "untitledField": "حقل بلا عنوان", + "newFieldBadge": "(جديد)", + "mandatoryLabel": "إلزامي", + "hiddenLabel": "مخفي", + "accessButton": "الصلاحيات", + "emptyState": "لم يتم إرفاق أي حقول بعد. أرفق حقلاً مخصصًا موجودًا أو أنشئ حقلاً جديدًا.", + "pickerPlaceholderEmpty": "لا مزيد من الحقول لإرفاقها", + "pickerPlaceholderDefault": "إرفاق حقل (حقول) موجودة…", + "attachButton": "إرفاق", + "attachButtonWithCount": "إرفاق ({{count}})", + "createNewFieldButton": "إنشاء حقل جديد" +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerSubmitAudienceLead.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerSubmitAudienceLead.json new file mode 100644 index 0000000000..8fb456f3d9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerSubmitAudienceLead.json @@ -0,0 +1,5 @@ +{ + "errors": { + "submitFailed": "تعذر إرسال الرد. يرجى المحاولة مرة أخرى." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerUseCreateAudienceCampaign.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerUseCreateAudienceCampaign.json new file mode 100644 index 0000000000..f7eac1ebb9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerUseCreateAudienceCampaign.json @@ -0,0 +1,8 @@ +{ + "success": { + "createCampaign": "تم إنشاء الحملة بنجاح" + }, + "errors": { + "createCampaign": "فشل إنشاء الحملة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/audienceManagerUseUpdateAudienceCampaign.json b/frontend-admin-dashboard/public/locales/ar/audienceManagerUseUpdateAudienceCampaign.json new file mode 100644 index 0000000000..229cea9812 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/audienceManagerUseUpdateAudienceCampaign.json @@ -0,0 +1,8 @@ +{ + "success": { + "updateCampaign": "تم التحديث بنجاح" + }, + "errors": { + "updateCampaign": "فشل تحديث الحملة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/authoredCourses.json b/frontend-admin-dashboard/public/locales/ar/authoredCourses.json new file mode 100644 index 0000000000..3e2c7bc27e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/authoredCourses.json @@ -0,0 +1,46 @@ +{ + "copyCreated": "تم إنشاء نسخة قابلة للتعديل بنجاح", + "copyFailed": "فشل إنشاء نسخة قابلة للتعديل", + "submittedForReview": "تم إرسال الدورة للمراجعة", + "submitFailed": "فشل الإرسال للمراجعة", + "courseNotFound": "الدورة غير موجودة", + "deletedSuccess": "تم حذف الدورة بنجاح", + "deleteFailed": "فشل حذف الدورة", + "status": { + "published": "منشورة", + "draft": "مسودة", + "inReview": "قيد المراجعة" + }, + "errorLoadingCourses": "خطأ في تحميل الدورات", + "retry": "إعادة المحاولة", + "searchCourses": "ابحث عن الدورات...", + "noCoursesFoundTitle": "لم يتم العثور على دورات", + "noCoursesMatchSearch": "لا توجد دورات مطابقة لـ \"{{search}}\"", + "noCoursesCreatedYet": "لم تُنشئ أي دورة بعد.", + "moreCount": "+{{count}} أخرى", + "copyBadge": "نسخة", + "inCatalog": "في الكتالوج", + "private": "خاص", + "enrolledCount": "{{count}} {{term}} مسجّل", + "updatedAgo": "تم التحديث قبل {{time}}", + "viewCourse": "عرض الدورة", + "submitForReview": "إرسال للمراجعة", + "viewHistory": "عرض السجل", + "copyToEdit": "نسخ للتعديل", + "deleteConfirmTitle": "هل أنت متأكد أنك تريد حذف هذه الدورة؟", + "deleteConfirmBody": "لا يمكن التراجع عن هذا الإجراء. سيؤدي هذا إلى حذف دورتك وبياناتها نهائيًا من خوادمنا.", + "cancel": "إلغاء", + "delete": "حذف", + "courseHistory": "سجل الدورة", + "courseHistoryWithName": "سجل الدورة · {{name}}", + "loadingHistory": "جارٍ تحميل السجل…", + "loadHistoryFailed": "فشل تحميل سجل الدورة.", + "courseIdLabel": "معرّف الدورة:", + "statusLabel": "الحالة:", + "versionLabel": "الإصدار:", + "originalLabel": "الأصل:", + "auditLogs": "سجلات التدقيق", + "byActor": "بواسطة: {{actor}}", + "commentLabel": "تعليق: {{comment}}", + "noHistoryAvailable": "لا يوجد سجل متاح." +} diff --git a/frontend-admin-dashboard/src/locales/ar/common.json b/frontend-admin-dashboard/public/locales/ar/common.json similarity index 100% rename from frontend-admin-dashboard/src/locales/ar/common.json rename to frontend-admin-dashboard/public/locales/ar/common.json diff --git a/frontend-admin-dashboard/public/locales/ar/courseDetails.json b/frontend-admin-dashboard/public/locales/ar/courseDetails.json new file mode 100644 index 0000000000..89e8ce4bb4 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/courseDetails.json @@ -0,0 +1,55 @@ +{ + "restriction": { + "banner": "التعديل مقيّد: هذه الدورة {{status}}.", + "statusPublished": "منشورة", + "statusUnderReview": "قيد المراجعة", + "publishedHint": "انتقل إلى دوراتي لإنشاء نسخة قابلة للتعديل.", + "reviewHint": "لا يمكنك تعديل المحتوى أثناء وجوده قيد المراجعة." + }, + "addedToCatalog": "أُضيف إلى الكتالوج", + "offlineSettings": "إعدادات عدم الاتصال", + "advancedIds": { + "moreOptions": "المزيد من الخيارات", + "title": "متقدم", + "notAvailable": "{{label}} غير متاح", + "copied": "تم نسخ {{label}}", + "copyFailed": "فشل النسخ", + "courseId": "معرّف {{course}}", + "packageSessionId": "معرّف جلسة الحزمة", + "sessionId": "معرّف {{session}}", + "levelId": "معرّف {{level}}" + }, + "viewLess": "عرض أقل", + "viewMore": "عرض المزيد", + "youtubePlayerTitle": "مشغّل فيديو يوتيوب", + "videoNotSupported": "متصفحك لا يدعم وسم الفيديو.", + "courseBannerAlt": "شعار {{course}}", + "batchSubgroup": "الدفعة / المجموعة الفرعية", + "selectBatch": "اختر الدفعة", + "errorLoadingSlideCounts": "خطأ في تحميل عدد الشرائح", + "courseHighlights": "أبرز ما في {{course}}", + "whatYoullLearn": "ماذا ستتعلم؟", + "aboutThis": "عن هذا {{course}}", + "whoShouldJoin": "من يجب أن ينضم؟", + "authors": "المؤلفون", + "loadingInstructors": "جارٍ تحميل المعلمين...", + "dripCondition": { + "saveFailed": "فشل حفظ شرط النشر التدريجي. يرجى المحاولة مرة أخرى.", + "updateFailed": "فشل تحديث شرط النشر التدريجي. يرجى المحاولة مرة أخرى.", + "deleteFailed": "فشل حذف شرط النشر التدريجي. يرجى المحاولة مرة أخرى." + }, + "slideType": { + "aiContent": "محتوى بالذكاء الاصطناعي", + "video": "فيديو", + "codeEditor": "محرر الأكواد", + "pdf": "PDF", + "document": "مستند", + "presentation": "عرض تقديمي", + "jupyterNotebook": "دفتر Jupyter", + "scratchProject": "مشروع Scratch", + "question": "سؤال", + "quiz": "اختبار قصير", + "assignment": "واجب", + "defaultSlide": "شريحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/courseList.json b/frontend-admin-dashboard/public/locales/ar/courseList.json new file mode 100644 index 0000000000..7e18cb103a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/courseList.json @@ -0,0 +1,26 @@ +{ + "filters": "عوامل التصفية", + "clear": "مسح", + "closeFilters": "إغلاق عوامل التصفية", + "academicSession": "الفصل الدراسي", + "allTerm": "كل الـ {{term}}", + "noOptionsAvailable": "لا توجد خيارات متاحة", + "applyFilters": "تطبيق عوامل التصفية", + "filterCourses": "تصفية الـ {{courses}}", + "clearSearch": "مسح البحث", + "searchCourses": "ابحث عن {{courses}}...", + "sortBy": "الترتيب حسب", + "oldest": "الأقدم", + "newest": "الأحدث", + "moreCount": "+{{count}} أخرى", + "inCatalog": "في الكتالوج", + "private": "خاص", + "enrolledCount": "{{count}} {{term}} مسجّل", + "viewCourse": "عرض الـ {{course}}", + "deleteConfirmTitle": "هل أنت متأكد أنك تريد حذف هذا الـ {{course}}؟", + "deleteConfirmBody": "لا يمكن التراجع عن هذا الإجراء.", + "cancel": "إلغاء", + "deleting": "جارٍ الحذف...", + "confirm": "تأكيد", + "noCoursesFound": "لم يتم العثور على {{courses}}." +} diff --git a/frontend-admin-dashboard/public/locales/ar/courseMaterial.json b/frontend-admin-dashboard/public/locales/ar/courseMaterial.json new file mode 100644 index 0000000000..15f09b483d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/courseMaterial.json @@ -0,0 +1,18 @@ +{ + "deletedSuccess": "تم حذف الدورة بنجاح", + "deleteFailed": "فشل حذف الدورة", + "exploreTerm": "استكشف الـ {{term}}", + "tabs": { + "authoredTerm": "الـ {{term}} التي أنشأتها", + "allTerm": "كل الـ {{term}}", + "termInReview": "{{term}} قيد المراجعة", + "termApproval": "الموافقة على الـ {{term}}" + }, + "noTermFound": "لم يتم العثور على {{term}}", + "tryAddingNewTerm": "جرّب إضافة {{term}} جديد.", + "courseSettings": "إعدادات الدورة", + "createTermWithAI": "إنشاء {{term}} بالذكاء الاصطناعي", + "exploreSubtitle": "نظّم مواردك التعليمية وارفعها وتتبّعها بسهولة في مكان واحد.", + "noTermFoundForTab": "لم يتم العثور على {{term}} لهذا التبويب.", + "noTermFoundForFilters": "لم يتم العثور على {{term}} للفلاتر المطبّقة." +} diff --git a/frontend-admin-dashboard/public/locales/ar/courseStructure.json b/frontend-admin-dashboard/public/locales/ar/courseStructure.json new file mode 100644 index 0000000000..3f2887b605 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/courseStructure.json @@ -0,0 +1,74 @@ +{ + "chapterHeader": { + "updating": "جارٍ التحديث...", + "saveChanges": "حفظ التغييرات" + }, + "thumbnailAlt": "صورة مصغّرة", + "importContent": { + "noBatchSelected": "لم يتم تحديد دفعة — اختر جلسة/مستوى أولاً.", + "sameBatch": "الدفعة المصدر والدفعة الهدف متطابقتان — اختر دفعة مختلفة.", + "targetHasContent": "تحتوي هذه الدفعة بالفعل على محتوى خاص بها. ربطها سيؤدي إلى مزج المحتوى المرتبط بالمحتوى الحالي. اختر \"إنشاء نسخة منفصلة\" بدلاً من ذلك.", + "summary": "{{subjects}} مادة، {{modules}} وحدة، {{chapters}} فصل، {{slides}} شريحة", + "modeLinked": "مرتبط بالمصدر", + "modeCopied": "تم نسخه", + "warningSuffix": "{{summary}}. {{count}} تحذير(ات) بشأن شرط النشر التدريجي.", + "successMessage": "المحتوى {{mode}} ({{summary}}).", + "failed": "فشل استيراد المحتوى إلى هذه الدفعة.", + "importing": "جارٍ الاستيراد…", + "importButton": "استيراد محتوى" + }, + "dripConditions": { + "saveFailed": "فشل حفظ شروط النشر التدريجي. يرجى المحاولة مرة أخرى.", + "reorderSubjectsFailed": "فشل إعادة ترتيب المواد. تم التراجع عن التغييرات.", + "reorderModulesFailed": "فشل إعادة ترتيب الوحدات. تم التراجع عن التغييرات.", + "reorderChaptersFailed": "فشل إعادة ترتيب الفصول. تم التراجع عن التغييرات." + }, + "untitledSlide": "شريحة بلا عنوان", + "courseStructureHeading": "هيكل الدورة", + "scheduleUnlock": "جدولة فتح القفل", + "collapseAll": "طي الكل", + "expandAll": "توسيع الكل", + "actions": { + "edit": "تعديل", + "delete": "حذف", + "unlockRule": "قاعدة فتح القفل", + "offlineAvailability": "التوفر دون اتصال" + }, + "add": "إضافة", + "noSlidesInChapter": "لا توجد {{term}} في هذا الفصل.", + "manageTeachers": "إدارة {{teacher}}", + "viewAndManageTeachers": "عرض وإدارة {{teacher}} المعيّنين لهذه الدفعة.", + "selectBatchFor": { + "liveSessions": "اختر دفعة لعرض جلساتها المباشرة.", + "reports": "اختر دفعة لعرض تقاريرها.", + "offlineDownloads": "اختر دفعة لعرض تنزيلاتها دون اتصال.", + "discussion": "اختر دفعة لعرض نقاشها." + }, + "contentStructureHeading": "هيكل المحتوى", + "navigateFolders": "تصفّح محتوى دورتك باستخدام المجلدات", + "numbering": { + "subject": "المادة {{number}}", + "module": "الوحدة {{number}}", + "chapter": "الفصل {{number}}", + "slide": "الشريحة {{number}}" + }, + "restriction": { + "banner": "التعديل مقيّد: هذه الدورة {{status}}.", + "statusPublished": "منشورة", + "statusUnderReview": "قيد المراجعة", + "statusRestricted": "مقيّدة", + "publishedHint": "انتقل إلى دوراتي لإنشاء نسخة قابلة للتعديل.", + "reviewHint": "لا يمكنك تعديل المحتوى أثناء وجوده قيد المراجعة.", + "restrictedHint": "لا يمكنك تعديل هذا المحتوى." + }, + "updatingStructure": "جارٍ تحديث هيكل الدورة...", + "editSubjectHeading": "تعديل المادة", + "editModuleHeading": "تعديل الوحدة", + "editChapterHeading": "تعديل الفصل", + "confirmDeletion": { + "title": "تأكيد الحذف", + "body": "هل أنت متأكد أنك تريد حذف \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء.", + "cancel": "إلغاء" + }, + "defaultNodeName": "هذا المحتوى" +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardAccountDetailsEdit.json b/frontend-admin-dashboard/public/locales/ar/dashboardAccountDetailsEdit.json new file mode 100644 index 0000000000..2200c5caa4 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardAccountDetailsEdit.json @@ -0,0 +1,37 @@ +{ + "dialogTitle": "تعديل تفاصيل الحساب", + "username": { + "sectionTitle": "اسم المستخدم", + "label": "اسم المستخدم*", + "placeholder": "أدخل اسم المستخدم", + "current": "الحالي: {{username}}" + }, + "password": { + "sectionTitle": "تغيير كلمة المرور", + "newLabel": "كلمة المرور الجديدة*", + "newPlaceholder": "أدخل كلمة المرور الجديدة", + "confirmLabel": "تأكيد كلمة المرور الجديدة*", + "confirmPlaceholder": "أكّد كلمة المرور الجديدة", + "minLengthHint": "4 أحرف على الأقل", + "match": "كلمتا المرور متطابقتان", + "mismatch": "كلمتا المرور غير متطابقتين" + }, + "validation": { + "usernameMin": "يجب أن يتكون اسم المستخدم من 3 أحرف على الأقل", + "usernameMax": "يجب أن يكون اسم المستخدم أقل من 50 حرفًا", + "newPasswordMin": "يجب أن تتكون كلمة المرور الجديدة من 4 أحرف على الأقل", + "confirmPasswordRequired": "يرجى تأكيد كلمة المرور" + }, + "toast": { + "loadUserFailed": "فشل تحميل بيانات المستخدم", + "userIdMissing": "لم يتم العثور على معرّف المستخدم. يرجى المحاولة مرة أخرى.", + "updateSuccess": "تم تحديث تفاصيل الحساب بنجاح!", + "usernameExists": "اسم المستخدم هذا موجود بالفعل. يرجى اختيار اسم مستخدم مختلف.", + "updateFailed": "فشل تحديث تفاصيل الحساب. يرجى المحاولة مرة أخرى." + }, + "actions": { + "cancel": "إلغاء", + "update": "تحديث التفاصيل", + "updating": "جارٍ التحديث..." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardAddTeachers.json b/frontend-admin-dashboard/public/locales/ar/dashboardAddTeachers.json new file mode 100644 index 0000000000..f364ce4041 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardAddTeachers.json @@ -0,0 +1,32 @@ +{ + "trigger": { + "inviteUsers": "دعوة مستخدمين" + }, + "dialog": { + "title": "إضافة عضو فريق" + }, + "tabs": { + "existingMembers": "الأعضاء الحاليون", + "inviteNew": "دعوة عضو جديد" + }, + "existing": { + "searchPlaceholder": "ابحث عن أعضاء الفريق...", + "noMatchingMembers": "لم يتم العثور على أعضاء مطابقين", + "noMembersAvailable": "لا يوجد أعضاء فريق متاحون", + "assignButton": "تعيين {{name}}" + }, + "invite": { + "form": { + "namePlaceholder": "الاسم الكامل (الأول والأخير)", + "nameLabel": "الاسم الكامل", + "emailPlaceholder": "أدخل البريد الإلكتروني", + "emailLabel": "البريد الإلكتروني" + }, + "inviteButton": "دعوة مستخدم" + }, + "validation": { + "nameRequired": "الاسم الكامل مطلوب", + "emailRequired": "البريد الإلكتروني مطلوب", + "emailInvalid": "صيغة البريد الإلكتروني غير صحيحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardAdminProfile.json b/frontend-admin-dashboard/public/locales/ar/dashboardAdminProfile.json new file mode 100644 index 0000000000..11c3000aaf --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardAdminProfile.json @@ -0,0 +1,41 @@ +{ + "trigger": { + "editProfile": "تعديل الملف الشخصي" + }, + "dialog": { + "title": "تعديل الملف الشخصي" + }, + "logoAlt": "الشعار", + "menu": { + "uploadNew": "رفع صورة جديدة", + "removeImage": "إزالة الصورة" + }, + "fields": { + "name": { + "label": "اسم الملف الشخصي", + "placeholder": "الاسم الكامل (الأول والأخير)" + }, + "email": { + "label": "البريد الإلكتروني", + "placeholder": "you@email.com" + }, + "phone": { + "label": "رقم الجوال", + "placeholder": "123 456 7890" + } + }, + "roles": { + "label": "نوع الدور", + "empty": "لا توجد أدوار مُسندة", + "managedNote": "تُدار الأدوار من قِبل مسؤول مؤسستك ولا يمكن تغييرها من هنا." + }, + "sections": { + "contactInformation": "معلومات الاتصال" + }, + "actions": { + "saveChanges": "حفظ التغييرات" + }, + "toast": { + "updateSuccess": "تم تحديث بياناتك بنجاح!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardAdminProfileSchema.json b/frontend-admin-dashboard/public/locales/ar/dashboardAdminProfileSchema.json new file mode 100644 index 0000000000..2907cb1c1f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardAdminProfileSchema.json @@ -0,0 +1,6 @@ +{ + "validation": { + "nameRequired": "الاسم مطلوب", + "invalidEmail": "عنوان البريد الإلكتروني غير صالح" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardAnalyticsErrorDisplay.json b/frontend-admin-dashboard/public/locales/ar/dashboardAnalyticsErrorDisplay.json new file mode 100644 index 0000000000..02df7d6ec5 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardAnalyticsErrorDisplay.json @@ -0,0 +1,10 @@ +{ + "errorMessage": "تعذر تحميل {{widgetName}}", + "sessionExpiredNotice": "ربما تكون جلستك قد انتهت أو تتطلب إعادة المصادقة.", + "debugInfoLabel": "معلومات التصحيح", + "actions": { + "refreshPage": "تحديث الصفحة", + "login": "تسجيل الدخول", + "tryAgain": "إعادة المحاولة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardAnalyticsErrorHandler.json b/frontend-admin-dashboard/public/locales/ar/dashboardAnalyticsErrorHandler.json new file mode 100644 index 0000000000..ea7e17d9ae --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardAnalyticsErrorHandler.json @@ -0,0 +1,14 @@ +{ + "errors": { + "authRequired": "المصادقة مطلوبة. يرجى تحديث الصفحة أو تسجيل الدخول مرة أخرى.", + "sessionExpired": "انتهت الجلسة. يرجى تسجيل الدخول مرة أخرى.", + "accessDenied": "تم رفض الوصول. ليس لديك صلاحية لعرض هذه البيانات.", + "notFound": "لم يتم العثور على بيانات التحليلات لهذه المؤسسة.", + "tooManyRequests": "طلبات كثيرة جدًا. يرجى الانتظار قليلاً ثم المحاولة مرة أخرى.", + "serverUnavailable": "الخادم غير متاح مؤقتًا. نحن نعمل على إصلاح ذلك.", + "serviceError": "خطأ في الخدمة ({{status}}). يرجى المحاولة مرة أخرى لاحقًا.", + "connectionLost": "انقطع الاتصال. يرجى التحقق من اتصالك بالإنترنت والمحاولة مرة أخرى.", + "requestTimedOut": "انتهت مهلة الطلب. يستغرق الخادم وقتًا طويلاً للاستجابة.", + "genericFallback": "تعذر تحميل البيانات. يرجى المحاولة مرة أخرى لاحقًا." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardAnalyticsWidgets.json b/frontend-admin-dashboard/public/locales/ar/dashboardAnalyticsWidgets.json new file mode 100644 index 0000000000..2802a266f2 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardAnalyticsWidgets.json @@ -0,0 +1,10 @@ +{ + "header": { + "title": "نشاط المتعلمين", + "subtitle": "رؤى فورية واتجاهات النشاط" + }, + "actions": { + "viewFullInsights": "عرض التحليلات الكاملة" + }, + "loading": "جارٍ تحميل التحليلات..." +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardAssessmentCenterWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardAssessmentCenterWidget.json new file mode 100644 index 0000000000..fc54cf4bf3 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardAssessmentCenterWidget.json @@ -0,0 +1,16 @@ +{ + "heading": { + "title": "مركز التقييم", + "description": "إنشاء التقييمات وإدارتها وتقييمها" + }, + "badges": { + "tests": "اختبارات", + "papers": "أوراق أسئلة" + }, + "features": { + "createAssessment": "إنشاء تقييم", + "viewAssessments": "عرض التقييمات", + "questionPapers": "أوراق الأسئلة", + "evaluationCenter": "مركز التقييم النهائي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardBatchAndSubjectSelection.json b/frontend-admin-dashboard/public/locales/ar/dashboardBatchAndSubjectSelection.json new file mode 100644 index 0000000000..c8b30e9093 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardBatchAndSubjectSelection.json @@ -0,0 +1,18 @@ +{ + "header": { + "title": "اختر الدفعة و{{subjectPlural}}" + }, + "search": { + "placeholder": "البحث عن الدفعات..." + }, + "subjects": { + "selectLabel": "اختر المواد", + "selectPlaceholder": "اختر المواد", + "description": "يمكنك اختيار عدة مواد لهذه الدفعة", + "requiredError": "مطلوب مادة واحدة على الأقل" + }, + "emptyState": { + "noMatchingBatches": "لا توجد دفعات مطابقة", + "noBatchesAvailable": "لا توجد دفعات متاحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardCompletionStatusComponent.json b/frontend-admin-dashboard/public/locales/ar/dashboardCompletionStatusComponent.json new file mode 100644 index 0000000000..1f057de4e5 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardCompletionStatusComponent.json @@ -0,0 +1,3 @@ +{ + "visitorsLabel": "الزوار" +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardCurrentlyActiveUsersWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardCurrentlyActiveUsersWidget.json new file mode 100644 index 0000000000..648daabbab --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardCurrentlyActiveUsersWidget.json @@ -0,0 +1,26 @@ +{ + "header": { + "title": "المستخدمون النشطون حاليًا", + "subtitle": "جلسات المستخدمين ونشاطهم في الوقت الفعلي" + }, + "stats": { + "onlineNow": "متصل الآن", + "moreUsers_zero": "+{{count}} مستخدم إضافي متصل", + "moreUsers_one": "+{{count}} مستخدم واحد إضافي متصل", + "moreUsers_two": "+{{count}} مستخدمان إضافيان متصلان", + "moreUsers_few": "+{{count}} مستخدمين إضافيين متصلين", + "moreUsers_many": "+{{count}} مستخدمًا إضافيًا متصلًا", + "moreUsers_other": "+{{count}} مستخدم إضافي متصل" + }, + "error": { + "message": "فشل تحميل المستخدمين النشطين" + }, + "empty": { + "message": "لا يوجد مستخدمون نشطون في الوقت الحالي" + }, + "device": { + "desktop": "سطح المكتب", + "mobile": "الجوال", + "tablet": "الجهاز اللوحي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardDailyActivityTrendWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardDailyActivityTrendWidget.json new file mode 100644 index 0000000000..91ab1aa192 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardDailyActivityTrendWidget.json @@ -0,0 +1,35 @@ +{ + "header": { + "title": "اتجاه النشاط اليومي", + "subtitle": "نشاط المستخدمين خلال الأسبوع الماضي" + }, + "error": { + "message": "تعذر تحميل بيانات الاتجاه" + }, + "empty": { + "message": "لم يُسجَّل أي نشاط هذا الأسبوع" + }, + "stats": { + "users": "المستخدمون", + "sessions": "الجلسات", + "avgDuration": "متوسط المدة", + "avgDurationUnit": "د" + }, + "chart": { + "months": [ + "يناير", + "فبراير", + "مارس", + "أبريل", + "مايو", + "يونيو", + "يوليو", + "أغسطس", + "سبتمبر", + "أكتوبر", + "نوفمبر", + "ديسمبر" + ], + "dateLabel": "{{day}} {{month}}" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardDeviceUsageWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardDeviceUsageWidget.json new file mode 100644 index 0000000000..53a47d99f3 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardDeviceUsageWidget.json @@ -0,0 +1,30 @@ +{ + "header": { + "title": "استخدام الأجهزة", + "subtitle": "توزيع المنصات بين المستخدمين" + }, + "error": { + "message": "فشل تحميل بيانات الأجهزة" + }, + "empty": { + "message": "لا تتوفر بيانات عن الأجهزة" + }, + "device": { + "desktop": "سطح المكتب", + "mobile": "الجوال", + "tablet": "الجهاز اللوحي" + }, + "tooltip": { + "usage": "الاستخدام: {{value}}", + "users": "المستخدمون: {{value}}", + "percentOfTotal": "{{percentage}}٪ من الإجمالي" + }, + "stats": { + "users_zero": "{{count}} مستخدم", + "users_one": "{{count}} مستخدم واحد", + "users_two": "{{count}} مستخدمان", + "users_few": "{{count}} مستخدمين", + "users_many": "{{count}} مستخدمًا", + "users_other": "{{count}} مستخدم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardEditLiveLinkDialog.json b/frontend-admin-dashboard/public/locales/ar/dashboardEditLiveLinkDialog.json new file mode 100644 index 0000000000..f5c84c6be2 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardEditLiveLinkDialog.json @@ -0,0 +1,10 @@ +{ + "heading": "تعديل رابط الحصة المباشرة", + "currentLinkLabel": "الرابط الحالي", + "newLinkLabel": "الرابط الجديد", + "newLinkPlaceholder": "أدخل الرابط الجديد", + "actions": { + "cancel": "إلغاء", + "update": "تحديث" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardEditProfileComponent.json b/frontend-admin-dashboard/public/locales/ar/dashboardEditProfileComponent.json new file mode 100644 index 0000000000..f803b0ba07 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardEditProfileComponent.json @@ -0,0 +1,72 @@ +{ + "trigger": { + "editInstitute": "تعديل المؤسسة", + "addDetails": "إضافة التفاصيل" + }, + "dialog": { + "title": "تعديل المؤسسة" + }, + "logoAlt": "الشعار", + "fields": { + "instituteName": { + "label": "اسم المؤسسة", + "placeholder": "اسم المؤسسة" + }, + "instituteType": { + "label": "نوع المؤسسة" + }, + "instituteEmail": { + "label": "البريد الإلكتروني للمؤسسة", + "placeholder": "البريد الإلكتروني للمؤسسة" + }, + "institutePhoneNumber": { + "label": "رقم هاتف المؤسسة", + "placeholder": "123 456 7890" + }, + "instituteWebsite": { + "label": "الموقع الإلكتروني للمؤسسة", + "placeholder": "الموقع الإلكتروني للمؤسسة" + }, + "instituteAddress": { + "label": "العنوان", + "placeholder": "سطر العنوان 1" + }, + "instituteCity": { + "label": "المدينة/القرية", + "placeholder": "اختر المدينة/القرية" + }, + "instituteState": { + "label": "الولاية", + "placeholder": "اختر الولاية" + }, + "instituteCountry": { + "label": "الدولة", + "placeholder": "اختر الدولة" + }, + "institutePinCode": { + "label": "الرمز البريدي", + "placeholder": "أدخل الرمز البريدي" + } + }, + "sections": { + "contactInformation": "معلومات الاتصال", + "locationDetails": "تفاصيل الموقع", + "instituteTheme": "سمة المؤسسة" + }, + "theme": { + "current": "الحالي", + "changeTheme": "تغيير السمة" + }, + "actions": { + "saveChanges": "حفظ التغييرات" + }, + "toast": { + "updateSuccess": "تم تحديث بياناتك بنجاح!" + }, + "validation": { + "instituteNameRequired": "اسم المؤسسة مطلوب", + "instituteTypeRequired": "الرجاء اختيار نوع المؤسسة", + "invalidEmail": "عنوان البريد الإلكتروني غير صالح", + "invalidWebsite": "رابط الموقع الإلكتروني غير صالح" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardEnrollLearnersWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardEnrollLearnersWidget.json new file mode 100644 index 0000000000..da70675b56 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardEnrollLearnersWidget.json @@ -0,0 +1,15 @@ +{ + "header": { + "title": "تسجيل {{term}}", + "description": "إدارة تسجيل {{term}} والدعوات" + }, + "badges": { + "learnerCount": "{{count}} {{term}}", + "batchCount": "{{count}} {{term}}" + }, + "actions": { + "inviteNew": "دعوة {{term}} جدد", + "manageList": "إدارة قائمة {{term}}", + "enrollmentRequests": "طلبات التسجيل" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardFinanceSummaryWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardFinanceSummaryWidget.json new file mode 100644 index 0000000000..c7cfed9f4a --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardFinanceSummaryWidget.json @@ -0,0 +1,12 @@ +{ + "header": { + "title": "لمحة مالية", + "description": "تدفق الأموال في لمحة", + "openButton": "فتح" + }, + "tiles": { + "outstanding": "المستحق", + "overdue": "متأخر السداد", + "approvals": "الموافقات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardFreshInstituteEmptyState.json b/frontend-admin-dashboard/public/locales/ar/dashboardFreshInstituteEmptyState.json new file mode 100644 index 0000000000..259f019bb0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardFreshInstituteEmptyState.json @@ -0,0 +1,34 @@ +{ + "header": { + "title": "مرحبًا — لنبدأ بإعداد مؤسستك", + "subtitle": "أنجز هذه الخطوات وستمتلئ لوحة التحكم تلقائيًا." + }, + "progress": "{{completed}} / {{total}}", + "items": { + "profile": { + "label": "أكمل ملف مؤسستك التعريفي", + "description": "أضف الهوية البصرية وبيانات الاتصال والتفاصيل الأساسية.", + "cta": "تعديل الملف التعريفي" + }, + "level": { + "label": "أنشئ أول مستوى دراسي", + "description": "أعدّ المستويات التي تُدرّسها مؤسستك.", + "cta": "إضافة مستوى" + }, + "course": { + "label": "أنشئ أول دورة تدريبية", + "description": "أضف دورة لتتمكن من تسجيل المتعلمين فيها.", + "cta": "إضافة دورة" + }, + "batch": { + "label": "أنشئ أول دفعة", + "description": "اجمع المتعلمين في دفعة ضمن جلسة دراسية.", + "cta": "إضافة دفعة" + }, + "learner": { + "label": "ادعُ أول متعلم", + "description": "أرسل دعوة أو سجّل طالبًا يدويًا.", + "cta": "إضافة متعلم" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardHourlyActivityWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardHourlyActivityWidget.json new file mode 100644 index 0000000000..51ca2ce39d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardHourlyActivityWidget.json @@ -0,0 +1,29 @@ +{ + "header": { + "title": "النشاط بالساعة", + "subtitle": "توزيع النشاط على مدار اليوم" + }, + "peakHour": { + "label": "ساعة الذروة" + }, + "error": { + "message": "فشل تحميل بيانات النشاط بالساعة" + }, + "empty": { + "message": "لا تتوفر بيانات نشاط بالساعة" + }, + "tooltip": { + "activity": "النشاط: {{value}}", + "users": "المستخدمون: {{value}}" + }, + "period": { + "night": "الليل", + "morning": "الصباح", + "afternoon": "بعد الظهر", + "evening": "المساء" + }, + "time": { + "am": "{{hour}} صباحًا", + "pm": "{{hour}} مساءً" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardIndex.json b/frontend-admin-dashboard/public/locales/ar/dashboardIndex.json new file mode 100644 index 0000000000..40d07ea799 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardIndex.json @@ -0,0 +1,72 @@ +{ + "volt": { + "title": "مرحبًا بك في Volt", + "subtitle": "مستقبل العروض التقديمية التفاعلية.", + "redirecting": "جارٍ إعادة توجيهك إلى مساحة عملك..." + }, + "alertsDialog": { + "title": "تنبيهات النظام", + "loading": "جارٍ التحميل...", + "loadMore": "تحميل المزيد", + "empty": "لا توجد تنبيهات" + }, + "myCourses": { + "title": "دوراتي", + "viewAll": "عرض الكل", + "description": "وصول سريع إلى الدورات التي أنشأتها وطلبات المراجعة الخاصة بك", + "authored": "الدورات المنشأة", + "inReview": "قيد المراجعة", + "loadError": "فشل تحميل بيانات الدورات" + }, + "page": { + "title": "لوحة التحكم", + "metaDescription": "تعرض هذه الصفحة لوحة تحكم المؤسسة." + }, + "greeting": { + "morning": "صباح الخير، {{name}}!", + "afternoon": "مساء الخير، {{name}}!", + "evening": "مساء الخير، {{name}}!" + }, + "subtitle": { + "admin": "إليك كيف تسير الأمور اليوم في {{instituteName}}.", + "adminFallbackInstitute": "مؤسستك", + "nonAdmin": "تابع دوراتك ومتعلميك بنظرة واحدة." + }, + "header": { + "roleFallback": "مسؤول" + }, + "welcomeVideo": { + "text": "مرحبًا بك! يسعدنا وجودك هنا. لنقم معًا بإعداد لوحة تحكم المسؤول وجعل التعلّم سلسًا وممتعًا." + }, + "profileCard": { + "title": "أكمل ملف مؤسستك التعريفي", + "dismissAriaLabel": "إغلاق بطاقة اكتمال الملف التعريفي", + "percentComplete": "اكتمال {{percent}}%" + }, + "namingCard": { + "title": "إعدادات التسمية", + "description": "خصّص قواعد التسمية المستخدمة في جميع أنحاء مؤسستك", + "button": "إعدادات التسمية", + "dismissAriaLabel": "إغلاق بطاقة إعدادات التسمية" + }, + "revenueTrends": { + "subOrgTitle": "المبلغ المُحصَّل — مؤسستي" + }, + "roleTypeCard": { + "title": "المستخدمون حسب نوع الدور" + }, + "aiCard": { + "dismissAriaLabel": "إغلاق بطاقة ميزات الذكاء الاصطناعي", + "title": "جرّب ميزات الذكاء الاصطناعي الجديدة!", + "description": "استكشف أحدث أدوات الذكاء الاصطناعي لتحسين طريقة تدريسك", + "features": { + "pdf": "أسئلة من ملف PDF", + "lectureAudio": "أسئلة من التسجيل الصوتي للمحاضرة", + "sortTopicWise": "فرز الأسئلة حسب الموضوع", + "image": "أسئلة من صورة", + "lectureFeedback": "الحصول على تقييم للمحاضرة", + "planLecture": "خطط لمحاضرتك" + }, + "manyMore": "والمزيد" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardIndiaStateMap.json b/frontend-admin-dashboard/public/locales/ar/dashboardIndiaStateMap.json new file mode 100644 index 0000000000..9726ccb9f4 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardIndiaStateMap.json @@ -0,0 +1,16 @@ +{ + "legend": { + "title": "التوزيع في الهند", + "hoverPrompt": "مرّر المؤشر فوق ولاية" + }, + "actions": { + "enlargeMap": "تكبير الخريطة" + }, + "map": { + "ariaLabel": "{{subOrgPlural}} حسب الولاية الهندية", + "tooltip": "{{name}}: {{count}}" + }, + "dialog": { + "heading": "{{subOrgPlural}} حسب الولاية" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardInstituteUsersOptions.json b/frontend-admin-dashboard/public/locales/ar/dashboardInstituteUsersOptions.json new file mode 100644 index 0000000000..50b1c73742 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardInstituteUsersOptions.json @@ -0,0 +1,49 @@ +{ + "menu": { + "changeRoleType": "تغيير نوع الدور", + "disableUser": "تعطيل المستخدم", + "enableUser": "تفعيل المستخدم", + "deleteUser": "حذف المستخدم" + }, + "changeRoleType": { + "title": "تغيير الأدوار", + "roleTypeLabel": "نوع الدور", + "submit": "إرسال", + "validation": { + "roleRequired": "مطلوب نوع دور واحد على الأقل" + }, + "toast": { + "success": "تم تغيير أدوار المستخدم بنجاح!" + } + }, + "disableUser": { + "title": "تعطيل المستخدم", + "attention": "تنبيه", + "confirmPrefix": "هل أنت متأكد أنك تريد تعطيل", + "confirmSuffix": "؟", + "confirmButton": "نعم", + "toast": { + "success": "تم تعطيل المستخدم بنجاح!" + } + }, + "enableUser": { + "title": "تفعيل المستخدم", + "attention": "تنبيه", + "confirmPrefix": "هل أنت متأكد أنك تريد تفعيل", + "confirmSuffix": "؟", + "confirmButton": "نعم", + "toast": { + "success": "تم تفعيل المستخدم بنجاح!" + } + }, + "deleteUser": { + "title": "حذف المستخدم", + "attention": "تنبيه", + "confirmPrefix": "هل أنت متأكد أنك تريد حذف", + "confirmSuffix": "؟", + "confirmButton": "نعم", + "toast": { + "success": "تم حذف المستخدم بنجاح!" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardInstituteUsersTab.json b/frontend-admin-dashboard/public/locales/ar/dashboardInstituteUsersTab.json new file mode 100644 index 0000000000..7a4cc8521c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardInstituteUsersTab.json @@ -0,0 +1,9 @@ +{ + "emptyState": { + "message": "لا يوجد مستخدمون في المؤسسة." + }, + "status": { + "active": "نشط", + "disabled": "معطل" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardInviteUsersComponent.json b/frontend-admin-dashboard/public/locales/ar/dashboardInviteUsersComponent.json new file mode 100644 index 0000000000..aa54f58ecd --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardInviteUsersComponent.json @@ -0,0 +1,22 @@ +{ + "trigger": { + "inviteUsers": "دعوة مستخدمين" + }, + "dialog": { + "title": "دعوة مستخدم" + }, + "form": { + "nameLabel": "الاسم الكامل", + "namePlaceholder": "الاسم الكامل (الأول والأخير)", + "emailLabel": "البريد الإلكتروني", + "emailPlaceholder": "أدخل البريد الإلكتروني", + "roleTypeLabel": "نوع الدور", + "submit": "دعوة المستخدم" + }, + "validation": { + "nameRequired": "الاسم الكامل مطلوب", + "emailRequired": "البريد الإلكتروني مطلوب", + "emailInvalid": "صيغة البريد الإلكتروني غير صحيحة", + "roleRequired": "مطلوب نوع دور واحد على الأقل" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardInviteUsersOptions.json b/frontend-admin-dashboard/public/locales/ar/dashboardInviteUsersOptions.json new file mode 100644 index 0000000000..104a330d14 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardInviteUsersOptions.json @@ -0,0 +1,45 @@ +{ + "menu": { + "editUser": "تعديل", + "resendInvite": "إعادة إرسال الدعوة", + "cancelInvite": "إلغاء الدعوة" + }, + "editUser": { + "title": "تعديل", + "nameLabel": "الاسم الكامل", + "namePlaceholder": "الاسم الكامل (الأول والأخير)", + "emailLabel": "البريد الإلكتروني", + "emailPlaceholder": "أدخل البريد الإلكتروني", + "roleTypeLabel": "نوع الدور", + "submit": "تعديل المستخدم", + "validation": { + "nameRequired": "الاسم الكامل مطلوب", + "emailRequired": "البريد الإلكتروني مطلوب", + "emailInvalid": "صيغة البريد الإلكتروني غير صحيحة", + "roleRequired": "مطلوب نوع دور واحد على الأقل" + }, + "toast": { + "success": "تم تحديث دعوة هذا المستخدم بنجاح!" + } + }, + "resendInvite": { + "title": "إعادة إرسال الدعوة", + "attention": "تنبيه", + "confirmPrefix": "هل أنت متأكد أنك تريد إعادة إرسال الدعوة إلى", + "confirmSuffix": "؟", + "confirmButton": "نعم", + "toast": { + "success": "تمت إعادة إرسال دعوة هذا المستخدم بنجاح!" + } + }, + "cancelInvite": { + "title": "إلغاء الدعوة", + "attention": "تنبيه", + "confirmPrefix": "هل أنت متأكد أنك تريد إلغاء الدعوة الخاصة بـ", + "confirmSuffix": "؟", + "confirmButton": "نعم", + "toast": { + "success": "تم إلغاء دعوة هذا المستخدم بنجاح!" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardInviteUsersTab.json b/frontend-admin-dashboard/public/locales/ar/dashboardInviteUsersTab.json new file mode 100644 index 0000000000..13d44ff860 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardInviteUsersTab.json @@ -0,0 +1,5 @@ +{ + "emptyState": { + "message": "لم تتم دعوة أي مستخدمين حتى الآن." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardKpisService.json b/frontend-admin-dashboard/public/locales/ar/dashboardKpisService.json new file mode 100644 index 0000000000..09e8c0bfc7 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardKpisService.json @@ -0,0 +1,32 @@ +{ + "kpis": { + "activeLearners": { + "label": "{{learners}} النشطون", + "subtitle": "من إجمالي {{total}}" + }, + "totalCourses": { + "label": "إجمالي {{courses}}", + "subtitle": "{{courses}} نشطة" + }, + "teamMembers": { + "label": "أعضاء الفريق", + "subtitle": "{{admins}}، و{{teachers}} والموظفون" + }, + "outstandingFees": { + "label": "الرسوم المستحقة", + "subtitle": "مستحقة عبر البنود المتأخرة" + }, + "overdueItems": { + "label": "البنود المتأخرة", + "subtitle": "تحتاج إلى متابعة" + }, + "classesToday": { + "label": "{{sessions}} اليوم", + "subtitle": "{{sessions}} مجدولة" + } + }, + "breakdown": { + "inactive": "غير نشط", + "terminated": "منتهٍ" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardLearnerTab.json b/frontend-admin-dashboard/public/locales/ar/dashboardLearnerTab.json new file mode 100644 index 0000000000..a443dd9f54 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardLearnerTab.json @@ -0,0 +1,21 @@ +{ + "header": { + "title": "مرحبًا", + "description": "لديك إمكانية الوصول إلى ميزات المسؤول والمتعلم. اختر كيف تريد المتابعة:" + }, + "options": { + "learner": { + "title": "المتابعة كمتعلم", + "description": "الوصول إلى دوراتك ومواد التعلم" + }, + "admin": { + "title": "المتابعة كمسؤول", + "description": "إدارة مؤسستك ودوراتك التعليمية" + } + }, + "actions": { + "goToLearnerPortal": "الانتقال إلى بوابة المتعلم", + "stayAsAdmin": "البقاء كمسؤول", + "dontShowAgain": "عدم إظهار هذا مرة أخرى" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardLearningCenterWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardLearningCenterWidget.json new file mode 100644 index 0000000000..103825a206 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardLearningCenterWidget.json @@ -0,0 +1,19 @@ +{ + "header": { + "title": "مركز التعلم", + "description": "إدارة {{courseTerm}} و{{sessionTerm}} والمواد الدراسية" + }, + "features": { + "studyLibrary": { + "title": "مكتبة الدراسة", + "description": "الوصول إلى المواد والموارد الدراسية" + }, + "liveSessions": { + "description": "إدارة الجلسات التعليمية المباشرة" + }, + "batchManagement": { + "titleTemplate": "إدارة {{term}}", + "description": "تنظيم الطلاب في مجموعات" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardLiveClassesWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardLiveClassesWidget.json new file mode 100644 index 0000000000..aedeb73aef --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardLiveClassesWidget.json @@ -0,0 +1,56 @@ +{ + "header": { + "addButton": "إضافة", + "upcomingSessionsCount_zero": "{{count}} جلسات قادمة", + "upcomingSessionsCount_one": "{{count}} جلسة قادمة واحدة", + "upcomingSessionsCount_two": "{{count}} جلستان قادمتان", + "upcomingSessionsCount_few": "{{count}} جلسات قادمة", + "upcomingSessionsCount_many": "{{count}} جلسة قادمة", + "upcomingSessionsCount_other": "{{count}} جلسة قادمة", + "noUpcomingSessions": "لا توجد جلسات قادمة مجدولة" + }, + "emptyState": { + "noSessionsScheduledYet": "لا توجد {{term}} مجدولة بعد" + }, + "sessionCard": { + "upcomingLabel": "قادمة", + "deleteSession": "حذف الجلسة", + "viewParticipantDetails": "عرض تفاصيل المشاركين" + }, + "moreCount_zero": "+{{count}} المزيد", + "moreCount_one": "+{{count}} المزيد", + "moreCount_two": "+{{count}} المزيد", + "moreCount_few": "+{{count}} المزيد", + "moreCount_many": "+{{count}} المزيد", + "moreCount_other": "+{{count}} المزيد", + "participantDialog": { + "heading": "تفاصيل المشاركين", + "tabs": { + "registeredUsers": "المستخدمون المسجلون", + "attendance": "الحضور" + }, + "registration": { + "title": "التسجيلات", + "exportButton": "تصدير" + }, + "attendance": { + "title": "الحضور", + "exportButton": "تصدير" + } + }, + "deleteDialog": { + "heading": "تقرير الحضور", + "confirmMessage": "هل تريد حذف كل حصة في هذه الجلسة؟", + "yes": "نعم", + "no": "لا" + }, + "csvExport": { + "registrationsDownloaded": "تم تنزيل التسجيلات بنجاح.", + "attendanceDownloaded": "تم تنزيل تقرير الحضور بنجاح.", + "attendanceStatus": { + "present": "حاضر", + "absent": "غائب", + "unmarked": "غير محدد" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardMentorshipStatsWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardMentorshipStatsWidget.json new file mode 100644 index 0000000000..6f7764592f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardMentorshipStatsWidget.json @@ -0,0 +1,34 @@ +{ + "header": { + "eyebrow": "الإرشاد", + "title": "المرشدون والمسترشدون" + }, + "actions": { + "manage": "إدارة", + "pendingTooltip": "متعلمون في انتظار الربط بمرشد", + "pendingReview_zero": "لا يوجد ({{count}}) ما يُراجع", + "pendingReview_one": "{{count}} عنصر واحد للمراجعة", + "pendingReview_two": "{{count}} عنصران للمراجعة", + "pendingReview_few": "{{count}} عناصر للمراجعة", + "pendingReview_many": "{{count}} عنصرًا للمراجعة", + "pendingReview_other": "{{count}} عنصر للمراجعة" + }, + "tiles": { + "mentors": { + "label": "المرشدون", + "subtitle": "المرشدون النشطون" + }, + "mentees": { + "label": "المسترشدون", + "subtitle": "الطلاب المُرشَدون" + }, + "today": { + "label": "اليوم", + "subtitle": "جلسات اليوم" + }, + "upcoming": { + "label": "القادمة", + "subtitle": "الأيام السبعة القادمة" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardMostActiveUsersWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardMostActiveUsersWidget.json new file mode 100644 index 0000000000..7303c6daf4 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardMostActiveUsersWidget.json @@ -0,0 +1,39 @@ +{ + "header": { + "title": "أكثر المستخدمين نشاطًا", + "subtitle": "أفضل المستخدمين أداءً من حيث النشاط والتفاعل" + }, + "error": { + "message": "فشل تحميل المستخدمين النشطين" + }, + "empty": { + "message": "لا تتوفر بيانات نشاط للمستخدمين" + }, + "status": { + "online": "متصل", + "recent": "نشط مؤخرًا", + "offline": "غير متصل", + "unknown": "غير معروف" + }, + "device": { + "desktop": "سطح المكتب", + "mobile": "الجوال", + "tablet": "الجهاز اللوحي" + }, + "stats": { + "sessions_zero": "{{count}} جلسة", + "sessions_one": "{{count}} جلسة واحدة", + "sessions_two": "{{count}} جلستان", + "sessions_few": "{{count}} جلسات", + "sessions_many": "{{count}} جلسة", + "sessions_other": "{{count}} جلسة", + "calls_zero": "{{count}} مكالمة", + "calls_one": "{{count}} مكالمة واحدة", + "calls_two": "{{count}} مكالمتان", + "calls_few": "{{count}} مكالمات", + "calls_many": "{{count}} مكالمة", + "calls_other": "{{count}} مكالمة", + "totalTime": "إجمالي الوقت", + "last": "آخر نشاط: {{date}}" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardMyPendingActionsWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardMyPendingActionsWidget.json new file mode 100644 index 0000000000..de72e04395 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardMyPendingActionsWidget.json @@ -0,0 +1,22 @@ +{ + "heading": { + "title": "الإجراءات المعلقة", + "description": "عناصر بانتظارك الآن" + }, + "error": "تعذر تحميل الإجراءات المعلقة.", + "empty": { + "title": "كل شيء على ما يرام", + "description": "لا يوجد ما يستدعي انتباهك الآن." + }, + "actionType": { + "overduePayment": "دفعة متأخرة", + "pendingApproval": "موافقة معلقة", + "unreadAlert": "تنبيه غير مقروء" + }, + "age": { + "now": "الآن", + "lessThanHour": "أقل من ساعة", + "hoursFormat": "{{hours}} س", + "daysFormat": "{{days}} ي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardOnboardingTrackerWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardOnboardingTrackerWidget.json new file mode 100644 index 0000000000..b303ef6108 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardOnboardingTrackerWidget.json @@ -0,0 +1,27 @@ +{ + "header": { + "progress_zero": "حالة التنفيذ لديك · اكتمل {{count}}٪", + "progress_one": "حالة التنفيذ لديك · اكتمل {{count}}٪", + "progress_two": "حالة التنفيذ لديك · اكتمل {{count}}٪", + "progress_few": "حالة التنفيذ لديك · اكتمل {{count}}٪", + "progress_many": "حالة التنفيذ لديك · اكتمل {{count}}٪", + "progress_other": "حالة التنفيذ لديك · اكتمل {{count}}٪" + }, + "empty": { + "message": "لا توجد مراحل بعد." + }, + "status": { + "done": "مكتمل", + "inProgress": "قيد التنفيذ", + "blocked": "معلّق", + "notStarted": "لم يبدأ" + }, + "milestone": { + "eta": "الموعد المتوقع {{date}}", + "confirmButton": "تأكيد" + }, + "comment": { + "placeholder": "أضف تعليقًا للفريق…", + "sendButton": "إرسال" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardPendingActionsService.json b/frontend-admin-dashboard/public/locales/ar/dashboardPendingActionsService.json new file mode 100644 index 0000000000..927d3159c0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardPendingActionsService.json @@ -0,0 +1,20 @@ +{ + "overduePayment": { + "title": "{{name}} — {{amount}} متأخر السداد", + "subtitle_zero": "{{count}} يوم متأخر · {{feeType}}", + "subtitle_one": "{{count}} يوم واحد متأخر · {{feeType}}", + "subtitle_two": "{{count}} يومان متأخران · {{feeType}}", + "subtitle_few": "{{count}} أيام متأخرة · {{feeType}}", + "subtitle_many": "{{count}} يومًا متأخرًا · {{feeType}}", + "subtitle_other": "{{count}} يوم متأخر · {{feeType}}", + "defaultFeeType": "رسوم" + }, + "pendingApproval": { + "title": "الموافقة على التسوية لـ {{name}}", + "subtitle": "{{type}} · {{amount}}", + "defaultType": "تسوية" + }, + "unreadAlert": { + "defaultTitle": "إشعار" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardQuickActions.json b/frontend-admin-dashboard/public/locales/ar/dashboardQuickActions.json new file mode 100644 index 0000000000..5ee38e84c3 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardQuickActions.json @@ -0,0 +1,14 @@ +{ + "actions": { + "addStudent": "إضافة طالب", + "newBatch": "دفعة جديدة", + "announcement": "إعلان", + "payments": "المدفوعات", + "reports": "التقارير", + "todaysClasses": "حصص اليوم", + "myCourses": "دوراتي", + "newCourse": "دورة جديدة", + "assessments": "التقييمات", + "evaluations": "عمليات التقييم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardRealTimeActiveUsersWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardRealTimeActiveUsersWidget.json new file mode 100644 index 0000000000..3e75601841 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardRealTimeActiveUsersWidget.json @@ -0,0 +1,12 @@ +{ + "header": { + "title": "نشط الآن", + "subtitle": "المستخدمون المتصلون حاليًا" + }, + "stats": { + "liveUpdates": "تحديثات مباشرة" + }, + "error": { + "widgetName": "المستخدمين النشطين الآن" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardRecentNotificationsWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardRecentNotificationsWidget.json new file mode 100644 index 0000000000..2089a2e6f9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardRecentNotificationsWidget.json @@ -0,0 +1,10 @@ +{ + "heading": { + "title": "الإشعارات الأخيرة", + "description": "أحدث تنبيهات النظام الخاصة بحسابك" + }, + "seeAll": "عرض الكل", + "empty": { + "title": "لا توجد إشعارات حديثة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardRecentTransactionsWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardRecentTransactionsWidget.json new file mode 100644 index 0000000000..b73801c0c1 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardRecentTransactionsWidget.json @@ -0,0 +1,16 @@ +{ + "heading": "أحدث المعاملات", + "viewAll": "عرض الكل", + "description": "أحدث نشاط الدفع في مؤسستك", + "emptyState": "لا توجد معاملات حتى الآن.", + "status": { + "success": "ناجحة", + "pending": "قيد الانتظار", + "failed": "فشلت", + "cancelled": "ملغاة" + }, + "fallback": { + "unknownUser": "غير معروف", + "manualVendor": "يدوي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardRevenueTrendsWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardRevenueTrendsWidget.json new file mode 100644 index 0000000000..a5e5453212 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardRevenueTrendsWidget.json @@ -0,0 +1,36 @@ +{ + "heading": "المبلغ المحصَّل", + "rangeGroup": "الفترة الزمنية للتحصيل", + "ranges": { + "3d": "3 أيام", + "7d": "7 أيام", + "24d": "24 يومًا", + "all": "الكل" + }, + "subtitle": "مدفوعات مسدَّدة · آخر {{range}}", + "totalCollected": "إجمالي المحصَّل", + "payments_zero": "{{formatted}} دفعة", + "payments_one": "دفعة واحدة ({{formatted}})", + "payments_two": "دفعتان ({{formatted}})", + "payments_few": "{{formatted}} دفعات", + "payments_many": "{{formatted}} دفعة", + "payments_other": "{{formatted}} دفعة", + "emptyState": "لا توجد تحصيلات في هذه الفترة", + "tooltip": { + "collected": "المحصَّل" + }, + "months": { + "jan": "ينا", + "feb": "فبر", + "mar": "مار", + "apr": "أبر", + "may": "ماي", + "jun": "يون", + "jul": "يول", + "aug": "أغس", + "sep": "سبت", + "oct": "أكت", + "nov": "نوف", + "dec": "ديس" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardRoleTypeComponent.json b/frontend-admin-dashboard/public/locales/ar/dashboardRoleTypeComponent.json new file mode 100644 index 0000000000..90ebe8b3cd --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardRoleTypeComponent.json @@ -0,0 +1,14 @@ +{ + "dialog": { + "triggerButton": "إدارة المستخدمين", + "title": "إدارة مستخدمي أنواع الأدوار" + }, + "tabs": { + "instituteUsers": "مستخدمو المؤسسة", + "invites": "الدعوات" + }, + "filters": { + "roleTypeLabel": "نوع الدور", + "statusLabel": "الحالة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardRoleTypeFilterButtons.json b/frontend-admin-dashboard/public/locales/ar/dashboardRoleTypeFilterButtons.json new file mode 100644 index 0000000000..f044f892fd --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardRoleTypeFilterButtons.json @@ -0,0 +1,4 @@ +{ + "filterButton": "تصفية", + "resetButton": "إعادة تعيين" +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardServiceUsageWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardServiceUsageWidget.json new file mode 100644 index 0000000000..a688cdaa0f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardServiceUsageWidget.json @@ -0,0 +1,29 @@ +{ + "header": { + "title": "استخدام الخدمات", + "subtitle": "الخدمات وواجهات البرمجة الأكثر نشاطًا" + }, + "tooltip": { + "usage": "الاستخدام: {{value}}", + "users": "المستخدمون: {{value}}", + "avgResponse": "متوسط زمن الاستجابة: {{value}} مللي ثانية" + }, + "empty": { + "message": "لا تتوفر بيانات خدمات" + }, + "error": { + "widgetName": "استخدام الخدمات" + }, + "list": { + "users_zero": "{{count}} مستخدم", + "users_one": "{{count}} مستخدم واحد", + "users_two": "{{count}} مستخدمان", + "users_few": "{{count}} مستخدمين", + "users_many": "{{count}} مستخدمًا", + "users_other": "{{count}} مستخدم" + }, + "footer": { + "avgResponseTime": "متوسط زمن الاستجابة:", + "ms": "{{value}} مللي ثانية" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgActivityDuesWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgActivityDuesWidget.json new file mode 100644 index 0000000000..37271d6cdb --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgActivityDuesWidget.json @@ -0,0 +1,13 @@ +{ + "heading": "خطتي والمستحقات", + "planPayment": { + "label": "دفع الخطة", + "of": "من {{amount}}", + "percentPaid": "تم دفع {{percent}}٪" + }, + "dues": { + "outstanding": "المستحق", + "pendingInstallments": "الأقساط المعلقة", + "nextDue": "الاستحقاق القادم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgGeographyWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgGeographyWidget.json new file mode 100644 index 0000000000..2e8d5ac087 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgGeographyWidget.json @@ -0,0 +1,43 @@ +{ + "header": { + "title": "{{term}} حسب الموقع", + "subtitle": "أين تقع {{term}} الخاصة بك" + }, + "groupBy": { + "ariaLabel": "تجميع حسب" + }, + "dims": { + "state": "الولاية", + "city": "المدينة", + "pincode": "الرمز البريدي" + }, + "manage": "إدارة", + "registered": "المسجَّل", + "dateRange": { + "ariaLabel": "نطاق تاريخ التسجيل", + "all": "الكل", + "last24h": "24 س", + "last3d": "3 أيام", + "last7d": "7 أيام", + "custom": "مخصص", + "fromDate": "تاريخ البدء", + "toDate": "تاريخ الانتهاء", + "to": "إلى" + }, + "stats": { + "states": "الولايات", + "cities": "المدن", + "pincodes": "الرموز البريدية", + "located": "محدَّد الموقع" + }, + "topLocations": { + "state": "أهم الولايات", + "city": "أهم المدن", + "pincode": "أهم الرموز البريدية" + }, + "table": { + "active": "نشط", + "seats": "المقاعد" + }, + "notSpecified": "غير محدد" +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgOverviewWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgOverviewWidget.json new file mode 100644 index 0000000000..68a49f87d6 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgOverviewWidget.json @@ -0,0 +1,26 @@ +{ + "header": { + "title": "لمحة عن {{plural}}", + "subtitle": "نظرة سريعة على شبكة {{singular}} الخاصة بك" + }, + "manage": "إدارة", + "stats": { + "total": { + "label": "إجمالي {{plural}}", + "subtitle": "{{plural}} المسجّلة" + }, + "active": { + "label": "نشطة", + "subtitle": "لديها خطة نشطة" + }, + "learners": { + "label": "المتعلمون", + "subtitle": "المسجّلون عبر {{plural}}" + }, + "seats": { + "label": "المقاعد", + "subtitleOccupied": "المشغولة من السعة", + "subtitleNoCap": "لا يوجد حد أقصى معيّن" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgSeatCoursesWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgSeatCoursesWidget.json new file mode 100644 index 0000000000..f539cc5b0c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgSeatCoursesWidget.json @@ -0,0 +1,29 @@ +{ + "heading": "المتعلمون والدورات", + "manage": "إدارة", + "recentEnrollments": { + "label": "التسجيلات الأخيرة", + "oweFees_zero": "لا أحد عليه رسوم مستحقة ({{count}})", + "oweFees_one": "شخص واحد عليه رسوم مستحقة ({{count}})", + "oweFees_two": "شخصان عليهما رسوم مستحقة ({{count}})", + "oweFees_few": "{{count}} عليهم رسوم مستحقة", + "oweFees_many": "{{count}} عليهم رسوم مستحقة", + "oweFees_other": "{{count}} عليهم رسوم مستحقة", + "empty": "لا توجد تسجيلات حتى الآن", + "learnerFallback": "متعلم", + "joined": "انضم في {{date}}", + "due": "{{amount}} مستحق", + "paid": "مدفوع" + }, + "courses": { + "label": "دوراتي", + "fallbackName": "دورة", + "empty": "لا توجد دورات معيّنة حتى الآن", + "learnerCountTitle_zero": "لا يوجد متعلمون ({{count}})", + "learnerCountTitle_one": "متعلم واحد ({{count}})", + "learnerCountTitle_two": "متعلمان ({{count}})", + "learnerCountTitle_few": "{{count}} متعلمين", + "learnerCountTitle_many": "{{count}} متعلمًا", + "learnerCountTitle_other": "{{count}} متعلم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgSelfStatsWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgSelfStatsWidget.json new file mode 100644 index 0000000000..4547e2af96 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardSubOrgSelfStatsWidget.json @@ -0,0 +1,29 @@ +{ + "welcomeBack": "مرحبًا بعودتك", + "defaultOrgName": "مؤسستي", + "manage": "إدارة", + "stats": { + "learners": { + "label": "المتعلمون", + "subtitle": "الأعضاء المسجَّلون" + }, + "seats": { + "label": "المقاعد", + "seatsLeft_zero": "لا مقاعد متبقية ({{count}})", + "seatsLeft_one": "مقعد واحد متبقٍ ({{count}})", + "seatsLeft_two": "مقعدان متبقيان ({{count}})", + "seatsLeft_few": "{{count}} مقاعد متبقية", + "seatsLeft_many": "{{count}} مقعدًا متبقيًا", + "seatsLeft_other": "{{count}} مقعد متبقٍ", + "noCap": "لا حد أقصى للمقاعد" + }, + "collected": { + "label": "المُحصَّل", + "subtitle": "الرسوم المستلمة" + }, + "outstanding": { + "label": "المستحق", + "subtitle": "الرسوم المستحقة" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardTopVlesWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardTopVlesWidget.json new file mode 100644 index 0000000000..114afbcbee --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardTopVlesWidget.json @@ -0,0 +1,12 @@ +{ + "heading": "أفضل {{plural}}", + "subtitle": "حسب المقاعد المستخدمة", + "viewAll": "عرض الكل", + "unnamedName": "بلا اسم", + "planStatus": { + "active": "نشطة", + "pending": "قيد الانتظار", + "expired": "منتهية", + "none": "بلا خطة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardUnresolvedDoubtsWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardUnresolvedDoubtsWidget.json new file mode 100644 index 0000000000..ed88bbebf0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardUnresolvedDoubtsWidget.json @@ -0,0 +1,15 @@ +{ + "card": { + "heading": "الاستفسارات غير المحلولة", + "requiresAction": "يتطلب إجراءً" + }, + "message": { + "unresolved_zero": "لا توجد لديك استفسارات غير محلولة (<1>{{count}}) من آخر 7 أيام تحتاج إلى اهتمام.", + "unresolved_one": "لديك <1>{{count}} استفسار غير محلول من آخر 7 أيام يحتاج إلى اهتمام.", + "unresolved_two": "لديك <1>{{count}} استفساران غير محلولين من آخر 7 أيام يحتاجان إلى اهتمام.", + "unresolved_few": "لديك <1>{{count}} استفسارات غير محلولة من آخر 7 أيام تحتاج إلى اهتمام.", + "unresolved_many": "لديك <1>{{count}} استفسارًا غير محلول من آخر 7 أيام يحتاج إلى اهتمام.", + "unresolved_other": "لديك <1>{{count}} استفسار غير محلول من آخر 7 أيام يحتاج إلى اهتمام.", + "allResolved": "تم حل جميع الاستفسارات. عمل رائع!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardUseInstituteAssignees.json b/frontend-admin-dashboard/public/locales/ar/dashboardUseInstituteAssignees.json new file mode 100644 index 0000000000..13e0e6a530 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardUseInstituteAssignees.json @@ -0,0 +1,3 @@ +{ + "unnamed": "بدون اسم" +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardUseInstituteTeachers.json b/frontend-admin-dashboard/public/locales/ar/dashboardUseInstituteTeachers.json new file mode 100644 index 0000000000..13e0e6a530 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardUseInstituteTeachers.json @@ -0,0 +1,3 @@ +{ + "unnamed": "بدون اسم" +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardUserActivitySummaryWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardUserActivitySummaryWidget.json new file mode 100644 index 0000000000..856ec7a8ce --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardUserActivitySummaryWidget.json @@ -0,0 +1,22 @@ +{ + "header": { + "title": "نشاط اليوم", + "subtitle": "ملخص فوري لتفاعل المستخدمين" + }, + "stats": { + "uniqueUsers": "المستخدمون الفريدون", + "sessions": "الجلسات", + "apiCalls": "استدعاءات واجهة البرمجة", + "activityTime": "وقت النشاط", + "avgSession": "متوسط الجلسة", + "peakHour": "ساعة الذروة" + }, + "suffix": { + "hours": "س", + "minutes": "د", + "oclock": ":00" + }, + "error": { + "widgetName": "ملخص النشاط" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardUserAnalyticsTab.json b/frontend-admin-dashboard/public/locales/ar/dashboardUserAnalyticsTab.json new file mode 100644 index 0000000000..1c7be68504 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardUserAnalyticsTab.json @@ -0,0 +1,6 @@ +{ + "overview": { + "title": "نظرة عامة على لوحة التحكم", + "subtitle": "جميع المقاييس الرئيسية لمؤسستك في لمحة واحدة." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/dashboardVLEInsightsWidget.json b/frontend-admin-dashboard/public/locales/ar/dashboardVLEInsightsWidget.json new file mode 100644 index 0000000000..a4eaffb7e9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/dashboardVLEInsightsWidget.json @@ -0,0 +1,41 @@ +{ + "heading": "رؤى {{plural}}", + "subtitle": "الخطط والمقاعد والنمو عبر شبكتك", + "manage": "إدارة", + "planStatus": { + "title": "حالة الخطة", + "active": "نشطة", + "pending": "قيد الانتظار", + "expired": "منتهية", + "inactive": "غير نشطة", + "none": "بلا خطة" + }, + "seatUtilization": { + "title": "استخدام المقاعد", + "seatsFraction": "{{used}} / {{total}} مقعدًا", + "seatsAvailable_zero": "{{formatted}} مقعد متاح عبر الشبكة", + "seatsAvailable_one": "{{formatted}} مقعد متاح عبر الشبكة", + "seatsAvailable_two": "{{formatted}} مقعدان متاحان عبر الشبكة", + "seatsAvailable_few": "{{formatted}} مقاعد متاحة عبر الشبكة", + "seatsAvailable_many": "{{formatted}} مقعدًا متاحًا عبر الشبكة", + "seatsAvailable_other": "{{formatted}} مقعد متاح عبر الشبكة" + }, + "growth": { + "title": "التسجيلات الجديدة", + "tooltipLabel": "{{plural}} جديدة" + }, + "months": { + "jan": "ينا", + "feb": "فبر", + "mar": "مار", + "apr": "أبر", + "may": "ماي", + "jun": "يون", + "jul": "يول", + "aug": "أغس", + "sep": "سبت", + "oct": "أكت", + "nov": "نوف", + "dec": "ديس" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAddParticipantsSchema.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAddParticipantsSchema.json new file mode 100644 index 0000000000..49bee6a4ea --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAddParticipantsSchema.json @@ -0,0 +1,5 @@ +{ + "validation": { + "endDateAfterStartDate": "يجب أن يكون تاريخ الانتهاء بعد تاريخ البدء." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAddingParticipantsTab.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAddingParticipantsTab.json new file mode 100644 index 0000000000..0f813f356e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAddingParticipantsTab.json @@ -0,0 +1,9 @@ +{ + "tabs": { + "selectBatch": "اختيار الدفعة", + "selectIndividually": "اختيار فردي" + }, + "batchList": { + "selectSectionPlaceholder": "اختر القسم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAnnouncementComponent.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAnnouncementComponent.json new file mode 100644 index 0000000000..12e9078196 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAnnouncementComponent.json @@ -0,0 +1,15 @@ +{ + "trigger": { + "makeAnnouncement": "إنشاء إعلان" + }, + "dialog": { + "heading": "إنشاء إعلان", + "titleLabel": "عنوان الإعلان", + "titlePlaceholder": "اكتب عنوانًا موجزًا للإعلان (مثال: الوقت المتبقي، تم حل المشكلة التقنية)", + "instructionsHeading": "تعليمات التقييم", + "publishButton": "نشر الإعلان" + }, + "validation": { + "titleRequired": "العنوان مطلوب" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentAccessControlTab.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentAccessControlTab.json new file mode 100644 index 0000000000..93fa9e125c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentAccessControlTab.json @@ -0,0 +1,15 @@ +{ + "sections": { + "creationAccessTitle": "صلاحية إنشاء الواجب", + "liveNotificationsTitle": "إشعارات الواجب المباشر", + "submissionReportsTitle": "صلاحية تسليم الواجب والتقارير", + "evaluationTitle": "صلاحية التقييم" + }, + "roles": { + "admin": "مسؤول", + "contentCreator": "منشئ المحتوى", + "assessmentCreator": "منشئ التقييم", + "evaluator": "مقيّم", + "teacher": "معلم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentBasicInfoTab.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentBasicInfoTab.json new file mode 100644 index 0000000000..8584786421 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentBasicInfoTab.json @@ -0,0 +1,27 @@ +{ + "fields": { + "homeworkName": { + "label": "اسم الواجب المنزلي:" + }, + "subject": { + "label": "المادة:" + }, + "assessmentInstructions": { + "sectionTitle": "تعليمات التقييم:" + }, + "liveDateRange": { + "sectionTitle": "نطاق التاريخ المباشر", + "startDateLabel": "تاريخ ووقت البدء:", + "endDateLabel": "تاريخ ووقت الانتهاء:" + }, + "attemptSettings": { + "sectionTitle": "إعدادات المحاولات", + "reattemptCountLabel": "عدد إعادة محاولة الواجب المنزلي:", + "durationSettingsLabel": "إعدادات مدة الواجب المنزلي:", + "entireDurationLabel": "مدة الواجب المنزلي بالكامل", + "minuteValue": "{{count}} دقيقة", + "previewLabel": "معاينة الواجب المنزلي:", + "switchSectionsLabel": "السماح للمشاركين بالتبديل بين الأقسام" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentColumns.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentColumns.json new file mode 100644 index 0000000000..31ece8f98d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentColumns.json @@ -0,0 +1,17 @@ +{ + "columns": { + "name": "الاسم", + "batch": "الدفعة", + "enrollmentNumber": "رقم التسجيل", + "collegeSchool": "الكلية/المدرسة", + "gender": "الجنس", + "mobileNumber": "رقم الجوال", + "emailId": "البريد الإلكتروني", + "city": "المدينة", + "state": "المنطقة" + }, + "sort": { + "ascending": "تصاعدي", + "descending": "تنازلي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentDetailsHelper.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentDetailsHelper.json new file mode 100644 index 0000000000..a143061c76 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentDetailsHelper.json @@ -0,0 +1,6 @@ +{ + "units": { + "minutesShort": "د", + "secondsShort": "ث" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentDetailsSearchComponent.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentDetailsSearchComponent.json new file mode 100644 index 0000000000..b5fd89827e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentDetailsSearchComponent.json @@ -0,0 +1,3 @@ +{ + "searchByNamePlaceholder": "البحث بالاسم" +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentParticipantsTab.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentParticipantsTab.json new file mode 100644 index 0000000000..4524d9759e --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentParticipantsTab.json @@ -0,0 +1,22 @@ +{ + "heading": "المشاركون في التقييم", + "participantsCountInternal_zero": "{{count}} مشارك (داخلي)", + "participantsCountInternal_one": "{{count}} مشارك واحد (داخلي)", + "participantsCountInternal_two": "{{count}} مشاركان (داخلي)", + "participantsCountInternal_few": "{{count}} مشاركين (داخلي)", + "participantsCountInternal_many": "{{count}} مشاركًا (داخلي)", + "participantsCountInternal_other": "{{count}} مشارك (داخلي)", + "joinLink": "رابط الانضمام", + "qrCode": "رمز الاستجابة السريعة", + "notifyParticipantsViaEmail": "إشعار المشاركين عبر البريد الإلكتروني:", + "whenAssessmentCreated": "عند إنشاء التقييم:", + "beforeAssessmentGoesLive": "قبل بدء التقييم مباشرةً:", + "minutesValue_zero": "{{count}} دقيقة", + "minutesValue_one": "{{count}} دقيقة", + "minutesValue_two": "{{count}} دقيقتان", + "minutesValue_few": "{{count}} دقائق", + "minutesValue_many": "{{count}} دقيقة", + "minutesValue_other": "{{count}} دقيقة", + "whenAssessmentGoesLive": "عند بدء التقييم مباشرةً:", + "whenAssessmentReportsGenerated": "عند إنشاء تقارير التقييم:" +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentPreview.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentPreview.json new file mode 100644 index 0000000000..0549e78050 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentPreview.json @@ -0,0 +1,22 @@ +{ + "joinLink": { + "label": "رابط الانضمام:" + }, + "announcement": { + "dialogTitle": "إعلان الواجب المباشر", + "empty": "لا توجد إعلانات", + "postedTodayAt": "اليوم، 11:28 صباحًا" + }, + "actions": { + "save": "حفظ", + "exit": "خروج", + "addQuestion": "إضافة سؤال" + }, + "questionType": { + "MCQS": "اختيار من متعدد (إجابة واحدة)", + "MCQM": "اختيار من متعدد (إجابات متعددة)" + }, + "errors": { + "incompleteQuestions": "بعض أسئلتك غير مكتملة أو تحتاج إلى مراجعة!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentQuestionsSection.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentQuestionsSection.json new file mode 100644 index 0000000000..06dfb2034c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentQuestionsSection.json @@ -0,0 +1,55 @@ +{ + "header": { + "mcqSingleCorrect": "اختيار من متعدد (إجابة واحدة):", + "mcqMultipleCorrect": "اختيار من متعدد (إجابات متعددة):", + "total": "الإجمالي:" + }, + "sectionDescription": { + "title": "وصف القسم" + }, + "duration": { + "sectionDuration": "مدة القسم:", + "minutes_zero": "{{count}} دقيقة", + "minutes_one": "{{count}} دقيقة واحدة", + "minutes_two": "{{count}} دقيقتان", + "minutes_few": "{{count}} دقائق", + "minutes_many": "{{count}} دقيقة", + "minutes_other": "{{count}} دقيقة" + }, + "markingScheme": { + "marksPerQuestionDefault": "الدرجات لكل سؤال (افتراضي):", + "negativeMarking": "الدرجات السالبة:", + "partialMarking": "التقييم الجزئي:", + "cutoffMarking": "الحد الأدنى للدرجات:" + }, + "randomization": { + "problemRandomization": "عشوائية الأسئلة:" + }, + "table": { + "surveyQuestionsTitle": "أسئلة الاستبيان", + "adaptiveMarkingRulesTitle": "قواعد التقييم التكيفي", + "headers": { + "qno": "رقم السؤال", + "question": "السؤال", + "questionType": "نوع السؤال", + "marks": "الدرجات", + "penalty": "الخصم", + "time": "الوقت" + }, + "questionTypeLabels": { + "MCQS": "اختيار من متعدد (إجابة واحدة)", + "MCQM": "اختيار من متعدد (إجابات متعددة)", + "NUMERIC": "رقمي", + "CMCQS": "استيعاب نصي - اختيار من متعدد (إجابة واحدة)", + "CMCQM": "استيعاب نصي - اختيار من متعدد (إجابات متعددة)", + "CNUMERIC": "استيعاب نصي - رقمي", + "ONE_WORD": "كلمة واحدة", + "LONG_ANSWER": "إجابة طويلة", + "TRUE_FALSE": "صواب/خطأ", + "CODING": "برمجة" + } + }, + "totalMarks": { + "label": "إجمالي الدرجات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentStudentLeaderboard.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentStudentLeaderboard.json new file mode 100644 index 0000000000..fc33c03861 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentStudentLeaderboard.json @@ -0,0 +1,12 @@ +{ + "heading": "لوحة المتصدرين", + "table": { + "duration": "{{minutes}} دقيقة {{seconds}} ثانية", + "percentileLabel": "المئين", + "marksLabel": "الدرجات" + }, + "export": { + "pdfSuccess": "تم تصدير بيانات لوحة المتصدرين بصيغة PDF بنجاح", + "csvSuccess": "تم تصدير بيانات لوحة المتصدرين بصيغة CSV بنجاح" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentSubmissionsFilterButtons.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentSubmissionsFilterButtons.json new file mode 100644 index 0000000000..cbfc607e87 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentSubmissionsFilterButtons.json @@ -0,0 +1,4 @@ +{ + "filter": "تصفية", + "reset": "إعادة تعيين" +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentSubmissionsStudentTable.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentSubmissionsStudentTable.json new file mode 100644 index 0000000000..d0d4c98f01 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentSubmissionsStudentTable.json @@ -0,0 +1,4 @@ +{ + "loading": "جارٍ التحميل...", + "errorLoadingData": "حدث خطأ أثناء تحميل البيانات" +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentSubmissionsTab.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentSubmissionsTab.json new file mode 100644 index 0000000000..aef81c5e32 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentSubmissionsTab.json @@ -0,0 +1,25 @@ +{ + "tabs": { + "attempted": "تمت المحاولة", + "ongoing": "جارٍ", + "pending": "معلّق" + }, + "participantsTabs": { + "internal": "المشاركون الداخليون", + "external": "المشاركون الخارجيون" + }, + "batchSelectionTabs": { + "batch": "اختيار الدفعة", + "individual": "اختيار فردي" + }, + "actions": { + "revaluate": "إعادة التقييم" + }, + "dialogs": { + "revaluateResultTitle": "إعادة تقييم النتيجة" + }, + "toasts": { + "exportPdfSuccess": "تم تصدير بيانات قائمة تسليمات الطلاب بصيغة PDF بنجاح", + "exportCsvSuccess": "تم تصدير بيانات قائمة تسليمات الطلاب بصيغة CSV بنجاح" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentTabIndex.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentTabIndex.json new file mode 100644 index 0000000000..4532cc1066 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationAssessmentTabIndex.json @@ -0,0 +1,31 @@ +{ + "heading": { + "title": "تفاصيل الواجب" + }, + "helmet": { + "title": "تفاصيل الواجب", + "description": "تعرض هذه الصفحة جميع التفاصيل المتعلقة بهذا الواجب." + }, + "actions": { + "previewHomework": "معاينة الواجب", + "exportOffline": "تصدير دون اتصال", + "noSectionsError": "لم تتم إضافة أي أقسام لهذا الواجب." + }, + "badge": { + "visibility": { + "PRIVATE": "خاص", + "PUBLIC": "عام" + }, + "status": { + "COMPLETED": "مكتمل" + } + }, + "tabs": { + "overview": "نظرة عامة", + "submissions": "التقديمات", + "basicInfo": "المعلومات الأساسية", + "questions": "الأسئلة", + "participants": "المشاركون", + "accessControl": "التحكم في الوصول" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationBasicInfoFormSchema.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationBasicInfoFormSchema.json new file mode 100644 index 0000000000..d285802318 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationBasicInfoFormSchema.json @@ -0,0 +1,5 @@ +{ + "assessmentNameRequired": "اسم التقييم مطلوب", + "endDateAfterStartDate": "يجب أن يكون تاريخ الانتهاء بعد تاريخ البدء", + "reattemptCountPositive": "يجب أن يكون عدد إعادة المحاولة أكبر من 0" +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationBulkActions.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationBulkActions.json new file mode 100644 index 0000000000..acde2627b0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationBulkActions.json @@ -0,0 +1,5 @@ +{ + "selected": "محدد", + "reset": "إعادة تعيين", + "bulkActions": "إجراءات جماعية" +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationBulkActionsMenuAttempted.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationBulkActionsMenuAttempted.json new file mode 100644 index 0000000000..8ea2e17982 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationBulkActionsMenuAttempted.json @@ -0,0 +1,17 @@ +{ + "menu": { + "provideReattempt": "منح إعادة محاولة", + "revaluate": "إعادة التقييم", + "questionWise": "حسب السؤال", + "entireAssessment": "التقييم بالكامل", + "releaseResult": "نشر النتيجة" + }, + "actionInfo": { + "selectedStudents_zero": "لا يوجد طلاب محددون ({{count}})", + "selectedStudents_one": "طالب واحد محدد ({{count}})", + "selectedStudents_two": "طالبان محددان ({{count}})", + "selectedStudents_few": "{{count}} طلاب محددون", + "selectedStudents_many": "{{count}} طالبًا محددًا", + "selectedStudents_other": "{{count}} طالب محدد" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationBulkActionsMenuOngoing.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationBulkActionsMenuOngoing.json new file mode 100644 index 0000000000..e6000f84a7 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationBulkActionsMenuOngoing.json @@ -0,0 +1,14 @@ +{ + "menu": { + "increaseAssessmentTime": "زيادة وقت التقييم", + "closeSubmission": "إغلاق التسليم" + }, + "actionInfo": { + "selectedStudents_zero": "لا يوجد طلاب محددون ({{count}})", + "selectedStudents_one": "طالب واحد محدد ({{count}})", + "selectedStudents_two": "طالبان محددان ({{count}})", + "selectedStudents_few": "{{count}} طلاب محددون", + "selectedStudents_many": "{{count}} طالبًا محددًا", + "selectedStudents_other": "{{count}} طالب محدد" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationBulkActionsMenuPending.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationBulkActionsMenuPending.json new file mode 100644 index 0000000000..459158b9d8 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationBulkActionsMenuPending.json @@ -0,0 +1,14 @@ +{ + "menu": { + "sendReminder": "إرسال تذكير", + "removeParticipants": "إزالة المشاركين" + }, + "actionInfo": { + "selectedStudents_zero": "لا يوجد طلاب محددون ({{count}})", + "selectedStudents_one": "طالب واحد محدد ({{count}})", + "selectedStudents_two": "طالبان محددان ({{count}})", + "selectedStudents_few": "{{count}} طلاب محددون", + "selectedStudents_many": "{{count}} طالبًا محددًا", + "selectedStudents_other": "{{count}} طالب محدد" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationCloseSubmissionComponent.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationCloseSubmissionComponent.json new file mode 100644 index 0000000000..62be555541 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationCloseSubmissionComponent.json @@ -0,0 +1,7 @@ +{ + "dialog": { + "heading": "إغلاق التسليم", + "confirmMessage": "هل أنت متأكد أنك تريد إغلاق التسليم لـ {{name}}؟" + }, + "doneButton": "تم" +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationCreateAssessmentComponent.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationCreateAssessmentComponent.json new file mode 100644 index 0000000000..467e7f09ac --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationCreateAssessmentComponent.json @@ -0,0 +1,15 @@ +{ + "steps": { + "basicInfo": "المعلومات الأساسية", + "addQuestion": "إضافة سؤال", + "addParticipants": "إضافة مشاركين", + "accessControl": "التحكم بالوصول" + }, + "page": { + "title": "إنشاء تقييم", + "metaDescription": "هذه الصفحة مخصصة لإنشاء تقييم للطلاب من قبل المسؤول." + }, + "noCourseDialog": { + "type": "إنشاء تقييم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationCreateAssessmentHelper.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationCreateAssessmentHelper.json new file mode 100644 index 0000000000..8606c2f8a8 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationCreateAssessmentHelper.json @@ -0,0 +1,8 @@ +{ + "qrCodeNotFound": "لم يتم العثور على رمز الاستجابة السريعة!", + "defaultRegistrationFields": { + "fullName": "الاسم الكامل", + "email": "البريد الإلكتروني", + "phoneNumber": "رقم الهاتف" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationGlobalReleaseResultAssessment.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationGlobalReleaseResultAssessment.json new file mode 100644 index 0000000000..c89e003868 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationGlobalReleaseResultAssessment.json @@ -0,0 +1,14 @@ +{ + "trigger": { + "releaseResult": "إصدار النتيجة" + }, + "dialog": { + "title": "إصدار النتيجة لجميع {{learnerPlural}}", + "attention": "تنبيه", + "confirmText": "هل أنت متأكد أنك تريد إصدار النتيجة لجميع {{learnerLower}}؟", + "confirmButton": "نعم" + }, + "toasts": { + "releaseResultSuccess": "تم إصدار نتيجة هذا التقييم لجميع الطلاب. يجب على المشاركين التحقق من بريدهم الإلكتروني!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationGlobalRevaluateAssessment.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationGlobalRevaluateAssessment.json new file mode 100644 index 0000000000..ea2a02a4bc --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationGlobalRevaluateAssessment.json @@ -0,0 +1,14 @@ +{ + "trigger": { + "entireAssessment": "التقييم بالكامل" + }, + "dialog": { + "title": "إعادة تقييم جميع {{term}}", + "attention": "تنبيه", + "confirmText": "هل أنت متأكد أنك تريد إعادة التقييم لجميع {{term}}؟", + "confirmButton": "نعم" + }, + "toasts": { + "revaluateSuccess": "تمت إعادة تقييم محاولة هذا التقييم لجميع الطلاب. يجب على المشاركين التحقق من بريدهم الإلكتروني!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationGlobalRevaluateQuestionWise.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationGlobalRevaluateQuestionWise.json new file mode 100644 index 0000000000..6b0bb77706 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationGlobalRevaluateQuestionWise.json @@ -0,0 +1,15 @@ +{ + "trigger": { + "questionWise": "حسب السؤال" + }, + "table": { + "questionNumber": "رقم السؤال", + "question": "السؤال" + }, + "toasts": { + "revaluateSuccess": "تمت إعادة تقييم محاولة هذا التقييم لجميع الطلاب. يجب على المشاركين التحقق من بريدهم الإلكتروني!" + }, + "actions": { + "revaluate": "إعادة التقييم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationIncreaseAssessmentTime.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationIncreaseAssessmentTime.json new file mode 100644 index 0000000000..5b3b5bd980 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationIncreaseAssessmentTime.json @@ -0,0 +1,23 @@ +{ + "dialog": { + "heading": "زيادة وقت التقييم" + }, + "assessment": { + "title": "التقييم بالكامل", + "increaseByLabel": "زيادة بمقدار", + "selectSectionPlaceholder": "اختر القسم", + "doneButton": "تم" + }, + "section": { + "title": "القسم {{index}}", + "increaseByLabel": "زيادة بمقدار", + "selectSectionPlaceholder": "اختر القسم", + "doneButton": "تم" + }, + "question": { + "title": "السؤال {{index}}", + "increaseByLabel": "زيادة بمقدار", + "selectSectionPlaceholder": "اختر القسم", + "doneButton": "تم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationMultipleCorrectMainView.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationMultipleCorrectMainView.json new file mode 100644 index 0000000000..79dca041a9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationMultipleCorrectMainView.json @@ -0,0 +1,17 @@ +{ + "emptyState": "يرجى إضافة سؤال لعرض تفاصيله", + "settings": { + "title": "إعدادات الأسئلة", + "questionType": "نوع السؤال", + "marks": "الدرجات", + "negativeMarking": "الخصم عند الخطأ", + "timeLimit": "الوقت المحدد", + "hrs": "ساعة", + "min": "دقيقة" + }, + "question": "السؤال {{number}}", + "answer": "الإجابة:", + "addOption": "إضافة خيار", + "removeOption": "إزالة الخيار", + "explanation": "الشرح:" +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationMultipleCorrectPPTView.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationMultipleCorrectPPTView.json new file mode 100644 index 0000000000..71f5e32241 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationMultipleCorrectPPTView.json @@ -0,0 +1,12 @@ +{ + "optionLabels": { + "a": "(a.)", + "b": "(b.)", + "c": "(c.)", + "d": "(d.)" + }, + "menu": { + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationProvideReattemptDialog.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationProvideReattemptDialog.json new file mode 100644 index 0000000000..5ffb2b1588 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationProvideReattemptDialog.json @@ -0,0 +1,10 @@ +{ + "dialog": { + "heading": "توفير إعادة المحاولة", + "confirmMessage": "هل أنت متأكد من رغبتك في توفير إعادة المحاولة للمحدد {{name}}؟" + }, + "doneButton": "تم", + "toasts": { + "reattemptSuccess": "تم توفير إعادة المحاولة للمشارك (المشاركين) المحددين." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationProvideReleaseResult.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationProvideReleaseResult.json new file mode 100644 index 0000000000..c5a5ddb139 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationProvideReleaseResult.json @@ -0,0 +1,10 @@ +{ + "dialog": { + "heading": "إصدار النتيجة", + "confirmMessage": "هل أنت متأكد من رغبتك في إصدار النتيجة للمحدد {{name}}؟" + }, + "doneButton": "تم", + "toasts": { + "releaseSuccess": "تم إصدار نتيجة هذا التقييم للطلاب المحددين. يرجى التحقق من بريدك الإلكتروني!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationProvideRevaluateAssessmentDialog.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationProvideRevaluateAssessmentDialog.json new file mode 100644 index 0000000000..fe5ad3867f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationProvideRevaluateAssessmentDialog.json @@ -0,0 +1,10 @@ +{ + "dialog": { + "heading": "إعادة تقييم التقييم", + "confirmMessage": "هل أنت متأكد من رغبتك في إعادة تقييم الاختبار للمحدد {{name}}؟" + }, + "doneButton": "تم", + "toasts": { + "revaluateSuccess": "تمت إعادة تقييم محاولة هذا التقييم للطلاب المحددين. يرجى التحقق من بريدك الإلكتروني!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationProvideRevaluateQuestionwiseDialog.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationProvideRevaluateQuestionwiseDialog.json new file mode 100644 index 0000000000..e50c88a287 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationProvideRevaluateQuestionwiseDialog.json @@ -0,0 +1,13 @@ +{ + "dialog": { + "heading": "إعادة التقييم حسب السؤال" + }, + "table": { + "questionNo": "رقم السؤال", + "question": "السؤال" + }, + "revaluateButton": "إعادة التقييم", + "toasts": { + "revaluateSuccess": "تمت إعادة تقييم محاولة هذا التقييم للطلاب المحددين. يرجى التحقق من بريدك الإلكتروني!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionAnalysisChart.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionAnalysisChart.json new file mode 100644 index 0000000000..05d87cfb77 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionAnalysisChart.json @@ -0,0 +1,21 @@ +{ + "title": "تحليل الأسئلة", + "legend": { + "correct": "صحيح", + "partiallyCorrect": "صحيح جزئيًا", + "wrongResponses": "إجابات خاطئة", + "skip": "تم التخطي" + }, + "chart": { + "xAxisLabel": "السؤال", + "yAxisLabel": "عدد المشاركين" + }, + "actions": { + "checkQuestionInsights": "عرض إحصاءات الأسئلة", + "selectSectionPlaceholder": "اختر القسم", + "export": "تصدير" + }, + "dialog": { + "questionInsightsTitle": "إحصاءات الأسئلة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionInsights.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionInsights.json new file mode 100644 index 0000000000..6815f8ff5b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionInsights.json @@ -0,0 +1,37 @@ +{ + "question": { + "label": "السؤال ({{number}}.) ", + "correctAnswer": "الإجابة الصحيحة:", + "explanation": "الشرح:", + "topCorrectResponses": "أفضل 3 إجابات صحيحة سريعة", + "responseTime_zero": "{{count}} ثانية", + "responseTime_one": "{{count}} ثانية واحدة", + "responseTime_two": "{{count}} ثانيتان", + "responseTime_few": "{{count}} ثوانٍ", + "responseTime_many": "{{count}} ثانية", + "responseTime_other": "{{count}} ثانية" + }, + "stats": { + "totalAttempts_zero": "إجمالي المحاولات: {{count}} طالب", + "totalAttempts_one": "إجمالي المحاولات: {{count}} طالب واحد", + "totalAttempts_two": "إجمالي المحاولات: {{count}} طالبان", + "totalAttempts_few": "إجمالي المحاولات: {{count}} طلاب", + "totalAttempts_many": "إجمالي المحاولات: {{count}} طالبًا", + "totalAttempts_other": "إجمالي المحاولات: {{count}} طالب", + "correctRespondents": "المجيبون الصحيحون: ", + "partiallyCorrectRespondents": "المجيبون الصحيحون جزئيًا: ", + "wrongRespondents": "المجيبون الخاطئون: ", + "skipped": "تم التخطي: ", + "notAvailable": "غير متاح", + "viewList": "عرض القائمة" + }, + "dialog": { + "correctRespondents": "المجيبون الصحيحون", + "partiallyCorrectRespondents": "المجيبون الصحيحون جزئيًا", + "wrongRespondents": "المجيبون الخاطئون", + "skippedRespondents": "من تخطوا السؤال" + }, + "toasts": { + "exportPdfSuccess": "تم تصدير بيانات تحليلات أسئلة الطلاب بصيغة PDF بنجاح" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionInsightsAnalysisChartComponent.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionInsightsAnalysisChartComponent.json new file mode 100644 index 0000000000..e4276b6c6b --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionInsightsAnalysisChartComponent.json @@ -0,0 +1,8 @@ +{ + "legend": { + "correct": "صحيح", + "partiallyCorrect": "صحيح جزئيًا", + "wrongResponse": "إجابة خاطئة", + "skipped": "تم التخطي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionsMarkRankGraph.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionsMarkRankGraph.json new file mode 100644 index 0000000000..c289e03f96 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionsMarkRankGraph.json @@ -0,0 +1,12 @@ +{ + "title": "الرسم البياني للدرجات والترتيب", + "chart": { + "rankAxisLabel": "الترتيب", + "marksAxisLabel": "الدرجات المحصلة", + "markSeriesLabel": "الدرجات" + }, + "export": { + "pdfSuccess": "تم تصدير بيانات ترتيب الطلاب ودرجاتهم كملف PDF بنجاح", + "csvSuccess": "تم تصدير بيانات ترتيب الطلاب ودرجاتهم كملف CSV بنجاح" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionsPieChart.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionsPieChart.json new file mode 100644 index 0000000000..a0ac6ec42d --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionsPieChart.json @@ -0,0 +1,22 @@ +{ + "labels": { + "createdOn": "أُنشئ في:", + "startDateAndTime": "تاريخ ووقت البدء:", + "endDateAndTime": "تاريخ ووقت الانتهاء:", + "duration": "المدة:", + "totalParticipants": "إجمالي المشاركين:" + }, + "stats": { + "avgDuration": "متوسط المدة", + "avgMarks": "متوسط الدرجات", + "minutesValue": "{{value}} دقيقة" + }, + "legend": { + "ongoing": "جارٍ", + "ongoingWithCount": "جارٍ ({{value}})", + "pending": "قيد الانتظار", + "pendingWithCount": "قيد الانتظار ({{value}})", + "attempted": "تمت المحاولة", + "attemptedWithCount": "تمت المحاولة ({{value}})" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionsRankMarkTable.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionsRankMarkTable.json new file mode 100644 index 0000000000..062c450640 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationQuestionsRankMarkTable.json @@ -0,0 +1,8 @@ +{ + "table": { + "rank": "الترتيب", + "marks": "الدرجات", + "percentile": "المئين", + "participants": "عدد المشاركين" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationRemoveParticipantsComponent.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationRemoveParticipantsComponent.json new file mode 100644 index 0000000000..c9e715a8f3 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationRemoveParticipantsComponent.json @@ -0,0 +1,5 @@ +{ + "heading": "إزالة المشاركين", + "confirmText": "هل أنت متأكد أنك تريد إزالة المشاركين لـ {{name}}؟", + "doneButton": "تم" +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestDetailsDropdownMenu.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestDetailsDropdownMenu.json new file mode 100644 index 0000000000..fd171afdf6 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestDetailsDropdownMenu.json @@ -0,0 +1,51 @@ +{ + "common": { + "open": "فتح", + "attention": "تنبيه", + "dateText": "التاريخ", + "timeText": "الوقت", + "reminderBodyPrefix": "سيتم إرسال تذكير بالواجب المنزلي إلى جميع", + "participants_zero": "لا يوجد مشاركون ({{count}})", + "participants_one": "مشارك واحد ({{count}})", + "participants_two": "مشاركان اثنان ({{count}})", + "participants_few": "{{count}} مشاركين", + "participants_many": "{{count}} مشاركًا", + "participants_other": "{{count}} مشارك", + "reminderBodySuffix": "الذين لم يظهروا بعد من الدفعات المخصصة." + }, + "menu": { + "viewHomeworkDetails": "عرض تفاصيل الواجب المنزلي", + "deleteHomework": "حذف الواجب المنزلي" + }, + "reminderDialog": { + "title": "إرسال تذكير", + "send": "إرسال" + }, + "deleteDialog": { + "title": "حذف الواجب المنزلي", + "confirmPrefix": "هل أنت متأكد أنك تريد حذف", + "confirmSuffix": "؟", + "slidesDeleteFailedToast": "تم حذف الواجب المنزلي، ولكن تعذّرت إزالة شرائح الدورة الخاصة به.", + "successToast": "تم حذف الواجب المنزلي بنجاح!", + "delete": "حذف" + }, + "pauseDialog": { + "title": "إيقاف الحالة المباشرة مؤقتًا", + "pauseUntilLabel": "الإيقاف المؤقت حتى", + "pause": "إيقاف مؤقت" + }, + "resumeDialog": { + "title": "استئناف الحالة المباشرة", + "confirmPrefix": "هل تريد استئناف واجبك المنزلي المباشر", + "testName": "العين البشرية والعالم الملون", + "confirmSuffix": "؟", + "resume": "استئناف" + }, + "reopenDialog": { + "title": "إعادة فتح الواجب المنزلي", + "selectDateTime": "اختر تاريخ ووقت إعادة فتح الواجب المنزلي", + "startDateTimeLabel": "تاريخ ووقت البدء", + "endDateTimeLabel": "تاريخ ووقت الانتهاء", + "reopen": "إعادة فتح" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestFilterButtons.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestFilterButtons.json new file mode 100644 index 0000000000..cbfc607e87 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestFilterButtons.json @@ -0,0 +1,4 @@ +{ + "filter": "تصفية", + "reset": "إعادة تعيين" +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestFilters.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestFilters.json new file mode 100644 index 0000000000..c66d73bad9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestFilters.json @@ -0,0 +1,7 @@ +{ + "selectedCount": "{{count}} محدد", + "search": { + "placeholder": "ابحث في {{label}}...", + "noResults": "لم يتم العثور على نتائج." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestHeaderDescription.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestHeaderDescription.json new file mode 100644 index 0000000000..4525fa8628 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestHeaderDescription.json @@ -0,0 +1,5 @@ +{ + "heading": "إدارة شاملة للاختبارات", + "description": "راقب وأدر جميع الواجبات بسهولة من خلال رؤية شاملة للاختبارات الجارية والقادمة والسابقة. احصل على وصول سريع لتفاصيل كل اختبار وجدوله وحالته، لضمان إشراف منظم على عملية الاختبار بأكملها من البداية إلى النهاية.", + "createHomeworkButton": "إنشاء واجب" +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestMainComponent.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestMainComponent.json new file mode 100644 index 0000000000..a54f43e1b0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestMainComponent.json @@ -0,0 +1,21 @@ +{ + "meta": { + "title": "جدولة الاختبارات", + "description": "تعرض هذه الصفحة قائمة بجميع الاختبارات المجدولة، ويمكن أيضًا جدولة تقييم من هنا." + }, + "navHeading": "قائمة إنشاء الواجبات", + "tabs": { + "liveEmpty": "لا توجد اختبارات مباشرة حاليًا.", + "upcomingEmpty": "لا توجد اختبارات قادمة مجدولة.", + "previousEmpty": "لا توجد اختبارات سابقة متاحة.", + "draftEmpty": "لا توجد مسودات اختبارات متاحة." + }, + "filters": { + "mode": "الوضع", + "type": "النوع", + "evaluation": "التقييم" + }, + "noCourseDialog": { + "type": "إنشاء تقييم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestSearchComponent.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestSearchComponent.json new file mode 100644 index 0000000000..502d893ae0 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestSearchComponent.json @@ -0,0 +1,3 @@ +{ + "searchPlaceholder": "ابحث عن ورقة الأسئلة" +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestTabList.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestTabList.json new file mode 100644 index 0000000000..8afa9425bf --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationScheduleTestTabList.json @@ -0,0 +1,8 @@ +{ + "tabs": { + "live": "مباشر", + "upcoming": "قادم", + "previous": "سابق", + "drafts": "المسودات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationSectionsEditQuestionFormSchema.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationSectionsEditQuestionFormSchema.json new file mode 100644 index 0000000000..d11a6a48b2 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationSectionsEditQuestionFormSchema.json @@ -0,0 +1,14 @@ +{ + "questionNameRequired": "اسم السؤال مطلوب", + "optionRequired": "الخيار {{number}} مطلوب", + "mcqs": { + "optionsRequired": "يجب أن تحتوي أسئلة الاختيار من متعدد (إجابة واحدة) على خيارات", + "exactlyFourOptions": "يجب أن يحتوي سؤال الاختيار من متعدد (إجابة واحدة) على 4 خيارات بالضبط", + "exactlyOneSelected": "يجب اختيار خيار واحد بالضبط في سؤال الاختيار من متعدد (إجابة واحدة)" + }, + "mcqm": { + "optionsRequired": "يجب أن تحتوي أسئلة الاختيار من متعدد (إجابات متعددة) على خيارات", + "exactlyFourOptions": "يجب أن يحتوي سؤال الاختيار من متعدد (إجابات متعددة) على 4 خيارات بالضبط", + "atLeastOneSelected": "يجب اختيار خيار واحد على الأقل في سؤال الاختيار من متعدد (إجابات متعددة)" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationSendReminderComponent.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationSendReminderComponent.json new file mode 100644 index 0000000000..ae2e1c45b9 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationSendReminderComponent.json @@ -0,0 +1,6 @@ +{ + "heading": "إرسال تذكير", + "confirmPrefix": "هل أنت متأكد أنك تريد إرسال تذكير إلى", + "confirmSuffix": "؟", + "done": "تم" +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationSingleCorrectMainView.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationSingleCorrectMainView.json new file mode 100644 index 0000000000..c3ea4879ef --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationSingleCorrectMainView.json @@ -0,0 +1,24 @@ +{ + "emptyState": "يرجى إضافة سؤال لعرض تفاصيل السؤال", + "settings": { + "title": "إعدادات الأسئلة", + "questionType": "نوع السؤال", + "marks": "الدرجات", + "negativeMarking": "العلامات السلبية", + "timeLimit": "الحد الزمني", + "hours": "ساعة", + "minutes": "دقيقة" + }, + "question": { + "label": "السؤال {{number}}" + }, + "answer": { + "label": "الإجابة:", + "optionLabel": "({{letter}}.)", + "removeOption": "إزالة الخيار", + "addOption": "إضافة خيار" + }, + "explanation": { + "label": "الشرح:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationSingleCorrectPPTView.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationSingleCorrectPPTView.json new file mode 100644 index 0000000000..71f5e32241 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationSingleCorrectPPTView.json @@ -0,0 +1,12 @@ +{ + "optionLabels": { + "a": "(a.)", + "b": "(b.)", + "c": "(c.)", + "d": "(d.)" + }, + "menu": { + "duplicateSlide": "تكرار الشريحة", + "deleteSlide": "حذف الشريحة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep1BasicInfo.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep1BasicInfo.json new file mode 100644 index 0000000000..e7f8ac5b44 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep1BasicInfo.json @@ -0,0 +1,51 @@ +{ + "heading": { + "createHomework": "إنشاء واجب منزلي", + "updateHomework": "تحديث الواجب المنزلي" + }, + "page": { + "title": "معلومات أساسية", + "nextButton": "التالي", + "updateButton": "تحديث" + }, + "toast": { + "updateSuccess": "تم تحديث بيانات الخطوة 1 بنجاح!", + "saveSuccess": "تم حفظ بيانات الخطوة 1 بنجاح!" + }, + "errors": { + "saveFailed": "فشل حفظ المعلومات الأساسية." + }, + "fields": { + "assessmentName": { + "label": "اسم التقييم", + "placeholder": "أضف العنوان" + }, + "assessmentInstructions": { + "sectionTitle": "تعليمات التقييم", + "placeholder": "اكتب تعليمات الواجب المنزلي" + }, + "liveDateRange": { + "sectionTitle": "نطاق التاريخ المباشر", + "startDateLabel": "تاريخ ووقت البدء", + "endDateLabel": "تاريخ ووقت الانتهاء" + }, + "attemptSettings": { + "sectionTitle": "إعدادات المحاولات", + "reattemptCountLabel": "عدد إعادة المحاولة", + "reattemptCountPlaceholder": "عدد إعادة المحاولة" + }, + "evaluationType": { + "label": "نوع التقييم" + }, + "submissionType": { + "label": "نوع التسليم" + }, + "assessmentPreview": { + "allowLabel": "السماح بمعاينة التقييم", + "previewTimeLimitLabel": "الحد الزمني للمعاينة" + }, + "switchSections": { + "label": "السماح لـ {{term}} بالتبديل بين الأقسام" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep2AddingQuestions.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep2AddingQuestions.json new file mode 100644 index 0000000000..c9f4fcf531 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep2AddingQuestions.json @@ -0,0 +1,30 @@ +{ + "heading": "إضافة أسئلة", + "defaultSectionName": "القسم {{number}}", + "actions": { + "update": "تحديث", + "next": "التالي", + "addSection": "إضافة قسم" + }, + "toast": { + "updateSuccess": "تم تحديث بيانات الخطوة 2 بنجاح!", + "saveSuccess": "تم حفظ بيانات الخطوة 2 بنجاح!" + }, + "errors": { + "saveQuestionsFailed": "فشل حفظ الأسئلة." + }, + "durationDistribution": { + "entireAssessmentLabel": "مدة التقييم بالكامل", + "sectionWiseLabel": "المدة حسب القسم", + "questionWiseLabel": "المدة حسب السؤال", + "entireAssessmentHeading": "مدة التقييم بالكامل", + "entireAssessmentDescription": "حدد حدًا زمنيًا واحدًا للتقييم بأكمله.", + "sectionWiseHeading": "المدة حسب القسم", + "sectionWiseDescription": "خصص وقتًا محددًا لكل قسم في علامة تبويب الأقسام. ستكون مدة التقييم الإجمالية مجموع أوقات جميع الأقسام.", + "questionWiseHeading": "المدة حسب السؤال", + "questionWiseDescription": "حدد حدودًا زمنية فردية لكل سؤال في علامة تبويب الأقسام، حيث يتوفر حقل إدخال الوقت بجانب كل سؤال.", + "entireTestDurationLabel": "مدة الاختبار بالكامل", + "hrs": "ساعات", + "minutes": "دقائق" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep2GenerateQuestionsFromAI.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep2GenerateQuestionsFromAI.json new file mode 100644 index 0000000000..44f2b88f9f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep2GenerateQuestionsFromAI.json @@ -0,0 +1,44 @@ +{ + "trigger": { + "label": "الإنشاء بالذكاء الاصطناعي", + "badge": "(Vacademy AI)" + }, + "dialog": { + "title": "إنشاء أسئلة بالذكاء الاصطناعي" + }, + "generate": { + "cardTitle": "إنشاء أسئلة", + "cardDescription": "اطلب من الذكاء الاصطناعي استخدام ملف PDF أو صورة أو أي موضوع لإنشاء أسئلة جديدة", + "dialogTitle": "إنشاء أسئلة", + "upload": { + "toolName": "VSmart Upload", + "shortDescription": "(إنشاء أسئلة عن طريق رفع ملفات PDF أو Word أو PowerPoint)", + "description": "أنشئ أوراق الأسئلة فورًا برفع المواد الدراسية بصيغة PDF أو Word أو PowerPoint. تستخدم Vsmart Upload الذكاء الاصطناعي لتحليل ملفك بالكامل وإنشاء أسئلة ذات صلة ومنظمة جيدًا — أو تتيح لك لصق المحتوى يدويًا إن أردت. لا حاجة لأي تنسيق، فقط ارفع الملف وابدأ. مثالية للمعلمين ومُعدّي الامتحانات ومدرّبي الشركات الذين يعملون بمحتوى موجود مثل شرائح المحاضرات أو أدلة الدراسة أو نشرات المقررات. سواء كنت تُعدّ تقييمات لفصل دراسي أو مجموعة تدريبية أو جلسة تدريب — توفر لك Vsmart Upload ساعات من الجهد بتحويل موادك إلى أوراق أسئلة جاهزة للاستخدام." + }, + "audio": { + "toolName": "Vsmart Audio", + "shortDescription": "(إنشاء أسئلة عن طريق رفع ملفات صوتية)", + "description": "حوّل أي محاضرة أو اجتماع أو تسجيل صوتي إلى ورقة أسئلة كاملة. ما عليك سوى رفع ملف بصيغة MP3 أو WAV أو غيرها — تقوم Vsmart Audio بتفريغ المحتوى واستخدام الذكاء الاصطناعي لإنشاء أسئلة منظمة ومرتبطة بالسياق، دون الحاجة لتفريغ يدوي أو تحرير. مثالية لمعامل اللغات، ووحدات التعلم القائمة على البودكاست، وجلسات تدريب الشركات، والمحاضرات المسجلة في الجامعات أو مراكز التدريب. يمكن للمدربين والمعلمين إعادة استخدام الموارد الصوتية الموجودة لإنشاء اختبارات أو أسئلة فهم أو مواضيع نقاش بنقرات قليلة، مما يعزز المشاركة ويرسّخ نتائج التعلم." + }, + "topics": { + "toolName": "Vsmart Topics", + "shortDescription": "(إنشاء أسئلة عن طريق تحديد المواضيع)", + "description": "أنشئ أوراق أسئلة مخصصة خلال ثوانٍ بمجرد كتابة موضوع أو مفهوم أو تعليمات. تستخدم Vsmart Prompt ذكاءً اصطناعيًا متقدمًا لفهم مدخلاتك وإنشاء مجموعة أسئلة مصممة خصيصًا — تغطي مستويات صعوبة وأنماطًا ومهارات معرفية متنوعة، وكلها متوافقة مع احتياجاتك. مثالية للمعلمين والمدربين ورؤساء الأقسام الأكاديمية الراغبين في تقييمات سريعة حول مواضيع محددة دون رفع أي مادة. سواء كان ذلك لاختبار مفاجئ أو مراجعة مفاهيم أو جلسة سريعة، توفر Vsmart Prompt أسئلة دقيقة ومتنوعة بأقل قدر من المدخلات." + } + }, + "extract": { + "cardTitle": "استخراج أسئلة", + "cardDescription": "اطلب من الذكاء الاصطناعي استخراج الأسئلة من أي ملف PDF أو صورة أو محاضرة صوتية", + "dialogTitle": "استخراج أسئلة", + "file": { + "toolName": "Vsmart Extract", + "shortDescription": "(استخراج أسئلة عن طريق رفع ملفات PDF أو Word أو PowerPoint)", + "description": "استخرج بسهولة جميع الأسئلة الموجودة في أي ملف PDF — سواء كانت ورقة امتحان سابقة أو ورقة تمارين أو بنك أسئلة. تفحص Vsmart Extract الملف بالكامل، وتحدد أنماط الأسئلة، وتنظمها بدقة لإعادة استخدامها أو تحريرها أو تصديرها بسهولة. مثالية للمعلمين والفرق الأكاديمية التي تتعامل مع ملفات PDF قديمة أو موارد مشتركة أو أوراق ممسوحة ضوئيًا. وفّر الوقت بدلًا من نسخ الأسئلة أو إعادة كتابتها يدويًا — تساعد Vsmart Extract المدارس ومراكز التدريب وأقسام تدريب الشركات على بناء أرشيفات رقمية أو إنشاء تقييمات محدّثة من المواد القديمة بسرعة." + }, + "image": { + "toolName": "Vsmart Image", + "shortDescription": "(استخراج أسئلة عن طريق رفع صور)", + "description": "حوّل الصور إلى أسئلة بسهولة تامة. تستخدم Vsmart Image تقنية التعرف الضوئي على الحروف المتقدمة والذكاء الاصطناعي لمسح الصور الفوتوغرافية والصفحات الممسوحة ضوئيًا والملاحظات المكتوبة بخط اليد أو لقطات الشاشة — واستخراج أسئلة منظمة منها. ما عليك سوى رفع صورتك وترك الذكاء الاصطناعي يقوم بالباقي. مثالية للمعلمين ومراكز التدريب والمدربين الذين يتلقون غالبًا محتوى على شكل ملاحظات مكتوبة بخط اليد أو لقطات من الكتب المدرسية أو صور السبورة. سواء كنت تُرقمن أوراق اختبارات قديمة أو تستخرج أسئلة من مواد مطبوعة — تنقل Vsmart Image المحتوى التناظري إلى سير عملك الرقمي في ثوانٍ." + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep2SectionInfo.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep2SectionInfo.json new file mode 100644 index 0000000000..d09b76c4b1 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep2SectionInfo.json @@ -0,0 +1,63 @@ +{ + "header": { + "mcqSingleCorrect": "اختيار من متعدد (إجابة واحدة):", + "mcqMultipleCorrect": "اختيار من متعدد (إجابات متعددة):", + "total": "الإجمالي:" + }, + "uploadSection": { + "title": "تحميل ورقة الأسئلة", + "uploadFromDevice": "التحميل من الجهاز", + "uploadFromDeviceDialogTitle": "تحميل ورقة الأسئلة من الجهاز", + "createManually": "الإنشاء يدويًا", + "createManuallyDialogTitle": "إنشاء ورقة أسئلة يدويًا", + "chooseSavedPaper": "اختيار ورقة محفوظة", + "chooseSavedDialogTitle": "اختيار ورقة أسئلة محفوظة من القائمة" + }, + "sectionDescription": { + "title": "وصف القسم", + "placeholder": "صف هذا القسم" + }, + "duration": { + "questionDuration": "مدة السؤال", + "sectionDuration": "مدة القسم", + "hrs": "ساعات", + "minutes": "دقائق" + }, + "markingScheme": { + "marksPerQuestion": { + "title": "الدرجات لكل سؤال", + "default": "(افتراضي)" + }, + "negativeMarking": "الدرجات السالبة", + "partialMarking": "التقييم الجزئي" + }, + "randomization": { + "problemRandomization": "عشوائية الأسئلة" + }, + "table": { + "adaptiveMarkingRulesTitle": "قواعد التقييم التكيفي", + "headers": { + "qno": "رقم السؤال", + "question": "السؤال", + "questionType": "نوع السؤال", + "marks": "الدرجات", + "penalty": "الخصم", + "time": "الوقت" + }, + "questionTypeLabels": { + "MCQS": "اختيار من متعدد (إجابة واحدة)", + "MCQM": "اختيار من متعدد (إجابات متعددة)", + "NUMERIC": "رقمي", + "CMCQS": "استيعاب نصي - اختيار من متعدد (إجابة واحدة)", + "CMCQM": "استيعاب نصي - اختيار من متعدد (إجابات متعددة)", + "CNUMERIC": "استيعاب نصي - رقمي", + "ONE_WORD": "كلمة واحدة", + "LONG_ANSWER": "إجابة طويلة", + "TRUE_FALSE": "صواب/خطأ", + "CODING": "برمجة" + } + }, + "totalMarks": { + "label": "إجمالي الدرجات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep3AddingParticipants.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep3AddingParticipants.json new file mode 100644 index 0000000000..b8bc4fbe7c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep3AddingParticipants.json @@ -0,0 +1,61 @@ +{ + "header": { + "title": "إضافة مشاركين", + "update": "تحديث", + "next": "التالي" + }, + "participantAccess": { + "heading": "إعدادات وصول المشاركين", + "closedTest": { + "label": "اختبار مغلق:", + "description": "قصر التقييم على مشاركين محددين عن طريق تخصيصه لمجموعات المؤسسة أو تحديد {{term}} فرديين." + }, + "openTest": { + "label": "اختبار مفتوح:", + "description": "السماح لأي شخص بالتسجيل في هذا التقييم عبر رابط مشترك. يمكن أيضًا تسجيل {{term}} المؤسسة مسبقًا عن طريق اختيار المجموعات أو الأفراد." + } + }, + "registration": { + "heading": "تسجيل التقييم", + "startDateLabel": "تاريخ ووقت البدء", + "endDateLabel": "تاريخ ووقت الانتهاء", + "aboutHeading": "حول تسجيل التقييم", + "instructionsPlaceholder": "تعليمات التسجيل", + "fields": { + "heading": "حقول نموذج التسجيل", + "helperText": "انقر على أيقونة القلم لإعادة تسمية حقل أو تعديل خياراته", + "addGenderButton": "إضافة الجنس", + "addStateButton": "إضافة الولاية", + "addCityButton": "إضافة المدينة", + "addSchoolCollegeButton": "إضافة المدرسة/الكلية", + "addCustomFieldButton": "إضافة حقل مخصص" + }, + "preview": { + "triggerButton": "معاينة نموذج التسجيل", + "heading": "معاينة نموذج التسجيل", + "registerNowButton": "سجّل الآن" + } + }, + "joinLink": { + "heading": "رابط الانضمام", + "placeholder": "رابط الانضمام" + }, + "qrCode": { + "heading": "رمز الاستجابة السريعة" + }, + "notifyParticipants": { + "heading": "إشعار المشاركين عبر البريد الإلكتروني:", + "whenAssessmentCreated": "عند إنشاء التقييم", + "beforeAssessmentGoesLive": "قبل أن يصبح التقييم مباشرًا", + "notifyBeforeLabel": "الإشعار قبل", + "whenAssessmentLive": "عندما يصبح التقييم مباشرًا", + "whenReportGenerated": "عند إنشاء تقارير التقييم" + }, + "toasts": { + "updateSuccess": "تم تحديث بيانات الخطوة 3 بنجاح!", + "saveSuccess": "تم حفظ بيانات الخطوة 3 بنجاح!" + }, + "errors": { + "saveFailed": "فشل حفظ المشاركين." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep4AccessControl.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep4AccessControl.json new file mode 100644 index 0000000000..28b992c522 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep4AccessControl.json @@ -0,0 +1,40 @@ +{ + "page": { + "title": "التحكم في الوصول", + "updateButton": "تحديث", + "saveButton": "حفظ", + "publishButton": "نشر" + }, + "sections": { + "creationAccess": "صلاحية إنشاء التقييم", + "liveAssessmentNotification": "إشعار التقييم المباشر", + "submissionAndReportAccess": "صلاحية تسليم التقييم والتقارير", + "evaluationProcess": "عملية التقييم" + }, + "toast": { + "updateSuccess": "تم تحديث تقييمك بنجاح!", + "saveSuccess": "تم حفظ تقييمك بنجاح!", + "updateAndPublishSuccess": "تم تحديث تقييمك ونشره بنجاح!", + "publishSuccess": "تم نشر تقييمك بنجاح!", + "cancelInvitationSuccess": "تم إلغاء دعوة هذا المستخدم بنجاح!" + }, + "errors": { + "saveFailed": "فشل حفظ إعدادات التحكم في الوصول.", + "publishFailed": "فشل نشر التقييم." + }, + "addUserDialog": { + "addButton": "إضافة", + "title": "إضافة مستخدم", + "roleTypeFilterLabel": "نوع الدور", + "selectAllLabel": "تحديد الكل", + "doneButton": "تم" + }, + "cancelInvitationDialog": { + "trigger": "إلغاء الدعوة", + "title": "إلغاء الدعوة", + "attention": "تنبيه", + "confirmMessagePrefix": "هل أنت متأكد أنك تريد إلغاء الدعوة الخاصة بـ", + "confirmMessageSuffix": "؟", + "confirmButton": "نعم" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep4InviteUsers.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep4InviteUsers.json new file mode 100644 index 0000000000..430f859a46 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStep4InviteUsers.json @@ -0,0 +1,20 @@ +{ + "trigger": { + "inviteUsers": "دعوة مستخدمين" + }, + "dialog": { + "title": "دعوة مستخدم", + "fullNamePlaceholder": "الاسم الكامل (الأول والأخير)", + "fullNameLabel": "الاسم الكامل", + "emailPlaceholder": "أدخل البريد الإلكتروني", + "emailLabel": "البريد الإلكتروني", + "roleTypeLabel": "نوع الدور", + "submitButton": "دعوة المستخدم" + }, + "validation": { + "nameRequired": "الاسم الكامل مطلوب", + "emailRequired": "البريد الإلكتروني مطلوب", + "invalidEmail": "صيغة البريد الإلكتروني غير صحيحة", + "roleTypeRequired": "مطلوب نوع دور واحد على الأقل" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentAttemptDropdown.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentAttemptDropdown.json new file mode 100644 index 0000000000..1f9e3f810f --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentAttemptDropdown.json @@ -0,0 +1,30 @@ +{ + "dialogs": { + "attentionLabel": "انتباه", + "yes": "نعم", + "provideReattempt": { + "title": "توفير محاولة إعادة", + "confirmMessagePrefix": "هل أنت متأكد أنك تريد منح فرصة إعادة المحاولة لـ" + }, + "releaseResult": { + "title": "إصدار النتيجة", + "confirmMessagePrefix": "هل أنت متأكد أنك تريد إصدار النتيجة لـ" + }, + "revaluateEntireAssessment": { + "title": "إعادة تقييم التقييم بالكامل", + "confirmMessagePrefix": "هل أنت متأكد أنك تريد إعادة التقييم لـ", + "confirmMessageSuffix": "للتقييم بأكمله؟" + } + }, + "dropdown": { + "provideReattempt": "توفير محاولة إعادة", + "revaluate": "إعادة التقييم", + "questionWise": "حسب السؤال", + "entireAssessment": "التقييم بالكامل", + "releaseResult": "إصدار النتيجة" + }, + "toasts": { + "releaseResultSuccess": "تم إعادة تقييم محاولتك لهذا التقييم. يرجى التحقق من بريدك الإلكتروني!", + "revaluateSuccess": "تم إعادة تقييم محاولتك لهذا التقييم. يرجى التحقق من بريدك الإلكتروني!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentColumns.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentColumns.json new file mode 100644 index 0000000000..032677d88c --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentColumns.json @@ -0,0 +1,27 @@ +{ + "columns": { + "details": "التفاصيل", + "name": "الاسم", + "attemptDate": "تاريخ المحاولة", + "startTime": "وقت البدء", + "endTime": "وقت الانتهاء", + "duration": "المدة", + "score": "الدرجة", + "evaluationStatus": "حالة التقييم", + "enrollmentNumber": "رقم التسجيل", + "gender": "الجنس", + "responseTime": "وقت الاستجابة", + "phoneNumber": "رقم الهاتف", + "emailId": "البريد الإلكتروني", + "city": "المدينة", + "state": "المنطقة" + }, + "status": { + "evaluated": "تم التقييم", + "pending": "قيد الانتظار" + }, + "sort": { + "ascending": "تصاعدي", + "descending": "تنازلي" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentOngoingDropdown.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentOngoingDropdown.json new file mode 100644 index 0000000000..1ae9b6f781 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentOngoingDropdown.json @@ -0,0 +1,22 @@ +{ + "dropdown": { + "increaseSubmissionTime": "زيادة وقت التسليم", + "closeSubmission": "إغلاق التسليم" + }, + "dialogs": { + "attentionLabel": "انتباه", + "closeButton": "إغلاق", + "closeSubmission": { + "title": "إغلاق التسليم", + "confirmMessagePrefix": "هل أنت متأكد أنك تريد إغلاق تسليم التقييم الخاص بـ" + }, + "increaseAssessmentTime": { + "title": "زيادة وقت التقييم", + "entireAssessment": "التقييم بالكامل", + "increaseBy": "زيادة بمقدار", + "selectSectionPlaceholder": "اختر القسم", + "sectionNumber": "القسم {{number}}", + "questionNumber": "السؤال {{number}}" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentPendingDropdown.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentPendingDropdown.json new file mode 100644 index 0000000000..d39dffd1b4 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentPendingDropdown.json @@ -0,0 +1,21 @@ +{ + "dropdown": { + "sendReminder": "إرسال تذكير", + "removeParticipants": "إزالة المشاركين" + }, + "dialogs": { + "attentionLabel": "انتباه", + "sendReminder": { + "title": "إرسال تذكير", + "confirmMessagePrefix": "سيتم إرسال تذكير إلى", + "confirmMessageSuffix": "الذي لم يظهر بعد في التقييم", + "sendButton": "إرسال" + }, + "removeParticipant": { + "title": "إزالة المشارك", + "confirmMessagePrefix": "هل أنت متأكد أنك تريد إزالة", + "confirmMessageSuffix": "من هذا التقييم؟", + "removeButton": "إزالة" + } + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentQuestionwiseFilterButtons.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentQuestionwiseFilterButtons.json new file mode 100644 index 0000000000..cbfc607e87 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentQuestionwiseFilterButtons.json @@ -0,0 +1,4 @@ +{ + "filter": "تصفية", + "reset": "إعادة تعيين" +} diff --git a/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentRevaluateQuestionWise.json b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentRevaluateQuestionWise.json new file mode 100644 index 0000000000..fb15e25c79 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/homeworkCreationStudentRevaluateQuestionWise.json @@ -0,0 +1,13 @@ +{ + "title": "إعادة التقييم حسب السؤال", + "table": { + "qNo": "رقم السؤال", + "question": "السؤال" + }, + "buttons": { + "revaluate": "إعادة التقييم" + }, + "toasts": { + "revaluateSuccess": "تم إعادة تقييم محاولتك لهذا التقييم. يرجى التحقق من بريدك الإلكتروني!" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/managePagesAiPageWizard.json b/frontend-admin-dashboard/public/locales/ar/managePagesAiPageWizard.json new file mode 100644 index 0000000000..16b4d5e704 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/managePagesAiPageWizard.json @@ -0,0 +1,136 @@ +{ + "dialogTitle": "إنشاء صفحة بالذكاء الاصطناعي", + "pageTypes": { + "homepage": "الصفحة الرئيسية", + "courses": "جميع الدورات", + "courseLanding": "صفحة هبوط لدورة", + "about": "من نحن", + "admissions": "القبول والتسجيل", + "contact": "تواصل معنا" + }, + "brief": { + "assistantBanner": "قام المساعد بتعبئة هذا من محادثتك. تحقق من نوع الصفحة أدناه — فهو يحدد كيفية بناء الصفحة — ثم عدّل ما تريد قبل المتابعة.", + "pageTypeQuestion": "ما نوع الصفحة؟", + "briefLabel": "أخبرنا عن هذه الصفحة", + "briefPlaceholder": "مثال: صفحة هبوط لدورة Arduino لدينا موجهة لطلاب المدارس — أبرز المشاريع العملية وحصة التجربة المجانية وشهادات أولياء الأمور. نبرة ودودة وراقية في آن واحد.", + "anyLanguageHint": "اكتب بأي لغة تريدها — سيتطابق نص الصفحة معها.", + "nextHintReady": "الخطوة التالية: ارفع شعارك وصورك ولقطات شاشة للمواقع التي تريد أن تشبهها هذه الصفحة.", + "nextHintEmpty": "أضف موجزًا للمتابعة — يليه شعارك وصورك ولقطات الإلهام.", + "matchThemeLabel": "مطابقة سمة موقعي الحالية", + "matchThemeDescription": "يحافظ على الألوان والخطوط المستخدمة بالفعل في صفحاتك الأخرى. أطفئه فقط إن أردت أن تقترح هذه الصفحة سمتها الخاصة — فذلك سيعيد تصميم الموقع بأكمله عند القبول.", + "useRealDataLabel": "استخدام بيانات {{course}} الحقيقية", + "useRealDataDescription_zero": "لن يشير النص إلى أي عروض فعلية ({{count}})", + "useRealDataDescription_one": "سيشير النص إلى عرضك الفعلي الوحيد ({{count}} تم العثور عليه)", + "useRealDataDescription_two": "سيشير النص إلى عرضيك الفعليين ({{count}} تم العثور عليهما)", + "useRealDataDescription_few": "سيشير النص إلى عروضك الفعلية ({{count}} تم العثور عليها)", + "useRealDataDescription_many": "سيشير النص إلى عروضك الفعلية ({{count}} تم العثور عليها)", + "useRealDataDescription_other": "سيشير النص إلى عروضك الفعلية ({{count}} تم العثور عليها)", + "wholeSiteLabel": "إنشاء موقع كامل", + "wholeSiteDescription": "الرئيسية ومن نحن وتواصل معنا بسمة تصميم موحّدة (يستهلك رصيدًا أكبر)" + }, + "assets": { + "intro": "أضف صورًا أو شعارك أو لافتات — سيضعها الذكاء الاصطناعي حيث تناسب. كل شيء اختياري.", + "captionPlaceholder": "ما هذه الصورة؟ (مثال: مختبر الروبوتات لدينا)", + "addImageLabel": "إضافة صورة", + "addThisImage": "إضافة هذه الصورة", + "logoHeading": "بحاجة إلى شعار؟", + "logoPromptPlaceholder": "صف علامتك التجارية (مثال: صاروخ لأكاديمية برمجة)", + "generating": "جارٍ الإنشاء…", + "generateLogoOptions": "إنشاء 3 خيارات لشعار", + "logoCaption": "الشعار", + "useThisLogo": "استخدام هذا الشعار", + "rebuildHeading": "إعادة البناء من موقعك الحالي (اختياري)", + "rebuildDescription": "نقرأ المحتوى الفعلي لصفحتك الحالية ونعيد بناءه هنا — استورد محتوى تملكه.", + "rebuildUrlPlaceholder": "https://your-current-site.com", + "inspirationHeading": "لقطات شاشة لمواقع تعجبك (اختياري)", + "inspirationDescription": "نقرأ التخطيط واتجاه التصميم فقط — لا نقرأ محتواها أبدًا.", + "addScreenshotLabel": "إضافة لقطة شاشة", + "addThisScreenshot": "إضافة لقطة الشاشة هذه" + }, + "confirm": { + "ready": "جاهز للإنشاء", + "imageCount_zero": "لا صور ({{count}})", + "imageCount_one": "صورة واحدة ({{count}})", + "imageCount_two": "صورتان ({{count}})", + "imageCount_few": "{{count}} صور", + "imageCount_many": "{{count}} صورة", + "imageCount_other": "{{count}} صورة", + "realDataSummary": "بيانات {{course}} الحقيقية", + "genericContentSummary": "محتوى عام", + "generateImagesLabel": "إنشاء الصور تلقائيًا", + "generateImagesDescription": "ينشئ الذكاء الاصطناعي صورة رئيسية وبضعة عناصر مرئية (يستهلك رصيدًا إضافيًا)", + "estimatedCostLabel": "التكلفة التقديرية:", + "estimatedCredits_zero": "لا رصيد ({{count}})", + "estimatedCredits_one": "رصيد واحد ({{count}})", + "estimatedCredits_two": "رصيدان ({{count}})", + "estimatedCredits_few": "{{count}} أرصدة", + "estimatedCredits_many": "{{count}} رصيدًا", + "estimatedCredits_other": "{{count}} رصيد", + "estimatedCreditsUnknown": "— رصيد", + "balance": "الرصيد {{value}}", + "insufficientBalance": "— الرصيد غير كافٍ", + "generating": "جارٍ تصميم الأقسام وكتابة نصك — عادةً في أقل من دقيقة…" + }, + "review": { + "siteReady_zero": "الموقع جاهز — لا صفحات ({{count}})، بسمة تصميم موحّدة:", + "siteReady_one": "الموقع جاهز — صفحة واحدة ({{count}})، بسمة تصميم موحّدة:", + "siteReady_two": "الموقع جاهز — صفحتان ({{count}})، بسمة تصميم موحّدة:", + "siteReady_few": "الموقع جاهز — {{count}} صفحات، بسمة تصميم موحّدة:", + "siteReady_many": "الموقع جاهز — {{count}} صفحة، بسمة تصميم موحّدة:", + "siteReady_other": "الموقع جاهز — {{count}} صفحة، بسمة تصميم موحّدة:", + "sectionsCount_zero": "لا أقسام ({{count}})", + "sectionsCount_one": "قسم واحد ({{count}})", + "sectionsCount_two": "قسمان ({{count}})", + "sectionsCount_few": "{{count}} أقسام", + "sectionsCount_many": "{{count}} قسمًا", + "sectionsCount_other": "{{count}} قسم", + "applyThemeLabel": "تطبيق سمة الموقع المطابقة", + "themeCaption": "{{preset}} · {{font}}", + "themeSummaryFull": "سمة {{preset}} · {{font}} · يضبط الألوان والخطوط في الموقع بأكمله", + "defaultThemeLabel": "افتراضية", + "siteAddNote": "يضيف كل الصفحات كتغييرات غير محفوظة — راجعها على لوحة التصميم، ثم احفظ وانشر.", + "optionTab": "الخيار {{index}}", + "draftReady": "المسودة جاهزة — {{title}} · {{sections}}", + "untitledPage": "صفحة بلا عنوان", + "warningsSummary_zero": "لا شيء للتحقق منه قبل النشر ({{count}})", + "warningsSummary_one": "أمر واحد ({{count}}) للتحقق منه قبل النشر", + "warningsSummary_two": "أمران ({{count}}) للتحقق منهما قبل النشر", + "warningsSummary_few": "{{count}} أمور للتحقق منها قبل النشر", + "warningsSummary_many": "{{count}} أمرًا للتحقق منه قبل النشر", + "warningsSummary_other": "{{count}} أمر للتحقق منه قبل النشر", + "moreWarnings": "+{{count}} أخرى", + "pageAddNote": "القبول يضيف هذه الصفحة إلى موقعك كتغيير غير محفوظ — راجعها على لوحة التصميم، ثم احفظ وانشر." + }, + "footer": { + "preferForm": "تفضّل نموذجًا؟ استخدم الموجز السريع", + "assistantBack": "المساعد", + "nextImages": "التالي: الصور", + "back": "رجوع", + "nextGenerate": "التالي: الإنشاء", + "generateSite": "إنشاء الموقع", + "generatePage": "إنشاء الصفحة", + "addPagesToSite_zero": "إضافة الصفحات إلى الموقع ({{count}})", + "addPagesToSite_one": "إضافة صفحة واحدة ({{count}}) إلى الموقع", + "addPagesToSite_two": "إضافة صفحتين ({{count}}) إلى الموقع", + "addPagesToSite_few": "إضافة {{count}} صفحات إلى الموقع", + "addPagesToSite_many": "إضافة {{count}} صفحة إلى الموقع", + "addPagesToSite_other": "إضافة {{count}} صفحة إلى الموقع", + "tryAnotherDirection": "تجربة اتجاه آخر", + "addToSite": "إضافة إلى الموقع" + }, + "toast": { + "generationFailedTitle": "فشل الإنشاء", + "logoGenerationFailedTitle": "فشل إنشاء الشعار", + "siteGenerationFailedTitle": "فشل إنشاء الموقع", + "tryAgain": "يرجى المحاولة مرة أخرى.", + "pagesAddedTitle_zero": "لم تُضَف أي صفحة ({{count}})", + "pagesAddedTitle_one": "تمت إضافة صفحة واحدة ({{count}})", + "pagesAddedTitle_two": "تمت إضافة صفحتين ({{count}})", + "pagesAddedTitle_few": "تمت إضافة {{count}} صفحات", + "pagesAddedTitle_many": "تمت إضافة {{count}} صفحة", + "pagesAddedTitle_other": "تمت إضافة {{count}} صفحة", + "pagesAddedDescription": "راجعها على لوحة التصميم، ثم احفظ وانشر.", + "pageAddedTitle": "تمت إضافة الصفحة", + "pageAddedDescription": "راجعها على لوحة التصميم، ثم احفظ وانشر عندما تكون جاهزًا." + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/managePagesComponentPreviews.json b/frontend-admin-dashboard/public/locales/ar/managePagesComponentPreviews.json new file mode 100644 index 0000000000..49d52ec6af --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/managePagesComponentPreviews.json @@ -0,0 +1,156 @@ +{ + "productPageOffer": { + "seeAll": "عرض الكل", + "pickPage": "اختر صفحة منتج من لوحة الخصائص لعرض دوراتها هنا.", + "loading": "جاري تحميل الدورات…", + "noCourses": "لا تحتوي صفحة المنتج هذه على دورات نشطة بعد.", + "courseFallback": "دورة", + "free": "مجاني", + "viewCourse": "عرض الدورة", + "enrolNow": "سجّل الآن", + "addToCart": "أضف إلى السلة", + "checkout": "المتابعة إلى الدفع", + "basketPreviewCount_zero": "لم يتم تحديد أي دورة", + "basketPreviewCount_one": "تم تحديد دورة واحدة", + "basketPreviewCount_two": "تم تحديد دورتان", + "basketPreviewCount_few": "تم تحديد {{count}} دورات", + "basketPreviewCount_many": "تم تحديد {{count}} دورة", + "basketPreviewCount_other": "تم تحديد {{count}} دورة", + "hiddenCountCapped_zero": "+ {{count}} دورة إضافية — ينتهي الصف ببطاقة ترتبط بصفحة المنتج الكاملة.", + "hiddenCountCapped_one": "+ {{count}} دورة واحدة إضافية — ينتهي الصف ببطاقة ترتبط بصفحة المنتج الكاملة.", + "hiddenCountCapped_two": "+ {{count}} دورتان إضافيتان — ينتهي الصف ببطاقة ترتبط بصفحة المنتج الكاملة.", + "hiddenCountCapped_few": "+ {{count}} دورات إضافية — ينتهي الصف ببطاقة ترتبط بصفحة المنتج الكاملة.", + "hiddenCountCapped_many": "+ {{count}} دورة إضافية — ينتهي الصف ببطاقة ترتبط بصفحة المنتج الكاملة.", + "hiddenCountCapped_other": "+ {{count}} دورة إضافية — ينتهي الصف ببطاقة ترتبط بصفحة المنتج الكاملة.", + "hiddenCountPaged_zero": "+ {{count}} دورة إضافية — يتصفح الزوار {{totalPages}} صفحة{{suffix}}.", + "hiddenCountPaged_one": "+ {{count}} دورة واحدة إضافية — يتصفح الزوار {{totalPages}} صفحات{{suffix}}.", + "hiddenCountPaged_two": "+ {{count}} دورتان إضافيتان — يتصفح الزوار {{totalPages}} صفحات{{suffix}}.", + "hiddenCountPaged_few": "+ {{count}} دورات إضافية — يتصفح الزوار {{totalPages}} صفحات{{suffix}}.", + "hiddenCountPaged_many": "+ {{count}} دورة إضافية — يتصفح الزوار {{totalPages}} صفحة{{suffix}}.", + "hiddenCountPaged_other": "+ {{count}} دورة إضافية — يتصفح الزوار {{totalPages}} صفحة{{suffix}}.", + "pagedSuffixCarousel": "، مع تمرير كل صف أفقيًا", + "pagedSuffixScrollable": "، مع التمرير داخل القسم" + }, + "leadForm": { + "pickCampaign": "اختر حملة من لوحة الخصائص — ستظهر حقول النموذج هنا.", + "loading": "جاري تحميل حقول النموذج…", + "noFields": "لا تحتوي هذه الحملة على حقول نموذج بعد — أضفها في مدير الجمهور.", + "submit": "إرسال" + }, + "detailBlocks": { + "empty": "أضف كتلة لكل برنامج تريد توثيقه." + }, + "header": { + "getStarted": "ابدأ الآن", + "logoAlt": "الشعار" + }, + "hero": { + "titleFallback": "عنوان القسم الرئيسي", + "getStarted": "ابدأ الآن", + "photoFallback": "صورة {{index}}", + "imageArea": "منطقة الصورة" + }, + "footer": { + "platform": "المنصة", + "bottomNote": "© 2025" + }, + "stats": { + "empty": "لم تتم إضافة إحصاءات بعد" + }, + "testimonial": { + "textFallback": "نص الشهادة…", + "authorFallback": "طالب", + "empty": "أضف شهادات في لوحة الخصائص" + }, + "mediaShowcase": { + "slideFallback": "الشريحة {{index}}", + "moreSlides_zero": "+ {{count}} شريحة أخرى", + "moreSlides_one": "+ {{count}} شريحة واحدة أخرى", + "moreSlides_two": "+ {{count}} شريحتان أخريان", + "moreSlides_few": "+ {{count}} شرائح أخرى", + "moreSlides_many": "+ {{count}} شريحة أخرى", + "moreSlides_other": "+ {{count}} شريحة أخرى", + "slideCount_zero": "{{count}} شريحة · تخطيط شرائح متحركة", + "slideCount_one": "{{count}} شريحة واحدة · تخطيط شرائح متحركة", + "slideCount_two": "{{count}} شريحتان · تخطيط شرائح متحركة", + "slideCount_few": "{{count}} شرائح · تخطيط شرائح متحركة", + "slideCount_many": "{{count}} شريحة · تخطيط شرائح متحركة", + "slideCount_other": "{{count}} شريحة · تخطيط شرائح متحركة", + "mediaItemFallback": "عنصر وسائط", + "typeImage": "صورة", + "typeVideo": "فيديو", + "empty": "أضف شرائح أو عناصر وسائط في لوحة الخصائص" + }, + "video": { + "addUrl": "أضف رابط فيديو في الخصائص" + }, + "ctaBanner": { + "headingFallback": "دعوة لاتخاذ إجراء" + }, + "gallery": { + "imageFallback": "صورة {{index}}" + }, + "dataPlaceholder": { + "defaultDescription": "يعرض بيانات مباشرة على الصفحة المنشورة" + }, + "marquee": { + "defaultItem1": "دورات الأعلى تقييمًا", + "defaultItem2": "أكثر من 10,000 متعلم", + "defaultItem3": "مدربون خبراء", + "scrollingHint": "→ تمرير" + }, + "dispatcher": { + "buyRentHeading": "اختر مسارك", + "buy": "شراء", + "rent": "استئجار", + "courseCatalogLabel": "كتالوج الدورات", + "courseCatalogDesc": "يعرض الدورات المباشرة — \"{{title}}\"", + "ourCoursesDefault": "دوراتنا", + "bookCatalogueLabel": "كتالوج الكتب", + "bookCatalogueDesc": "يعرض الكتب المباشرة — \"{{title}}\"", + "bookCollectionDefault": "مجموعة الكتب", + "cartLabel": "عربة التسوق", + "cartDesc": "عربة الطالب مع العناصر وتدفق الدفع", + "courseDetailsLabel": "تفاصيل الدورة", + "courseDetailsDesc": "يعرض بيانات تفاصيل الدورة الحالية", + "bookDetailsLabel": "تفاصيل الكتاب", + "bookDetailsDesc": "يعرض بيانات تفاصيل الكتاب الحالي", + "policyLabel": "صفحة السياسة", + "policyDesc": "يعرض محتوى السياسة / الشروط", + "tabItemFallback": "عنصر {{index}}", + "tabTabFallback": "تبويب {{index}}", + "tabContentDefault": "محتوى التبويب", + "logoCloudEmptyTicker": "أضف عناصر الشريط المتحرك عبر لوحة الخصائص", + "logoCloudEmptyLogos": "أضف الشعارات عبر لوحة الخصائص", + "mapEmbedPreview": "معاينة تضمين الخريطة", + "mapEmbedAddUrl": "أضف رابط تضمين خرائط جوجل", + "countdownHeadingFallback": "يبدأ الحدث خلال", + "unitDays": "أيام", + "unitHours": "ساعات", + "unitMins": "دقائق", + "unitSecs": "ثوانٍ", + "textBlockDefault": "كتلة نصية — انقر لتعديل المحتوى", + "uploadImage": "قم بتحميل صورة", + "buttonFallback": "زر", + "newsletterPlaceholder": "أدخل بريدك الإلكتروني", + "newsletterButton": "اشترك", + "columnLayoutLabel": "تخطيط {{cols}} أعمدة · الفجوة: {{gap}}", + "slotEmpty": "الفتحة {{index}} — فارغة", + "slotComponentCount_zero": "{{count}} مكوّن", + "slotComponentCount_one": "{{count}} مكوّن واحد", + "slotComponentCount_two": "{{count}} مكوّنان", + "slotComponentCount_few": "{{count}} مكوّنات", + "slotComponentCount_many": "{{count}} مكوّنًا", + "slotComponentCount_other": "{{count}} مكوّن", + "filterCategory": "الفئة", + "filterLevel": "المستوى", + "filterPrice": "السعر", + "courseImage": "صورة الدورة", + "courseGridNote": "شبكة الدورات · يتم تحميل المحتوى عند التشغيل", + "htmlPageEmptyTitle": "صفحة HTML فارغة", + "htmlPageEmptyBody": "الصق كود HTML وCSS في لوحة الخصائص.", + "htmlBlockLabel": "كتلة HTML", + "htmlBlockEmpty": "كتلة HTML فارغة", + "unknownComponent": "مكوّن غير معروف: {{type}}" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/managePagesComponentTemplates.json b/frontend-admin-dashboard/public/locales/ar/managePagesComponentTemplates.json new file mode 100644 index 0000000000..b72a5be931 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/managePagesComponentTemplates.json @@ -0,0 +1,252 @@ +{ + "header": { + "title": "منصتي", + "navHome": "الرئيسية", + "navCourses": "الدورات", + "authLogin": "تسجيل الدخول" + }, + "heroSection": { + "eyebrow": "التسجيل مفتوح الآن للدفعة الجديدة", + "title": "مرحبًا بكم في منصتنا", + "subheading": "طريقك إلى الإتقان يبدأ من هنا", + "description": "

ابدأ رحلتك التعليمية اليوم مع دورات يقودها خبراء مصممة لتحقيق النجاح في الواقع العملي.

", + "tagOnline": "عبر الإنترنت", + "tagSelfPaced": "بالوتيرة الخاصة بك", + "tagCertified": "معتمد", + "exploreCoursesButton": "استكشف الدورات", + "talkToUsButton": "تحدث إلينا", + "statLearnersLabel": "المتعلمون", + "statRatingLabel": "متوسط التقييم", + "heroImageAlt": "صورة رئيسية" + }, + "courseCatalog": { + "title": "دوراتنا", + "filterLevelLabel": "المستوى" + }, + "footer": { + "leftTitle": "منصتي", + "leftText": "مرحبًا بكم في منصتنا.", + "rightTitle": "روابط" + }, + "mediaShowcase": { + "headerText": "قصص النجاح", + "description": "استمع مباشرة من متعلمينا." + }, + "statsHighlights": { + "headerText": "إنجازاتنا", + "description": "أرقام تعبر عن نمونا.", + "statStudentsLabel": "الطلاب" + }, + "testimonialSection": { + "headerText": "ماذا يقول طلابنا", + "description": "آراء حقيقية من متعلمينا." + }, + "bookCatalogue": { + "title": "مجموعة الكتب" + }, + "buyRentSection": { + "heading": "اختر مسارك", + "buyButtonLabel": "شراء", + "rentButtonLabel": "استئجار" + }, + "policyRenderer": { + "shippingTitle": "السياسة", + "shippingContent": "

المحتوى هنا

" + }, + "faqSection": { + "headerText": "الأسئلة الشائعة", + "subheading": "كل ما تحتاج لمعرفته.", + "faq1Question": "ما هي الدورات التي تقدمونها؟", + "faq1Answer": "نقدم مجموعة واسعة من الدورات في تخصصات متعددة.", + "faq2Question": "كيف يمكنني التسجيل؟", + "faq2Answer": "ببساطة قم بالتسجيل، وتصفح كتالوجنا، وانقر على تسجيل في أي دورة.", + "faq3Question": "هل هناك تجربة مجانية؟", + "faq3Answer": "نعم! العديد من دوراتنا تقدم معاينة مجانية." + }, + "videoEmbed": { + "title": "شاهد قصتنا" + }, + "ctaBanner": { + "heading": "هل أنت مستعد للبدء؟", + "subheading": "انضم إلى آلاف المتعلمين وابدأ رحلتك اليوم.", + "buttonText": "ابدأ التعلم" + }, + "pricingTable": { + "headerText": "اختر خطتك", + "subheading": "أسعار بسيطة وشفافة للجميع.", + "basicName": "أساسي", + "basicPrice": "مجاني", + "basicDescription": "مثالي للبدء", + "basicFeature1": "5 دورات", + "basicFeature2": "الوصول إلى المجتمع", + "basicFeature3": "دعم عبر البريد الإلكتروني", + "basicButtonText": "ابدأ الآن", + "proName": "احترافي", + "proPeriod": "/شهريًا", + "proDescription": "للمتعلمين الجادين", + "proFeature1": "دورات غير محدودة", + "proFeature2": "دعم ذو أولوية", + "proFeature3": "شهادات", + "proFeature4": "جلسات مباشرة", + "proButtonText": "احصل على الاحترافي" + }, + "leadForm": { + "title": "سجل اهتمامك", + "subtitle": "املأ بياناتك وسنتواصل معك.", + "submitLabel": "إرسال", + "successMessage": "شكرًا لك! لقد استلمنا بياناتك." + }, + "contactForm": { + "heading": "تواصل معنا", + "subheading": "يسعدنا أن نسمع منك. أرسل لنا رسالة!", + "nameLabel": "الاسم الكامل", + "emailLabel": "البريد الإلكتروني", + "phoneLabel": "رقم الهاتف", + "messageLabel": "الرسالة", + "submitLabel": "إرسال الرسالة", + "successMessage": "شكرًا لك! سنتواصل معك قريبًا." + }, + "teamSection": { + "headerText": "تعرف على فريقنا", + "subheading": "الأشخاص الشغوفون وراء منصتنا.", + "member1Name": "عضو الفريق", + "member1Role": "شريك مؤسس والرئيس التنفيذي", + "member1Bio": "شغوف بالتعليم والتكنولوجيا.", + "member2Name": "عضو الفريق", + "member2Role": "رئيس قسم التعلم", + "member2Bio": "ملتزم بخلق أفضل تجربة تعليمية." + }, + "announcementFeed": { + "headerText": "أحدث التحديثات", + "subheading": "ابق على اطلاع بأحدث أخبارنا.", + "item1Title": "إطلاق دورة جديدة", + "item1Summary": "يسعدنا أن نعلن عن سلسلة دوراتنا المتقدمة الجديدة.", + "item1Tag": "أخبار", + "item2Title": "تحديث المنصة", + "item2Summary": "قمنا بتحسين منصتنا لتوفير تجربة تعليمية أفضل.", + "item2Tag": "تحديث" + }, + "imageGallery": { + "headerText": "معرض الصور", + "image1Alt": "صورة المعرض 1", + "image2Alt": "صورة المعرض 2", + "image3Alt": "صورة المعرض 3" + }, + "tabsAccordion": { + "tab1Title": "التبويب 1", + "tab1Content": "

محتوى التبويب 1

", + "tab2Title": "التبويب 2", + "tab2Content": "

محتوى التبويب 2

", + "tab3Title": "التبويب 3", + "tab3Content": "

محتوى التبويب 3

" + }, + "logoCloud": { + "headerText": "موثوق به من قبل" + }, + "trustChip": { + "text": "موثوق به من قبل أكثر من 10,000 متعلم" + }, + "sectionHeading": { + "eyebrow": "لماذا تختارنا", + "title": "تعلّم يبقى أثره فعلاً", + "highlight": "يبقى أثره فعلاً", + "lead": "برامج مصممة حول النتائج — وليس مجرد المحتوى." + }, + "mapEmbed": { + "title": "موقعنا" + }, + "countdownTimer": { + "heading": "يبدأ الحدث خلال", + "expiredMessage": "لقد بدأ الحدث!" + }, + "textBlock": { + "content": "

عنوانك هنا

اكتب محتواك هنا. هذه كتلة نص غني — يمكنك إضافة عناوين وفقرات وقوائم وروابط والمزيد.

" + }, + "detailBlocks": { + "block1Tag": "البرنامج الرئيسي", + "block1Title": "البرنامج الرئيسي", + "block1Description": "جملة أو جملتان حول الفئة المستهدفة وما يغطيه هذا البرنامج.", + "block1Item1Title": "ما الذي يتم تغطيته", + "block1Item1Description": "تفصيل ملموس حول المنهج أو المواد أو طريقة التدريس.", + "block1Item2Title": "كيف يتم تدريسه", + "block1Item2Description": "دروس مباشرة، تسجيلات، جلسات لحل الاستفسارات — أيًا كان ما ينطبق هنا.", + "block1Item3Title": "التدريب والاختبار", + "block1Item3Description": "سلسلة اختبارات، أوراق سابقة، تحليلات.", + "block1Spec1Label": "الأهلية", + "block1Spec1Value": "من يمكنه الانضمام", + "block1Spec2Label": "النمط", + "block1Spec2Value": "حضوري + عبر الإنترنت", + "block1Spec3Label": "المدة", + "block1Spec3Value": "12 شهرًا", + "block1Spec4Label": "المستوى", + "block1Spec4Value": "من مبتدئ إلى متقدم", + "block1Note": "ملاحظة اختيارية — تنازلات أو أقساط أو أي شيء يستحق الإشارة إليه.", + "block2Tag": "الفئة", + "block2Title": "البرنامج الثاني", + "block2Description": "كرر هذه الكتلة لكل برنامج تقدمه.", + "block2Item1Title": "التفصيل الأول", + "block2Item1Description": "استبدل هذا بتفصيل حقيقي.", + "block2Item2Title": "التفصيل الثاني", + "block2Item2Description": "استبدل هذا بتفصيل حقيقي.", + "block2Spec1Label": "الأهلية", + "block2Spec1Value": "من يمكنه الانضمام", + "block2Spec2Label": "النمط", + "block2Spec2Value": "عبر الإنترنت" + }, + "featureGrid": { + "headerText": "لماذا تختارنا", + "subheading": "كل ما تحتاجه للنجاح", + "feature1Title": "مدربون خبراء", + "feature1Description": "تعلم من محترفين في المجال يتمتعون بسنوات من الخبرة.", + "feature1Chip1": "أعضاء هيئة تدريس من IIT/NIT", + "feature2Title": "محتوى غني", + "feature2Description": "احصل على مواد وموارد تعليمية شاملة.", + "feature3Title": "دورات معتمدة", + "feature3Description": "احصل على شهادات معترف بها عند الإتمام.", + "feature4Badge": "قسم التدريب", + "feature4Title": "CGP Career Avenues", + "feature4Description": "تدريب شامل في جميع فروع الهندسة.", + "feature4Bullet1": "GATE — علوم الحاسوب (CS)، الإلكترونيات والاتصالات (ECE)، الكهرباء (EEE)، الميكانيكا (ME)، المدني (CE)، الكيميائي (CH)", + "feature4Bullet2": "دفعات ما بعد GATE لـ ISRO/BARC/DRDO", + "feature4Bullet3": "لجنة خدمة كيرالا العامة (Kerala PSC) والتوظيف الجامعي" + }, + "imageBlock": { + "alt": "صورة" + }, + "buttonBlock": { + "text": "ابدأ الآن" + }, + "newsletterSignup": { + "heading": "ابق على اطلاع", + "subheading": "اشترك في نشرتنا الإخبارية للحصول على آخر التحديثات.", + "placeholder": "أدخل بريدك الإلكتروني", + "buttonText": "اشترك", + "successMessage": "شكرًا لاشتراكك!" + }, + "stepsProcess": { + "headerText": "كيف يعمل", + "subheading": "ابدأ في خطوات قليلة فقط", + "step1Title": "سجل", + "step1Description": "أنشئ حسابك المجاني في ثوانٍ.", + "step2Title": "اختر دورة", + "step2Description": "تصفح كتالوجنا واختر ما يثير اهتمامك.", + "step3Title": "ابدأ التعلم", + "step3Description": "احصل على مواد دورتك وابدأ." + }, + "marquee": { + "item1Text": "دورات ذات تقييم عالٍ", + "item2Text": "أكثر من 10,000 متعلم مسجل", + "item3Text": "منهج يقوده خبراء", + "item4Text": "تعلم بالوتيرة التي تناسبك", + "item5Text": "مهارات ذات صلة بسوق العمل" + }, + "productPageOffer": { + "title": "برامجنا", + "subtitle": "اختر برنامجًا وسجل في دقائق.", + "viewAllLabel": "عرض الكل", + "ctaLabel": "سجل الآن", + "cartCtaLabel": "أضف إلى السلة", + "checkoutCtaLabel": "المتابعة إلى الدفع", + "viewCourseLabel": "عرض الدورة" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/managePagesCourseSessionSelector.json b/frontend-admin-dashboard/public/locales/ar/managePagesCourseSessionSelector.json new file mode 100644 index 0000000000..2d18635252 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/managePagesCourseSessionSelector.json @@ -0,0 +1,45 @@ +{ + "sessionLabelDefault": "افتراضي", + "selectedRow": { + "loadingInvite": "جارٍ تحميل الدعوة…", + "selectingInvite": "جارٍ اختيار الدعوة…", + "invitesCount_zero": "لا توجد دعوات ({{count}})", + "invitesCount_one": "دعوة واحدة ({{count}})", + "invitesCount_two": "دعوتان ({{count}})", + "invitesCount_few": "{{count}} دعوات", + "invitesCount_many": "{{count}} دعوة", + "invitesCount_other": "{{count}} دعوة", + "free": "مجاني", + "preselected": "محدد مسبقًا", + "noMatchingPaymentOption": "لم يتم العثور على خيار دفع مطابق لهذه الدعوة والدورة.", + "noActiveInvite": "لا توجد دعوة نشطة لهذه الدورة. يرجى إنشاء دعوة أولاً." + }, + "tabs": { + "courses": "الدورات", + "suggestions": "الاقتراحات" + }, + "selectedCourses": { + "heading": "الدورات المحددة", + "addMore": "إضافة المزيد", + "combinedTotal": "الإجمالي الكلي:" + }, + "browser": { + "searchPlaceholder": "ابحث عن دورات أو دفعات…", + "selectAll": "+ تحديد الكل ({{count}})", + "refresh": "تحديث", + "loadingSessions": "جارٍ تحميل الدورات…", + "noSessionsMatch": "لا توجد دورات مطابقة لـ \"{{query}}\"", + "noActiveSessions": "لا توجد دورات نشطة.", + "otherGroup": "أخرى", + "added": "تمت الإضافة", + "add": "إضافة", + "loadingMore": "جارٍ تحميل المزيد…", + "loadMore_zero": "لا مزيد من الدورات لتحميلها ({{count}})", + "loadMore_one": "تحميل المزيد ({{count}} دورة واحدة متبقية)", + "loadMore_two": "تحميل المزيد ({{count}} دورتان متبقيتان)", + "loadMore_few": "تحميل المزيد ({{count}} دورات متبقية)", + "loadMore_many": "تحميل المزيد ({{count}} دورة متبقية)", + "loadMore_other": "تحميل المزيد ({{count}} دورة متبقية)", + "footerHint": "تم تحميل {{loaded}} من {{total}} دورة · تم تحديد {{selected}}" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/managePagesPageCanvas.json b/frontend-admin-dashboard/public/locales/ar/managePagesPageCanvas.json new file mode 100644 index 0000000000..4101d9bc70 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/managePagesPageCanvas.json @@ -0,0 +1,121 @@ +{ + "globalSettings": { + "title": "الإعدادات العامة", + "description": "تهيئة الترويسة والتذييل والخصائص العامة", + "header": "الترويسة", + "footer": "التذييل", + "selectedIndicator": "● محدد", + "overview": { + "title": "نظرة عامة على الإعدادات", + "catalogueType": "نوع الكتالوج", + "notAvailable": "غير متاح", + "themeMode": "وضع المظهر", + "fontFamily": "نوع الخط", + "defaultFont": "افتراضي", + "payment": "الدفع", + "leadCollection": "جمع العملاء المحتملين", + "enquiry": "الاستفسار", + "enabledBadge": "✓ مفعّل", + "disabledBadge": "✗ معطّل" + } + }, + "page": { + "notFound": "الصفحة غير موجودة", + "componentsCount_zero": "مكوّنات الصفحة • لا يوجد مكوّن ({{count}})", + "componentsCount_one": "مكوّنات الصفحة • مكوّن واحد ({{count}})", + "componentsCount_two": "مكوّنات الصفحة • مكوّنان ({{count}})", + "componentsCount_few": "مكوّنات الصفحة • {{count}} مكوّنات", + "componentsCount_many": "مكوّنات الصفحة • {{count}} مكوّنًا", + "componentsCount_other": "مكوّنات الصفحة • {{count}} مكوّن", + "duplicateComponentTitle": "تكرار المكوّن", + "deleteComponentTitle": "حذف المكوّن", + "confirmDeleteComponent": "هل تريد حذف هذا المكوّن؟", + "selected": "محدد", + "empty": { + "title": "لا توجد مكوّنات في هذه الصفحة", + "hint": "اسحب المكوّنات من المكتبة لإضافتها" + } + }, + "summary": { + "hero": { + "fallbackTitle": "قسم البطل" + }, + "catalogue": { + "fallbackTitle": "كتالوج الدورات", + "layoutLine": "التخطيط: {{layout}} • المرشحات: {{filters}}", + "filtersEnabled": "مفعّلة", + "filtersDisabled": "معطّلة", + "filterCountSuffix_zero": " • لا مرشحات ({{count}})", + "filterCountSuffix_one": " • مرشح واحد ({{count}})", + "filterCountSuffix_two": " • مرشحان ({{count}})", + "filterCountSuffix_few": " • {{count}} مرشحات", + "filterCountSuffix_many": " • {{count}} مرشحًا", + "filterCountSuffix_other": " • {{count}} مرشح" + }, + "courseDetails": { + "title": "صفحة تفاصيل الدورة", + "enquirySuffix": "الاستفسار • ", + "paymentSuffix": "الدفع • ", + "addToCart": "أضف إلى السلة" + }, + "cart": { + "title": "سلة التسوق", + "quantityControlsSuffix": "أدوات التحكم بالكمية • ", + "pricingEnabled": "التسعير مفعّل" + }, + "mediaShowcase": { + "title": "عرض الوسائط", + "layoutSlides_zero": "التخطيط: {{layout}} • لا شرائح ({{count}})", + "layoutSlides_one": "التخطيط: {{layout}} • شريحة واحدة ({{count}})", + "layoutSlides_two": "التخطيط: {{layout}} • شريحتان ({{count}})", + "layoutSlides_few": "التخطيط: {{layout}} • {{count}} شرائح", + "layoutSlides_many": "التخطيط: {{layout}} • {{count}} شريحة", + "layoutSlides_other": "التخطيط: {{layout}} • {{count}} شريحة", + "autoplaySuffix": " • تشغيل تلقائي" + }, + "buyRent": { + "fallbackTitle": "قسم الشراء/الإيجار", + "buyFallback": "شراء", + "rentFallback": "إيجار", + "buyRentLine": "{{buy}} • {{rent}}" + }, + "stats": { + "title": "أبرز الإحصاءات", + "count_zero": "لا توجد إحصاءات ({{count}})", + "count_one": "إحصائية واحدة ({{count}})", + "count_two": "إحصائيتان ({{count}})", + "count_few": "{{count}} إحصاءات", + "count_many": "{{count}} إحصائية", + "count_other": "{{count}} إحصائية" + }, + "testimonials": { + "fallbackTitle": "الشهادات", + "countLayout_zero": "لا توجد شهادات ({{count}}) • التخطيط: {{layout}}", + "countLayout_one": "شهادة واحدة ({{count}}) • التخطيط: {{layout}}", + "countLayout_two": "شهادتان ({{count}}) • التخطيط: {{layout}}", + "countLayout_few": "{{count}} شهادات • التخطيط: {{layout}}", + "countLayout_many": "{{count}} شهادة • التخطيط: {{layout}}", + "countLayout_other": "{{count}} شهادة • التخطيط: {{layout}}" + }, + "policy": { + "fallbackTitle": "محتوى السياسة", + "subtitle": "صفحة السياسة" + }, + "header": { + "fallbackTitle": "الترويسة", + "navItems_zero": "لا عناصر تنقل ({{count}})", + "navItems_one": "عنصر تنقل واحد ({{count}})", + "navItems_two": "عنصرا تنقل ({{count}})", + "navItems_few": "{{count}} عناصر تنقل", + "navItems_many": "{{count}} عنصر تنقل", + "navItems_other": "{{count}} عنصر تنقل" + }, + "footer": { + "title": "التذييل", + "layout": "التخطيط: {{layout}}" + }, + "hideDetails": "إخفاء التفاصيل", + "showDetails": "عرض التفاصيل", + "fullConfigurationLabel": "التهيئة الكاملة:" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/managePagesPageDesignEditor.json b/frontend-admin-dashboard/public/locales/ar/managePagesPageDesignEditor.json new file mode 100644 index 0000000000..71b5412b36 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/managePagesPageDesignEditor.json @@ -0,0 +1,231 @@ +{ + "palette": { + "header": { "label": "الترويسة", "description": "الشعار + شريط التنقل" }, + "heroSection": { "label": "بانر البطل", "description": "تخطيط مقسم مع مجموعة صور" }, + "footer": { "label": "التذييل", "description": "روابط + شريط حقوق النشر" }, + "productCourseGrid": { "label": "شبكة الدورات", "description": "قائمة الدورات مع عوامل التصفية" }, + "textBlock": { "label": "كتلة نصية", "description": "فقرة نص منسق" }, + "imageBlock": { "label": "صورة", "description": "صورة قابلة للنقر بعرض كامل" }, + "videoEmbed": { "label": "تضمين فيديو", "description": "تضمين يوتيوب / فيميو" }, + "htmlBlock": { "label": "كتلة HTML", "description": "HTML / CSS مخصص (معزول، بدون سكريبتات)" }, + "statsHighlights": { "label": "إحصائيات", "description": "أبرز الأرقام الرئيسية" }, + "testimonialSection": { "label": "الشهادات", "description": "آراء الطلاب" }, + "faqSection": { "label": "الأسئلة الشائعة", "description": "الأسئلة المتكررة" }, + "ctaBanner": { "label": "بانر دعوة لاتخاذ إجراء", "description": "قسم دعوة لاتخاذ إجراء" }, + "featureGrid": { "label": "شبكة الميزات", "description": "أبرز مزايا البرنامج" }, + "stepsProcess": { "label": "خطوات / عملية", "description": "كيف يعمل" }, + "marquee": { "label": "شريط متحرك", "description": "شريط نص متحرك" } + }, + "groups": { + "layout": "التخطيط", + "course": "الدورة", + "content": "المحتوى", + "marketing": "التسويق" + }, + "heroLayouts": { + "split": { "label": "منقسم", "description": "النص يسارًا، الصورة يمينًا" }, + "centered": { "label": "في المنتصف", "description": "النص في المنتصف، بعرض كامل" } + }, + "colorField": { + "reset": "إعادة تعيين" + }, + "common": { + "sectionColors": "ألوان القسم", + "background": "الخلفية", + "backgroundColor": "لون الخلفية", + "textColor": "لون النص", + "heading": "العنوان", + "subheading": "العنوان الفرعي", + "description": "الوصف", + "content": "المحتوى", + "title": "العنوان", + "image": "صورة", + "alignment": "المحاذاة", + "alignLeft": "يسار", + "alignCenter": "وسط", + "alignRight": "يمين", + "ctaButton": "زر الدعوة لاتخاذ إجراء", + "buttonBackground": "خلفية الزر", + "buttonTextColor": "لون نص الزر", + "add": "إضافة", + "labelPlaceholder": "التسمية", + "urlPlaceholder": "الرابط", + "urlOrSectionPlaceholder": "رابط أو #القسم", + "enrollNowPlaceholder": "سجّل الآن", + "descriptionEllipsisPlaceholder": "الوصف…", + "noEditorFor": "لا يوجد محرر لـ \"{{type}}\"" + }, + "heroEditor": { + "layoutLabel": "التخطيط", + "tags": "الوسوم", + "addTag": "إضافة وسم", + "tagPlaceholder": "وسم", + "headingPlaceholder": "بناء أسس قوية…", + "subheadingPlaceholder": "عنوان فرعي داعم قصير…", + "descriptionPlaceholder": "أضف وصفًا داعمًا…", + "ctaTextPlaceholder": "استكشف الدورات", + "ctaUrlPlaceholder": "رابط أو #courses", + "rightSideImage": "الصورة الجانبية اليمنى", + "singleImage": "صورة واحدة", + "photoCollage": "مجموعة صور (حتى 5)", + "collageHint": "تظهر كشبكة فسيفساء على الجانب.", + "photoLabel": "صورة {{number}}", + "backgroundImageOptional": "صورة الخلفية (اختياري)", + "backgroundImageHint": "تُعرض كخلفية بعرض كامل خلف النص المتوسط." + }, + "headerEditor": { + "navLinkColor": "لون روابط التنقل", + "siteTitle": "عنوان الموقع", + "siteTitlePlaceholder": "أكاديميتي", + "logo": "الشعار", + "navLinks": "روابط التنقل", + "addLink": "إضافة رابط" + }, + "footerEditor": { + "brandName": "اسم العلامة التجارية", + "brandTagline": "شعار العلامة التجارية", + "brandTaglinePlaceholder": "التعلّم بلا حدود.", + "linkSections": "أقسام الروابط", + "sectionTitlePlaceholder": "عنوان القسم", + "addLink": "إضافة رابط", + "copyrightNote": "ملاحظة حقوق النشر", + "copyrightPlaceholder": "© {{year}} أكاديميتي" + }, + "courseGridEditor": { + "sectionTitle": "عنوان القسم", + "sectionTitlePlaceholder": "دوراتنا", + "layoutPreset": "نمط التخطيط", + "layoutGrid3": "شبكة 3", + "layoutGrid4": "شبكة 4", + "layoutList": "قائمة", + "showFilters": "إظهار عوامل التصفية", + "showPrice": "إظهار السعر", + "showBadge": "إظهار الشارة" + }, + "textBlockEditor": { + "contentPlaceholder": "اكتب نصك هنا…" + }, + "imageBlockEditor": { + "altText": "النص البديل", + "altTextPlaceholder": "نص بديل وصفي", + "linkUrlOptional": "رابط الصورة (اختياري)" + }, + "htmlBlockEditor": { + "html": "HTML", + "cssScoped": "CSS (مخصص لهذا القسم)" + }, + "statsEditor": { + "headingPlaceholder": "أرقامنا", + "stats": "الإحصائيات", + "valuePlaceholder": "+10,000", + "labelPlaceholder": "الطلاب" + }, + "testimonialEditor": { + "headingPlaceholder": "ماذا يقول متعلمونا", + "testimonials": "الشهادات", + "namePlaceholder": "الاسم", + "rolePlaceholder": "الدور", + "feedbackPlaceholder": "الملاحظات…" + }, + "faqEditor": { + "headingPlaceholder": "الأسئلة الشائعة", + "questions": "الأسئلة", + "questionPlaceholder": "سؤال؟", + "answerPlaceholder": "الإجابة…" + }, + "videoEmbedEditor": { + "videoUrl": "رابط الفيديو", + "caption": "التسمية التوضيحية", + "aspectRatio": "نسبة العرض إلى الارتفاع" + }, + "ctaBannerEditor": { + "showButton": "إظهار الزر" + }, + "featureGridEditor": { + "headingPlaceholder": "لماذا تختارنا", + "columns": "الأعمدة", + "features": "الميزات", + "iconEmojiPlaceholder": "أيقونة/رمز تعبيري" + }, + "stepsEditor": { + "headingPlaceholder": "كيف تعمل", + "layout": "التخطيط", + "horizontal": "أفقي", + "vertical": "عمودي", + "steps": "الخطوات", + "stepTitlePlaceholder": "عنوان الخطوة" + }, + "marqueeEditor": { + "icons": { + "none": "بلا", + "star": "نجمة", + "check": "علامة صح", + "graduate": "خريج", + "trophy": "كأس", + "rocket": "صاروخ", + "bulb": "مصباح", + "books": "كتب", + "fire": "نار", + "sparkle": "بريق", + "diamond": "ماسة", + "circle": "دائرة", + "pipe": "خط عمودي" + }, + "appearance": "المظهر", + "iconColor": "لون الأيقونة", + "fontSize": "حجم الخط", + "motion": "الحركة", + "speed": "السرعة", + "speedSlow": "بطيء", + "speedMedium": "متوسط", + "speedFast": "سريع", + "direction": "الاتجاه", + "directionLeft": "يسار", + "directionRight": "يمين", + "pauseOnHover": "إيقاف مؤقت عند التمرير", + "defaultSeparatorIcon": "أيقونة الفاصل الافتراضية", + "defaultItem1": "العنصر 1", + "defaultItem2": "العنصر 2", + "newItem": "عنصر جديد", + "items": "العناصر", + "addItem": "إضافة عنصر", + "itemTextPlaceholder": "نص العنصر", + "noItemsYet": "لا توجد عناصر بعد. أضف بعضها أعلاه." + }, + "layersPanel": { + "noComponentsYet": "لا توجد مكونات بعد" + }, + "canvas": { + "moveUp": "نقل لأعلى", + "moveDown": "نقل لأسفل", + "hideComponent": "إخفاء المكون", + "showComponent": "إظهار المكون", + "removeComponent": "إزالة المكون", + "hiddenBadge": "مخفي", + "viewportDesktop": "سطح المكتب", + "viewportTablet": "لوحي", + "viewportMobile": "جوال", + "emptyHint": "اسحب المكونات هنا أو انقر على + للإضافة", + "componentCountHint_zero": "لا توجد مكونات ({{count}}) · انقر للتحديد", + "componentCountHint_one": "مكوّن واحد ({{count}}) · انقر للتحديد", + "componentCountHint_two": "مكوّنان ({{count}}) · انقر للتحديد", + "componentCountHint_few": "{{count}} مكونات · انقر للتحديد", + "componentCountHint_many": "{{count}} مكوّنًا · انقر للتحديد", + "componentCountHint_other": "{{count}} مكوّن · انقر للتحديد", + "pageEmptyTitle": "هذه الصفحة فارغة", + "pageEmptyHint": "اسحب من اللوحة اليسرى أو انقر على + لإضافة مكونات" + }, + "rightPanel": { + "pageSettings": "إعدادات الصفحة", + "brandColor": "لون العلامة التجارية", + "selectComponentHint": "انقر على أي مكون في اللوحة لتعديل خصائصه.", + "hide": "إخفاء", + "show": "إظهار", + "remove": "إزالة", + "design": "التصميم" + }, + "sidebar": { + "addTab": "إضافة", + "layersTab": "الطبقات" + } +} diff --git a/frontend-admin-dashboard/public/locales/ar/managePagesPropertyPanel.json b/frontend-admin-dashboard/public/locales/ar/managePagesPropertyPanel.json new file mode 100644 index 0000000000..11eb2f28e1 --- /dev/null +++ b/frontend-admin-dashboard/public/locales/ar/managePagesPropertyPanel.json @@ -0,0 +1,866 @@ +{ + "columnLayoutTitle": "تخطيط بعدد {{count}} أعمدة", + "actions": { + "tryAnotherVersion": "جرّب نسخة أخرى من هذا القسم (بالذكاء الاصطناعي)", + "cannotReorderNested": "لا يمكن إعادة ترتيب المكوّنات المتداخلة", + "moveUp": "تحريك لأعلى", + "moveDown": "تحريك لأسفل", + "cannotDuplicateNested": "لا يمكن تكرار المكوّنات المتداخلة", + "duplicate": "تكرار", + "delete": "حذف", + "copyToClipboard": "نسخ إلى الحافظة", + "clear": "مسح", + "add": "إضافة", + "cancel": "إلغاء" + }, + "enabled": "مفعّل", + "anchorId": { + "label": "معرّف المرساة", + "placeholder": "مثل pricing أو faq أو contact", + "linkToThis": "رابط لهذا:" + }, + "pageSettings": { + "title": "إعدادات الصفحة", + "publishTogetherHint": "تُنشر جميع الصفحات معًا عند الضغط على نشر في الشريط العلوي.", + "pageTitle": "عنوان الصفحة", + "routeSlug": "مسار الصفحة", + "pageBackgroundColor": "لون خلفية الصفحة", + "hideSiteChrome": { + "label": "إخفاء ترويسة الموقع وتذييله", + "hint": "فعّل هذا الخيار عندما تحتوي الصفحة بالفعل على تنقّل وتذييل خاصّين بها — وإلا سيرى الزوّار كلًّا منهما مرتين." + }, + "pasteType": "لصق: {{type}}", + "seo": { + "heading": "تحسين محركات البحث", + "metaTitle": "العنوان الوصفي", + "metaDescription": "الوصف الوصفي", + "metaDescriptionPlaceholder": "وصف مختصر للصفحة لمحركات البحث...", + "ogImage": "صورة المشاركة الاجتماعية", + "ogImagePlaceholder": "رابط صورة المشاركة الاجتماعية" + } + }, + "selectItemToEdit": "اختر عنصرًا لتحريره", + "componentNotFound": "المكوّن غير موجود", + "globalLayout": { + "titleHeader": "الترويسة العامة", + "titleFooter": "التذييل العام", + "appearsOnEveryPage": "يظهر في كل صفحة", + "removeHeader": "إزالة الترويسة العامة", + "removeFooter": "إزالة التذييل العام" + }, + "global": { + "title": "الإعدادات العامة", + "catalogueType": { + "heading": "نوع الكتالوج", + "type": "النوع", + "course": "دورة", + "product": "منتج" + }, + "theme": { + "heading": "السمة", + "colorPreset": "إعداد اللون المسبق", + "customColorOverride": "تجاوز لون مخصّص", + "usingPreset": "يُستخدم الإعداد المسبق", + "customColorHint": "يستبدل لون الإعداد المسبق بلون علامة تجارية مخصّص.", + "cornerStyle": "نمط الزوايا", + "headingScale": "مقياس العناوين", + "atmosphere": "الأجواء", + "motion": "الحركة", + "backToTopButton": "زر العودة للأعلى", + "mode": "الوضع", + "compactness": "مستوى التراص" + }, + "fonts": { + "heading": "الطباعة", + "customFonts": "خطوط مخصّصة", + "bodyFont": "خط النص", + "headingFont": "خط العناوين", + "sameAsBody": "نفس خط النص", + "serifSuffix": "(بذيول)", + "pairingHint": "اجمع بين عنوان بخط ذي ذيول ونص بخط بلا ذيول للحصول على طابع تحريري راقٍ." + }, + "payment": { + "heading": "الدفع", + "enable": "تفعيل المدفوعات", + "provider": "مزوّد الخدمة" + }, + "whatsapp": { + "heading": "زر واتساب", + "hint": "يعرض زر واتساب عائمًا في كل صفحة من موقعك. تُحتسب النقرات كاستفسارات في تحليلاتك.", + "numberLabel": "رقم واتساب (مع رمز الدولة)", + "prefilledMessage": "رسالة معبأة مسبقًا", + "prefilledMessagePlaceholder": "مرحبًا! أودّ معرفة المزيد عن دفعاتكم الدراسية.", + "buttonLabelOptional": "نص الزر (اختياري)", + "buttonLabelPlaceholder": "تحدّث معنا", + "position": "الموضع" + }, + "tracking": { + "heading": "التتبع والتحليلات", + "hint": "الصق المعرّفات من Google Analytics أو Meta Events Manager. تُحمَّل فقط في صفحات موقعك العامة، ويُطلق كل إرسال نموذج حدث تحويل Lead تلقائيًا — لتتمكن الحملات الإعلانية من التحسين بناءً على استفسارات حقيقية.", + "ga4Label": "Google Analytics 4 — معرّف القياس", + "metaPixelLabel": "بكسل Meta (فيسبوك)", + "gtmLabel": "Google Tag Manager — معرّف الحاوية", + "gtmHint": "استخدم GTM وحده، أو GA4/Pixel مباشرة — كلاهما يعمل." + }, + "leadCollection": { + "heading": "جمع العملاء المحتملين", + "enable": "تفعيل نموذج العملاء المحتملين", + "mandatory": "إلزامي", + "inviteLink": "رابط الدعوة", + "inviteLinkPlaceholder": "رابط دعوة اختياري" + }, + "courseFinder": { + "heading": "معالج البحث عن الدورات", + "hint": "يطلب من الزوّار اختيار عوامل التصفية خطوة بخطوة أول مرة يفتحون فيها هذه الصفحة، ثم يعرض الدورات المطابقة فقط. تُستمد الخيارات مباشرة من شبكة دورات هذه الصفحة — لا حاجة لأي إعداد إضافي.", + "enable": "تفعيل معالج البحث عن الدورات", + "stepsToAsk": "الخطوات المطلوب طرحها", + "stepsOrderHint": "تُطرح بهذا الترتيب الثابت — يُتخطى أي خطوة غير محدّدة، أو أي خطوة لا تملك دورات هذه الصفحة خيارات لها.", + "requireCompletion": "طلب إتمام كامل (بدون تخطٍّ)" + }, + "enquiry": { + "heading": "الاستفسار", + "enable": "تفعيل الاستفسار" + } + }, + "options": { + "default": "افتراضي", + "ocean": "محيط", + "forest": "غابة", + "sunset": "غروب", + "midnight": "منتصف الليل", + "rose": "وردي", + "violet": "بنفسجي", + "amber": "كهرماني", + "slate": "رمادي أردوازي", + "sharp": "حاد", + "rounded": "مدوّر", + "pill": "كبسولة", + "compact": "مضغوط", + "large": "كبير", + "display": "عرضي", + "flat": "مسطّح", + "soft": "ناعم", + "mesh": "شبكي", + "aurora": "شفق", + "subtle": "خفيف", + "medium": "متوسط", + "bold": "بارز", + "none": "بلا", + "calm": "هادئ", + "balanced": "متوازن", + "dynamic": "ديناميكي", + "light": "فاتح", + "dark": "داكن", + "small": "صغير", + "right": "يمين", + "left": "يسار", + "center": "وسط", + "top": "أعلى", + "bottom": "أسفل", + "stretch": "تمدّد", + "sm": "صغير", + "md": "متوسط", + "lg": "كبير", + "xl": "كبير جدًا", + "2xl": "كبير جدًا × 2", + "auto": "تلقائي", + "media": "وسائط", + "image": "صورة", + "video": "فيديو", + "openLink": "فتح رابط", + "openFormPopup": "فتح نافذة النموذج", + "bare": "بسيط", + "all": "الكل", + "solid": "متصل", + "dashed": "متقطع", + "dotted": "منقّط", + "tabs": "تبويبات", + "accordion": "أكورديون", + "plain": "بسيط", + "gradient": "متدرّج", + "underline": "تسطير", + "mark": "تمييز", + "logo": "شعار", + "marquee": "شريط متحرك", + "fast": "سريع", + "cards": "بطاقات", + "tint": "صبغة", + "info": "معلومة", + "bordered": "محاط بإطار", + "glass": "زجاجي", + "gradient-border": "إطار متدرّج", + "tinted": "مصبوغ", + "panel": "لوحة", + "photo": "صورة فوتوغرافية", + "full": "كامل", + "filled": "معبّأ", + "outline": "محدّد الحواف", + "ghost": "شبحي", + "inline": "في السطر", + "stacked": "مكدّس", + "horizontal": "أفقي", + "vertical": "عمودي", + "number": "رقم", + "icon": "أيقونة", + "dot": "نقطة", + "line": "خط", + "dots": "نقاط", + "card": "بطاقة", + "grid": "شبكة", + "minimal": "بسيط جدًا", + "slow": "بطيء", + "warn": "تحذير" + }, + "componentTypes": { + "heroSection": "القسم الرئيسي", + "courseCatalog": "كتالوج الدورات", + "bookCatalogue": "كتالوج الكتب", + "statsHighlights": "إحصاءات", + "testimonialSection": "الشهادات", + "mediaShowcase": "عرض الوسائط", + "faqSection": "الأسئلة الشائعة", + "ctaBanner": "بانر دعوة لاتخاذ إجراء", + "pricingTable": "التسعير", + "contactForm": "نموذج التواصل", + "teamSection": "الفريق", + "announcementFeed": "الإعلانات", + "imageGallery": "معرض الصور", + "videoEmbed": "فيديو", + "buyRentSection": "شراء/استئجار", + "policyRenderer": "السياسة", + "cartComponent": "السلة", + "courseDetails": "تفاصيل الدورة", + "bookDetails": "تفاصيل الكتاب", + "spacer": "فاصل مسافة", + "tabsAccordion": "تبويبات/أكورديون", + "logoCloud": "سحابة الشعارات", + "trustChip": "شارة الثقة", + "sectionHeading": "عنوان القسم", + "mapEmbed": "خريطة", + "countdownTimer": "العدّ التنازلي", + "textBlock": "كتلة نصية", + "featureGrid": "شبكة الميزات", + "imageBlock": "صورة", + "buttonBlock": "زر", + "newsletterSignup": "الاشتراك في النشرة الإخبارية", + "stepsProcess": "خطوات/عملية" + }, + "columnLayout": { + "layoutSettings": "إعدادات التخطيط", + "columns": "الأعمدة", + "columnGap": "الفراغ بين الأعمدة", + "verticalAlign": "المحاذاة العمودية", + "columnWidths": "عرض الأعمدة", + "colN": "العمود {{n}}", + "widthRatioPrecise": "نسبة العرض (دقيقة)", + "stackOnMobile": "تكديس على الجوال", + "reverseOrderOnMobile": "عكس الترتيب على الجوال", + "slotN": "الفتحة {{n}}", + "componentCount_zero": "{{count}} مكوّن", + "componentCount_one": "{{count}} مكوّن", + "componentCount_two": "{{count}} مكوّنان", + "componentCount_few": "{{count}} مكوّنات", + "componentCount_many": "{{count}} مكوّنًا", + "componentCount_other": "{{count}} مكوّن", + "slotEmpty": "فارغة — اسحب مكوّنًا إلى هنا من المكتبة", + "clickToEdit": "انقر للتحرير", + "removeFromSlot": "إزالة من الفتحة" + }, + "mediaShowcase": { + "defaults": { + "slideHeading": "شريحة جديدة", + "slideDescription": "أضف وصفك هنا", + "slideButtonText": "اعرف المزيد", + "mediaCaption": "عنصر جديد" + }, + "heading": "إعدادات العرض", + "layout": "التخطيط", + "layoutSlider": "شرائح (قسم رئيسي بعناوين)", + "layoutCarousel": "عرض دوّار (فيديو / صور)", + "layoutGrid": "شبكة (فيديو / صور)", + "autoplay": "التشغيل التلقائي", + "autoplayInterval": "فاصل التشغيل التلقائي (مللي ثانية)", + "slidesCount": "الشرائح ({{count}})", + "addSlide": "إضافة شريحة", + "slideNTitle": "الشريحة {{n}}: {{heading}}", + "backgroundImage": "صورة الخلفية", + "headingLabel": "العنوان", + "description": "الوصف", + "button": "الزر", + "buttonTextPlaceholder": "نص الزر", + "buttonLink": "رابط الزر", + "mediaItemsCount": "عناصر الوسائط ({{count}})", + "addItem": "إضافة عنصر", + "itemN": "العنصر {{n}}", + "typeLabel": "النوع", + "urlLabel": "الرابط", + "imageUrlPlaceholder": "https://... أو /assets/...", + "videoUrlPlaceholder": "https://youtube.com/... أو /assets/video.mp4", + "captionLabel": "التسمية التوضيحية", + "noMediaItems": "لا توجد عناصر وسائط بعد. انقر على \"إضافة عنصر\" للبدء." + }, + "bookCatalogue": { + "sort": { + "newest": "الأحدث", + "oldest": "الأقدم", + "priceLowToHigh": "السعر: من الأقل إلى الأعلى", + "priceHighToLow": "السعر: من الأعلى إلى الأقل", + "rating": "التقييم", + "nameAZ": "الاسم أ-ي", + "nameZA": "الاسم ي-أ" + }, + "heading": "إعدادات الكتالوج", + "title": "العنوان", + "showFilters": "إظهار عوامل التصفية", + "courseImageFit": "ملاءمة صورة الدورة", + "imageFitCover": "تعبئة (يقصّ الحواف)", + "imageFitContain": "ملاءمة الصورة كاملة", + "imageFitHint": "استخدم \"ملاءمة الصورة كاملة\" عندما تكون الأغلفة لافتات عريضة تحتوي نصًا قرب الحواف.", + "defaultSort": "الترتيب الافتراضي", + "defaultSortHint": "كيفية ترتيب الدورات عند فتح الصفحة. يمكن للمتعلمين تغييره لاحقًا. اختر \"السعر: من الأقل إلى الأعلى\" لإظهار الدورات المجانية أولًا.", + "advancedComingSoon": "إعدادات عوامل التصفية المتقدمة والسلة قادمة قريبًا." + }, + "buyRent": { + "heading": "إعدادات شراء/استئجار", + "headingField": "العنوان", + "buyOption": "خيار الشراء", + "rentOption": "خيار الاستئجار", + "buttonLabelPlaceholder": "نص الزر", + "levelFilterValuePlaceholder": "قيمة عامل تصفية المستوى" + }, + "generic": { + "properties": "الخصائص", + "complexPropertiesHidden": "بعض الخصائص المعقّدة مخفية. وسّع تفاصيل المكوّن لعرض ملف JSON الكامل." + }, + "header": { + "defaults": { + "newLink": "رابط جديد", + "login": "تسجيل الدخول", + "getStarted": "ابدأ الآن", + "enquireNow": "استفسر الآن" + }, + "heading": "إعدادات الترويسة", + "logo": "الشعار", + "title": "العنوان", + "backgroundColor": "لون الخلفية", + "textColor": "لون النص", + "navigationLinks": "روابط التنقّل", + "syncPagesTitle": "إنشاء تلقائي من الصفحات المنشورة", + "syncPages": "مزامنة الصفحات", + "labelPlaceholder": "النص", + "route": "المسار", + "openInSameTab": "الفتح في نفس التبويب", + "authCtaButtons": "أزرار الدخول / دعوة لاتخاذ إجراء", + "enquireForm": "استفسار (نموذج)", + "authCtaHint": "أزرار تظهر على يمين الترويسة (مثل تسجيل الدخول، إنشاء حساب). يفتح زر \"ابدأ الآن\" نموذج جمع العملاء المحتملين القديم؛ ويفتح \"فتح نافذة النموذج\" نموذج أي حملة جمهور (استفسار الآن، تسجيل فعالية) — اختر الحملة أسفل المفتاح.", + "buttonN": "الزر {{n}}", + "labelWithExamplePlaceholder": "النص (مثل تسجيل الدخول)", + "onClick": "عند النقر", + "formToOpen": "النموذج المراد فتحه (الحملة)", + "popupTitlePlaceholder": "عنوان النافذة (النص الافتراضي هو نص الزر)" + }, + "footer": { + "column2": "العمود 2", + "column3": "العمود 3", + "column4": "العمود 4", + "heading": "إعدادات التذييل", + "layout": "التخطيط", + "layoutTwoColumn": "عمودان", + "layoutThreeColumn": "ثلاثة أعمدة", + "layoutFourColumn": "أربعة أعمدة", + "column1Brand": "العمود 1 — العلامة التجارية", + "titleField": "العنوان", + "description": "الوصف", + "descriptionPlaceholder": "وصف المنصّة...", + "socialLinks": "روابط التواصل الاجتماعي", + "noSocialLinks": "لا توجد روابط تواصل اجتماعي بعد — انقر على إضافة.", + "sectionTitle": "عنوان القسم", + "sectionTitlePlaceholder": "مثل روابط سريعة", + "links": "الروابط", + "bottomNote": "ملاحظة أسفل الصفحة", + "bottomNotePlaceholder": "© 2025 شركتك. جميع الحقوق محفوظة." + }, + "hero": { + "heading": "إعدادات القسم الرئيسي", + "layout": "التخطيط", + "layoutSplit": "مقسّم", + "layoutCentered": "في المنتصف", + "layoutFullWidth": "عرض كامل", + "backgroundImage": "صورة الخلفية", + "backgroundImageHint": "صورة الخلفية تغطّي لون الخلفية. امسحها لاستخدام اللون أدناه.", + "backgroundColor": "لون الخلفية", + "eyebrowBadge": "شارة النص العلوي", + "eyebrowPlaceholder": "مثل الدفعة 4 · تبدأ في يوليو", + "eyebrowStyleBadge": "شارة (كبسولة + نقطة نشطة)", + "eyebrowStylePlain": "بسيط (نص بلون مميّز)", + "statChips": "شرائح الإحصاءات", + "statChipsHint": "يرى المتعلم حتى 4 شرائح.", + "statChipLabelPlaceholder": "مهندس تم تدريبه", + "removeStatChip": "إزالة شريحة الإحصاء", + "trustChip": "شارة الثقة", + "trustChipPlaceholder": "مثل \"موثوق من أكثر من 20,000 طالب\"", + "ratingLabel": "التقييم (0 = معطّل)", + "ctaButtonsMulti": "أزرار دعوة لاتخاذ إجراء (متعددة)", + "ctaButtonsHint": "عندما يحتوي أي زر هنا على نص، فإنه يحلّ محل الزر القديم الوحيد أدناه. يرى المتعلم حتى 3 أزرار.", + "buttonTextPlaceholder": "نص الزر", + "variantPrimary": "أساسي", + "variantSecondary": "ثانوي", + "removeButton": "إزالة الزر", + "actionNavigate": "الانتقال", + "actionOpenLeadCollection": "فتح نموذج العملاء المحتملين (قديم)", + "actionOpenCampaignForm": "فتح نموذج الحملة (نافذة منبثقة)", + "targetRoutePlaceholder": "المسار / الرابط المستهدف", + "audienceListLabel": "قائمة الجمهور / الحملة المراد ربطها", + "leftContent": "المحتوى الأيسر", + "titleField": "العنوان", + "description": "الوصف", + "descriptionPlaceholder": "أدخل وصف القسم...", + "rightImage": "الصورة اليمنى", + "imageField": "الصورة", + "altText": "النص البديل", + "altTextPlaceholder": "النص البديل", + "rightVideo": "الفيديو الأيمن", + "videoField": "الفيديو", + "videoHint": "الصق رابط يوتيوب أو Vimeo، أو ارفع ملف فيديو. للفيديو الأولوية على الصورة والعرض الدوّار أعلاه.", + "posterField": "صورة الغلاف (للفيديو المرفوع فقط)", + "carouselImages": "صور العرض الدوّار", + "carouselImagesHint": "أضف صورتين أو أكثر لتحويل وسائط القسم الرئيسي إلى عرض دوّار تلقائي. بصورة واحدة تبقى صورة مفردة؛ وبدون أي صورة تُستخدم الصورة اليمنى أعلاه.", + "slideN": "الشريحة {{n}}" + }, + "bookDetails": { + "heading": "إعدادات تفاصيل الكتاب", + "showEnquiry": "إظهار الاستفسار", + "showPayment": "إظهار الدفع", + "showAddToCart": "إظهار الإضافة إلى السلة" + }, + "cart": { + "heading": "إعدادات السلة", + "showItemImage": "إظهار صورة العنصر", + "showItemTitle": "إظهار عنوان العنصر", + "showQuantitySelector": "إظهار محدّد الكمية", + "showRemoveButton": "إظهار زر الإزالة", + "showPrice": "إظهار السعر", + "emptyStateMessage": "رسالة الحالة الفارغة" + }, + "stats": { + "defaults": { + "newStat": "إحصائية جديدة" + }, + "heading": "إعدادات إبراز الإحصاءات", + "headerText": "نص الترويسة", + "style": "النمط", + "styleCircle": "دائرة", + "stats": "الإحصاءات", + "valuePlaceholder": "القيمة" + }, + "testimonials": { + "defaults": { + "customerName": "اسم العميل", + "role": "الدور", + "feedback": "تجربة رائعة!" + }, + "heading": "إعدادات الشهادات", + "layout": "التخطيط", + "layoutCarousel": "عرض دوّار", + "layoutGridScroll": "شبكة قابلة للتمرير", + "layoutStaticGrid": "شبكة ثابتة", + "testimonials": "الشهادات", + "testimonialN": "الشهادة {{n}}", + "namePlaceholder": "الاسم", + "rolePlaceholder": "الدور", + "feedbackPlaceholder": "الرأي", + "avatarUrlPlaceholder": "رابط الصورة الرمزية", + "featured": "مميّز" + }, + "policy": { + "defaults": { + "title": "سياسة جديدة", + "content": "

محتوى السياسة هنا...

" + }, + "heading": "إعدادات السياسات", + "policies": "السياسات", + "contentHtml": "المحتوى (HTML)" + }, + "faq": { + "defaults": { + "question": "سؤال جديد", + "answer": "الإجابة هنا." + }, + "heading": "إعدادات الأسئلة الشائعة", + "subheading": "العنوان الفرعي", + "backgroundColor": "لون الخلفية", + "questionsCount": "الأسئلة ({{count}})", + "questionPlaceholder": "السؤال", + "answerPlaceholder": "الإجابة" + }, + "videoEmbed": { + "heading": "إعدادات الفيديو المضمّن", + "url": "رابط يوتيوب / Vimeo", + "caption": "التسمية التوضيحية", + "captionPlaceholder": "تسمية توضيحية اختيارية أسفل الفيديو", + "aspectRatio": "نسبة العرض إلى الارتفاع", + "aspect169": "16:9 (شاشة عريضة)", + "aspect43": "4:3 (قياسي)", + "aspect11": "1:1 (مربّع)", + "aspect916": "9:16 (عمودي)" + }, + "ctaBanner": { + "heading": "إعدادات بانر الدعوة لاتخاذ إجراء", + "headingField": "العنوان", + "layout": "التخطيط", + "layoutSplit": "مقسّم (النص يسارًا، الزر يمينًا)", + "showButton": "إظهار الزر" + }, + "pricingTable": { + "defaults": { + "planName": "خطة جديدة", + "feature1": "الميزة 1" + }, + "heading": "إعدادات جدول الأسعار", + "plansCount": "الخطط ({{count}})", + "addPlan": "إضافة خطة", + "planNamePlaceholder": "اسم الخطة", + "pricePlaceholder": "السعر (مثل 999 ₹)", + "descriptionPlaceholder": "الوصف", + "featuresOnePerLine": "الميزات (ميزة في كل سطر)", + "highlighted": "مميّزة (موصى بها)" + }, + "contactForm": { + "heading": "إعدادات نموذج التواصل", + "submitButtonLabel": "نص زر الإرسال", + "defaultSubmitLabel": "إرسال الرسالة", + "successMessage": "رسالة النجاح", + "submissionsHint": "تصل الإرسالات كعملاء محتملين إلى الحملة أعلاه (أو قائمة عملاء الموقع الافتراضية) — تظهر في مدير الجمهور ← أحدث العملاء المحتملين، مع إزالة التكرار وتعيين مستشار." + }, + "team": { + "defaults": { + "name": "عضو الفريق" + }, + "heading": "إعدادات قسم الفريق", + "membersCount": "الأعضاء ({{count}})", + "avatar": "الصورة الرمزية", + "roleTitlePlaceholder": "الدور / المسمّى الوظيفي", + "shortBioPlaceholder": "نبذة قصيرة" + }, + "announcement": { + "defaults": { + "title": "إعلان جديد", + "summary": "الملخّص هنا.", + "tag": "أخبار" + }, + "heading": "إعدادات موجز الإعلانات", + "layoutList": "قائمة", + "showDate": "إظهار التاريخ", + "showTag": "إظهار الوسم", + "announcementsCount": "الإعلانات ({{count}})", + "tagPlaceholder": "الوسم (مثل أخبار، تحديث)", + "summaryPlaceholder": "الملخّص" + }, + "imageGallery": { + "heading": "إعدادات معرض الصور", + "showCaptions": "إظهار التسميات التوضيحية", + "imagesCount": "الصور ({{count}})", + "addImage": "إضافة صورة", + "imageN": "الصورة {{n}}" + }, + "campaignPicker": { + "sendResponsesTo": "إرسال الردود إلى", + "loadingCampaigns": "جارٍ تحميل الحملات…", + "defaultWebsiteLeadsList": "قائمة عملاء الموقع الافتراضية", + "selectCampaign": "اختر حملة", + "newCampaignName": "اسم الحملة الجديدة", + "newCampaignNamePlaceholder": "مثل استفسارات الموقع", + "newCampaignHint": "تبدأ بالاسم الكامل والبريد الإلكتروني ورقم الهاتف. أضف حقولًا أخرى في أي وقت من مدير الجمهور.", + "creating": "جارٍ الإنشاء…", + "createAndUse": "إنشاء واستخدام", + "createError": "تعذّر إنشاؤها — يرجى المحاولة مرة أخرى.", + "newCampaign": "حملة جديدة", + "campaignsFromHint": "تأتي الحملات من مدير الجمهور — عدّل حقول نماذجها هناك." + }, + "campaignHealth": { + "checkingSubmissions": "جارٍ التحقق من الإرسالات…", + "leadsReceived_zero": "استُلم {{count}} عميل محتمل", + "leadsReceived_one": "استُلم {{count}} عميل محتمل", + "leadsReceived_two": "استُلم {{count}} عميلان محتملان", + "leadsReceived_few": "استُلم {{count}} عملاء محتملين", + "leadsReceived_many": "استُلم {{count}} عميلًا محتملًا", + "leadsReceived_other": "استُلم {{count}} عميل محتمل", + "leadsReceivedWithLast_zero": "استُلم {{count}} عميل محتمل · الأخير في {{date}}", + "leadsReceivedWithLast_one": "استُلم {{count}} عميل محتمل · الأخير في {{date}}", + "leadsReceivedWithLast_two": "استُلم {{count}} عميلان محتملان · الأخير في {{date}}", + "leadsReceivedWithLast_few": "استُلم {{count}} عملاء محتملين · الأخير في {{date}}", + "leadsReceivedWithLast_many": "استُلم {{count}} عميلًا محتملًا · الأخير في {{date}}", + "leadsReceivedWithLast_other": "استُلم {{count}} عميل محتمل · الأخير في {{date}}", + "sending": "جارٍ الإرسال…", + "testLeadDelivered": "✓ تم إرسال العميل المحتمل التجريبي", + "failedRetry": "فشلت العملية — إعادة المحاولة؟", + "sendTestLead": "إرسال عميل محتمل تجريبي" + }, + "leadForm": { + "hint": "يعرض نموذج تسجيل حملة مباشرة على الصفحة. الحقول والخيارات والعلامات الإلزامية تُضبط على الحملة في مدير الجمهور؛ تصل الإرسالات إلى تلك الحملة مع إزالة التكرار والتقييم وتعيين مستشار.", + "campaignLabel": "الحملة (النموذج + الوجهة)", + "titlePlaceholder": "سجّل اهتمامك", + "subtitle": "العنوان الفرعي", + "submitButtonLabel": "نص زر الإرسال", + "submitPlaceholder": "إرسال", + "successMessagePlaceholder": "شكرًا لك! لقد استلمنا بياناتك.", + "headerAlign": "محاذاة الترويسة" + }, + "productPageOffer": { + "status": { + "ACTIVE": "نشطة", + "DRAFT": "مسودة", + "INACTIVE": "غير نشطة", + "ARCHIVED": "مؤرشفة" + }, + "hint": "يعرض هنا دورات صفحة منتج ويرسل كل نقرة مباشرة إلى سلة تلك الصفحة. تُقرأ قائمة الدورات والأسعار والصور مباشرة من صفحة المنتج — عدّلها هناك، وليس هنا.", + "productPage": "صفحة المنتج", + "loadingProductPages": "جارٍ تحميل صفحات المنتجات…", + "selectProductPage": "اختر صفحة منتج", + "pageNotActiveWarning": "هذه الصفحة في حالة {{status}} — اجعلها {{activeStatus}} وإلا سيبقى القسم مخفيًا عن الزوّار.", + "coursesWillRender_zero": "ستظهر {{count}} دورة.", + "coursesWillRender_one": "ستظهر {{count}} دورة.", + "coursesWillRender_two": "ستظهر {{count}} دورتان.", + "coursesWillRender_few": "ستظهر {{count}} دورات.", + "coursesWillRender_many": "ستظهر {{count}} دورة.", + "coursesWillRender_other": "ستظهر {{count}} دورة.", + "noProductPages": "لا توجد صفحات منتجات بعد — أنشئ واحدة ضمن إدارة الصفحات > صفحات المنتجات.", + "titlePlaceholder": "برامجنا", + "subtitlePlaceholder": "اختر برنامجًا وسجّل خلال دقائق.", + "buttonLabel": "نص الزر", + "enrolNow": "سجّل الآن", + "checkoutModeSectionTitle": "الدفع", + "checkoutModeToggleLabel": "السماح للزوار باختيار عدة دورات", + "checkoutModeCartHint": "تُضاف كل بطاقة إلى سلة، ويتيح شريط في أسفل الصفحة إتمام الدفع للاختيار كاملاً دفعة واحدة. استخدم هذا الخيار عندما يشتري الزائر عادةً أكثر من دورة — كمجموعة مواد فصل دراسي مثلاً.", + "checkoutModeSingleHint": "تنتقل كل بطاقة مباشرةً إلى الدفع لدورة واحدة فقط. فعّل هذا الخيار للسماح للزائر بتجميع عدة دورات أولاً.", + "cartCtaLabelField": "نص زر الإضافة إلى السلة", + "cartCtaPlaceholder": "أضف إلى السلة", + "checkoutCtaLabelField": "نص زر الدفع", + "checkoutCtaPlaceholder": "المتابعة إلى الدفع", + "checkoutCtaHint": "يظهر على شريط السلة، وليس على البطاقات.", + "viewCourseButtonToggle": "زر \"عرض الدورة\" على كل بطاقة", + "viewCourseLabelField": "نص \"عرض الدورة\"", + "viewCoursePlaceholder": "عرض الدورة", + "viewCourseHint": "يفتح صفحة تفاصيل الدورة؛ التسجيل من هناك يعيدك إلى صفحة الدفع لصفحة المنتج هذه.", + "headerAlignment": "محاذاة الترويسة", + "headerSize": "حجم الترويسة", + "seeAllToggle": "رابط \"عرض الكل\" إلى صفحة المنتج", + "linkLabel": "نص الرابط", + "seeAllPlaceholder": "عرض الكل", + "layoutGridHint": "يلتفّ على عدة صفوف", + "layoutHorizontal": "أفقي", + "layoutCarouselHint": "صفّ واحد قابل للتمرير", + "carouselDescription": "تُوضع البطاقات في صفّ واحد يمرّره الزوّار سحبًا أو تمريرًا؛ تظهر أسهم عند وجود المزيد لعرضه. تحدّد الأعمدة عدد البطاقات الظاهرة في وقت واحد.", + "columnsVisible": "الأعمدة الظاهرة", + "browsing": "التصفّح", + "coursesPerPage": "الدورات لكل صفحة", + "pageSizeAllHint": "تظهر كل دورة في شبكة طويلة واحدة — مناسب فقط للقوائم القصيرة.", + "pageSizePagedHint": "يتصفّح الزوّار القائمة صفحة بصفحة؛ يعرض اللوح الصفحة الأولى.", + "cardsInRow": "البطاقات في الصفّ", + "railAllHint": "تُوضع كل دورة في صفّ واحد — في صفحة منتج طويلة سيصبح هذا الصفّ طويلًا جدًا.", + "railCappedHint": "يتوقف الصفّ هنا وينتهي ببطاقة ترتبط بصفحة المنتج الكاملة.", + "searchBox": "مربّع البحث", + "searchBoxHint": "يظهر فقط عندما تحتوي صفحة المنتج على 8 دورات أو أكثر.", + "scrollInsideSection": "التمرير داخل القسم", + "maxHeightPx": "أقصى ارتفاع (بكسل)", + "showOnEachCard": "إظهار على كل بطاقة", + "previewImage": "صورة معاينة", + "levelSessionChips": "شرائح المستوى / الجلسة", + "shortDescription": "وصف قصير", + "accessPeriod": "فترة الوصول", + "priceAndDiscount": "السعر والخصم" + }, + "productCourseGrid": { + "wholeCatalogueHint": "تعرض هذه الكتلة كامل كتالوج دوراتك — وهي غير مرتبطة بصفحة منتج، لذا لا توجد صفحة منتج لاختيارها هنا. لعرض دورات صفحة منتج واحدة (وإرسال النقرات إلى سلتها)، بدّل إلى عرض صفحة المنتج.", + "switchToOffer": "التبديل إلى عرض صفحة المنتج", + "titlePlaceholder": "كل الدورات", + "filtersSidebar": "الشريط الجانبي لعوامل التصفية", + "price": "السعر", + "badges": "الشارات" + }, + "htmlPage": { + "someWontRender": "لن يظهر جزء من هذا الملصق", + "styleBlocksLost_zero": "{{count}} كتلة ${previewHtml}` + ? `${previewHtml}` // design-lint-ignore: portable literal default template color, not app theme (see EmbedCodeDialog.tsx precedent) : null; return ( @@ -2229,7 +2319,7 @@ function ThemeCard({ : 'border-border hover:border-muted-foreground/40' }`} > -
+
{srcDoc ? (

ComponentTypeAmount (") + .append(escHtml(currency)).append(")
").append(compName).append("").append(compType).append("
").append(escHtml(compName)).append("").append(escHtml(compType)).append("").append(comp.getAmount()).append("