mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-11-08 23:43:32 -05:00
Merge branch 'master' into feat-4488-enable-csp
This commit is contained in:
511 files changed
+20452
-10656
No files matched your search
+2
-1
@@ -40,7 +40,8 @@ composer.lock
|
||||
package.json
|
||||
package-lock.json
|
||||
gulpfile.js
|
||||
.env.example
|
||||
.env
|
||||
.env.*
|
||||
.dockerignore
|
||||
|
||||
# Temporary and backup files
|
||||
|
||||
+73
-40
@@ -2,7 +2,7 @@
|
||||
# ENVIRONMENT
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
CI_ENVIRONMENT = production
|
||||
CI_ENVIRONMENT=production
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# SECURITY: ALLOWED HOSTNAMES
|
||||
@@ -14,71 +14,104 @@ CI_ENVIRONMENT = production
|
||||
# In development, falls back to 'localhost' with an error log.
|
||||
#
|
||||
# Configure with comma-separated list of domains/subdomains:
|
||||
# app.allowedHostnames = 'yourdomain.com,www.yourdomain.com'
|
||||
# app.allowedHostnames='yourdomain.com,www.yourdomain.com'
|
||||
#
|
||||
# Or via environment variable (useful for Docker/Compose):
|
||||
# ALLOWED_HOSTNAMES=yourdomain.com,www.yourdomain.com
|
||||
#
|
||||
# For local development:
|
||||
# app.allowedHostnames = 'localhost'
|
||||
# app.allowedHostnames='localhost'
|
||||
#
|
||||
# Note: Do not include protocol (http/https) or port numbers.
|
||||
app.allowedHostnames = ''
|
||||
app.allowedHostnames=''
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# DATABASE
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
database.default.hostname = 'localhost'
|
||||
database.default.database = 'ospos'
|
||||
database.default.username = 'admin'
|
||||
database.default.password = 'pointofsale'
|
||||
database.default.DBDriver = 'MySQLi'
|
||||
database.default.DBPrefix = 'ospos_'
|
||||
database.default.hostname='localhost'
|
||||
database.default.database='ospos'
|
||||
database.default.username='admin'
|
||||
database.default.password='pointofsale'
|
||||
database.default.DBDriver='MySQLi'
|
||||
database.default.DBPrefix='ospos_'
|
||||
|
||||
database.development.hostname = 'localhost'
|
||||
database.development.database = 'ospos'
|
||||
database.development.username = 'admin'
|
||||
database.development.password = 'pointofsale'
|
||||
database.development.DBDriver = 'MySQLi'
|
||||
database.development.DBPrefix = 'ospos_'
|
||||
database.development.hostname='localhost'
|
||||
database.development.database='ospos'
|
||||
database.development.username='admin'
|
||||
database.development.password='pointofsale'
|
||||
database.development.DBDriver='MySQLi'
|
||||
database.development.DBPrefix='ospos_'
|
||||
|
||||
database.tests.hostname = 'localhost'
|
||||
database.tests.database = 'ospos'
|
||||
database.tests.username = 'admin'
|
||||
database.tests.password = 'pointofsale'
|
||||
database.tests.DBDriver = 'MySQLi'
|
||||
database.tests.DBPrefix = 'ospos_'
|
||||
database.tests.hostname='localhost'
|
||||
database.tests.database='ospos_test'
|
||||
database.tests.username='admin'
|
||||
database.tests.password='pointofsale'
|
||||
database.tests.DBDriver='MySQLi'
|
||||
database.tests.DBPrefix='ospos_'
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# ENCRYPTION
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
encryption.key = ''
|
||||
# Leave blank and the application auto-generates a unique key on first use.
|
||||
# For Docker/Compose, pass it via the ENCRYPTION_KEY env var instead:
|
||||
# docker run -e ENCRYPTION_KEY="$(openssl rand -hex 32)" opensourcepos
|
||||
# ENCRYPTION_KEY is read as a fallback when encryption.key is empty, so no
|
||||
# shared key needs to be committed or baked into the shipped image.
|
||||
encryption.key=''
|
||||
# Persistent secret for HMAC-hashing login-throttle cache keys. Left blank and
|
||||
# provisioned on startup (php spark env:provision); independent of encryption.key.
|
||||
# For Docker/Compose, pass it via the THROTTLE_KEY env var instead:
|
||||
# docker run -e THROTTLE_KEY="$(openssl rand -hex 32)" opensourcepos
|
||||
# THROTTLE_KEY is read as a fallback when throttle.key is empty, so no
|
||||
# shared secret needs to be committed or baked into the shipped image.
|
||||
throttle.key=''
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# LOGGER
|
||||
# - 0 = Disables logging, Error logging TURNED OFF
|
||||
# - 1 = Emergency Messages - System is unusable
|
||||
# - 2 = Alert Messages - Action Must Be Taken Immediately
|
||||
# - 3 = Critical Messages - Application component unavailable, unexpected exception.
|
||||
# - 4 = Runtime Errors - Don't need immediate action, but should be monitored.
|
||||
# - 5 = Warnings - Exceptional occurrences that are not errors.
|
||||
# - 6 = Notices - Normal but significant events.
|
||||
# - 7 = Info - Interesting events, like user logging in, etc.
|
||||
# - 8 = Debug - Detailed debug information.
|
||||
# - 9 = All Messages
|
||||
# - 0=Disables logging, Error logging TURNED OFF
|
||||
# - 1=Emergency Messages - System is unusable
|
||||
# - 2=Alert Messages - Action Must Be Taken Immediately
|
||||
# - 3=Critical Messages - Application component unavailable, unexpected exception.
|
||||
# - 4=Runtime Errors - Don't need immediate action, but should be monitored.
|
||||
# - 5=Warnings - Exceptional occurrences that are not errors.
|
||||
# - 6=Notices - Normal but significant events.
|
||||
# - 7=Info - Interesting events, like user logging in, etc.
|
||||
# - 8=Debug - Detailed debug information.
|
||||
# - 9=All Messages
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
logger.threshold = 0
|
||||
app.db_log_enabled = false
|
||||
logger.threshold=0
|
||||
app.db_log_enabled=false
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# HONEYPOT
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
honeypot.hidden = true
|
||||
honeypot.label = 'Fill This Field'
|
||||
honeypot.name = 'honeypot'
|
||||
honeypot.template = '<label>{label}</label><input type="text" name="{name}" value="">'
|
||||
honeypot.container = '<div style="display:none">{template}</div>'
|
||||
honeypot.hidden=true
|
||||
honeypot.label='Fill This Field'
|
||||
honeypot.name='honeypot'
|
||||
honeypot.template='<label>{label}</label><input type="text" name="{name}" value="">'
|
||||
honeypot.container='<div style="display:none">{template}</div>'
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# SECURITY: DISALLOW PASSWORD CHANGE
|
||||
#--------------------------------------------------------------------
|
||||
# When true, disables the "change password" feature for all employees.
|
||||
# Useful when passwords are managed by an external system (e.g. SSO/LDAP).
|
||||
#
|
||||
# DISALLOW_PASSWORD_CHANGE=false
|
||||
|
||||
DISALLOW_PASSWORD_CHANGE=false
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# SECURITY: DISALLOW GRANT CHANGE
|
||||
#--------------------------------------------------------------------
|
||||
# When true, disables changing an employee's grants for all employees.
|
||||
# New employees cannot be created with grants while this is enabled.
|
||||
# Useful for demo deployments.
|
||||
#
|
||||
# DISALLOW_GRANT_CHANGE=false
|
||||
|
||||
DISALLOW_GRANT_CHANGE=false
|
||||
@@ -13,8 +13,10 @@ This document describes the CI/CD workflows for OSPOS.
|
||||
|
||||
### Docker Images
|
||||
- Build and push `opensourcepos` Docker image for multiple architectures (linux/amd64, linux/arm64)
|
||||
- On master: tagged with version and `latest`
|
||||
- On other branches: tagged with version only
|
||||
- On `master`: tagged `master` and `<sha>`
|
||||
- On other branches: tagged `<branch>-<sha>`
|
||||
- On a semver tag (e.g. `3.4.2`): tagged `<version>` and `latest`
|
||||
- The version number is never stamped onto `master`/branch builds — it only appears on tag releases
|
||||
- Pushed to Docker Hub
|
||||
|
||||
### Releases
|
||||
@@ -39,10 +41,10 @@ The `GITHUB_TOKEN` is automatically provided by GitHub Actions.
|
||||
|
||||
## Workflow Triggers
|
||||
|
||||
- **Push to master** - Runs build, Docker push (with `latest` tag), and release
|
||||
- **Push to other branches** - Runs build and Docker push (version tag only)
|
||||
- **Push tags** - Runs build and Docker push (version tag only)
|
||||
- **Pull requests** - Runs build only (PHPUnit tests run in parallel via phpunit.yml)
|
||||
- **Push to master** - Runs build, Docker push (`master` + `<sha>` tags), and creates/updates the `unstable` release
|
||||
- **Push to other branches** - Runs build and Docker push (`<branch>-<sha>` tag)
|
||||
- **Push a semver tag** (e.g. `3.4.2`) - Runs build and Docker push (`<version>` + `latest` tags)
|
||||
- **Pull requests** - Runs build only (PHPUnit tests run in parallel via phpunit.yml); no Docker push
|
||||
|
||||
## Existing Workflows
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
@@ -86,6 +90,18 @@ jobs:
|
||||
env:
|
||||
GITHUB_TAG: ${{ github.ref_name }}
|
||||
|
||||
- name: Validate release tag
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
# The ZIP below is named from App.php, while the draft-release job
|
||||
# globs by the tag name. A manually-pushed tag that does not match
|
||||
# App.php would silently produce a draft release with no archive, so
|
||||
# fail fast instead.
|
||||
if [ "${GITHUB_REF_NAME}" != "${{ steps.version.outputs.version }}" ]; then
|
||||
echo "::error::tag ${GITHUB_REF_NAME} does not match App.php version ${{ steps.version.outputs.version }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create .env file
|
||||
run: |
|
||||
cp .env.example .env
|
||||
@@ -154,11 +170,19 @@ jobs:
|
||||
- name: Determine Docker tags
|
||||
id: tags
|
||||
run: |
|
||||
BRANCH=$(echo "${GITHUB_REF#refs/heads/}" | tr '/' '_')
|
||||
if [ "$BRANCH" = "master" ]; then
|
||||
echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${{ needs.build.outputs.version-tag }},${{ secrets.DOCKER_USERNAME }}/opensourcepos:master" >> $GITHUB_OUTPUT
|
||||
REGISTRY="${{ secrets.DOCKER_USERNAME }}/opensourcepos"
|
||||
SHA="${{ needs.build.outputs.short-sha }}"
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "tags=${REGISTRY}:${VERSION},${REGISTRY}:latest" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "$GITHUB_REF" == refs/heads/master ]]; then
|
||||
echo "tags=${REGISTRY}:master,${REGISTRY}:${SHA}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${{ needs.build.outputs.version-tag }}" >> $GITHUB_OUTPUT
|
||||
BRANCH="${GITHUB_REF#refs/heads/}"
|
||||
BRANCH="$(printf '%s' "$BRANCH" | LC_ALL=C tr -c 'A-Za-z0-9_.-' '_')"
|
||||
BRANCH="${BRANCH:0:$((128 - ${#SHA} - 1))}"
|
||||
[[ "$BRANCH" == [-.]* ]] && BRANCH="_${BRANCH:1}"
|
||||
echo "tags=${REGISTRY}:${BRANCH}-${SHA}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
@@ -172,8 +196,8 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
unstable-release:
|
||||
name: Create Unstable Release
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
@@ -213,3 +237,77 @@ jobs:
|
||||
draft: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
official-release:
|
||||
name: Create Official Release (Draft)
|
||||
needs: [build, docker]
|
||||
runs-on: ubuntu-22.04
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-${{ needs.build.outputs.short-sha }}
|
||||
path: dist/
|
||||
|
||||
- name: Build release notes
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
# Extract this version's changelog section (header + entries) from CHANGELOG.md.
|
||||
SECTION=$(awk -v v="$VERSION" '
|
||||
$0 ~ "^## \\[" v "\\] - " {insec=1; print; next}
|
||||
insec && $0 ~ "^## \\[" {insec=0; next}
|
||||
insec {print}
|
||||
' CHANGELOG.md)
|
||||
if [ -z "$SECTION" ]; then
|
||||
echo "WARNING: no changelog section found for $VERSION"
|
||||
SECTION="Changelog section for ${VERSION} is not present in CHANGELOG.md."
|
||||
fi
|
||||
{
|
||||
echo "## Upgrade instructions"
|
||||
echo ""
|
||||
echo "**Docker:**"
|
||||
echo ""
|
||||
echo '```bash'
|
||||
echo "docker pull ${{ secrets.DOCKER_USERNAME }}/opensourcepos:${VERSION}"
|
||||
echo "docker compose -f docker-compose.nginx.yml up -d"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "**Existing installation:** download one of the archives below, extract it over your current install, and run any new database migrations."
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "$SECTION"
|
||||
} > /tmp/release_notes.md
|
||||
echo "=== release notes (first 12 lines) ==="
|
||||
head -12 /tmp/release_notes.md
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
|
||||
- name: Create draft release
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
ZIP=$(ls dist/opensourcepos."${VERSION}".*.zip 2>/dev/null | head -1)
|
||||
if [ -n "$ZIP" ]; then
|
||||
gh release create "$VERSION" \
|
||||
--draft \
|
||||
--title "OpenSourcePOS ${VERSION}" \
|
||||
--notes-file /tmp/release_notes.md \
|
||||
"$ZIP"
|
||||
else
|
||||
gh release create "$VERSION" \
|
||||
--draft \
|
||||
--title "OpenSourcePOS ${VERSION}" \
|
||||
--notes-file /tmp/release_notes.md
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
@@ -8,15 +8,25 @@ on:
|
||||
jobs:
|
||||
delete_unstable_release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Delete last unstable release"
|
||||
uses: sgpublic/delete-release-action@v1.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.TOKEN }}
|
||||
with:
|
||||
release-drop: false
|
||||
release-drop-tag: false
|
||||
pre-release-drop: true
|
||||
pre-release-keep-count: -1
|
||||
pre-release-drop-tag: true
|
||||
|
||||
steps:
|
||||
- name: "Delete the unstable release (matched by name)"
|
||||
env:
|
||||
TOKEN: ${{ secrets.TOKEN }}
|
||||
RELEASE_NAME: "Unstable OpenSourcePOS"
|
||||
TAG_NAME: "unstable"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api="https://api.github.com/repos/${GITHUB_REPOSITORY}"
|
||||
# Resolve the release id from its exact name so only this one release
|
||||
# is ever touched; drafts and every other release are left alone.
|
||||
id=$(curl -fsSL -H "Authorization: Bearer ${TOKEN}" "${api}/releases?per_page=100" \
|
||||
| jq -r --arg n "${RELEASE_NAME}" '.[] | select(.name==$n) | .id // empty')
|
||||
if [ -z "${id}" ]; then
|
||||
echo "No release named '${RELEASE_NAME}' found; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
curl -fsSL -X DELETE -H "Authorization: Bearer ${TOKEN}" "${api}/releases/${id}" >/dev/null
|
||||
echo "Deleted release '${RELEASE_NAME}' (id=${id})"
|
||||
curl -fsSL -X DELETE -H "Authorization: Bearer ${TOKEN}" "${api}/git/refs/tags/${TAG_NAME}" >/dev/null \
|
||||
|| echo "Tag '${TAG_NAME}' already gone; skipping."
|
||||
echo "Deleted tag '${TAG_NAME}'"
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
comment:
|
||||
name: Comment deployment status
|
||||
needs: [prepare, deploy]
|
||||
if: always()
|
||||
if: always() && needs.prepare.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -71,8 +71,8 @@ jobs:
|
||||
- name: Start MariaDB
|
||||
run: |
|
||||
docker run -d --name mysql \
|
||||
-e MYSQL_ROOT_PASSWORD=root \
|
||||
-e MYSQL_DATABASE=ospos \
|
||||
-e MYSQL_ROOT_PASSWORD=root \
|
||||
-e MYSQL_DATABASE=ospos_test \
|
||||
-e MYSQL_USER=admin \
|
||||
-e MYSQL_PASSWORD=pointofsale \
|
||||
-p 3306:3306 \
|
||||
@@ -84,6 +84,13 @@ jobs:
|
||||
done
|
||||
echo "MariaDB is ready!"
|
||||
|
||||
# Grant admin the CREATE/DROP privileges it needs to drop and
|
||||
# recreate the test database at the start of each test class.
|
||||
# Scoped to ospos_test only — never global *.* — so a compromised
|
||||
# test process cannot alter or drop unrelated schemas.
|
||||
docker exec mysql mysql -u root -proot \
|
||||
--execute="GRANT CREATE, DROP ON ospos_test.* TO 'admin'@'%'; FLUSH PRIVILEGES;"
|
||||
|
||||
- name: Get composer cache directory
|
||||
run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV
|
||||
|
||||
@@ -102,11 +109,36 @@ jobs:
|
||||
- name: Create .env file
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Provision per-run encryption key
|
||||
# Generates a unique key for this run and exports it as a real OS env
|
||||
# var (ENCRYPTION_KEY), which the app's Encryption config reads as a
|
||||
# fallback after the normal .env lookup. This is supported explicitly,
|
||||
# so no shared key is committed or shipped.
|
||||
run: |
|
||||
KEY=$(openssl rand -hex 32)
|
||||
printf 'ENCRYPTION_KEY=%s\n' "$KEY" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Provision per-run throttle key
|
||||
# checkThrottleEncryption() is a read-only guard that throws unless a
|
||||
# throttle.key is present (app/Helpers/security_helper.php). It is
|
||||
# normally minted at container startup via `php spark env:provision`,
|
||||
# so the test environment must provide one.
|
||||
#
|
||||
# We write it into the .env file (replacing the empty placeholder
|
||||
# copied from .env.example) rather than exporting an OS env var:
|
||||
# CodeIgniter's env() resolves $_ENV first, and DotEnv populates
|
||||
# $_ENV['throttle.key']='' from .env, which would shadow any OS var
|
||||
# before getenv() is ever consulted.
|
||||
run: |
|
||||
KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/^throttle\.key=''/throttle.key='$KEY'/" .env
|
||||
grep -Eq "^throttle\.key=.+" .env
|
||||
|
||||
- name: Run PHPUnit tests
|
||||
env:
|
||||
CI_ENVIRONMENT: testing
|
||||
MYSQL_HOST_NAME: 127.0.0.1
|
||||
run: composer test -- --log-junit test-results/junit.xml
|
||||
run: composer test -- --no-coverage --log-junit test-results/junit.xml
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
+132
-139
@@ -1,24 +1,40 @@
|
||||
name: Release Version Bump
|
||||
name: Release
|
||||
|
||||
# Cuts the current release (changelog + tag + draft release) and, optionally,
|
||||
# bumps App.php to the next dev version. One workflow, full release cycle.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Release current version:
|
||||
# - generate git-cliff changelog section (base..HEAD)
|
||||
# - insert it into CHANGELOG.md + GFM compare refs
|
||||
# - commit + push the changelog
|
||||
# - tag the current version and push the tag with the repo PAT
|
||||
# (a PAT push triggers build-release.yml, whose official-release job
|
||||
# creates the DRAFT GitHub Release with the changelog + assets)
|
||||
# 2. (optional) bump to the next dev version:
|
||||
# - update App.php / package.json / docker-compose / issue templates
|
||||
# - commit + push
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_type:
|
||||
description: 'Version bump type'
|
||||
next_bump:
|
||||
description: 'After cutting this release, bump App.php to the next dev version'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
- patch
|
||||
default: 'minor'
|
||||
- skip
|
||||
default: 'patch'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
name: Prepare Release
|
||||
release:
|
||||
name: Cut release and bump next version
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
@@ -26,147 +42,124 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Use the repo PAT (an admin token) so the changelog + bump pushes
|
||||
# below can update the protected master branch (enforce_admins is off).
|
||||
# GITHUB_TOKEN cannot, because master requires a PR review.
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
- name: Install git-cliff
|
||||
run: |
|
||||
CURRENT_VERSION=$(grep "application_version" app/Config/App.php | sed "s/.*= '\(.*\)';/\1/g")
|
||||
echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
V=2.14.2
|
||||
curl -sLSfL "https://github.com/orhun/git-cliff/releases/download/v${V}/git-cliff-${V}-x86_64-unknown-linux-musl.tar.gz" -o /tmp/cliff.tar.gz
|
||||
tar xzf /tmp/cliff.tar.gz -C /tmp
|
||||
mv "/tmp/git-cliff-${V}/git-cliff" /usr/local/bin/git-cliff
|
||||
chmod +x /usr/local/bin/git-cliff
|
||||
git cliff --version
|
||||
|
||||
- name: Calculate new version
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
CURRENT_VERSION="${{ steps.current_version.outputs.current_version }}"
|
||||
VERSION_TYPE="${{ github.event.inputs.version_type }}"
|
||||
|
||||
# Parse current version
|
||||
MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1)
|
||||
MINOR=$(echo $CURRENT_VERSION | cut -d. -f2)
|
||||
PATCH=$(echo $CURRENT_VERSION | cut -d. -f3)
|
||||
|
||||
# Bump version based on type
|
||||
case $VERSION_TYPE in
|
||||
major)
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
;;
|
||||
minor)
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
;;
|
||||
patch)
|
||||
PATCH=$((PATCH + 1))
|
||||
;;
|
||||
esac
|
||||
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "previous_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "New version: $NEW_VERSION (was: $CURRENT_VERSION, type: $VERSION_TYPE)"
|
||||
|
||||
- name: Update version in App.php
|
||||
run: |
|
||||
NEW_VERSION="${{ steps.version.outputs.new_version }}"
|
||||
sed -i "s/public string \\\$application_version = '[^']*';/public string \\\$application_version = '$NEW_VERSION';/" app/Config/App.php
|
||||
echo "Updated app/Config/App.php"
|
||||
|
||||
- name: Update version in package.json
|
||||
run: |
|
||||
NEW_VERSION="${{ steps.version.outputs.new_version }}"
|
||||
sed -i "s/\"version\": \"[^\"]*\",/\"version\": \"$NEW_VERSION\",/" package.json
|
||||
echo "Updated package.json"
|
||||
|
||||
- name: Update version in docker-compose.nginx.yml
|
||||
run: |
|
||||
NEW_VERSION="${{ steps.version.outputs.new_version }}"
|
||||
sed -i "s/jekkos\/opensourcepos:[^ ]*/jekkos\/opensourcepos:$NEW_VERSION/" docker-compose.nginx.yml
|
||||
echo "Updated docker-compose.nginx.yml"
|
||||
|
||||
- name: Update version in README.md
|
||||
run: |
|
||||
NEW_VERSION="${{ steps.version.outputs.new_version }}"
|
||||
# Extract major.minor for the "latest X.Y version" text
|
||||
MAJOR_MINOR=$(echo "$NEW_VERSION" | cut -d. -f1,2)
|
||||
sed -i "s/The latest \`[0-9]*\.[0-9]*\` version/The latest \`${MAJOR_MINOR}\` version/" README.md
|
||||
echo "Updated README.md with version ${MAJOR_MINOR}"
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
PREVIOUS_VERSION="${{ steps.version.outputs.previous_version }}"
|
||||
NEW_VERSION="${{ steps.version.outputs.new_version }}"
|
||||
|
||||
# Get commits since last version
|
||||
if git rev-parse "$PREVIOUS_VERSION" >/dev/null 2>&1; then
|
||||
COMMITS=$(git log "$PREVIOUS_VERSION"..HEAD --pretty=format:"- %s" --no-merges)
|
||||
else
|
||||
COMMITS=$(git log --pretty=format:"- %s" --no-merges -50)
|
||||
APP_VERSION=$(grep "application_version" app/Config/App.php | sed "s/.*= '\(.*\)';/\1/g")
|
||||
TARGET="$APP_VERSION"
|
||||
if ! [[ "$TARGET" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "ERROR: target '$TARGET' is not in X.Y.Z form"; exit 1
|
||||
fi
|
||||
|
||||
# Create changelog entry
|
||||
CHANGELOG_FILE="CHANGELOG.md"
|
||||
|
||||
# Create the new version comparison link
|
||||
NEW_LINK="[${NEW_VERSION}]: https://github.com/opensourcepos/opensourcepos/compare/${PREVIOUS_VERSION}...${NEW_VERSION}"
|
||||
|
||||
# Insert new link after [unreleased] line
|
||||
sed -i "/^\[unreleased\]/a $NEW_LINK" "$CHANGELOG_FILE"
|
||||
|
||||
# Update [unreleased] link to start from new version
|
||||
sed -i "s|^\[unreleased\]: .*|\[unreleased\]: https://github.com/opensourcepos/opensourcepos/compare/${NEW_VERSION}...HEAD|" "$CHANGELOG_FILE"
|
||||
|
||||
# Create version header and content using temp file to avoid sed issues with special characters
|
||||
VERSION_DATE=$(date +%Y-%m-%d)
|
||||
VERSION_HEADER="## [$NEW_VERSION] - $VERSION_DATE"
|
||||
|
||||
# Create temp file with changelog entry
|
||||
TMP_FILE=$(mktemp)
|
||||
{
|
||||
echo ""
|
||||
echo "$VERSION_HEADER"
|
||||
echo ""
|
||||
echo "$COMMITS"
|
||||
} > "$TMP_FILE"
|
||||
|
||||
# Insert after Unreleased header
|
||||
sed -i "/^## \[Unreleased\]/r $TMP_FILE" "$CHANGELOG_FILE"
|
||||
rm "$TMP_FILE"
|
||||
|
||||
echo "Updated CHANGELOG.md"
|
||||
echo "Changelog entries:"
|
||||
echo "$COMMITS"
|
||||
BASE=$(git tag --list | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
if [ -z "$BASE" ]; then
|
||||
echo "ERROR: no previous release tag found"; exit 1
|
||||
fi
|
||||
if [ "$BASE" = "$TARGET" ]; then
|
||||
echo "ERROR: target $TARGET equals base $BASE; nothing to release"; exit 1
|
||||
fi
|
||||
if ! printf '%s\n%s\n' "$BASE" "$TARGET" | sort -VC 2>/dev/null; then
|
||||
echo "ERROR: target $TARGET is not greater than base $BASE"; exit 1
|
||||
fi
|
||||
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
|
||||
echo "base=$BASE" >> "$GITHUB_OUTPUT"
|
||||
echo "Releasing $TARGET (previous release: $BASE)"
|
||||
|
||||
- name: Update version in issue templates
|
||||
- name: Generate changelog section
|
||||
run: |
|
||||
NEW_VERSION="${{ steps.version.outputs.new_version }}"
|
||||
|
||||
# Calculate version to remove (keep 5 versions)
|
||||
PREVIOUS_VERSION="${{ steps.version.outputs.previous_version }}"
|
||||
|
||||
# Bug report template - insert new version after development (unreleased)
|
||||
BUG_TEMPLATE=".github/ISSUE_TEMPLATE/bug report.yml"
|
||||
sed -i "/- development (unreleased)/a\\ - OpenSourcePOS ${NEW_VERSION}" "$BUG_TEMPLATE"
|
||||
# Remove the oldest version (5th version from the end)
|
||||
sed -i "/OpenSourcePOS 3\\.3\\.7/d" "$BUG_TEMPLATE"
|
||||
echo "Updated $BUG_TEMPLATE"
|
||||
|
||||
# Feature request template - insert new version after development (unreleased)
|
||||
FEATURE_TEMPLATE=".github/ISSUE_TEMPLATE/feature_request.yml"
|
||||
sed -i "/- development (unreleased)/a\\ - OpenSourcePOS ${NEW_VERSION}" "$FEATURE_TEMPLATE"
|
||||
# Remove the oldest version (5th version from the end)
|
||||
sed -i "/OpenSourcePOS 3\\.3\\.7/d" "$FEATURE_TEMPLATE"
|
||||
echo "Updated $FEATURE_TEMPLATE"
|
||||
git cliff --config cliff.toml \
|
||||
--tag "${{ steps.version.outputs.target }}" \
|
||||
--output /tmp/section.md \
|
||||
"${{ steps.version.outputs.base }}..HEAD"
|
||||
sed -i '/./,$!d' /tmp/section.md
|
||||
echo "Generated $(grep -c '^- ' /tmp/section.md) entries"
|
||||
echo "=== first 5 ==="
|
||||
head -5 /tmp/section.md
|
||||
|
||||
- name: Commit version bump
|
||||
- name: Insert into CHANGELOG.md
|
||||
run: |
|
||||
TARGET="${{ steps.version.outputs.target }}"
|
||||
BASE="${{ steps.version.outputs.base }}"
|
||||
sed -i "/^\[unreleased\]:/a [${TARGET}]: https://github.com/opensourcepos/opensourcepos/compare/${BASE}...${TARGET}" CHANGELOG.md
|
||||
sed -i "s|^\[unreleased\]:.*|\[unreleased\]: https://github.com/opensourcepos/opensourcepos/compare/${TARGET}...HEAD|" CHANGELOG.md
|
||||
LINE=$(grep -nE '^## \[[0-9]+\.' CHANGELOG.md | head -1 | cut -d: -f1)
|
||||
printf '%s\n\n' "$(cat /tmp/section.md)" > /tmp/section_final.md
|
||||
sed -i "$((LINE-1))r /tmp/section_final.md" CHANGELOG.md
|
||||
echo "Inserted $TARGET section before line $LINE"
|
||||
|
||||
- name: Commit changelog
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
NEW_VERSION="${{ steps.version.outputs.new_version }}"
|
||||
|
||||
git add app/Config/App.php package.json docker-compose.nginx.yml CHANGELOG.md README.md .github/ISSUE_TEMPLATE/
|
||||
git commit -m "chore: release version $NEW_VERSION"
|
||||
git push origin HEAD
|
||||
git add CHANGELOG.md
|
||||
git commit -m "docs: add ${{ steps.version.outputs.target }} changelog"
|
||||
git push origin HEAD
|
||||
|
||||
- name: Tag and push (PAT triggers build-release.yml)
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.target }}"
|
||||
git tag "$TAG"
|
||||
# Push with the repo PAT (not GITHUB_TOKEN) so the tag-push event
|
||||
# triggers build-release.yml, whose official-release job creates the
|
||||
# draft release with the changelog and assets.
|
||||
git push "https://x-access-token:${RELEASE_PAT}@github.com/opensourcepos/opensourcepos.git" "refs/tags/$TAG"
|
||||
echo "Released $TAG — draft release will be created by build-release.yml"
|
||||
env:
|
||||
RELEASE_PAT: ${{ secrets.TOKEN }}
|
||||
|
||||
- name: Bump to next dev version (optional)
|
||||
if: ${{ inputs.next_bump != 'skip' }}
|
||||
run: |
|
||||
CURRENT="${{ steps.version.outputs.target }}"
|
||||
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
||||
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
||||
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
||||
case "${{ inputs.next_bump }}" in
|
||||
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
||||
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
|
||||
patch) PATCH=$((PATCH + 1)) ;;
|
||||
*) echo "ERROR: unknown bump type ${{ inputs.next_bump }}"; exit 1 ;;
|
||||
esac
|
||||
NEXT="${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "Bumping to next dev version $NEXT"
|
||||
|
||||
sed -i "s/public string \\\$application_version = '[^']*';/public string \\\$application_version = '$NEXT';/" app/Config/App.php
|
||||
sed -i "s/\"version\": \"[^\"]*\",/\"version\": \"$NEXT\",/" package.json
|
||||
# Keep package-lock.json in sync: bump only the @opensourcepos/opensourcepos
|
||||
# package version (top-level + packages.""), leave dependency versions alone.
|
||||
awk -v v="$NEXT" '
|
||||
/"name": "@opensourcepos\/opensourcepos"/ { expect=1 }
|
||||
expect && /"version": / { sub(/"version": "[^"]*"/, "\"version\": \"" v "\""); expect=0 }
|
||||
{ print }
|
||||
' package-lock.json > .package-lock.tmp && mv .package-lock.tmp package-lock.json
|
||||
# Update the "latest X.Y version" README line only when major.minor changes
|
||||
NEW_MM=$(echo "$NEXT" | cut -d. -f1,2)
|
||||
OLD_MM=$(echo "$CURRENT" | cut -d. -f1,2)
|
||||
if [ "$NEW_MM" != "$OLD_MM" ]; then
|
||||
sed -i "s/The latest \`[0-9]*\.[0-9]*\` version/The latest \`${NEW_MM}\` version/" README.md
|
||||
fi
|
||||
|
||||
echo "=== version refs after bump ==="
|
||||
grep "application_version" app/Config/App.php
|
||||
grep '"version"' package.json | head -1
|
||||
grep -A1 '"name": "@opensourcepos/opensourcepos"' package-lock.json
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add app/Config/App.php package.json package-lock.json
|
||||
[ "$NEW_MM" != "$OLD_MM" ] && git add README.md
|
||||
git commit -m "chore: bump version to $NEXT"
|
||||
git push origin HEAD
|
||||
+3
-1
@@ -76,6 +76,8 @@ system/
|
||||
*~
|
||||
*.~
|
||||
.env
|
||||
.env.lock
|
||||
.env.tmp.*
|
||||
auth.json
|
||||
|
||||
*.png
|
||||
@@ -86,5 +88,5 @@ auth.json
|
||||
/writable/logs/*.log
|
||||
/writable/debugbar/*.json
|
||||
/app/Database/database.sql
|
||||
/writable/cache/settings
|
||||
/writable/cache/*
|
||||
/.env.bak
|
||||
@@ -7,8 +7,12 @@ This document provides guidance for AI agents working on the Open Source Point o
|
||||
- **PSR-12** enforced via PHP-CS-Fixer (config: `.php-cs-fixer.no-header.php`)
|
||||
- Follow PHP CodeIgniter 4 coding standards
|
||||
- `camelCase` for variables and methods; `PascalCase` for classes; `UPPER_CASE` for constants
|
||||
- When editing existing code containing non-PSR-compliant local variable names, refactor those variable names to `camelCase` as part of the edit
|
||||
- All newly written code (variables, classes, functions) must use PSR-compliant naming, regardless of surrounding code style
|
||||
- PHP 8.2+ features acceptable (named arguments, enums, readonly properties)
|
||||
- Write PHP 8.2+ compatible code with proper type declarations
|
||||
- Always import classes, functions, and constants with a `use` statement at the top of the file instead of referencing them inline via fully-qualified name (e.g. `use Config\Database;` then `Database::connect()`, not `\Config\Database::connect()`)
|
||||
- No useless comments or docblocks: if the code speaks for itself, explanatory comments are unnecessary. Do not add comments or docblocks that merely restate what the code already makes clear (e.g. `@param array $items` next to `array $items`, or a comment repeating a method's name) — only comment on non-obvious rationale, constraints, or behavior
|
||||
- Views in `app/Views/errors/html/` are excluded from the fixer
|
||||
- Run fixer before committing: `vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.no-header.php`
|
||||
- **JavaScript**: use `const` for variables that are never reassigned, `let` for variables that are. Never use `var`.
|
||||
@@ -22,6 +26,7 @@ This document provides guidance for AI agents working on the Open Source Point o
|
||||
|
||||
- Run PHPUnit tests: `composer test`
|
||||
- Tests must pass before submitting changes
|
||||
- **One test file per class under test.** A controller, model, library, or helper gets exactly one test file covering all of its behavior — `Item_kits.php` → `tests/Controllers/Item_kitsTest.php` (or `Item_kitsControllerTest.php`, matching this codebase's existing `*ControllerTest.php` suffix for controllers), `Sale_lib.php` → `tests/Libraries/Sale_libTest.php`, etc. Do not create feature- or endpoint-scoped test files alongside a class's main test file (e.g. no `Item_kitsBarcodeTest.php` next to `Item_kitsControllerTest.php`) — add the new test methods to the existing file for that class instead. If no test file exists yet for the class, create the one canonical file rather than a narrowly-scoped one.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -41,13 +46,18 @@ This document provides guidance for AI agents working on the Open Source Point o
|
||||
|
||||
- When adding new keys to language files, add the key to all `app/Language/*/` variants
|
||||
- **New keys must be inserted in alphabetical order** within the language array
|
||||
- Non-English files must use an empty string (`''`) as the value when no translation is provided — CodeIgniter automatically falls back to the default (`en`) language
|
||||
- Non-English files must use an empty string (`''`) as the value when no translation is provided — CodeIgniter automatically falls back to the default (`en`) language. This applies only when a translation genuinely isn't available yet.
|
||||
- **When explicitly asked to translate a phrase for a non-English language file, always provide the actual translation** — never leave the value as an empty string, and never leave source English text in a non-English language file
|
||||
- Never copy English text from a neighboring key as a value for a non-English language file, even if that neighboring key is already untranslated — evaluate each key independently
|
||||
- Only `app/Language/en/` and `app/Language/en-GB/` should contain English strings
|
||||
- When translating a string containing placeholders (e.g. `{filePath}`, `{reason}`) or literal filenames/keys (e.g. `throttle.key`, `.env`), keep that placeholder or filename text unchanged and untranslated — move it to whatever position is grammatically correct in the translated sentence
|
||||
- Plugin language files (`app/Plugins/*/Language/`) follow the same localization rules as `app/Language/`
|
||||
- Use `'` to encapsulate key and string values. If the value contains `'` then it should be escaped as `\'`
|
||||
- Align the `=>` of a newly inserted key with the `=>` column already used by the rest of the file, if that file pads keys to a fixed column (not all do — some files have no padding at all). If the new key is longer than the widest existing key and would push the alignment column further right, reformat the whole file to the new wider column rather than leaving only the new line at a different width
|
||||
|
||||
## Security
|
||||
|
||||
- Never commit secrets, credentials, or `.env` files
|
||||
- Use parameterized queries to prevent SQL injection
|
||||
- Validate and sanitize all user input
|
||||
- Never reference security advisory IDs (CVE, GHSA, etc.) in code, comments, commit messages, docblocks, documentation, or URLs — treat them the same as secrets. They act as a roadmap for attackers researching the exact exploit a fix addresses.
|
||||
@@ -14,6 +14,7 @@ The build process uses the build tools "npm" and "gulp" to piece everything toge
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Install Node.js 20 or later (the build fails on Node 18 and earlier - one of the license reporting dependencies requires newer JavaScript regex features)
|
||||
- Install the latest version of NPM (tested using version 9.4.2)
|
||||
- Install the latest version of Composer (tested using composer 2.5.1)
|
||||
|
||||
|
||||
+239
-1
@@ -1,4 +1,5 @@
|
||||
[unreleased]: https://github.com/opensourcepos/opensourcepos/compare/3.4.1...HEAD
|
||||
[unreleased]: https://github.com/opensourcepos/opensourcepos/compare/3.4.2...HEAD
|
||||
[3.4.2]: https://github.com/opensourcepos/opensourcepos/compare/3.4.1...3.4.2
|
||||
[3.4.1]: https://github.com/opensourcepos/opensourcepos/compare/3.4.0...3.4.1
|
||||
[3.4.0]: https://github.com/opensourcepos/opensourcepos/compare/3.3.9...3.4.0
|
||||
[3.3.9]: https://github.com/opensourcepos/opensourcepos/compare/3.3.8...3.3.9
|
||||
@@ -33,6 +34,243 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.4.2] - 2026-09-30
|
||||
- Fix writable folder permission check (#4270) (#4273) by @jekkos
|
||||
- Extended payment delete fix (#4274) by @jekkos
|
||||
- Upgrade github workflow (#3708) (#4280) by @jekkos
|
||||
- Fix typo in writeable (#4270) by @jekkos
|
||||
- Fix migration 20250522000000 (#4284) by @jekkos
|
||||
- Upgrade to ci 4.6.2 (#4296) (#4298) by @jekkos
|
||||
- Fix barcode generation in items (#4270) by @jekkos
|
||||
- Allow empty tax category id (#4285) (#4288) by @jekkos
|
||||
- Fix security incident email address (#4298) by @jekkos
|
||||
- Fix item kits update (#4294) by @jekkos
|
||||
- Revert toast message sanitization (#4302) by @jekkos
|
||||
- Fix for suspended sales (#4283) (#4303) by @jekkos
|
||||
- Fix reference to uploads folder (#4270) (#4286) by @jekkos
|
||||
- Add generic try/catch in import (#4302) by @jekkos
|
||||
- Bump jspdf from 3.0.1 to 3.0.2 (#4309) by @dependabot[bot]
|
||||
- Fix mount path for uploads (#4308) by @jekkos
|
||||
- Add transactions to missing config keys migration. (#4318) by @Joe Williams
|
||||
- [Feature] Add logging to migrations (#4327) by @Joe Williams
|
||||
- Clean up docker compose setup (#4308) by @jekkos
|
||||
- Fix tax configuration pages (#4331) by @jekkos
|
||||
- Update SECURITY.md contact (#4335) by @jekkos
|
||||
- Add server side validation for password (#4335) by @jekkos
|
||||
- Add env variable to disallow pwd change (#4325) by @jekkos
|
||||
- Add recent releases to issue template (#4317) by @jekkos
|
||||
- Add DOMpurify + fix XSS (#4341) by @jekkos
|
||||
- Fix attachment cid (#4314) by @jekkos
|
||||
- Add DOMPurify to JS includes (#4341) by @jekkos
|
||||
- Allow anonymous giftcard creation (#4278) by @jekkos
|
||||
- Fix toast notifications in config (#4341) (#4343) by @jekkos
|
||||
- Fix creation of date attribute value (#4310) (#4344) by @jekkos
|
||||
- Fix wrong migration script location (#4285) by @jekkos
|
||||
- Escape return_policy in receipt + invoice (#4349) by @jekkos
|
||||
- Fix for detailed suppliers report (#4351) by @jekkos
|
||||
- Add show/hide cost price & profit feature - in reports #4130 (#4350) by @BhojKamal
|
||||
- Fix travis build after merge (#4130) by @jekkos
|
||||
- Add equals as permitted URI character (#4329) by @Chathura Dilushanka
|
||||
- Fix multiple XSS vulnerabilities (#3965) (#4356) by @jekkos
|
||||
- Bump lodash from 4.17.21 to 4.17.23 (#4369) by @dependabot[bot]
|
||||
- Bump jspdf and jspdf-autotable (#4373) by @dependabot[bot]
|
||||
- Fix XSS vulnerabilities in invoices + receipts (#3965) (#4363) by @jekkos
|
||||
- Fix XSS vulnerability in attributes (#3965) by @jekkos
|
||||
- Fix XSS vulnerability in register (#3965) by @jekkos
|
||||
- Fix XSS vulnerability in register (#3965) by @jekkos
|
||||
- Fix XSS vulnerabilities in invoice_email.php view by @jekkos
|
||||
- Fix permission bypass in Reports submodule access control (#4389) by @jekkos
|
||||
- Use Content-Type application/json for AJAX responses (#4357) by @jekkos
|
||||
- Language Array Key Typo Fix (#4371) by @Lucas Lyimo
|
||||
- Fix: Refresh session language for employee after update. (#4245) by @jekkos
|
||||
- Fix Docker image upload by replacing slashes in TAG by @jekkos
|
||||
- Fix broken object-level authorization in Employees controller (CVE-worthy) (#4391) by @jekkos
|
||||
- Bump dompurify from 3.3.1 to 3.3.2 (#4402) by @dependabot[bot]
|
||||
- Fix incorrect argument types in migration round_number() methods (#4403) by @jekkos
|
||||
- dd validation for invalid stock locations in CSV import (#4399) by @jekkos
|
||||
- fix(security): whitelist and validate invoice template types (#4393) by @jekkos
|
||||
- Fix second-order SQL injection in currency_symbol config (#4390) by @jekkos
|
||||
- Add row-level authorization to password change endpoints (#4401) by @jekkos
|
||||
- Fix: Handle image filenames with spaces in thumbnails by @jekkos
|
||||
- Fix: Sanitize image filenames to prevent thumbnail display issues (#4372) by @jekkos
|
||||
- Add migration to fix existing image filenames with spaces (#4372) by @jekkos
|
||||
- Refactor: Move ADMIN_MODULES to constants, rename methods to camelCase by @jekkos
|
||||
- Fix SQL injection in custom attribute search by @Ollama
|
||||
- Fix stored XSS vulnerability in item descriptions by @Ollama
|
||||
- Fix stored XSS vulnerabilities in employee permissions and customer data by @Ollama
|
||||
- Fix: Preserve CHECKBOX attribute state when adding attributes (#4385) by @jekkos
|
||||
- Fix payment type becoming null when editing sales by @Ollama
|
||||
- Fix broken SQL injection fix - use havingLike() instead of having() with named params by @Ollama
|
||||
- Fix mass assignment vulnerability in bulk edit (GHSA-49mq-h2g4-grr9) by @Ollama
|
||||
- Sync language files (#3468) by @Ollama
|
||||
- Add workflow to auto-update issue templates with releases by @Ollama
|
||||
- Update SECURITY.md with published security advisories by @Ollama
|
||||
- Bump jspdf from 4.1.0 to 4.2.0 (#4383) by @dependabot[bot]
|
||||
- Add filter persistence for table views via URL query string (#4400) by @jekkos
|
||||
- Fix filter persistence javascript issues (#4400) by @jekkos
|
||||
- Fix PHPUnit test configuration for database connectivity (#4430) by @jekkos
|
||||
- Fix IDOR vulnerability in password change (GHSA-mcc2-8rp2-q6ch) (#4427) by @jekkos
|
||||
- Fix XSS vulnerability in tax invoice view (#4432) by @jekkos
|
||||
- Fix permission bypass in Sales.getManage() access control (#4428) by @jekkos
|
||||
- Update SECURITY.md with published security advisories (#4431) by @jekkos
|
||||
- Fix SQL injection in suggestions column configuration (#4421) by @jekkos
|
||||
- Fix PHPUnit environment variables not being set (#4434) by @jekkos
|
||||
- Fix DECIMAL attribute not respecting locale format (#4422) by @jekkos
|
||||
- Fix stored XSS vulnerability in Attribute Definitions (GHSA-rvfg-ww4r-rwqf) (#4429) by @jekkos
|
||||
- Fix: Host Header Injection vulnerability (GHSA-jchf-7hr6-h4f3) by @Ollama
|
||||
- Fix stored XSS in gcaptcha_site_key on login page by @Ollama
|
||||
- Fix stored XSS via stock location name by @Ollama
|
||||
- Fix Token_lib::render() for PHP 8.4 compatibility by @Ollama
|
||||
- Use CIUnitTestCase for consistency with other tests by @Ollama
|
||||
- Fix: Pass parameter to generate() and add composite format tests by @Ollama
|
||||
- Fix strftime directives handling and tighten test assertions by @Ollama
|
||||
- Add AGENTS.md with coding guidelines for AI agents by @Ollama
|
||||
- Fix: Add Debit Card filter to Daily Sales and Takings by @Ollama
|
||||
- Fix Taxes Summary Report totals not matching row values by @Ollama
|
||||
- Add unit tests for Taxes Summary Report calculations by @Ollama
|
||||
- Fix rounding consistency and update tests per review feedback by @Ollama
|
||||
- Rewrite tests to use database integration testing by @Ollama
|
||||
- Add seed data to tests for proper integration testing by @Ollama
|
||||
- Fix: Restrict employee selection in expenses and receivings forms by @Ollama
|
||||
- Fix review comments: remove redundant loop and add XSS escaping by @Ollama
|
||||
- Bump jspdf from 4.2.0 to 4.2.1 by @dependabot[bot]
|
||||
- Bump picomatch from 2.3.1 to 2.3.2 (#4451) by @dependabot[bot]
|
||||
- fix: Clear sale session after completing sale by @Ollama
|
||||
- fix: Remove redundant clear_mode() calls by @Ollama
|
||||
- Translate missing strings in multiple languages by @Ollama
|
||||
- Fix translation issues from code review by @Ollama
|
||||
- Remove English fallbacks from non-English translations by @Ollama
|
||||
- Add Calendar.php translations for missing languages by @Ollama
|
||||
- feat: migrate CI from Travis to GitHub Actions with enhancements by @Ollama
|
||||
- refactor: remove tables.sql and constraints.sql (#4447) by @Ollama
|
||||
- refactor: remove build-database gulp task (#4447) by @Ollama
|
||||
- refactor: optimize Docker image size by @Ollama
|
||||
- fix: remove duplicate phpunit.xml that prevented tests from running by @Ollama
|
||||
- fix: Use file-based session until database is migrated by @Ollama
|
||||
- feat: Improve migration UX on login page by @Ollama
|
||||
- Disable opencode workflow + run docker build by @jekkos
|
||||
- Fix negative price/quantity/discount validation (GHSA-wv3j-pp8r-7q43) (#4450) by @Nozomu Sasaki (Paul)
|
||||
- fix(ci): replace / with _ in branch names for Docker tags by @Ollama
|
||||
- fix(security): prevent command injection in sendmail path configuration by @Ollama
|
||||
- fix(security): prevent SQL injection in tax controller sort columns by @Ollama
|
||||
- feat: add release workflow with automated version bumping by @Ollama
|
||||
- refactor: simplify release workflow to version bump only by @Ollama
|
||||
- fix: address review comments by @Ollama
|
||||
- fix: address all review comments and restore issue template version update by @Ollama
|
||||
- fix: Tax Rate form not loading due to router service failure (#4479) by @jekkos
|
||||
- fix: Handle empty database on fresh install (#4467) by @jekkos
|
||||
- Fix: Improve allowedHostnames .env configuration and fail-fast in production (#4482) by @jekkos
|
||||
- [Feature]: Case-sensitive attribute updates and CSV Import attribute deletion capability (#4384) by @objecttothis
|
||||
- fix: change docker image tag to master by @jekkos
|
||||
- Update to CodeIgniter 4.7.2 (#4485) by @objecttothis
|
||||
- Bump lodash from 4.17.23 to 4.18.1 (#4462) by @dependabot[bot]
|
||||
- [Fix]: Add missing return statements to Sales Controller functions by @Ollama
|
||||
- Encourage users to star the project by @objecttothis
|
||||
- Bump dompurify from 3.3.2 to 3.4.0 (#4512) by @dependabot[bot]
|
||||
- fix: propagate attribute definition failures in postSaveGeneral() (#4509) by @jekkos
|
||||
- fix: Escape dynamic output and fix CSS property in barcode_sheet.php (#4501) by @jekkos
|
||||
- Fix CRC currency reverting to EUR/LAK in locale config (#4511) by @jekkos
|
||||
- fix: Add missing $img_tag variable in Sales::getSendPdf() (#4515) by @jekkos
|
||||
- fix: Language dropdown not displaying saved language correctly (#4518) by @jekkos
|
||||
- fix: Scope orWhere clauses in Item::exists() and Item::get_item_id() (#4520) by @jekkos
|
||||
- fix: Update calendar translations (#4498) by @jekkos
|
||||
- fix: Catch mysqli_sql_exception in DB fallback handlers for fresh Docker installs (#4525) by @jekkos
|
||||
- fix(home): improve internal data type handling for user identification in auth process by @enricodelarosa
|
||||
- Assignable Keyboard Shortcuts Updates (#4532) by @WShells
|
||||
- chore: miscellaneous updates and improvements (#4530) by @BudsieBuds
|
||||
- chore(deps): bump minimatch from 3.1.2 to 3.1.5 (#4536) by @dependabot[bot]
|
||||
- chore: sync project files to match upstream templates (#4537) by @BudsieBuds
|
||||
- fix(ci): include hidden files in Docker build context (#4543) by @jekkos
|
||||
- feat: add ALLOWED_HOSTNAMES environment variable support for Docker/Compose (#4544) by @jekkos
|
||||
- fix(docker): correct permissions and fix migration barcode_type error (#4546) by @jekkos
|
||||
- docs: Update SECURITY.md with disclosure process (#4549) by @jekkos
|
||||
- feat: Bank transfer and wallet payment option added #4540 (#4547) by @BhojKamal
|
||||
- fix(security): Path traversal vulnerability in getPicThumb (#4545) by @jekkos
|
||||
- fix(security): SQL injection and path traversal vulnerabilities (#4539) by @jekkos
|
||||
- fix: Capture CSV import failures in save_tax_data and save_inventory_quantities (#4507) by @jekkos
|
||||
- fix: validate attributeId > 0 in saveAttributeLink() (#4508) by @jekkos
|
||||
- feat: Add deployment workflow with approval gates (#4522) by @jekkos
|
||||
- Bugfixes to get Migration working on MySQL and MariaDB (#4551) by @objecttothis
|
||||
- Bugfix: Sale search in register not handling trailing space properly (#4557) by @objecttothis
|
||||
- fix: cast string returns to int in MY_Migration (#4560) by @jekkos
|
||||
- Add fallback for allowedHostnames environment variable (#4565) by @objecttothis
|
||||
- fix: Allow searching by Sale ID in Takings/Daily Sales view (#4569) by @jekkos
|
||||
- Add Guards to Database Migration (#4571) by @objecttothis
|
||||
- fix: tax rate inputs blank with comma-decimal locales (#4555) by @jekkos
|
||||
- fix(security): Fix DOMPDF RCE and customer email sanitization (#4568) by @jekkos
|
||||
- Fix overly lenient date validation (#4574) by @objecttothis
|
||||
- fix(security): Escape attribute value in register by @jekkos
|
||||
- chore(deps): bump dompurify from 3.4.0 to 3.4.11 (#4578) by @dependabot[bot]
|
||||
- Bugfix: Fix problems with migration UI in login (#4589) by @objecttothis
|
||||
- Forgotten commit from login migration branch (#4592) by @objecttothis
|
||||
- Feature: Payment reference code (#4587) by @objecttothis
|
||||
- fix(giftcard): correct return type and rename getGiftcardId method (#4600) by @objecttothis
|
||||
- chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#4602) by @dependabot[bot]
|
||||
- bugfix(reports): crash on detailed sales report when sale has multiple payments with reference codes (#4599) by @objecttothis
|
||||
- style(models): normalize quote style in SQL GROUP_CONCAT expression (#4608) by @objecttothis
|
||||
- chore(deps): upgrade dompdf from v2.0.8 to v3.1.6 (#4610) by @objecttothis
|
||||
- chore(deps): bump brace-expansion (#4614) by @dependabot[bot]
|
||||
- chore(deps): add xlsx via SheetJS CDN and upgrade tableexport plugin (#4615) by @objecttothis
|
||||
- chore(deps): bump lodash.template from 4.5.0 to 4.18.1 (#4616) by @objecttothis
|
||||
- fix(login): skip auth validation on new install to allow migration (#4609) by @objecttothis
|
||||
- fix: Wrap postSave() in single transaction for atomicity (#4506) by @jekkos
|
||||
- refactor: Replace var with let/const in JavaScript files (#4503) by @jekkos
|
||||
- fix: get_definition_by_name() returns single row instead of multi-dimensional array (#4452) (#4464) by @Jonathan Chang
|
||||
- fix(config): validate theme param to prevent XSS via invalid theme (#4620) by @objecttothis
|
||||
- fix(sales): enforce server-side authorization for price changes (#4631) by @objecttothis
|
||||
- fix(sales): escape quote number in email template to prevent XSS (#4625) by @objecttothis
|
||||
- refactor(migrations): rename execute_script to executeScript across all migrations (#4611) by @objecttothis
|
||||
- fix(auth): validate gcaptcha before password to prevent bypass (#4618) by @objecttothis
|
||||
- refactor: apply PSR-12 naming to Attribute definition methods (#4624) by @Rayan Abdul Cader
|
||||
- fix(security): sanitize filenames and escape logo path in config (#4630) by @objecttothis
|
||||
- fix: use db_connect() for item save transactions (#4636) by @richardmilles
|
||||
- fix(xss): remove redundant escaping that double-encoded item attribute values (#4628) by @objecttothis
|
||||
- chore(deps): bump codeigniter4/framework from 4.7.2 to 4.7.4 (#4638) by @dependabot[bot]
|
||||
- chore(deps): bump dompurify from 3.4.12 to 3.4.13 (#4639) by @dependabot[bot]
|
||||
- Reject item CSV imports whose header row is missing required columns (#4597) by @Sai Asish Y
|
||||
- fix(items): validate item_number and skip receiving quantity default for temp items (#4621) by @objecttothis
|
||||
- Feature: CodeIgniter Throttler (#4619) by @objecttothis
|
||||
- Bugfix: Resolve Race Condition in Rewards and Gift Card Spending (#4640) by @objecttothis
|
||||
- hotfix(auth): hash throttler keys to improve security (#4646) by @objecttothis
|
||||
- fix(items): add explicit sentinel value for clearing supplier in bulk edit (#4617) by @objecttothis
|
||||
- Codeigniter changes between 4.7.2 and 4.7.4 (#4650) by @objecttothis
|
||||
- Hotfix: Fix CI3 database migration caused by regression (#4649) by @objecttothis
|
||||
- fix(sales): gate per-record endpoints behind reports_sales grant (#4627) by @objecttothis
|
||||
- fix(email): update method call to camelCase for PSR-12 compliance (#4659) by @objecttothis
|
||||
- Feature admin account safeguards (#4657) by @objecttothis
|
||||
- Ensure payload data is escaped to prevent XSS (#4664) by @objecttothis
|
||||
- fix(sales): enforce reports_sales grant on search endpoint (#4673) by @objecttothis
|
||||
- fix(reports, home): resolve double-URL-decoding bypass for method grants (#4660) (#4666) by @objecttothis
|
||||
- Bugfix tax names (#4677) by @objecttothis
|
||||
- feat(validation, tests): add `valid_path_strict` rule and integrate into mailpath validation (#4684) by @objecttothis
|
||||
- fix(sales): harden payment validation and gift card handling by @objecttothis
|
||||
- fix: prevent duplicate items when editing imported rows (#4634) by @richardmilles
|
||||
- fix(barcode): resolve string interpolation issue in barcode display html (#4692) by @Vighnesh Nilajakar
|
||||
- fix(sales): gate getSearch behind reports_sales grant by @jekkos
|
||||
- bugfix(sales): reject non-negative gift-card amount_tendered (#4674) by @jekkos
|
||||
- fix(sales): harden unsuspend with auth, status gating, and null safety by @objecttothis
|
||||
- fix(tests): resolve all phpunit failures — clean-DB suite green (#4626) (#4691) by @jekkos
|
||||
- fix(licenses): guard malformed data, parallelize gulp tasks, require Node 20 by @objecttothis
|
||||
- fix(validation): broaden sendmail path regex, expand i18n, strip advisory IDs by @objecttothis
|
||||
- fix(security): handle special characters in `.env` key values and improve insertion logic (#4656) by @objecttothis
|
||||
- fix(locale): validate language_code against known locales to block path traversal (#4704) by @jekkos
|
||||
- Fix GHSA-frx7-c5vv-m3mr: recompute cashup total server-side and force owner identity (#4706) by @jekkos
|
||||
- feat(security): add THROTTLE_KEY env-var fallback for throttle.key (#4707) by @jekkos
|
||||
- fix(i18n): translate remaining English labels in Swiss German Items.php (#4701) by @Rayan Abdul Cader
|
||||
- fix(ci): stop stamping app version onto master and branch Docker tags (#4709) by @jekkos
|
||||
- chore(deps): bump fflate from 0.8.2 to 0.8.3 (#4690) by @dependabot[bot]
|
||||
- fix(i18n): swap print_delay_autoreturn number/required messages in 5 locales (#4699) by @Rayan Abdul Cader
|
||||
- chore(release): unified git-cliff release workflow (changelog + tag + optional bump) (#4711) by @jekkos
|
||||
- fix(release): push changelog/bump to master via admin PAT (GITHUB_TOKEN blocked by branch protection) by @jekkos
|
||||
- docs: add 3.4.2 changelog by @github-actions[bot]
|
||||
- chore: bump version to 3.4.3 by @github-actions[bot]
|
||||
- fix(security): HTML-escape attribute dropdown option labels in items attributes view (#4715) by @jekkos
|
||||
- fix(release): keep package-lock.json version in sync on bump (#4718) by @jekkos
|
||||
- fix(security): strip all HTML tags from $.notify alert messages (#4716) by @jekkos
|
||||
- chore: strip advisory IDs from code comments and changelog (#4720) by @jekkos
|
||||
- fix(security): report unwritable .env.lock, make throttle limits configurable (#4714) by @jekkos
|
||||
- chore: reset 3.4.2 (undo premature 3.4.3 bump + stale changelog) for re-cut by @jekkos
|
||||
|
||||
## [3.4.1] - 2025-06-05
|
||||
- Feature: PSR-12 Compliant Indentation by @objecttothis in ([#4196](https://github.com/opensourcepos/opensourcepos/pull/4196))
|
||||
- Add .env to dist zip by @jekkos in ([#4199](https://github.com/opensourcepos/opensourcepos/pull/4199))
|
||||
|
||||
+11
-4
@@ -10,7 +10,7 @@
|
||||
|
||||
### Allowed Hostnames (REQUIRED for Production)
|
||||
|
||||
⚠️ **CRITICAL**: OpenSourcePOS validates the Host header to prevent Host Header Injection attacks (GHSA-jchf-7hr6-h4f3). **You MUST configure `app.allowedHostnames` for production deployments. If not configured, the application will fail to start.**
|
||||
⚠️ **CRITICAL**: OpenSourcePOS validates the Host header to prevent Host Header Injection attacks. **You MUST configure `app.allowedHostnames` for production deployments. If not configured, the application will fail to start.**
|
||||
|
||||
**Add to your `.env` file:**
|
||||
|
||||
@@ -34,9 +34,8 @@ RuntimeException: Security: allowedHostnames is not configured.
|
||||
**Solution**: Add `app.allowedHostnames` to your `.env` file with your domain(s).
|
||||
|
||||
**Why this matters:**
|
||||
- Prevents Host Header Injection attacks (GHSA-jchf-7hr6-h4f3)
|
||||
- Prevents Host Header Injection attacks
|
||||
- Ensures URLs are generated with the correct domain
|
||||
- Security advisory: https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-jchf-7hr6-h4f3
|
||||
- Fixes issue #4480: .env configuration now works via comma-separated values
|
||||
|
||||
### HTTPS Behind Proxy
|
||||
@@ -72,7 +71,15 @@ Docker runs natively on Mac and Linux. Windows requires WSL2 to be installed. Pl
|
||||
|
||||
**Be aware that this setup is not suited for production usage! Change the default passwords in the compose file before exposing the containers publicly.**
|
||||
|
||||
Start the containers using the following command
|
||||
First create a **regular `.env` file** in the project root (a missing one is not
|
||||
auto-created as a file — see the compose `create_host_path: false` setting). Copy
|
||||
the shipped example:
|
||||
|
||||
```
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Then start the containers:
|
||||
|
||||
```
|
||||
docker-compose up
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Libraries\CI3SecretConverter;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use Exception;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Idempotent startup provisioning of the encryption + throttle keys.
|
||||
*
|
||||
* In bare-metal deployments the web runtime can auto-provision keys inline
|
||||
* (see checkEncryption()/checkThrottleEncryption(), gated by .env
|
||||
* writability). In Docker/Compose deployments .env is typically mounted
|
||||
* read-only, so keys are minted here at container start via
|
||||
* `php spark env:provision`. For legacy CI3 deployments this command
|
||||
* also re-encrypts stored CI3 secrets to the CI4 cipher (shared
|
||||
* implementation with the interactive ConvertToCI4 migration).
|
||||
*/
|
||||
class EnvProvision extends BaseCommand
|
||||
{
|
||||
/**
|
||||
* The command's group.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $group = 'Environment';
|
||||
|
||||
protected $name = 'env:provision';
|
||||
|
||||
protected $usage = 'env:provision';
|
||||
|
||||
protected $description = 'Ensures the encryption and throttle keys are provisioned and converts any legacy CI3-encrypted secrets.';
|
||||
|
||||
public function run(array $params): void
|
||||
{
|
||||
helper('security');
|
||||
|
||||
$throttleKey = provisionThrottleKey();
|
||||
CLI::write('throttle.key : ' . ($throttleKey !== '' ? 'present' : 'MISSING'), 'green');
|
||||
|
||||
$encryptionConfig = config('Encryption');
|
||||
$key = (string) ($encryptionConfig->key ?? '');
|
||||
|
||||
if ($key !== '' && strlen($key) >= 64) {
|
||||
CLI::write('encryption.key : CI4 key already present', 'green');
|
||||
CLI::newLine();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$converter = new CI3SecretConverter();
|
||||
|
||||
if ($key !== '' && strlen($key) < 64) {
|
||||
// DB read, safe outside the .env lock.
|
||||
$plain = $converter->decryptAll($key);
|
||||
$hasData = $this->anyNonEmpty($plain);
|
||||
|
||||
// Backup -> rotate -> re-encrypt -> verify -> persist under a single .env lock.
|
||||
rotateEncryptionKeyTransaction($key, static function () use ($plain, $hasData, $converter): void {
|
||||
$encrypted = $converter->encryptAll($plain);
|
||||
|
||||
if (array_diff_assoc($plain, $converter->verifyAll($encrypted)) !== []) {
|
||||
throw new RuntimeException('Failed to verify converted encryption data.');
|
||||
}
|
||||
|
||||
if ($hasData) {
|
||||
$converter->saveAll($encrypted);
|
||||
}
|
||||
});
|
||||
|
||||
CLI::write('encryption.key : rotated CI3 -> CI4 key', 'green');
|
||||
if ($hasData) {
|
||||
CLI::write('legacy secrets : converted and verified to CI4 cipher', 'green');
|
||||
}
|
||||
} else {
|
||||
// Fresh key (no old key to decrypt): a single atomic write suffices;
|
||||
// the transaction wrapper still serialises it against other workers.
|
||||
rotateEncryptionKeyTransaction(null, static function (): void {});
|
||||
|
||||
CLI::write('encryption.key : new CI4 key generated', 'green');
|
||||
|
||||
if ($this->legacySecretsPresent()) {
|
||||
CLI::write('legacy secrets : WARNING - stored CI3 secrets found but no CI3 key to decrypt them; they could not be recovered', 'yellow');
|
||||
}
|
||||
}
|
||||
|
||||
CLI::newLine();
|
||||
CLI::write('env:provision complete.', 'green');
|
||||
CLI::newLine();
|
||||
}
|
||||
|
||||
private function anyNonEmpty(array $plain): bool
|
||||
{
|
||||
foreach ($plain as $value) {
|
||||
if ((string) $value !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function legacySecretsPresent(): bool
|
||||
{
|
||||
try {
|
||||
$appConfig = model('Appconfig');
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (CI3SecretConverter::LEGACY_KEYS as $col) {
|
||||
try {
|
||||
if ($appConfig->get_value($col) !== '') {
|
||||
return true;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -12,7 +12,7 @@ class App extends BaseConfig
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public string $application_version = '3.4.2';
|
||||
public string $application_version = '3.4.3';
|
||||
|
||||
/**
|
||||
* This is the commit hash for the version you are currently using.
|
||||
@@ -307,7 +307,7 @@ class App extends BaseConfig
|
||||
/**
|
||||
* Validates and returns a trusted hostname.
|
||||
*
|
||||
* Security: Prevents Host Header Injection attacks (GHSA-jchf-7hr6-h4f3)
|
||||
* Security: Prevents Host Header Injection attacks
|
||||
* by validating the HTTP_HOST against a whitelist of allowed hostnames.
|
||||
*
|
||||
* In production: Fails fast if allowedHostnames is not configured.
|
||||
|
||||
@@ -84,9 +84,12 @@ defined('EXIT__AUTO_MAX') || define('EXIT__AUTO_MAX', 125); // highest a
|
||||
/**
|
||||
* Global Constants.
|
||||
*/
|
||||
const NEW_ENTRY = -1;
|
||||
const ACTIVE = 0;
|
||||
const DELETED = 1;
|
||||
const NEW_ENTRY = -1;
|
||||
const ACTIVE = 0;
|
||||
const DELETED = 1;
|
||||
const INSUFFICIENT_GIFTCARD_BALANCE = -2;
|
||||
const INSUFFICIENT_REWARD_POINTS = -3;
|
||||
const INSUFFICIENT_STOCK = -4;
|
||||
|
||||
/**
|
||||
* Attribute Related Constants.
|
||||
|
||||
@@ -106,4 +106,53 @@ class Encryption extends BaseConfig
|
||||
* by CI3 Encryption default configuration.
|
||||
*/
|
||||
public string $cipher = 'AES-256-CTR';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
if ($this->key === '') {
|
||||
// Fallback sources (notably the ENCRYPTION_KEY Docker var, which the
|
||||
// parent never reads) were not decode-parsed, so run them through
|
||||
// the same parser to keep hex2bin:/base64: keys consistent.
|
||||
$this->key = self::parseKey(self::resolveKey(
|
||||
(string) ($_SERVER['encryption.key'] ?? ''),
|
||||
(string) ($_ENV['encryption.key'] ?? ''),
|
||||
(string) getenv('encryption.key'),
|
||||
(string) getenv('ENCRYPTION_KEY'),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a key's `hex2bin:`/`base64:` prefix, mirroring
|
||||
* BaseConfig::parseEncryptionKey(); kept static so it is unit-testable.
|
||||
*/
|
||||
public static function parseKey(string $key): string
|
||||
{
|
||||
if (str_starts_with($key, 'hex2bin:')) {
|
||||
return (string) hex2bin(substr($key, 8));
|
||||
}
|
||||
|
||||
if (str_starts_with($key, 'base64:')) {
|
||||
return (string) base64_decode(substr($key, 7), true);
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first non-empty source (highest precedence first). Cascading
|
||||
* past empty strings (vs `??`) avoids a blank value shadowing the real key.
|
||||
*/
|
||||
public static function resolveKey(string ...$sources): string
|
||||
{
|
||||
foreach ($sources as $source) {
|
||||
if ($source !== '') {
|
||||
return $source;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@ use App\Events\Method;
|
||||
|
||||
Events::on('pre_system', static function (): void {
|
||||
if (ENVIRONMENT !== 'testing') {
|
||||
if (ini_get('zlib.output_compression')) {
|
||||
$value = ini_get('zlib.output_compression');
|
||||
if (filter_var($value, FILTER_VALIDATE_BOOLEAN) || (int) $value > 0) {
|
||||
throw FrameworkException::forEnabledZlibOutputCompression();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Config;
|
||||
|
||||
use App\Filters\Throttle;
|
||||
use CodeIgniter\Config\Filters as BaseFilters;
|
||||
use CodeIgniter\Filters\Cors;
|
||||
use CodeIgniter\Filters\CSRF;
|
||||
@@ -12,6 +13,7 @@ use CodeIgniter\Filters\InvalidChars;
|
||||
use CodeIgniter\Filters\PageCache;
|
||||
use CodeIgniter\Filters\PerformanceMetrics;
|
||||
use CodeIgniter\Filters\SecureHeaders;
|
||||
use App\Filters\IsLoggedIn;
|
||||
|
||||
class Filters extends BaseFilters
|
||||
{
|
||||
@@ -34,6 +36,8 @@ class Filters extends BaseFilters
|
||||
'forcehttps' => ForceHTTPS::class,
|
||||
'pagecache' => PageCache::class,
|
||||
'performance' => PerformanceMetrics::class,
|
||||
'isLoggedIn' => IsLoggedIn::class,
|
||||
'throttle' => Throttle::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -75,6 +79,7 @@ class Filters extends BaseFilters
|
||||
'honeypot',
|
||||
'csrf' => ['except' => 'login|migrate'],
|
||||
'invalidchars',
|
||||
'isLoggedIn' => ['except' => 'login|migrate'],
|
||||
],
|
||||
'after' => [
|
||||
'toolbar',
|
||||
@@ -107,7 +112,9 @@ class Filters extends BaseFilters
|
||||
*
|
||||
* @var array<string, array<string, list<string>>>
|
||||
*/
|
||||
public array $filters = [];
|
||||
public array $filters = [
|
||||
'throttle' => ['before' => ['login', 'migrate']],
|
||||
];
|
||||
|
||||
/**
|
||||
* Constructor to conditionally disable CSRF filter in testing environment
|
||||
|
||||
@@ -39,6 +39,7 @@ class OSPOS extends BaseConfig
|
||||
|
||||
try {
|
||||
$db = Database::connect();
|
||||
$db->resetDataCache();
|
||||
|
||||
if (!$db->tableExists('app_config')) {
|
||||
$this->settings = $this->getDefaultSettings();
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
use CodeIgniter\Router\RouteCollection;
|
||||
|
||||
/**
|
||||
* @var RouteCollection $routes
|
||||
*/
|
||||
/** @var RouteCollection $routes */
|
||||
$routes->setDefaultController('Login');
|
||||
|
||||
$routes->get('/', 'Login::index');
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
/**
|
||||
* Resolves the on-disk locations the security_helper functions read/write for
|
||||
* the runtime-generated .env secrets (encryption.key + throttle.key).
|
||||
*
|
||||
* Centralising these paths in a config object (mirroring Config\Encryption)
|
||||
* lets the helper and the test suite agree on the same source of truth, and
|
||||
* lets tests redirect all three to a per-run sandbox by mutating the shared
|
||||
* instance — without changing any helper signature.
|
||||
*
|
||||
* The defaults are the production locations; tests override them in setUp().
|
||||
*/
|
||||
class SecurityEnv extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* Path to the .env file that holds the two runtime-generated secrets.
|
||||
*/
|
||||
public string $envPath = ROOTPATH . '.env';
|
||||
|
||||
/**
|
||||
* Backup location used around encryption-key rotation so a failing
|
||||
* conversion can be rolled back from the previous .env contents.
|
||||
*/
|
||||
public string $backupPath = WRITEPATH . '/backup/.env.bak';
|
||||
|
||||
/**
|
||||
* Dedicated mutex file for coordinating concurrent .env writes. Kept
|
||||
* separate from .env itself because on Windows a file cannot be
|
||||
* renamed/deleted while a handle to it is open.
|
||||
*/
|
||||
public string $lockPath = ROOTPATH . '.env.lock';
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class Session extends BaseConfig
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The session storage driver to use:
|
||||
* - `CodeIgniter\Session\Handlers\ArrayHandler` (for testing)
|
||||
* - `CodeIgniter\Session\Handlers\FileHandler`
|
||||
* - `CodeIgniter\Session\Handlers\DatabaseHandler`
|
||||
* - `CodeIgniter\Session\Handlers\MemcachedHandler`
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Models\Employee;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
use Config\OSPOS;
|
||||
use Config\Services;
|
||||
use DirectoryIterator;
|
||||
use IntlChar;
|
||||
|
||||
/**
|
||||
* @property Employee employee
|
||||
@@ -39,13 +41,6 @@ class OSPOSRules
|
||||
return false;
|
||||
}
|
||||
|
||||
$password = $data['password'];
|
||||
if (!$employee->login($username, $password)) {
|
||||
$error = lang('Login.invalid_username_and_password');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$gcaptcha_enabled = array_key_exists('gcaptcha_enable', $this->config) && $this->config['gcaptcha_enable'];
|
||||
if ($gcaptcha_enabled) {
|
||||
$g_recaptcha_response = $this->request->getPost('g-recaptcha-response');
|
||||
@@ -57,6 +52,13 @@ class OSPOSRules
|
||||
}
|
||||
}
|
||||
|
||||
$password = $data['password'];
|
||||
if (!$employee->login($username, $password)) {
|
||||
$error = lang('Login.invalid_username_and_password');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -66,7 +68,7 @@ class OSPOSRules
|
||||
* @param $response
|
||||
* @return bool true on successful GCaptcha verification or false if GCaptcha failed.
|
||||
*/
|
||||
private function gcaptcha_check($response): bool
|
||||
protected function gcaptcha_check($response): bool
|
||||
{
|
||||
if (!empty($response)) {
|
||||
$check = [
|
||||
@@ -150,4 +152,124 @@ class OSPOSRules
|
||||
|
||||
return $value !== false && $value >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the candidate value is greater than or equal to another
|
||||
* field in the same request (proper cross-field comparison).
|
||||
*
|
||||
* CI4's built-in greater_than_equal_to[field] rule does not resolve the
|
||||
* [field] token to that field's value, so this rule performs the real
|
||||
* comparison and sets a human-readable error on failure.
|
||||
*
|
||||
* @param string $candidate The value being validated (e.g. max).
|
||||
* @param string $otherField The field to compare against (e.g. min).
|
||||
* @param array $data The full set of data being validated.
|
||||
* @param string|null $error Error message set on failure.
|
||||
* @return bool
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function gte_field(string $candidate, string $otherField, array $data, ?string &$error = null): bool
|
||||
{
|
||||
$other = $data[$otherField] ?? null;
|
||||
|
||||
if (is_numeric($candidate) && is_numeric($other) && (float) $candidate < (float) $other) {
|
||||
$error = 'The value must be a number greater than or equal to the ' . $otherField . ' field.';
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the candidate theme name matches an installed bootswatch theme directory.
|
||||
*
|
||||
* @param string $theme
|
||||
* @param string|null $error
|
||||
* @return bool
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function themeExists(string $theme, ?string &$error = null): bool
|
||||
{
|
||||
$dir = new DirectoryIterator('resources/bootswatch');
|
||||
|
||||
foreach ($dir as $fileInfo) {
|
||||
if (
|
||||
$fileInfo->isDir()
|
||||
&& !$fileInfo->isDot()
|
||||
&& $fileInfo->getFilename() !== 'fonts'
|
||||
&& $fileInfo->getFilename() === $theme
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unicode-aware version of CodeIgniter's built-in `alpha_numeric_punct` rule, which only
|
||||
* matches ASCII (`preg_match('\A[A-Z0-9 ~!#$%\&\*\-_+=|:.]+\z/i', ...)`) and so rejects
|
||||
* legitimate non-English text (e.g. accented or CJK characters). Allows unicode letters,
|
||||
* combining marks (so base+diacritic sequences pass), and digits in place of `A-Z0-9`, and
|
||||
* reuses the exact same punctuation set as the original rule (`~!#$%&*-_+=|:.` plus space),
|
||||
* extended with `'` and `,` to accommodate real-world tax names (e.g. "O'Brien's Tax",
|
||||
* "Impôt, incl."). `<` and `>` are deliberately absent from the punctuation set, same as in
|
||||
* the original rule, so this also serves as a defense-in-depth backstop against HTML
|
||||
* injection (the primary fix is escaping at render time).
|
||||
*
|
||||
* @param string $candidate
|
||||
* @param string|null $error
|
||||
* @return bool
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function unicode_alpha_numeric_punct(string $candidate, ?string &$error = null): bool
|
||||
{
|
||||
$allowedPunctuation = ['~', '!', '#', '$', '%', '&', '*', '-', '_', '+', '=', '|', ':', '.', ' ', "'", ','];
|
||||
|
||||
$allowedCategories = [
|
||||
IntlChar::CHAR_CATEGORY_UPPERCASE_LETTER,
|
||||
IntlChar::CHAR_CATEGORY_LOWERCASE_LETTER,
|
||||
IntlChar::CHAR_CATEGORY_TITLECASE_LETTER,
|
||||
IntlChar::CHAR_CATEGORY_MODIFIER_LETTER,
|
||||
IntlChar::CHAR_CATEGORY_OTHER_LETTER,
|
||||
IntlChar::CHAR_CATEGORY_NON_SPACING_MARK,
|
||||
IntlChar::CHAR_CATEGORY_COMBINING_SPACING_MARK,
|
||||
IntlChar::CHAR_CATEGORY_ENCLOSING_MARK,
|
||||
IntlChar::CHAR_CATEGORY_DECIMAL_DIGIT_NUMBER,
|
||||
IntlChar::CHAR_CATEGORY_LETTER_NUMBER,
|
||||
IntlChar::CHAR_CATEGORY_OTHER_NUMBER,
|
||||
];
|
||||
|
||||
foreach (mb_str_split($candidate) as $character) {
|
||||
if (in_array($character, $allowedPunctuation, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!in_array(IntlChar::charType($character), $allowedCategories, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a plain filesystem path, allowing space/colon/backslash for Windows paths and
|
||||
* trailing sendmail-style args. Excludes shell metacharacters since this value is concatenated
|
||||
* unescaped into a popen() call. Uses \A...\z, not ^...$, since $ also matches before a
|
||||
* trailing newline.
|
||||
*
|
||||
* @param string $candidate
|
||||
* @param string|null $error
|
||||
* @return bool
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function valid_path_strict(string $candidate, ?string &$error = null): bool
|
||||
{
|
||||
if ($candidate === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) preg_match('/\A[a-zA-Z0-9_\-\/.: \\\\]+\z/', $candidate);
|
||||
}
|
||||
}
|
||||
+2
-8
@@ -5,10 +5,6 @@ namespace Config;
|
||||
use CodeIgniter\Config\View as BaseView;
|
||||
use CodeIgniter\View\ViewDecoratorInterface;
|
||||
|
||||
/**
|
||||
* @phpstan-type parser_callable (callable(mixed): mixed)
|
||||
* @phpstan-type parser_callable_string (callable(mixed): mixed)&string
|
||||
*/
|
||||
class View extends BaseView
|
||||
{
|
||||
/**
|
||||
@@ -34,8 +30,7 @@ class View extends BaseView
|
||||
* { title|esc(js) }
|
||||
* { created_on|date(Y-m-d)|esc(attr) }
|
||||
*
|
||||
* @var array<string, string>
|
||||
* @phpstan-var array<string, parser_callable_string>
|
||||
* @var array<string, (callable(mixed): mixed)&string>
|
||||
*/
|
||||
public $filters = [];
|
||||
|
||||
@@ -44,8 +39,7 @@ class View extends BaseView
|
||||
* by the core Parser by creating aliases that will be replaced with
|
||||
* any callable. Can be single or tag pair.
|
||||
*
|
||||
* @var array<string, callable|list<string>|string>
|
||||
* @phpstan-var array<string, list<parser_callable_string>|parser_callable_string|parser_callable>
|
||||
* @var array<string, (callable(mixed...): mixed)|((callable(mixed...): mixed)&string)|list<(callable(mixed...): mixed)&string>>
|
||||
*/
|
||||
public $plugins = [];
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Controllers;
|
||||
use App\Models\Attribute;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Services;
|
||||
use JsonException;
|
||||
|
||||
require_once('Secure_Controller.php');
|
||||
|
||||
@@ -64,8 +65,14 @@ class Attributes extends Secure_Controller
|
||||
*/
|
||||
public function postSaveAttributeValue(): ResponseInterface
|
||||
{
|
||||
$attributeValue = $this->request->getPost('attribute_value');
|
||||
|
||||
if (!is_string($attributeValue) || $attributeValue === '') {
|
||||
return $this->response->setJSON(['success' => false]);
|
||||
}
|
||||
|
||||
$success = $this->attribute->saveAttributeValue(
|
||||
html_entity_decode($this->request->getPost('attribute_value')),
|
||||
$attributeValue,
|
||||
$this->request->getPost('definition_id', FILTER_SANITIZE_NUMBER_INT),
|
||||
$this->request->getPost('item_id', FILTER_SANITIZE_NUMBER_INT) ?? false,
|
||||
$this->request->getPost('attribute_id', FILTER_SANITIZE_NUMBER_INT) ?? false
|
||||
@@ -81,8 +88,14 @@ class Attributes extends Secure_Controller
|
||||
*/
|
||||
public function postDeleteDropdownAttributeValue(): ResponseInterface
|
||||
{
|
||||
$attributeValue = $this->request->getPost('attribute_value');
|
||||
|
||||
if (!is_string($attributeValue) || $attributeValue === '') {
|
||||
return $this->response->setJSON(['success' => false]);
|
||||
}
|
||||
|
||||
$success = $this->attribute->deleteDropdownAttributeValue(
|
||||
html_entity_decode($this->request->getPost('attribute_value')),
|
||||
$attributeValue,
|
||||
$this->request->getPost('definition_id', FILTER_SANITIZE_NUMBER_INT)
|
||||
);
|
||||
|
||||
@@ -132,11 +145,25 @@ class Attributes extends Secure_Controller
|
||||
|
||||
$definition_name = $definition_data['definition_name'];
|
||||
|
||||
if ($definition_id == NO_DEFINITION_ID) {
|
||||
try {
|
||||
$definition_values = json_decode($this->request->getPost('definition_values') ?? '', false, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
$definition_values = null;
|
||||
}
|
||||
|
||||
if (!is_array($definition_values) || array_filter($definition_values, static fn ($value) => !is_string($value)) !== []) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => lang('Attributes.definition_error_adding_updating', [$definition_name]),
|
||||
'id' => NEW_ENTRY
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->attribute->saveDefinition($definition_data, $definition_id)) {
|
||||
// New definition
|
||||
if ($definition_id == NO_DEFINITION_ID) {
|
||||
$definition_values = json_decode(html_entity_decode($this->request->getPost('definition_values')));
|
||||
|
||||
foreach ($definition_values as $definition_value) {
|
||||
$this->attribute->saveAttributeValue($definition_value, $definition_data['definition_id']);
|
||||
}
|
||||
@@ -221,7 +248,7 @@ class Attributes extends Secure_Controller
|
||||
private function get_attributes(int $definition_flags = 0): array
|
||||
{
|
||||
$definition_flag_names = [];
|
||||
foreach (Attribute::get_definition_flags() as $id => $term) {
|
||||
foreach (Attribute::getDefinitionFlags() as $id => $term) {
|
||||
if ($id & $definition_flags) {
|
||||
$definition_flag_names[$id] = lang('Attributes.' . strtolower($term) . '_visibility');
|
||||
}
|
||||
@@ -230,26 +257,26 @@ class Attributes extends Secure_Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $definition_id
|
||||
* @param int $definitionId
|
||||
* @return string
|
||||
*/
|
||||
public function getView(int $definition_id = NO_DEFINITION_ID): string
|
||||
public function getView(int $definitionId = NO_DEFINITION_ID): string
|
||||
{
|
||||
$info = $this->attribute->getAttributeInfo($definition_id);
|
||||
$info = $this->attribute->getAttributeInfo($definitionId);
|
||||
foreach (get_object_vars($info) as $property => $value) {
|
||||
$info->$property = $value;
|
||||
}
|
||||
|
||||
$data['definition_id'] = $definition_id;
|
||||
$data['definition_values'] = $this->attribute->get_definition_values($definition_id);
|
||||
$data['definition_group'] = $this->attribute->get_definitions_by_type(GROUP, $definition_id);
|
||||
$data['definition_id'] = $definitionId;
|
||||
$data['definition_values'] = $this->attribute->getDefinitionValues($definitionId);
|
||||
$data['definition_group'] = $this->attribute->getDefinitionsByType(GROUP, $definitionId);
|
||||
$data['definition_group'][''] = lang('Common.none_selected_text');
|
||||
$data['definition_info'] = $info;
|
||||
|
||||
$show_all = Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_RECEIVINGS | Attribute::SHOW_IN_SALES;
|
||||
$data['definition_flags'] = $this->get_attributes($show_all);
|
||||
$selected_flags = $info->definition_flags === '' ? $show_all : $info->definition_flags;
|
||||
$data['selected_definition_flags'] = $this->get_attributes($selected_flags);
|
||||
$showAll = Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_RECEIVINGS | Attribute::SHOW_IN_SALES | Attribute::SHOW_IN_SEARCH;
|
||||
$data['definition_flags'] = $this->get_attributes($showAll);
|
||||
$selectedFlags = $info->definition_flags === '' ? $showAll : $info->definition_flags;
|
||||
$data['selected_definition_flags'] = $this->get_attributes($selectedFlags);
|
||||
|
||||
return view('attributes/form', $data);
|
||||
}
|
||||
|
||||
@@ -211,20 +211,29 @@ class Cashups extends Secure_Controller
|
||||
$close_date = $this->request->getPost('close_date');
|
||||
$close_date_formatter = date_create_from_format($this->config['dateformat'] . ' ' . $this->config['timeformat'], $close_date);
|
||||
|
||||
$open_amount_cash = parse_decimals($this->request->getPost('open_amount_cash'));
|
||||
$transfer_amount_cash = parse_decimals($this->request->getPost('transfer_amount_cash'));
|
||||
$closed_amount_cash = parse_decimals($this->request->getPost('closed_amount_cash'));
|
||||
$closed_amount_due = parse_decimals($this->request->getPost('closed_amount_due'));
|
||||
$closed_amount_card = parse_decimals($this->request->getPost('closed_amount_card'));
|
||||
$closed_amount_check = parse_decimals($this->request->getPost('closed_amount_check'));
|
||||
|
||||
$logged_in_employee = $this->employee->get_logged_in_employee_info();
|
||||
|
||||
$cash_up_data = [
|
||||
'open_date' => $open_date_formatter->format('Y-m-d H:i:s'),
|
||||
'close_date' => $close_date_formatter->format('Y-m-d H:i:s'),
|
||||
'open_amount_cash' => parse_decimals($this->request->getPost('open_amount_cash')),
|
||||
'transfer_amount_cash' => parse_decimals($this->request->getPost('transfer_amount_cash')),
|
||||
'closed_amount_cash' => parse_decimals($this->request->getPost('closed_amount_cash')),
|
||||
'closed_amount_due' => parse_decimals($this->request->getPost('closed_amount_due')),
|
||||
'closed_amount_card' => parse_decimals($this->request->getPost('closed_amount_card')),
|
||||
'closed_amount_check' => parse_decimals($this->request->getPost('closed_amount_check')),
|
||||
'closed_amount_total' => parse_decimals($this->request->getPost('closed_amount_total')),
|
||||
'open_amount_cash' => $open_amount_cash,
|
||||
'transfer_amount_cash' => $transfer_amount_cash,
|
||||
'closed_amount_cash' => $closed_amount_cash,
|
||||
'closed_amount_due' => $closed_amount_due,
|
||||
'closed_amount_card' => $closed_amount_card,
|
||||
'closed_amount_check' => $closed_amount_check,
|
||||
'closed_amount_total' => $this->_calculate_total($open_amount_cash, $transfer_amount_cash, $closed_amount_due, $closed_amount_cash, $closed_amount_card, $closed_amount_check),
|
||||
'note' => $this->request->getPost('note') != null,
|
||||
'description' => $this->request->getPost('description', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
|
||||
'open_employee_id' => $this->request->getPost('open_employee_id', FILTER_SANITIZE_NUMBER_INT),
|
||||
'close_employee_id' => $this->request->getPost('close_employee_id', FILTER_SANITIZE_NUMBER_INT),
|
||||
'open_employee_id' => $logged_in_employee->person_id,
|
||||
'close_employee_id' => $logged_in_employee->person_id,
|
||||
'deleted' => $this->request->getPost('deleted') != null
|
||||
];
|
||||
|
||||
|
||||
+95
-52
@@ -64,7 +64,7 @@ class Config extends Secure_Controller
|
||||
$this->db = Database::connect();
|
||||
|
||||
helper('security');
|
||||
if (check_encryption()) {
|
||||
if (checkEncryption()) {
|
||||
$this->encrypter = Services::encrypter();
|
||||
} else {
|
||||
log_message('alert', 'Error preparing encryption key');
|
||||
@@ -158,14 +158,20 @@ class Config extends Secure_Controller
|
||||
$file = file_get_contents('license/npm-prod.LICENSES');
|
||||
$array = json_decode($file, true);
|
||||
|
||||
foreach ($array as $dependency) {
|
||||
$license[$i]['text'] .= "library: {$dependency['name']}\n";
|
||||
$license[$i]['text'] .= "authors: {$dependency['author']}\n";
|
||||
$license[$i]['text'] .= "website: {$dependency['homepage']}\n";
|
||||
$license[$i]['text'] .= "version: {$dependency['installedVersion']}\n";
|
||||
$license[$i]['text'] .= "license: {$dependency['licenseType']}\n";
|
||||
if (is_array($array)) {
|
||||
foreach ($array as $dependency) {
|
||||
if (!is_array($dependency) || count(array_intersect(['name', 'author', 'homepage', 'installedVersion', 'licenseType'], array_keys($dependency))) !== 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$license[$i]['text'] .= "\n";
|
||||
$license[$i]['text'] .= "library: {$dependency['name']}\n";
|
||||
$license[$i]['text'] .= "authors: {$dependency['author']}\n";
|
||||
$license[$i]['text'] .= "website: {$dependency['homepage']}\n";
|
||||
$license[$i]['text'] .= "version: {$dependency['installedVersion']}\n";
|
||||
$license[$i]['text'] .= "license: {$dependency['licenseType']}\n";
|
||||
|
||||
$license[$i]['text'] .= "\n";
|
||||
}
|
||||
}
|
||||
$license[$i]['text'] = rtrim($license[$i]['text'], "\n");
|
||||
}
|
||||
@@ -178,14 +184,20 @@ class Config extends Secure_Controller
|
||||
$file = file_get_contents('license/npm-dev.LICENSES');
|
||||
$array = json_decode($file, true);
|
||||
|
||||
foreach ($array as $dependency) {
|
||||
$license[$i]['text'] .= "library: {$dependency['name']}\n";
|
||||
$license[$i]['text'] .= "authors: {$dependency['author']}\n";
|
||||
$license[$i]['text'] .= "website: {$dependency['homepage']}\n";
|
||||
$license[$i]['text'] .= "version: {$dependency['installedVersion']}\n";
|
||||
$license[$i]['text'] .= "license: {$dependency['licenseType']}\n";
|
||||
if (is_array($array)) {
|
||||
foreach ($array as $dependency) {
|
||||
if (!is_array($dependency) || count(array_intersect(['name', 'author', 'homepage', 'installedVersion', 'licenseType'], array_keys($dependency))) !== 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$license[$i]['text'] .= "\n";
|
||||
$license[$i]['text'] .= "library: {$dependency['name']}\n";
|
||||
$license[$i]['text'] .= "authors: {$dependency['author']}\n";
|
||||
$license[$i]['text'] .= "website: {$dependency['homepage']}\n";
|
||||
$license[$i]['text'] .= "version: {$dependency['installedVersion']}\n";
|
||||
$license[$i]['text'] .= "license: {$dependency['licenseType']}\n";
|
||||
|
||||
$license[$i]['text'] .= "\n";
|
||||
}
|
||||
}
|
||||
$license[$i]['text'] = rtrim($license[$i]['text'], "\n");
|
||||
}
|
||||
@@ -254,27 +266,29 @@ class Config extends Secure_Controller
|
||||
$data['selected_image_allowed_types'] = explode(',', $this->config['image_allowed_types']);
|
||||
|
||||
// Integrations Related fields
|
||||
$data['mailchimp'] = [];
|
||||
$data['mailchimp'] = [];
|
||||
$data['smtp_pass_set'] = !empty($this->config['smtp_pass']);
|
||||
$data['msg_pwd_set'] = !empty($this->config['msg_pwd']);
|
||||
|
||||
if (check_encryption()) { // TODO: Hungarian notation
|
||||
if (checkEncryption()) { // TODO: Hungarian notation
|
||||
if (!isset($this->encrypter)) {
|
||||
helper('security');
|
||||
$this->encrypter = Services::encrypter();
|
||||
}
|
||||
|
||||
$data['mailchimp']['api_key'] = (isset($this->config['mailchimp_api_key']) && !empty($this->config['mailchimp_api_key']))
|
||||
? $this->encrypter->decrypt($this->config['mailchimp_api_key'])
|
||||
: '';
|
||||
$data['mailchimp']['api_key'] = '';
|
||||
$data['mailchimp']['api_key_set'] = !empty($this->config['mailchimp_api_key']);
|
||||
|
||||
$data['mailchimp']['list_id'] = (isset($this->config['mailchimp_list_id']) && !empty($this->config['mailchimp_list_id']))
|
||||
? $this->encrypter->decrypt($this->config['mailchimp_list_id'])
|
||||
: '';
|
||||
|
||||
// Remove any backup of .env created by check_encryption()
|
||||
remove_backup();
|
||||
removeBackup();
|
||||
} else {
|
||||
$data['mailchimp']['api_key'] = '';
|
||||
$data['mailchimp']['list_id'] = '';
|
||||
$data['mailchimp']['api_key'] = '';
|
||||
$data['mailchimp']['api_key_set'] = false;
|
||||
$data['mailchimp']['list_id'] = '';
|
||||
}
|
||||
|
||||
$data['mailchimp']['lists'] = $this->_mailchimp();
|
||||
@@ -349,10 +363,11 @@ class Config extends Secure_Controller
|
||||
|
||||
$filename = $file->getClientName();
|
||||
$info = pathinfo($filename);
|
||||
helper('security');
|
||||
|
||||
$file_info = [
|
||||
'orig_name' => $filename,
|
||||
'raw_name' => $info['filename'],
|
||||
'raw_name' => sanitize_filename($info['filename']),
|
||||
'file_ext' => $file->guessExtension()
|
||||
];
|
||||
|
||||
@@ -370,6 +385,14 @@ class Config extends Secure_Controller
|
||||
*/
|
||||
public function postSaveGeneral(): ResponseInterface
|
||||
{
|
||||
$rules = [
|
||||
'theme' => 'permit_empty|themeExists',
|
||||
];
|
||||
if (!$this->validate($rules)) {
|
||||
$errors = $this->validator->getErrors();
|
||||
return $this->response->setJSON(['success' => false, 'message' => reset($errors)]);
|
||||
}
|
||||
|
||||
$batchSaveData = [
|
||||
'theme' => $this->request->getPost('theme'),
|
||||
'login_form' => $this->request->getPost('login_form'),
|
||||
@@ -476,14 +499,19 @@ class Config extends Secure_Controller
|
||||
{
|
||||
$rules = [
|
||||
'payment_reference_code_min' => 'required|integer|greater_than[0]',
|
||||
'payment_reference_code_max' => 'required|integer|greater_than_equal_to[payment_reference_code_min]',
|
||||
'payment_reference_code_max' => 'required|integer|gte_field[payment_reference_code_min]',
|
||||
];
|
||||
if (!$this->validate($rules)) {
|
||||
$errors = $this->validator->getErrors();
|
||||
return $this->response->setJSON(['success' => false, 'message' => reset($errors)]);
|
||||
}
|
||||
|
||||
$exploded = explode(":", $this->request->getPost('language'));
|
||||
$language = $this->request->getPost('language');
|
||||
if (!in_array($language, array_keys(get_languages()), true)) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Invalid language']);
|
||||
}
|
||||
|
||||
$exploded = explode(":", $language);
|
||||
$currency_symbol = $this->request->getPost('currency_symbol');
|
||||
$batch_save_data = [
|
||||
'currency_symbol' => htmlspecialchars($currency_symbol ?? ''),
|
||||
@@ -522,25 +550,32 @@ class Config extends Secure_Controller
|
||||
*/
|
||||
public function postSaveEmail(): ResponseInterface
|
||||
{
|
||||
$password = '';
|
||||
$postedPass = (string) $this->request->getPost('smtp_pass');
|
||||
|
||||
if (check_encryption() && !empty($this->request->getPost('smtp_pass'))) {
|
||||
$password = $this->encrypter->encrypt($this->request->getPost('smtp_pass'));
|
||||
if (checkEncryption() && $postedPass !== '') {
|
||||
$password = $this->encrypter->encrypt($postedPass);
|
||||
} else {
|
||||
$password = (string) ($this->config['smtp_pass'] ?? '');
|
||||
}
|
||||
|
||||
$protocol = $this->request->getPost('protocol');
|
||||
$mailpath = $this->request->getPost('mailpath');
|
||||
|
||||
// Validate mailpath: required for sendmail, optional for others but must be safe if provided
|
||||
$isMailpathRequired = ($protocol === 'sendmail');
|
||||
$isMailpathProvided = !empty($mailpath);
|
||||
$isMailpathValid = $isMailpathProvided && preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $mailpath);
|
||||
$rules = [
|
||||
'mailpath' => [
|
||||
'label' => lang('Config.email_mailpath'),
|
||||
'rules' => ($protocol === 'sendmail' ? 'required' : 'permit_empty') . '|valid_path_strict'
|
||||
]
|
||||
];
|
||||
$messages = [
|
||||
'mailpath' => [
|
||||
'required' => lang('Config.mailpath_invalid'),
|
||||
'valid_path_strict' => lang('Config.mailpath_invalid')
|
||||
]
|
||||
];
|
||||
|
||||
if (($isMailpathRequired && !$isMailpathProvided) || ($isMailpathProvided && !$isMailpathValid)) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => lang('Config.mailpath_invalid')
|
||||
]);
|
||||
if ($error = $this->validateFields($rules, $messages)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$batch_save_data = [
|
||||
@@ -568,10 +603,12 @@ class Config extends Secure_Controller
|
||||
*/
|
||||
public function postSaveMessage(): ResponseInterface
|
||||
{
|
||||
$password = '';
|
||||
$postedPwd = (string) $this->request->getPost('msg_pwd');
|
||||
|
||||
if (check_encryption() && !empty($this->request->getPost('msg_pwd'))) {
|
||||
$password = $this->encrypter->encrypt($this->request->getPost('msg_pwd'));
|
||||
if (checkEncryption() && $postedPwd !== '') {
|
||||
$password = $this->encrypter->encrypt($postedPwd);
|
||||
} else {
|
||||
$password = (string) ($this->config['msg_pwd'] ?? '');
|
||||
}
|
||||
|
||||
$batch_save_data = [
|
||||
@@ -634,19 +671,25 @@ class Config extends Secure_Controller
|
||||
*/
|
||||
public function postSaveMailchimp(): ResponseInterface
|
||||
{
|
||||
$api_key = '';
|
||||
$list_id = '';
|
||||
$postedKey = (string) $this->request->getPost('mailchimp_api_key');
|
||||
$postedList = (string) $this->request->getPost('mailchimp_list_id');
|
||||
|
||||
if (check_encryption()) {
|
||||
$api_key_unencrypted = $this->request->getPost('mailchimp_api_key');
|
||||
if (!empty($api_key_unencrypted)) {
|
||||
$api_key = $this->encrypter->encrypt($api_key_unencrypted);
|
||||
}
|
||||
if (checkEncryption()) {
|
||||
$api_key = $postedKey !== ''
|
||||
? $this->encrypter->encrypt($postedKey)
|
||||
: (string) ($this->config['mailchimp_api_key'] ?? '');
|
||||
|
||||
$list_id_unencrypted = $this->request->getPost('mailchimp_list_id');
|
||||
if (!empty($list_id_unencrypted)) {
|
||||
$list_id = $this->encrypter->encrypt($list_id_unencrypted);
|
||||
}
|
||||
$list_id = $postedList !== ''
|
||||
? $this->encrypter->encrypt($postedList)
|
||||
: (string) ($this->config['mailchimp_list_id'] ?? '');
|
||||
} else {
|
||||
$api_key = $postedKey !== ''
|
||||
? (string) $postedKey
|
||||
: (string) ($this->config['mailchimp_api_key'] ?? '');
|
||||
|
||||
$list_id = $postedList !== ''
|
||||
? (string) $postedList
|
||||
: (string) ($this->config['mailchimp_list_id'] ?? '');
|
||||
}
|
||||
|
||||
$batch_save_data = ['mailchimp_api_key' => $api_key, 'mailchimp_list_id' => $list_id];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Module;
|
||||
use CodeIgniter\HTTP\Exceptions\RedirectException;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Services;
|
||||
|
||||
@@ -79,8 +80,7 @@ class Employees extends Persons
|
||||
$current_user = $this->employee->get_logged_in_employee_info();
|
||||
|
||||
if ($employee_id != NEW_ENTRY && !$this->employee->canModifyEmployee($person_info->person_id, $current_user->person_id)) {
|
||||
header('Location: ' . base_url('no_access/employees/employees'));
|
||||
exit();
|
||||
throw new RedirectException('no_access/employees/employees');
|
||||
}
|
||||
|
||||
foreach (get_object_vars($person_info) as $property => $value) {
|
||||
@@ -114,13 +114,13 @@ class Employees extends Persons
|
||||
* Inserts/updates an employee
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function postSave(int $employee_id = NEW_ENTRY): ResponseInterface
|
||||
public function postSave(int $employeeId = NEW_ENTRY): ResponseInterface
|
||||
{
|
||||
$current_user = $this->employee->get_logged_in_employee_info();
|
||||
$currentUser = $this->employee->get_logged_in_employee_info();
|
||||
|
||||
if ($employee_id != NEW_ENTRY) {
|
||||
$target_employee = $this->employee->get_info($employee_id);
|
||||
if (!$this->employee->canModifyEmployee($target_employee->person_id, $current_user->person_id)) {
|
||||
if ($employeeId != NEW_ENTRY) {
|
||||
$targetEmployee = $this->employee->get_info($employeeId);
|
||||
if (!$this->employee->canModifyEmployee($targetEmployee->person_id, $currentUser->person_id)) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => lang('Employees.error_updating_admin'),
|
||||
@@ -129,17 +129,17 @@ class Employees extends Persons
|
||||
}
|
||||
}
|
||||
|
||||
$first_name = $this->request->getPost('first_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS); // TODO: duplicated code
|
||||
$last_name = $this->request->getPost('last_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$firstName = $this->request->getPost('first_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS); // TODO: duplicated code
|
||||
$lastName = $this->request->getPost('last_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$email = strtolower($this->request->getPost('email', FILTER_SANITIZE_EMAIL));
|
||||
|
||||
// format first and last name properly
|
||||
$first_name = $this->nameize($first_name);
|
||||
$last_name = $this->nameize($last_name);
|
||||
$firstName = $this->nameize($firstName);
|
||||
$lastName = $this->nameize($lastName);
|
||||
|
||||
$person_data = [
|
||||
'first_name' => $first_name,
|
||||
'last_name' => $last_name,
|
||||
$personData = [
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
'gender' => $this->request->getPost('gender', FILTER_SANITIZE_NUMBER_INT),
|
||||
'email' => $email,
|
||||
'phone_number' => $this->request->getPost('phone_number', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
|
||||
@@ -152,27 +152,55 @@ class Employees extends Persons
|
||||
'comments' => $this->request->getPost('comments', FILTER_SANITIZE_FULL_SPECIAL_CHARS)
|
||||
];
|
||||
|
||||
$grants_array = [];
|
||||
$isAdmin = $this->employee->isAdmin($current_user->person_id);
|
||||
$grantsArray = [];
|
||||
$isAdmin = $this->employee->isAdmin($currentUser->person_id);
|
||||
|
||||
foreach ($this->module->get_all_permissions()->getResult() as $permission) {
|
||||
$grants = [];
|
||||
$grant = $this->request->getPost('grant_' . $permission->permission_id) != null ? $this->request->getPost('grant_' . $permission->permission_id, FILTER_SANITIZE_FULL_SPECIAL_CHARS) : '';
|
||||
|
||||
if ($grant == $permission->permission_id) {
|
||||
if (!$isAdmin && !$this->employee->has_grant($permission->permission_id, $current_user->person_id)) {
|
||||
if (!$isAdmin && !$this->employee->has_grant($permission->permission_id, $currentUser->person_id)) {
|
||||
continue;
|
||||
}
|
||||
$grants['permission_id'] = $permission->permission_id;
|
||||
$grants['menu_group'] = $this->request->getPost('menu_group_' . $permission->permission_id) != null ? $this->request->getPost('menu_group_' . $permission->permission_id, FILTER_SANITIZE_FULL_SPECIAL_CHARS) : '--';
|
||||
$grants_array[] = $grants;
|
||||
$grantsArray[] = $grants;
|
||||
}
|
||||
}
|
||||
|
||||
$minimumGrants = ['employees', 'home', 'office'];
|
||||
$missingMinimumGrant = array_diff($minimumGrants, array_column($grantsArray, 'permission_id'));
|
||||
|
||||
if ($isAdmin && $employeeId == $currentUser->person_id && !empty($missingMinimumGrant)) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => lang('Employees.error_cannot_remove_own_minimum_grant'),
|
||||
'id' => $employeeId
|
||||
]);
|
||||
}
|
||||
|
||||
if (filter_var(getenv('DISALLOW_GRANT_CHANGE'), FILTER_VALIDATE_BOOLEAN)
|
||||
&& $this->hasGrantsChanged($employeeId, $isAdmin, $currentUser, $grantsArray)) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => lang('Employees.error_grant_change_disallowed'),
|
||||
'id' => $employeeId
|
||||
]);
|
||||
}
|
||||
|
||||
if (!empty($this->request->getPost('password')) && ENVIRONMENT != 'testing' && filter_var(getenv('DISALLOW_PASSWORD_CHANGE'), FILTER_VALIDATE_BOOLEAN)) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => lang('Employees.error_password_change_disallowed'),
|
||||
'id' => $employeeId
|
||||
]);
|
||||
}
|
||||
|
||||
// Password has been changed OR first time password set
|
||||
if (!empty($this->request->getPost('password')) && ENVIRONMENT != 'testing') {
|
||||
$exploded = explode(":", $this->request->getPost('language', FILTER_SANITIZE_FULL_SPECIAL_CHARS));
|
||||
$employee_data = [
|
||||
$employeeData = [
|
||||
'username' => $this->request->getPost('username', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
|
||||
'password' => password_hash($this->request->getPost('password'), PASSWORD_DEFAULT),
|
||||
'hash_version' => 2,
|
||||
@@ -181,42 +209,79 @@ class Employees extends Persons
|
||||
];
|
||||
} else { // Password not changed
|
||||
$exploded = explode(":", $this->request->getPost('language', FILTER_SANITIZE_FULL_SPECIAL_CHARS));
|
||||
$employee_data = [
|
||||
$employeeData = [
|
||||
'username' => $this->request->getPost('username', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
|
||||
'language_code' => $exploded[0],
|
||||
'language' => $exploded[1]
|
||||
];
|
||||
|
||||
// In the testing environment the password above is never persisted
|
||||
// (see the condition on the first branch), yet ospos_employees.password
|
||||
// is NOT NULL. When creating a new employee, supply a placeholder hash
|
||||
// so the insert succeeds and the grant-handling logic under test is
|
||||
// not masked by a constraint failure. Production behavior is unchanged.
|
||||
if (ENVIRONMENT === 'testing' && $employeeId == NEW_ENTRY) {
|
||||
$employeeData['password'] = password_hash('test-placeholder', PASSWORD_DEFAULT);
|
||||
$employeeData['hash_version'] = 2;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->employee->save_employee($person_data, $employee_data, $grants_array, $employee_id)) {
|
||||
if ($this->employee->save_employee($personData, $employeeData, $grantsArray, $employeeId)) {
|
||||
// New employee
|
||||
if ($employee_id == NEW_ENTRY) {
|
||||
if ($employeeId == NEW_ENTRY) {
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'message' => lang('Employees.successful_adding') . ' ' . $first_name . ' ' . $last_name,
|
||||
'id' => $employee_data['person_id']
|
||||
'message' => lang('Employees.successful_adding') . ' ' . $firstName . ' ' . $lastName,
|
||||
'id' => $employeeData['person_id']
|
||||
]);
|
||||
} else { // Existing employee
|
||||
$logged_in_employee_id = session()->get('person_id');
|
||||
if ($employee_id == $logged_in_employee_id) {
|
||||
session()->set('language_code', $employee_data['language_code']);
|
||||
session()->set('language', $employee_data['language']);
|
||||
$loggedInEmployeeId = session()->get('person_id');
|
||||
if ($employeeId == $loggedInEmployeeId) {
|
||||
session()->set('language_code', $employeeData['language_code']);
|
||||
session()->set('language', $employeeData['language']);
|
||||
}
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'message' => lang('Employees.successful_updating') . ' ' . $first_name . ' ' . $last_name,
|
||||
'id' => $employee_id
|
||||
'message' => lang('Employees.successful_updating') . ' ' . $firstName . ' ' . $lastName,
|
||||
'id' => $employeeId
|
||||
]);
|
||||
}
|
||||
} else { // Failure
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => lang('Employees.error_adding_updating') . ' ' . $first_name . ' ' . $last_name,
|
||||
'message' => lang('Employees.error_adding_updating') . ' ' . $firstName . ' ' . $lastName,
|
||||
'id' => NEW_ENTRY
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the submitted grants differ from the employee's current grants,
|
||||
* limited to the permissions the current user has authority over when not an admin.
|
||||
*/
|
||||
private function hasGrantsChanged(int $employeeId, bool $isAdmin, object $currentUser, array $grantsArray): bool
|
||||
{
|
||||
$currentGrantIds = [];
|
||||
|
||||
if ($employeeId != NEW_ENTRY) {
|
||||
$currentGrantIds = array_column($this->employee->get_employee_grants($employeeId), 'permission_id');
|
||||
|
||||
if (!$isAdmin) {
|
||||
$currentGrantIds = array_values(array_filter(
|
||||
$currentGrantIds,
|
||||
fn ($permissionId) => $this->employee->has_grant($permissionId, $currentUser->person_id)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$submittedGrantIds = array_column($grantsArray, 'permission_id');
|
||||
|
||||
sort($currentGrantIds);
|
||||
sort($submittedGrantIds);
|
||||
|
||||
return $currentGrantIds !== $submittedGrantIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* This deletes employees from the employees table
|
||||
* @return ResponseInterface
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Libraries\MY_Migration;
|
||||
use App\Models\Employee;
|
||||
use CodeIgniter\HTTP\RedirectResponse;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
@@ -10,6 +11,13 @@ class Home extends Secure_Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$methodName = urldecode(service('request')->getUri()->getSegment(2));
|
||||
|
||||
if ($methodName === 'logout') {
|
||||
$this->employee = model(Employee::class);
|
||||
return;
|
||||
}
|
||||
|
||||
parent::__construct('home', null, 'home');
|
||||
}
|
||||
|
||||
|
||||
@@ -255,39 +255,39 @@ class Item_kits extends Secure_Controller
|
||||
/**
|
||||
* AJAX called function that generates barcodes for selected item_kits.
|
||||
*
|
||||
* @param string $item_kit_ids Colon separated list of item_kit_id values to generate barcodes for.
|
||||
* @param string $itemKitIds Colon separated list of item_kit_id values to generate barcodes for.
|
||||
* @return string
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function getGenerateBarcodes(string $item_kit_ids): string
|
||||
public function getGenerateBarcodes(string $itemKitIds): string
|
||||
{
|
||||
$barcode_lib = new Barcode_lib();
|
||||
$barcodeLib = new Barcode_lib();
|
||||
$result = [];
|
||||
|
||||
$item_kit_ids = explode(':', $item_kit_ids);
|
||||
foreach ($item_kit_ids as $item_kid_id) {
|
||||
$itemKitIds = explode(':', $itemKitIds);
|
||||
foreach ($itemKitIds as $itemKitId) {
|
||||
// Calculate the total cost and retail price of the Kit, so it can be added to the barcode text at the bottom
|
||||
$item_kit = $this->_add_totals_to_item_kit($this->item_kit->get_info($item_kid_id));
|
||||
$itemKit = $this->_add_totals_to_item_kit($this->item_kit->get_info($itemKitId));
|
||||
|
||||
$item_kid_id = 'KIT ' . urldecode($item_kid_id);
|
||||
$itemKitId = 'KIT ' . $itemKitId;
|
||||
|
||||
$result[] = [
|
||||
'name' => $item_kit->name,
|
||||
'item_id' => $item_kid_id,
|
||||
'item_number' => $item_kid_id,
|
||||
'cost_price' => $item_kit->total_cost_price,
|
||||
'unit_price' => $item_kit->total_unit_price
|
||||
'name' => $itemKit->name,
|
||||
'item_id' => $itemKitId,
|
||||
'item_number' => $itemKitId,
|
||||
'cost_price' => $itemKit->total_cost_price,
|
||||
'unit_price' => $itemKit->total_unit_price
|
||||
];
|
||||
}
|
||||
|
||||
$data['items'] = $result;
|
||||
$barcode_config = $barcode_lib->get_barcode_config();
|
||||
$barcodeConfig = $barcodeLib->get_barcode_config();
|
||||
// In case the selected barcode type is not Code39 or Code128 we set by default Code128
|
||||
// The rationale for this is that EAN codes cannot have strings as seed, so 'KIT ' is not allowed
|
||||
if ($barcode_config['barcode_type'] != 'C39' && $barcode_config['barcode_type'] != 'C128') {
|
||||
$barcode_config['barcode_type'] = 'C128';
|
||||
if ($barcodeConfig['barcode_type'] != 'C39' && $barcodeConfig['barcode_type'] != 'C128') {
|
||||
$barcodeConfig['barcode_type'] = 'C128';
|
||||
}
|
||||
$data['barcode_config'] = $barcode_config;
|
||||
$data['barcode_config'] = $barcodeConfig;
|
||||
|
||||
// Display barcodes
|
||||
return view("barcodes/barcode_sheet", $data);
|
||||
|
||||
+210
-57
@@ -13,9 +13,12 @@ use App\Models\Item_taxes;
|
||||
use App\Models\Stock_location;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\Tax_category;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use CodeIgniter\Images\Handlers\BaseHandler;
|
||||
use CodeIgniter\HTTP\DownloadResponse;
|
||||
use CodeIgniter\Validation\FormatRules;
|
||||
use Config\Database;
|
||||
use Config\OSPOS;
|
||||
use Config\Services;
|
||||
use Exception;
|
||||
@@ -25,6 +28,7 @@ require_once('Secure_Controller.php');
|
||||
|
||||
class Items extends Secure_Controller
|
||||
{
|
||||
private BaseConnection $db;
|
||||
private BaseHandler $image;
|
||||
private Barcode_lib $barcode_lib;
|
||||
private Item_lib $item_lib;
|
||||
@@ -44,6 +48,8 @@ class Items extends Secure_Controller
|
||||
{
|
||||
parent::__construct('items');
|
||||
|
||||
$this->db = Database::connect();
|
||||
|
||||
$this->session = Services::session();
|
||||
|
||||
$this->image = Services::image();
|
||||
@@ -70,7 +76,7 @@ class Items extends Secure_Controller
|
||||
{
|
||||
$this->session->set('allow_temp_items', 0);
|
||||
|
||||
$data['table_headers'] = get_items_manage_table_headers();
|
||||
$data['table_headers'] = getItemsManageTableHeaders();
|
||||
|
||||
// Restore stock_location from URL or session
|
||||
$stockLocation = $this->request->getGet('stock_location', FILTER_SANITIZE_NUMBER_INT);
|
||||
@@ -105,12 +111,14 @@ class Items extends Secure_Controller
|
||||
$search = $this->request->getGet('search', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
|
||||
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
|
||||
$sort = $this->sanitizeSortColumn(item_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'item_id');
|
||||
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
|
||||
$this->item_lib->set_item_location($this->request->getGet('stock_location'));
|
||||
|
||||
$definition_names = $this->attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS);
|
||||
$definitionNamesWithTypes = $this->attribute->getDefinitionsByFlags(Attribute::SHOW_IN_ITEMS, true);
|
||||
$definitionIds = array_keys($definitionNamesWithTypes);
|
||||
|
||||
$sort = $this->sanitizeSortColumn(itemSortColumns($definitionIds), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'items.item_id');
|
||||
|
||||
$filters = [
|
||||
'start_date' => $this->request->getGet('start_date'),
|
||||
@@ -123,25 +131,79 @@ class Items extends Secure_Controller
|
||||
'search_custom' => false,
|
||||
'is_deleted' => false,
|
||||
'temporary' => false,
|
||||
'definition_ids' => array_keys($definition_names)
|
||||
'definition_ids' => $definitionIds
|
||||
];
|
||||
|
||||
// Check if any filter is set in the multiselect dropdown
|
||||
$request_filters = array_fill_keys($this->request->getGet('filters', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? [], true);
|
||||
$filters = array_merge($filters, $request_filters);
|
||||
$items = $this->item->search($search, $filters, $limit, $offset, $sort, $order);
|
||||
$total_rows = $this->item->get_found_rows($search, $filters);
|
||||
$data_rows = [];
|
||||
$requestFilters = array_fill_keys($this->request->getGet('filters', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? [], true);
|
||||
$filters = array_merge($filters, $requestFilters);
|
||||
|
||||
foreach ($items->getResult() as $item) {
|
||||
$data_rows[] = get_item_data_row($item);
|
||||
// When search_custom is enabled, include attributes that are searchable but may not be visible in table
|
||||
if (!empty($filters['search_custom'])) {
|
||||
$searchableDefinitions = $this->attribute->getDefinitionsByFlags(Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_SEARCH);
|
||||
$filters['definition_ids'] = array_keys($searchableDefinitions);
|
||||
}
|
||||
|
||||
$items = $this->item->search($search, $filters, $limit, $offset, $sort, $order);
|
||||
$itemResults = $items->getResult();
|
||||
$totalRows = $this->item->get_found_rows($search, $filters);
|
||||
$taxPercentsByItemId = $this->buildTaxPercentsByItem($itemResults);
|
||||
|
||||
$dataRows = [];
|
||||
|
||||
foreach ($itemResults as $item) {
|
||||
$dataRows[] = getItemDataRow($item, $definitionNamesWithTypes, $taxPercentsByItemId);
|
||||
|
||||
if ($item->pic_filename !== null) {
|
||||
$this->update_pic_filename($item);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]);
|
||||
return $this->response->setJSON(['total' => $totalRows, 'rows' => $dataRows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array item_id => formatted tax percents string
|
||||
*/
|
||||
private function buildTaxPercentsByItem(array $items): array
|
||||
{
|
||||
$config = config(OSPOS::class)->settings;
|
||||
$taxPercentsByItemId = [];
|
||||
|
||||
if ($config['use_destination_based_tax']) {
|
||||
$taxCategoryIds = array_unique(array_filter(array_map(fn ($item) => $item->tax_category_id, $items)));
|
||||
|
||||
if (empty($taxCategoryIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$taxCategoryNames = [];
|
||||
foreach ($this->tax_category->get_multiple_info($taxCategoryIds)->getResult() as $taxCategoryInfo) {
|
||||
$taxCategoryNames[$taxCategoryInfo->tax_category_id] = $taxCategoryInfo->tax_category;
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
if ($item->tax_category_id !== null && isset($taxCategoryNames[$item->tax_category_id])) {
|
||||
$taxPercentsByItemId[$item->item_id] = $taxCategoryNames[$item->tax_category_id];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$itemIds = array_map(fn ($item) => $item->item_id, $items);
|
||||
$itemTaxesByItemId = $this->item_taxes->getInfoMultiple($itemIds);
|
||||
|
||||
foreach ($itemIds as $itemId) {
|
||||
$taxPercents = '';
|
||||
foreach ($itemTaxesByItemId[$itemId] ?? [] as $taxInfo) {
|
||||
$taxPercents .= to_tax_decimals($taxInfo['percent']) . '%, ';
|
||||
}
|
||||
|
||||
// Remove ', ' from last item
|
||||
$taxPercents = substr($taxPercents, 0, -2);
|
||||
$taxPercentsByItemId[$itemId] = !$taxPercents ? '-' : $taxPercents;
|
||||
}
|
||||
}
|
||||
|
||||
return $taxPercentsByItemId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,17 +332,21 @@ class Items extends Secure_Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $item_ids
|
||||
* @param string $itemIds
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function getRow(string $item_ids): ResponseInterface // TODO: An array would be better for parameter.
|
||||
public function getRow(string $itemIds): ResponseInterface // TODO: An array would be better for parameter.
|
||||
{
|
||||
$item_infos = $this->item->get_multiple_info(explode(':', $item_ids), $this->item_lib->get_item_location());
|
||||
$itemInfos = $this->item->get_multiple_info(explode(':', $itemIds), $this->item_lib->get_item_location());
|
||||
$itemResults = $itemInfos->getResult();
|
||||
|
||||
$definitionNames = $this->attribute->getDefinitionsByFlags(Attribute::SHOW_IN_ITEMS, true);
|
||||
$taxPercentsByItemId = $this->buildTaxPercentsByItem($itemResults);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($item_infos->getResult() as $item_info) {
|
||||
$result[$item_info->item_id] = get_item_data_row($item_info);
|
||||
foreach ($itemResults as $itemInfo) {
|
||||
$result[$itemInfo->item_id] = getItemDataRow($itemInfo, $definitionNames, $taxPercentsByItemId);
|
||||
}
|
||||
|
||||
return $this->response->setJSON($result);
|
||||
@@ -304,7 +370,7 @@ class Items extends Secure_Controller
|
||||
$data['default_tax_2_rate'] = '';
|
||||
$data['item_kit_disabled'] = !$this->employee->has_grant('item_kits', $this->employee->get_logged_in_employee_info()->person_id);
|
||||
$data['definition_values'] = $this->attribute->get_attributes_by_item($item_id);
|
||||
$data['definition_names'] = $this->attribute->get_definition_names();
|
||||
$data['definition_names'] = $this->attribute->getDefinitionNames();
|
||||
|
||||
foreach ($data['definition_values'] as $definition_id => $definition) {
|
||||
unset($data['definition_names'][$definition_id]);
|
||||
@@ -320,9 +386,9 @@ class Items extends Secure_Controller
|
||||
|
||||
if ($data['category_dropdown'] === '1') {
|
||||
$categories = ['' => lang('Items.none')];
|
||||
$category_options = $this->attribute->get_definition_values(CATEGORY_DEFINITION_ID);
|
||||
$category_options = array_combine($category_options, $category_options); // Overwrite indexes with values for saving in items table instead of attributes
|
||||
$data['categories'] = array_merge($categories, $category_options);
|
||||
$categoryOptions = $this->attribute->getDefinitionValues(CATEGORY_DEFINITION_ID);
|
||||
$categoryOptions = array_combine($categoryOptions, $categoryOptions); // Overwrite indexes with values for saving in items table instead of attributes
|
||||
$data['categories'] = array_merge($categories, $categoryOptions);
|
||||
|
||||
$data['selected_category'] = $item_info->category;
|
||||
}
|
||||
@@ -518,7 +584,7 @@ class Items extends Secure_Controller
|
||||
$data['item_id'] = $item_id;
|
||||
$definition_ids = json_decode($this->request->getGet('definition_ids') ?? '', true);
|
||||
$data['definition_values'] = $this->attribute->get_attributes_by_item($item_id) + $this->attribute->get_values_by_definitions($definition_ids);
|
||||
$data['definition_names'] = $this->attribute->get_definition_names();
|
||||
$data['definition_names'] = $this->attribute->getDefinitionNames();
|
||||
|
||||
foreach ($data['definition_values'] as $definition_id => $definition_value) {
|
||||
$attribute_value = $this->attribute->getAttributeValue($item_id, $definition_id);
|
||||
@@ -529,7 +595,7 @@ class Items extends Secure_Controller
|
||||
$values['selected_value'] = '';
|
||||
|
||||
if ($definition_value['definition_type'] === DROPDOWN) {
|
||||
$values['values'] = $this->attribute->get_definition_values($definition_id);
|
||||
$values['values'] = $this->attribute->getDefinitionValues($definition_id);
|
||||
$link_value = $this->attribute->get_link_value($item_id, $definition_id);
|
||||
$values['selected_value'] = (empty($link_value)) ? '' : $link_value->attribute_id;
|
||||
}
|
||||
@@ -554,7 +620,7 @@ class Items extends Secure_Controller
|
||||
$data['item_id'] = $item_id;
|
||||
$definition_ids = json_decode($this->request->getPost('definition_ids'), true);
|
||||
$data['definition_values'] = $this->attribute->get_attributes_by_item($item_id) + $this->attribute->get_values_by_definitions($definition_ids);
|
||||
$data['definition_names'] = $this->attribute->get_definition_names();
|
||||
$data['definition_names'] = $this->attribute->getDefinitionNames();
|
||||
|
||||
foreach ($data['definition_values'] as $definition_id => $definition_value) {
|
||||
$attribute_value = $this->attribute->getAttributeValue($item_id, $definition_id);
|
||||
@@ -565,7 +631,7 @@ class Items extends Secure_Controller
|
||||
$values['selected_value'] = '';
|
||||
|
||||
if ($definition_value['definition_type'] === DROPDOWN) {
|
||||
$values['values'] = $this->attribute->get_definition_values($definition_id);
|
||||
$values['values'] = $this->attribute->getDefinitionValues($definition_id);
|
||||
$link_value = $this->attribute->get_link_value($item_id, $definition_id);
|
||||
$values['selected_value'] = (empty($link_value)) ? '' : $link_value->attribute_id;
|
||||
}
|
||||
@@ -588,7 +654,10 @@ class Items extends Secure_Controller
|
||||
*/
|
||||
public function getBulkEdit(): string
|
||||
{
|
||||
$suppliers = ['' => lang('Items.none')];
|
||||
$suppliers = [
|
||||
'' => lang('Items.do_nothing'),
|
||||
Item::CLEAR_SUPPLIER_OPTION => lang('Items.none')
|
||||
];
|
||||
|
||||
foreach ($this->supplier->get_all()->getResultArray() as $row) {
|
||||
$suppliers[$row['person_id']] = $row['company_name'];
|
||||
@@ -610,6 +679,70 @@ class Items extends Secure_Controller
|
||||
return view('items/form_bulk', $data);
|
||||
}
|
||||
|
||||
private function validateItemFields(int $itemId): ?ResponseInterface
|
||||
{
|
||||
$itemNumber = $this->request->getPost('item_number');
|
||||
|
||||
if (!empty($itemNumber)) {
|
||||
$rules = [
|
||||
'item_number' => 'alpha_numeric_punct',
|
||||
];
|
||||
$messages = [
|
||||
'item_number' => [
|
||||
'alpha_numeric_punct' => lang('Items.item_number_invalid'),
|
||||
],
|
||||
];
|
||||
|
||||
$error = $this->validateFields($rules, $messages, $itemId);
|
||||
if ($error !== null) {
|
||||
return $error;
|
||||
}
|
||||
}
|
||||
|
||||
$taxNamesInput = $this->request->getPost('tax_names');
|
||||
|
||||
if (!empty($taxNamesInput)) {
|
||||
$rules = [
|
||||
'tax_names.*' => 'required|max_length[255]|unicode_alpha_numeric_punct',
|
||||
];
|
||||
$messages = [
|
||||
'tax_names.*' => [
|
||||
'required' => lang('Items.tax_name_invalid'),
|
||||
'max_length' => lang('Items.tax_name_invalid'),
|
||||
'unicode_alpha_numeric_punct' => lang('Items.tax_name_invalid'),
|
||||
],
|
||||
];
|
||||
|
||||
$error = $this->validateFields($rules, $messages, $itemId);
|
||||
if ($error !== null) {
|
||||
return $error;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function validateBulkUpdateFields(): ?ResponseInterface
|
||||
{
|
||||
$taxNamesInput = $this->request->getPost('tax_names');
|
||||
|
||||
if (!empty($taxNamesInput)) {
|
||||
$rules = [
|
||||
'tax_names.*' => 'max_length[255]|unicode_alpha_numeric_punct',
|
||||
];
|
||||
$messages = [
|
||||
'tax_names.*' => [
|
||||
'max_length' => lang('Items.tax_name_invalid'),
|
||||
'unicode_alpha_numeric_punct' => lang('Items.tax_name_invalid'),
|
||||
],
|
||||
];
|
||||
|
||||
return $this->validateFields($rules, $messages, NEW_ENTRY);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $itemId
|
||||
* @return ResponseInterface
|
||||
@@ -617,6 +750,12 @@ class Items extends Secure_Controller
|
||||
*/
|
||||
public function postSave(int $itemId = NEW_ENTRY): ResponseInterface
|
||||
{
|
||||
$validationError = $this->validateItemFields($itemId);
|
||||
|
||||
if ($validationError !== null) {
|
||||
return $validationError;
|
||||
}
|
||||
|
||||
$uploadData = $this->upload_image();
|
||||
$uploadSuccess = empty($uploadData['error']);
|
||||
|
||||
@@ -679,7 +818,8 @@ class Items extends Secure_Controller
|
||||
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
|
||||
// Wrap the entire save sequence in a single transaction for atomicity
|
||||
$this->db->transBegin();
|
||||
$db = db_connect();
|
||||
$db->transBegin();
|
||||
|
||||
$success = $this->item->save_value($itemData, $itemId);
|
||||
$newItem = false;
|
||||
@@ -748,14 +888,14 @@ class Items extends Secure_Controller
|
||||
|
||||
// Check all success conditions before committing
|
||||
if ($success && $uploadSuccess) {
|
||||
$this->db->transCommit();
|
||||
$db->transCommit();
|
||||
$message = lang('Items.successful_' . ($newItem ? 'adding' : 'updating')) . ' ' . $itemData['name'];
|
||||
|
||||
return $this->response->setJSON(['success' => true, 'message' => $message, 'id' => $itemId]);
|
||||
}
|
||||
|
||||
// Rollback on failure
|
||||
$this->db->transRollback();
|
||||
$db->transRollback();
|
||||
$message = $uploadSuccess ? lang('Items.error_adding_updating') . ' ' . $itemData['name'] : strip_tags($uploadData['error']);
|
||||
|
||||
return $this->response->setJSON(['success' => false, 'message' => $message, 'id' => $itemId]);
|
||||
@@ -897,37 +1037,34 @@ class Items extends Secure_Controller
|
||||
*/
|
||||
public function postBulkUpdate(): ResponseInterface
|
||||
{
|
||||
$items_to_update = $this->request->getPost('item_ids');
|
||||
$item_data = [];
|
||||
$validationError = $this->validateBulkUpdateFields();
|
||||
|
||||
foreach (Item::ALLOWED_BULK_EDIT_FIELDS as $field) {
|
||||
$value = $this->request->getPost($field);
|
||||
if ($field === 'supplier_id' && $value !== '') {
|
||||
$item_data[$field] = $value;
|
||||
} elseif ($value !== null && $value !== '') {
|
||||
$item_data[$field] = $value;
|
||||
}
|
||||
if ($validationError !== null) {
|
||||
return $validationError;
|
||||
}
|
||||
|
||||
// Item data could be empty if tax information is being updated
|
||||
if (empty($item_data) || $this->item->update_multiple($item_data, $items_to_update)) {
|
||||
$items_taxes_data = [];
|
||||
$tax_names = $this->request->getPost('tax_names');
|
||||
$tax_percents = $this->request->getPost('tax_percents');
|
||||
$tax_updated = false;
|
||||
$itemsToUpdate = $this->request->getPost('item_ids');
|
||||
$itemData = Item::filterBulkEditFields($this->request->getPost() ?? []);
|
||||
|
||||
foreach ($tax_percents as $tax_percent) {
|
||||
if (!empty($tax_names[$tax_percent]) && is_numeric($tax_percents[$tax_percent])) {
|
||||
$tax_updated = true;
|
||||
$items_taxes_data[] = ['name' => $tax_names[$tax_percent], 'percent' => $tax_percents[$tax_percent]];
|
||||
// Item data could be empty if tax information is being updated
|
||||
if (empty($itemData) || $this->item->updateMultiple($itemData, $itemsToUpdate)) {
|
||||
$itemsTaxesData = [];
|
||||
$taxNames = $this->request->getPost('tax_names');
|
||||
$taxPercents = $this->request->getPost('tax_percents');
|
||||
$taxUpdated = false;
|
||||
|
||||
foreach ($taxPercents as $tax_percent) {
|
||||
if (!empty($taxNames[$tax_percent]) && is_numeric($taxPercents[$tax_percent])) {
|
||||
$taxUpdated = true;
|
||||
$itemsTaxesData[] = ['name' => $taxNames[$tax_percent], 'percent' => $taxPercents[$tax_percent]];
|
||||
}
|
||||
}
|
||||
|
||||
if ($tax_updated) {
|
||||
$this->item_taxes->save_multiple($items_taxes_data, $items_to_update);
|
||||
if ($taxUpdated) {
|
||||
$this->item_taxes->save_multiple($itemsTaxesData, $itemsToUpdate);
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['success' => true, 'message' => lang('Items.successful_bulk_edit'), 'id' => $items_to_update]);
|
||||
return $this->response->setJSON(['success' => true, 'message' => lang('Items.successful_bulk_edit'), 'id' => $itemsToUpdate]);
|
||||
} else {
|
||||
return $this->response->setJSON(['success' => false, 'message' => lang('Items.error_updating_multiple')]);
|
||||
}
|
||||
@@ -959,8 +1096,8 @@ class Items extends Secure_Controller
|
||||
helper('importfile');
|
||||
$name = 'import_items.csv';
|
||||
$allowed_locations = $this->stock_location->get_allowed_locations();
|
||||
$allowed_attributes = $this->attribute->get_definition_names();
|
||||
$data = generate_import_items_csv($allowed_locations, $allowed_attributes);
|
||||
$allowedAttributes = $this->attribute->getDefinitionNames();
|
||||
$data = generate_import_items_csv($allowed_locations, $allowedAttributes);
|
||||
|
||||
return $this->response->download($name, $data);
|
||||
}
|
||||
@@ -991,19 +1128,25 @@ class Items extends Secure_Controller
|
||||
|
||||
$failCodes = [];
|
||||
$csvRows = get_csv_file($_FILES['file_path']['tmp_name']);
|
||||
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
$allowedStockLocations = $this->stock_location->get_allowed_locations();
|
||||
$attributeDefinitionNames = $this->attribute->get_definition_names();
|
||||
$attributeDefinitionNames = $this->attribute->getDefinitionNames();
|
||||
|
||||
if (!csvImportHasRequiredItemHeaders($csvRows, $allowedStockLocations, $attributeDefinitionNames)) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => lang('Items.csv_import_nodata_wrongformat')]);
|
||||
}
|
||||
|
||||
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
|
||||
unset($attributeDefinitionNames[NEW_ENTRY]); // Removes the common_none_selected_text from the array
|
||||
|
||||
$attributeData = [];
|
||||
|
||||
|
||||
foreach ($attributeDefinitionNames as $definitionName) {
|
||||
$attributeData[$definitionName] = $this->attribute->get_definition_by_name($definitionName)[0];
|
||||
$attributeData[$definitionName] = $this->attribute->getDefinitionByName($definitionName);
|
||||
|
||||
if ($attributeData[$definitionName]['definition_type'] === DROPDOWN) {
|
||||
$attributeData[$definitionName]['dropdown_values'] = $this->attribute->get_definition_values($attributeData[$definitionName]['definition_id']);
|
||||
$attributeData[$definitionName]['dropdown_values'] = $this->attribute->getDefinitionValues($attributeData[$definitionName]['definition_id']);
|
||||
}
|
||||
}
|
||||
$db = db_connect();
|
||||
@@ -1207,6 +1350,16 @@ class Items extends Secure_Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Check item_number for disallowed characters
|
||||
if (!empty($itemData['item_number'])) {
|
||||
$formatRules = new FormatRules();
|
||||
|
||||
if (!$formatRules->alpha_numeric_punct($itemData['item_number'])) {
|
||||
log_message('error', "invalid item_number: '{$itemData['item_number']}' contains disallowed characters");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check stock locations
|
||||
$invalidLocations = $this->validateCSVStockLocations($row, $allowedStockLocations);
|
||||
if (!empty($invalidLocations)) {
|
||||
|
||||
@@ -76,7 +76,7 @@ class Login extends BaseController
|
||||
];
|
||||
|
||||
if (!$this->validate($rules, $messages)) {
|
||||
$data['has_errors'] = !empty($validation->getErrors());
|
||||
$data['hasErrors'] = !empty($validation->getErrors());
|
||||
|
||||
return view('login', $data);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Employee;
|
||||
use App\Models\Module;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\OSPOS;
|
||||
|
||||
/**
|
||||
* Part of the grants mechanism to restrict access to modules that the user doesn't have permission for.
|
||||
@@ -13,10 +15,12 @@ use CodeIgniter\HTTP\ResponseInterface;
|
||||
*/
|
||||
class No_access extends BaseController
|
||||
{
|
||||
private Employee $employee;
|
||||
private Module $module;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->employee = model(Employee::class);
|
||||
$this->module = model(Module::class);
|
||||
}
|
||||
|
||||
@@ -30,6 +34,20 @@ class No_access extends BaseController
|
||||
$data['module_name'] = $this->module->get_module_name($module_id);
|
||||
$data['permission_id'] = $permission_id;
|
||||
|
||||
return view('no_access', $data);
|
||||
$userInfo = $this->employee->get_logged_in_employee_info();
|
||||
if ($userInfo === false || $this->request->isAJAX()) {
|
||||
return view('no_access', $data);
|
||||
}
|
||||
|
||||
$menuGroup = session()->get('menu_group');
|
||||
$allowedModules = $menuGroup == 'home'
|
||||
? $this->module->get_allowed_home_modules($userInfo->person_id)
|
||||
: $this->module->get_allowed_office_modules($userInfo->person_id);
|
||||
|
||||
$data['user_info'] = $userInfo;
|
||||
$data['allowed_modules'] = $allowedModules->getResult();
|
||||
$data['config'] = config(OSPOS::class)->settings;
|
||||
|
||||
return view('partial/header', $data) . view('no_access', $data) . view('partial/footer');
|
||||
}
|
||||
}
|
||||
+13
-11
@@ -25,6 +25,7 @@ use App\Models\Reports\Summary_sales;
|
||||
use App\Models\Reports\Summary_sales_taxes;
|
||||
use App\Models\Reports\Summary_suppliers;
|
||||
use App\Models\Reports\Summary_taxes;
|
||||
use CodeIgniter\HTTP\Exceptions\RedirectException;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\OSPOS;
|
||||
use Config\Services;
|
||||
@@ -55,8 +56,8 @@ class Reports extends Secure_Controller
|
||||
{
|
||||
parent::__construct('reports');
|
||||
$request = Services::request();
|
||||
$method_name = $request->getUri()->getSegment(2);
|
||||
$exploder = explode('_', $method_name);
|
||||
$methodName = urldecode($request->getUri()->getSegment(2));
|
||||
$exploder = explode('_', $methodName);
|
||||
|
||||
$this->attribute = config(Attribute::class);
|
||||
$this->config = config(OSPOS::class)->settings;
|
||||
@@ -79,15 +80,16 @@ class Reports extends Secure_Controller
|
||||
$this->inventory_summary = model(Inventory_summary::class);
|
||||
|
||||
if (sizeof($exploder) > 1) {
|
||||
preg_match('/(?:inventory)|([^_.]*)(?:_graph|_row)?$/', $method_name, $matches);
|
||||
preg_match('/(?:inventory)|([^_.]*)(?:_graph|_row)?$/', $methodName, $matches);
|
||||
preg_match('/^(.*?)([sy])?$/', array_pop($matches), $matches);
|
||||
$submodule_id = $matches[1] . ((count($matches) > 2) ? $matches[2] : 's');
|
||||
$submoduleId = $matches[1] . ((count($matches) > 2) ? $matches[2] : 's');
|
||||
} else {
|
||||
$submoduleId = null;
|
||||
}
|
||||
|
||||
// Check access to report submodule
|
||||
if (!$this->employee->has_grant('reports_' . $submodule_id, $this->employee->get_logged_in_employee_info()->person_id)) {
|
||||
header('Location: ' . base_url('no_access/reports/reports_' . $submodule_id));
|
||||
exit();
|
||||
}
|
||||
// Check access to report submodule
|
||||
if ($submoduleId !== null && !$this->employee->has_grant('reports_' . $submoduleId, $this->employee->get_logged_in_employee_info()->person_id)) {
|
||||
throw new RedirectException('no_access/reports/reports_' . $submoduleId);
|
||||
}
|
||||
|
||||
helper('report');
|
||||
@@ -1778,7 +1780,7 @@ class Reports extends Secure_Controller
|
||||
{
|
||||
$this->clearCache();
|
||||
|
||||
$definition_names = $this->attribute->get_definitions_by_flags(attribute::SHOW_IN_SALES, true);
|
||||
$definition_names = $this->attribute->getDefinitionsByFlags(attribute::SHOW_IN_SALES, true);
|
||||
|
||||
$inputs = [
|
||||
'start_date' => $start_date,
|
||||
@@ -1937,7 +1939,7 @@ class Reports extends Secure_Controller
|
||||
{
|
||||
$this->clearCache();
|
||||
|
||||
$definition_names = $this->attribute->get_definitions_by_flags(attribute::SHOW_IN_RECEIVINGS, true);
|
||||
$definition_names = $this->attribute->getDefinitionsByFlags(attribute::SHOW_IN_RECEIVINGS, true);
|
||||
|
||||
$inputs = ['start_date' => $start_date, 'end_date' => $end_date, 'receiving_type' => $receiving_type, 'location_id' => $location_id, 'definition_ids' => array_keys($definition_names)];
|
||||
|
||||
|
||||
+340
-202
@@ -125,26 +125,38 @@ class Sales extends Secure_Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $row_id
|
||||
* @param int $rowId
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function getRow(int $row_id): ResponseInterface
|
||||
public function getRow(int $rowId): ResponseInterface
|
||||
{
|
||||
$sale_info = $this->sale->get_info($row_id)->getRow();
|
||||
$data_row = get_sale_data_row($sale_info);
|
||||
$personId = $this->session->get('person_id');
|
||||
|
||||
return $this->response->setJSON($data_row);
|
||||
if (!$this->employee->has_grant('reports_sales', $personId)) {
|
||||
return $this->response->setStatusCode(403)->setJSON(['success' => false, 'message' => lang('Sales.not_authorized')]);
|
||||
}
|
||||
|
||||
$saleInfo = $this->sale->get_info($rowId)->getRow();
|
||||
$dataRow = getSaleDataRow($saleInfo);
|
||||
|
||||
return $this->response->setJSON($dataRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function getSearch(): ResponseInterface
|
||||
{
|
||||
$personId = $this->session->get('person_id');
|
||||
|
||||
if (!$this->employee->has_grant('reports_sales', $personId)) {
|
||||
return $this->response->setStatusCode(403)->setJSON(['success' => false, 'message' => lang('Sales.not_authorized')]);
|
||||
}
|
||||
|
||||
$search = $this->request->getGet('search', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
|
||||
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
|
||||
$sort = $this->sanitizeSortColumn(sales_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'sale_id');
|
||||
$sort = $this->sanitizeSortColumn(salesHeaders(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'sale_id');
|
||||
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
|
||||
$filters = [
|
||||
@@ -165,24 +177,24 @@ class Sales extends Secure_Controller
|
||||
];
|
||||
|
||||
// Check if any filter is set in the multiselect dropdown
|
||||
$request_filters = array_fill_keys($this->request->getGet('filters', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? [], true);
|
||||
$filters = array_merge($filters, $request_filters);
|
||||
$requestFilters = array_fill_keys($this->request->getGet('filters', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? [], true);
|
||||
$filters = array_merge($filters, $requestFilters);
|
||||
|
||||
$sales = $this->sale->search($search, $filters, $limit, $offset, $sort, $order);
|
||||
$total_rows = $this->sale->get_found_rows($search, $filters);
|
||||
$payments = $this->sale->get_payments_summary($search, $filters);
|
||||
$payment_summary = get_sales_manage_payments_summary($payments);
|
||||
$totalRows = $this->sale->get_found_rows($search, $filters);
|
||||
$payments = $this->sale->getPaymentsSummary($search, $filters);
|
||||
$paymentSummary = getSalesManagePaymentsSummary($payments);
|
||||
|
||||
$data_rows = [];
|
||||
$dataRows = [];
|
||||
foreach ($sales->getResult() as $sale) {
|
||||
$data_rows[] = get_sale_data_row($sale);
|
||||
$dataRows[] = getSaleDataRow($sale);
|
||||
}
|
||||
|
||||
if ($total_rows > 0) {
|
||||
$data_rows[] = get_sale_data_last_row($sales);
|
||||
if ($totalRows > 0) {
|
||||
$dataRows[] = getSaleDataLastRow($sales);
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows, 'payment_summary' => $payment_summary]);
|
||||
return $this->response->setJSON(['total' => $totalRows, 'rows' => $dataRows, 'payment_summary' => $paymentSummary]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,19 +408,40 @@ class Sales extends Secure_Controller
|
||||
$giftcard = model(Giftcard::class);
|
||||
$paymentType = $this->request->getPost('payment_type', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
|
||||
if (!is_string($paymentType) || $paymentType === '') {
|
||||
$data['error'] = lang('Sales.must_enter_numeric');
|
||||
|
||||
return $this->reload($data);
|
||||
}
|
||||
|
||||
if (
|
||||
$paymentType !== lang('Sales.giftcard')
|
||||
&& $paymentType !== lang('Sales.rewards')
|
||||
&& (str_contains($paymentType, lang('Sales.giftcard')) || str_contains($paymentType, lang('Sales.rewards')))
|
||||
) {
|
||||
$data['error'] = lang('Sales.must_enter_numeric');
|
||||
|
||||
return $this->reload($data);
|
||||
}
|
||||
|
||||
$isReturnMode = $this->sale_lib->get_mode() === 'return';
|
||||
$amountTenderedRule = $isReturnMode ? 'trim|required|decimal_locale' : 'trim|required|decimal_locale|nonNegativeDecimal';
|
||||
|
||||
if ($paymentType === lang('Sales.giftcard')) {
|
||||
$rules = ['amount_tendered' => 'trim|required|integer']; //For giftcards, amount_tendered becomes the giftcard number which must be an integer
|
||||
$rules = ['amount_tendered' => 'trim|required|integer'];
|
||||
$messages = ['amount_tendered' => lang('Sales.must_enter_numeric_giftcard')];
|
||||
} elseif (in_array($paymentType, get_reference_code_payment_types())) {
|
||||
$min = (int)($this->config['payment_reference_code_min'] ?? 3);
|
||||
$max = (int)($this->config['payment_reference_code_max'] ?? 20);
|
||||
$rules = [
|
||||
'amount_tendered' => 'trim|required|decimal_locale',
|
||||
'amount_tendered' => $amountTenderedRule,
|
||||
'reference_code' => "trim|required|alpha_numeric|min_length[$min]|max_length[$max]",
|
||||
];
|
||||
$messages = [
|
||||
'amount_tendered' => [
|
||||
'required' => lang('Sales.must_enter_numeric'),
|
||||
'required' => lang('Sales.must_enter_numeric'),
|
||||
'decimal_locale' => lang('Sales.must_enter_numeric'),
|
||||
'nonNegativeDecimal' => lang('Sales.negative_amount_tendered'),
|
||||
],
|
||||
'reference_code' => [
|
||||
'required' => lang('Sales.must_enter_reference_code'),
|
||||
@@ -418,8 +451,14 @@ class Sales extends Secure_Controller
|
||||
],
|
||||
];
|
||||
} else {
|
||||
$rules = ['amount_tendered' => 'trim|required|decimal_locale'];
|
||||
$messages = ['amount_tendered' => lang('Sales.must_enter_numeric')];
|
||||
$rules = ['amount_tendered' => $amountTenderedRule];
|
||||
$messages = [
|
||||
'amount_tendered' => [
|
||||
'required' => lang('Sales.must_enter_numeric'),
|
||||
'decimal_locale' => lang('Sales.must_enter_numeric'),
|
||||
'nonNegativeDecimal' => lang('Sales.negative_amount_tendered'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if (!$this->validate($rules, $messages)) {
|
||||
@@ -622,6 +661,7 @@ class Sales extends Secure_Controller
|
||||
$description = $this->request->getPost('description', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$serialnumber = $this->request->getPost('serialnumber', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$price = parse_decimals($this->request->getPost('price'));
|
||||
$price = $price !== false ? number_format((float) $price, totals_decimals(), '.', '') : $price;
|
||||
$quantity = parse_decimals($this->request->getPost('quantity'));
|
||||
$discount_type = $this->request->getPost('discount_type', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$discount = $discount_type
|
||||
@@ -647,6 +687,14 @@ class Sales extends Secure_Controller
|
||||
return $this->reload($data);
|
||||
}
|
||||
|
||||
// sales_change_price grant is enforced server-side here; the UI-only "change_price" flag must not be trusted
|
||||
$current_price = $this->sale_lib->get_cart()[$line]['price'] ?? null;
|
||||
$employee_id = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
if ($current_price !== null && bccomp((string)$price, (string)$current_price, $precision) != 0 && !$this->employee->has_grant('sales_change_price', $employee_id)) {
|
||||
$data['error'] = lang('Sales.not_authorized');
|
||||
return $this->reload($data);
|
||||
}
|
||||
|
||||
$item_location = $this->request->getPost('location', FILTER_SANITIZE_NUMBER_INT);
|
||||
$discounted_total = $this->request->getPost('discounted_total') != ''
|
||||
? parse_decimals($this->request->getPost('discounted_total') ?? '')
|
||||
@@ -709,21 +757,21 @@ class Sales extends Secure_Controller
|
||||
*/
|
||||
public function postComplete(): string // TODO: this function is huge. Probably should be refactored.
|
||||
{
|
||||
$sale_id = $this->sale_lib->get_sale_id();
|
||||
$saleId = $this->sale_lib->get_sale_id();
|
||||
$data = [];
|
||||
$data['dinner_table'] = $this->sale_lib->get_dinner_table();
|
||||
|
||||
$data['cart'] = $this->sale_lib->get_cart();
|
||||
|
||||
$data['include_hsn'] = (bool)$this->config['include_hsn'];
|
||||
$__time = time();
|
||||
$data['transaction_time'] = to_datetime($__time);
|
||||
$data['transaction_date'] = to_date($__time);
|
||||
$time = time();
|
||||
$data['transaction_time'] = to_datetime($time);
|
||||
$data['transaction_date'] = to_date($time);
|
||||
$data['show_stock_locations'] = $this->stock_location->show_locations('sales');
|
||||
$data['comments'] = $this->sale_lib->get_comment();
|
||||
$employee_id = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
$employee_info = $this->employee->get_info($employee_id);
|
||||
$data['employee'] = $employee_info->first_name . ' ' . mb_substr($employee_info->last_name, 0, 1);
|
||||
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
$employeeInfo = $this->employee->get_info($employeeId);
|
||||
$data['employee'] = $employeeInfo->first_name . ' ' . mb_substr($employeeInfo->last_name, 0, 1);
|
||||
|
||||
$data['company_info'] = implode("\n", [$this->config['address'], $this->config['phone']]);
|
||||
|
||||
@@ -741,26 +789,26 @@ class Sales extends Secure_Controller
|
||||
$data['print_after_sale'] = $this->session->get('sales_print_after_sale');
|
||||
$data['price_work_orders'] = $this->sale_lib->is_price_work_orders();
|
||||
$data['email_receipt'] = $this->sale_lib->is_email_receipt();
|
||||
$customer_id = $this->sale_lib->get_customer();
|
||||
$invoice_number = $this->sale_lib->get_invoice_number();
|
||||
$data["invoice_number"] = $invoice_number;
|
||||
$work_order_number = $this->sale_lib->get_work_order_number();
|
||||
$data["work_order_number"] = $work_order_number;
|
||||
$quote_number = $this->sale_lib->get_quote_number();
|
||||
$data["quote_number"] = $quote_number;
|
||||
$customer_info = $this->_load_customer_data($customer_id, $data);
|
||||
$customerId = $this->sale_lib->get_customer();
|
||||
$invoiceNumber = $this->sale_lib->get_invoice_number();
|
||||
$data["invoice_number"] = $invoiceNumber;
|
||||
$workOrderNumber = $this->sale_lib->get_work_order_number();
|
||||
$data["work_order_number"] = $workOrderNumber;
|
||||
$quoteNumber = $this->sale_lib->get_quote_number();
|
||||
$data["quote_number"] = $quoteNumber;
|
||||
$customerInfo = $this->_load_customer_data($customerId, $data);
|
||||
|
||||
if ($customer_info != null) {
|
||||
$data["customer_comments"] = $customer_info->comments;
|
||||
$data['tax_id'] = $customer_info->tax_id;
|
||||
if ($customerInfo != null) {
|
||||
$data["customer_comments"] = $customerInfo->comments;
|
||||
$data['tax_id'] = $customerInfo->tax_id;
|
||||
}
|
||||
$tax_details = $this->tax_lib->get_taxes($data['cart']); // TODO: Duplicated code
|
||||
$data['taxes'] = $tax_details[0];
|
||||
$taxDetails = $this->tax_lib->get_taxes($data['cart']); // TODO: Duplicated code
|
||||
$data['taxes'] = $taxDetails[0];
|
||||
$data['discount'] = $this->sale_lib->get_discount();
|
||||
$data['payments'] = $this->sale_lib->getPayments();
|
||||
|
||||
// Returns 'subtotal', 'total', 'cash_total', 'payment_total', 'amount_due', 'cash_amount_due', 'payments_cover_total'
|
||||
$totals = $this->sale_lib->get_totals($tax_details[0]);
|
||||
$totals = $this->sale_lib->get_totals($taxDetails[0]);
|
||||
$data['subtotal'] = $totals['subtotal'];
|
||||
$data['total'] = $totals['total'];
|
||||
$data['payments_total'] = $totals['payment_total'];
|
||||
@@ -779,6 +827,11 @@ class Sales extends Secure_Controller
|
||||
return $this->reload($data);
|
||||
}
|
||||
|
||||
if (!$totals['payments_cover_total'] && !$this->sale_lib->is_invoice_mode() && !$this->sale_lib->is_quote_mode()) {
|
||||
$data['error'] = lang('Sales.amount_due_not_covered');
|
||||
return $this->reload($data);
|
||||
}
|
||||
|
||||
if ($data['cash_mode']) { // TODO: Convert this to ternary notation
|
||||
$data['amount_due'] = $totals['cash_amount_due'];
|
||||
} else {
|
||||
@@ -808,43 +861,49 @@ class Sales extends Secure_Controller
|
||||
$data['print_price_info'] = true;
|
||||
|
||||
if ($this->sale_lib->is_invoice_mode()) {
|
||||
$invoice_format = $this->config['sales_invoice_format'];
|
||||
$invoiceFormat = $this->config['sales_invoice_format'];
|
||||
|
||||
// Generate final invoice number (if using the invoice in sales by receipt mode then the invoice number can be manually entered or altered in some way
|
||||
if (!empty($invoice_format) && $invoice_number == null) {
|
||||
if (!empty($invoiceFormat) && $invoiceNumber == null) {
|
||||
// The user can retain the default encoded format or can manually override it. It still passes through the rendering step.
|
||||
$invoice_number = $this->token_lib->render($invoice_format);
|
||||
$invoiceNumber = $this->token_lib->render($invoiceFormat);
|
||||
}
|
||||
|
||||
|
||||
if ($sale_id == NEW_ENTRY && $this->sale->check_invoice_number_exists($invoice_number)) {
|
||||
$data['error'] = lang('Sales.invoice_number_duplicate', [$invoice_number]);
|
||||
if ($saleId == NEW_ENTRY && $this->sale->check_invoice_number_exists($invoiceNumber)) {
|
||||
$data['error'] = lang('Sales.invoice_number_duplicate', [$invoiceNumber]);
|
||||
return $this->reload($data);
|
||||
} else {
|
||||
$data['invoice_number'] = $invoice_number;
|
||||
$data['invoice_number'] = $invoiceNumber;
|
||||
$data['sale_status'] = COMPLETED;
|
||||
$sale_type = SALE_TYPE_INVOICE;
|
||||
$saleType = SALE_TYPE_INVOICE;
|
||||
|
||||
$invoice_type = $this->config['invoice_type'];
|
||||
if (!Sale_lib::isValidInvoiceType($invoice_type)) {
|
||||
$invoice_type = 'invoice';
|
||||
$invoiceType = $this->config['invoice_type'];
|
||||
if (!Sale_lib::isValidInvoiceType($invoiceType)) {
|
||||
$invoiceType = 'invoice';
|
||||
}
|
||||
$invoice_view = $invoice_type;
|
||||
$invoiceView = $invoiceType;
|
||||
|
||||
// Save the data to the sales table
|
||||
$data['sale_id_num'] = $this->sale->save_value($sale_id, $data['sale_status'], $data['cart'], $customer_id, $employee_id, $data['comments'], $invoice_number, $work_order_number, $quote_number, $sale_type, $data['payments'], $data['dinner_table'], $tax_details);
|
||||
$data['sale_id_num'] = $this->sale->save_value($saleId, $data['sale_status'], $data['cart'], $customerId, $employeeId, $data['comments'], $invoiceNumber, $workOrderNumber, $quoteNumber, $saleType, $data['payments'], $data['dinner_table'], $taxDetails);
|
||||
$data['sale_id'] = 'POS ' . $data['sale_id_num'];
|
||||
|
||||
// Resort and filter cart lines for printing
|
||||
$data['cart'] = $this->sale_lib->sort_and_filter_cart($data['cart']);
|
||||
|
||||
if ($data['sale_id_num'] == NEW_ENTRY) {
|
||||
if ($data['sale_id_num'] === INSUFFICIENT_GIFTCARD_BALANCE) {
|
||||
$data['error_message'] = lang('Sales.insufficient_giftcard_balance');
|
||||
return $this->reload($data);
|
||||
} elseif ($data['sale_id_num'] === INSUFFICIENT_REWARD_POINTS) {
|
||||
$data['error_message'] = lang('Sales.insufficient_reward_points');
|
||||
return $this->reload($data);
|
||||
} elseif ($data['sale_id_num'] == NEW_ENTRY) {
|
||||
$data['error_message'] = lang('Sales.transaction_failed');
|
||||
return $this->reload($data);
|
||||
} else {
|
||||
$data['barcode'] = $this->barcode_lib->generate_receipt_barcode($data['sale_id']);
|
||||
$this->sale_lib->clear_all();
|
||||
return view('sales/' . $invoice_view, $data);
|
||||
return view('sales/' . $invoiceView, $data);
|
||||
}
|
||||
}
|
||||
} elseif ($this->sale_lib->is_work_order_mode()) {
|
||||
@@ -856,21 +915,33 @@ class Sales extends Secure_Controller
|
||||
$data['sales_work_order'] = lang('Sales.work_order');
|
||||
$data['work_order_number_label'] = lang('Sales.work_order_number');
|
||||
|
||||
if ($work_order_number == null) {
|
||||
if ($workOrderNumber == null) {
|
||||
// Generate work order number
|
||||
$work_order_format = $this->config['work_order_format'];
|
||||
$work_order_number = $this->token_lib->render($work_order_format);
|
||||
$workOrderFormat = $this->config['work_order_format'];
|
||||
$workOrderNumber = $this->token_lib->render($workOrderFormat);
|
||||
}
|
||||
|
||||
if ($sale_id == NEW_ENTRY && $this->sale->check_work_order_number_exists($work_order_number)) {
|
||||
if ($saleId == NEW_ENTRY && $this->sale->check_work_order_number_exists($workOrderNumber)) {
|
||||
$data['error'] = lang('Sales.work_order_number_duplicate');
|
||||
return $this->reload($data);
|
||||
} else {
|
||||
$data['work_order_number'] = $work_order_number;
|
||||
$data['work_order_number'] = $workOrderNumber;
|
||||
$data['sale_status'] = SUSPENDED;
|
||||
$sale_type = SALE_TYPE_WORK_ORDER;
|
||||
$saleType = SALE_TYPE_WORK_ORDER;
|
||||
|
||||
$data['sale_id_num'] = $this->sale->save_value($saleId, $data['sale_status'], $data['cart'], $customerId, $employeeId, $data['comments'], $invoiceNumber, $workOrderNumber, $quoteNumber, $saleType, $data['payments'], $data['dinner_table'], $taxDetails);
|
||||
|
||||
if ($data['sale_id_num'] === INSUFFICIENT_GIFTCARD_BALANCE) {
|
||||
$data['error_message'] = lang('Sales.insufficient_giftcard_balance');
|
||||
return $this->reload($data);
|
||||
} elseif ($data['sale_id_num'] === INSUFFICIENT_REWARD_POINTS) {
|
||||
$data['error_message'] = lang('Sales.insufficient_reward_points');
|
||||
return $this->reload($data);
|
||||
} elseif ($data['sale_id_num'] == NEW_ENTRY) {
|
||||
$data['error_message'] = lang('Sales.transaction_failed');
|
||||
return $this->reload($data);
|
||||
}
|
||||
|
||||
$data['sale_id_num'] = $this->sale->save_value($sale_id, $data['sale_status'], $data['cart'], $customer_id, $employee_id, $data['comments'], $invoice_number, $work_order_number, $quote_number, $sale_type, $data['payments'], $data['dinner_table'], $tax_details);
|
||||
$this->sale_lib->set_suspended_id($data['sale_id_num']);
|
||||
|
||||
$data['cart'] = $this->sale_lib->sort_and_filter_cart($data['cart']);
|
||||
@@ -884,21 +955,33 @@ class Sales extends Secure_Controller
|
||||
$data['sales_quote'] = lang('Sales.quote');
|
||||
$data['quote_number_label'] = lang('Sales.quote_number');
|
||||
|
||||
if ($quote_number == null) {
|
||||
if ($quoteNumber == null) {
|
||||
// Generate quote number
|
||||
$quote_format = $this->config['sales_quote_format'];
|
||||
$quote_number = $this->token_lib->render($quote_format);
|
||||
$quoteFormat = $this->config['sales_quote_format'];
|
||||
$quoteNumber = $this->token_lib->render($quoteFormat);
|
||||
}
|
||||
|
||||
if ($sale_id == NEW_ENTRY && $this->sale->check_quote_number_exists($quote_number)) {
|
||||
if ($saleId == NEW_ENTRY && $this->sale->check_quote_number_exists($quoteNumber)) {
|
||||
$data['error'] = lang('Sales.quote_number_duplicate');
|
||||
return $this->reload($data);
|
||||
} else {
|
||||
$data['quote_number'] = $quote_number;
|
||||
$data['quote_number'] = $quoteNumber;
|
||||
$data['sale_status'] = SUSPENDED;
|
||||
$sale_type = SALE_TYPE_QUOTE;
|
||||
$saleType = SALE_TYPE_QUOTE;
|
||||
|
||||
$data['sale_id_num'] = $this->sale->save_value($saleId, $data['sale_status'], $data['cart'], $customerId, $employeeId, $data['comments'], $invoiceNumber, $workOrderNumber, $quoteNumber, $saleType, $data['payments'], $data['dinner_table'], $taxDetails);
|
||||
|
||||
if ($data['sale_id_num'] === INSUFFICIENT_GIFTCARD_BALANCE) {
|
||||
$data['error_message'] = lang('Sales.insufficient_giftcard_balance');
|
||||
return $this->reload($data);
|
||||
} elseif ($data['sale_id_num'] === INSUFFICIENT_REWARD_POINTS) {
|
||||
$data['error_message'] = lang('Sales.insufficient_reward_points');
|
||||
return $this->reload($data);
|
||||
} elseif ($data['sale_id_num'] == NEW_ENTRY) {
|
||||
$data['error_message'] = lang('Sales.transaction_failed');
|
||||
return $this->reload($data);
|
||||
}
|
||||
|
||||
$data['sale_id_num'] = $this->sale->save_value($sale_id, $data['sale_status'], $data['cart'], $customer_id, $employee_id, $data['comments'], $invoice_number, $work_order_number, $quote_number, $sale_type, $data['payments'], $data['dinner_table'], $tax_details);
|
||||
$this->sale_lib->set_suspended_id($data['sale_id_num']);
|
||||
|
||||
$data['cart'] = $this->sale_lib->sort_and_filter_cart($data['cart']);
|
||||
@@ -911,29 +994,35 @@ class Sales extends Secure_Controller
|
||||
// Save the data to the sales table
|
||||
$data['sale_status'] = COMPLETED;
|
||||
if ($this->sale_lib->is_return_mode()) {
|
||||
$sale_type = SALE_TYPE_RETURN;
|
||||
$saleType = SALE_TYPE_RETURN;
|
||||
} else {
|
||||
$sale_type = SALE_TYPE_POS;
|
||||
$saleType = SALE_TYPE_POS;
|
||||
}
|
||||
|
||||
$data['sale_id_num'] = $this->sale->save_value($sale_id, $data['sale_status'], $data['cart'], $customer_id, $employee_id, $data['comments'], $invoice_number, $work_order_number, $quote_number, $sale_type, $data['payments'], $data['dinner_table'], $tax_details);
|
||||
$data['sale_id_num'] = $this->sale->save_value($saleId, $data['sale_status'], $data['cart'], $customerId, $employeeId, $data['comments'], $invoiceNumber, $workOrderNumber, $quoteNumber, $saleType, $data['payments'], $data['dinner_table'], $taxDetails);
|
||||
|
||||
$data['sale_id'] = 'POS ' . $data['sale_id_num'];
|
||||
|
||||
$data['cart'] = $this->sale_lib->sort_and_filter_cart($data['cart']);
|
||||
|
||||
if ($data['sale_id_num'] == NEW_ENTRY) {
|
||||
if ($data['sale_id_num'] === INSUFFICIENT_GIFTCARD_BALANCE) {
|
||||
$data['error_message'] = lang('Sales.insufficient_giftcard_balance');
|
||||
return $this->reload($data);
|
||||
} elseif ($data['sale_id_num'] === INSUFFICIENT_REWARD_POINTS) {
|
||||
$data['error_message'] = lang('Sales.insufficient_reward_points');
|
||||
return $this->reload($data);
|
||||
} elseif ($data['sale_id_num'] == NEW_ENTRY) {
|
||||
$data['error_message'] = lang('Sales.transaction_failed');
|
||||
return $this->reload($data);
|
||||
} else {
|
||||
$data['barcode'] = $this->barcode_lib->generate_receipt_barcode($data['sale_id']);
|
||||
|
||||
// Validate receipt template to prevent path traversal
|
||||
$receipt_template = $this->config['receipt_template'] ?? '';
|
||||
if (!Sale_lib::isValidReceiptTemplate($receipt_template)) {
|
||||
$receipt_template = 'receipt_default';
|
||||
$receiptTemplate = $this->config['receipt_template'] ?? '';
|
||||
if (!Sale_lib::isValidReceiptTemplate($receiptTemplate)) {
|
||||
$receiptTemplate = 'receipt_default';
|
||||
}
|
||||
$data['receipt_template_view'] = $receipt_template;
|
||||
$data['receipt_template_view'] = $receiptTemplate;
|
||||
|
||||
$this->sale_lib->clear_all();
|
||||
return view('sales/receipt', $data);
|
||||
@@ -944,38 +1033,44 @@ class Sales extends Secure_Controller
|
||||
/**
|
||||
* Email PDF invoice to customer. Used in app/Views/sales/form.php, invoice.php, quote.php, tax_invoice.php and work_order.php
|
||||
*
|
||||
* @param int $sale_id
|
||||
* @param int $saleId
|
||||
* @param string $type
|
||||
* @return ResponseInterface
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function getSendPdf(int $sale_id, string $type = 'invoice'): ResponseInterface
|
||||
public function getSendPdf(int $saleId, string $type = 'invoice'): ResponseInterface
|
||||
{
|
||||
$sale_data = $this->_load_sale_data($sale_id);
|
||||
$personId = $this->session->get('person_id');
|
||||
|
||||
if (!$this->employee->has_grant('reports_sales', $personId)) {
|
||||
return $this->response->setStatusCode(403)->setJSON(['success' => false, 'message' => lang('Sales.not_authorized')]);
|
||||
}
|
||||
|
||||
$saleData = $this->_load_sale_data($saleId);
|
||||
|
||||
$result = false;
|
||||
$message = lang('Sales.invoice_no_email');
|
||||
|
||||
if (!empty($sale_data['customer_email'])) {
|
||||
$to = $sale_data['customer_email'];
|
||||
$number = array_key_exists($type . "_number", $sale_data) ? $sale_data[$type . "_number"] : "";
|
||||
if (!empty($saleData['customer_email'])) {
|
||||
$to = $saleData['customer_email'];
|
||||
$number = array_key_exists($type . "_number", $saleData) ? $saleData[$type . "_number"] : "";
|
||||
$subject = lang('Sales.' . $type) . ' ' . $number;
|
||||
|
||||
$text = $this->config['invoice_email_message'];
|
||||
$tokens = [
|
||||
new Token_invoice_sequence($number),
|
||||
new Token_invoice_count('POS ' . $sale_data['sale_id']),
|
||||
new Token_customer((array)$sale_data)
|
||||
new Token_invoice_count('POS ' . $saleData['sale_id']),
|
||||
new Token_customer((array)$saleData)
|
||||
];
|
||||
$text = $this->token_lib->render($text, $tokens);
|
||||
$sale_data['mimetype'] = $this->email_lib->getLogoMimeType();
|
||||
$saleData['mimetype'] = $this->email_lib->getLogoMimeType();
|
||||
|
||||
// Build img_tag for email views that need it (receipt_email.php)
|
||||
$sale_data['img_tag'] = $this->email_lib->buildLogoImgTag();
|
||||
$saleData['img_tag'] = $this->email_lib->buildLogoImgTag();
|
||||
|
||||
// Generate email attachment: invoice in PDF format
|
||||
$view = Services::renderer();
|
||||
$html = $view->setData($sale_data)->render("sales/$type" . '_email', $sale_data);
|
||||
$html = $view->setData($saleData)->render("sales/$type" . '_email', $saleData);
|
||||
|
||||
// Load PDF helper
|
||||
helper(['dompdf', 'file']);
|
||||
@@ -989,32 +1084,38 @@ class Sales extends Secure_Controller
|
||||
|
||||
$this->sale_lib->clear_all();
|
||||
|
||||
return $this->response->setJSON(['success' => $result, 'message' => $message, 'id' => $sale_id]);
|
||||
return $this->response->setJSON(['success' => $result, 'message' => $message, 'id' => $saleId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emails sales receipt to customer. Used in app/Views/sales/receipt.php
|
||||
*
|
||||
* @param int $sale_id
|
||||
* @param int $saleId
|
||||
* @return ResponseInterface
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function getSendReceipt(int $sale_id): ResponseInterface
|
||||
public function getSendReceipt(int $saleId): ResponseInterface
|
||||
{
|
||||
$sale_data = $this->_load_sale_data($sale_id);
|
||||
$personId = $this->session->get('person_id');
|
||||
|
||||
if (!$this->employee->has_grant('reports_sales', $personId)) {
|
||||
return $this->response->setStatusCode(403)->setJSON(['success' => false, 'message' => lang('Sales.not_authorized')]);
|
||||
}
|
||||
|
||||
$saleData = $this->_load_sale_data($saleId);
|
||||
|
||||
$result = false;
|
||||
$message = lang('Sales.receipt_no_email');
|
||||
|
||||
if (!empty($sale_data['customer_email'])) {
|
||||
$sale_data['barcode'] = $this->barcode_lib->generate_receipt_barcode($sale_data['sale_id']);
|
||||
$sale_data['img_tag'] = $this->email_lib->buildLogoImgTag();
|
||||
if (!empty($saleData['customer_email'])) {
|
||||
$saleData['barcode'] = $this->barcode_lib->generate_receipt_barcode($saleData['sale_id']);
|
||||
$saleData['img_tag'] = $this->email_lib->buildLogoImgTag();
|
||||
|
||||
$to = $sale_data['customer_email'];
|
||||
$to = $saleData['customer_email'];
|
||||
$subject = lang('Sales.receipt');
|
||||
|
||||
$view = Services::renderer();
|
||||
$text = $view->setData($sale_data)->render('sales/receipt_email');
|
||||
$text = $view->setData($saleData)->render('sales/receipt_email');
|
||||
|
||||
$result = $this->email_lib->sendEmail($to, $subject, $text);
|
||||
|
||||
@@ -1023,7 +1124,7 @@ class Sales extends Secure_Controller
|
||||
|
||||
$this->sale_lib->clear_all();
|
||||
|
||||
return $this->response->setJSON(['success' => $result, 'message' => $message, 'id' => $sale_id]);
|
||||
return $this->response->setJSON(['success' => $result, 'message' => $message, 'id' => $saleId]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1319,13 +1420,19 @@ class Sales extends Secure_Controller
|
||||
/**
|
||||
* Load the sales receipt for a sale. Used in app/Views/sales/form.php
|
||||
*
|
||||
* @param int $sale_id
|
||||
* @param int $saleId
|
||||
* @return string
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function getReceipt(int $sale_id): string
|
||||
public function getReceipt(int $saleId): string|ResponseInterface
|
||||
{
|
||||
$data = $this->_load_sale_data($sale_id);
|
||||
$personId = $this->session->get('person_id');
|
||||
|
||||
if (!$this->employee->has_grant('reports_sales', $personId)) {
|
||||
return redirect()->to('no_access/sales/reports_sales');
|
||||
}
|
||||
|
||||
$data = $this->_load_sale_data($saleId);
|
||||
$this->sale_lib->clear_all();
|
||||
|
||||
return view('sales/receipt', $data);
|
||||
@@ -1334,13 +1441,19 @@ class Sales extends Secure_Controller
|
||||
/**
|
||||
* Loads the sales invoice for a sale. Used in app/Views/sales/form.php
|
||||
*
|
||||
* @param int $sale_id
|
||||
* @param int $saleId
|
||||
* @return string
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function getInvoice(int $sale_id): string
|
||||
public function getInvoice(int $saleId): string|ResponseInterface
|
||||
{
|
||||
$data = $this->_load_sale_data($sale_id);
|
||||
$personId = $this->session->get('person_id');
|
||||
|
||||
if (!$this->employee->has_grant('reports_sales', $personId)) {
|
||||
return redirect()->to('no_access/sales/reports_sales');
|
||||
}
|
||||
|
||||
$data = $this->_load_sale_data($saleId);
|
||||
$this->sale_lib->clear_all();
|
||||
|
||||
return view('sales/' . $data['invoice_view'], $data);
|
||||
@@ -1349,30 +1462,36 @@ class Sales extends Secure_Controller
|
||||
/**
|
||||
* Edits an existing sale or work order. Used in app/Views/sales/form.php
|
||||
*
|
||||
* @param int $sale_id
|
||||
* @param int $saleId
|
||||
* @return string
|
||||
* @throws ReflectionException
|
||||
*/
|
||||
public function getEdit(int $sale_id): string
|
||||
public function getEdit(int $saleId): string|ResponseInterface
|
||||
{
|
||||
$personId = $this->session->get('person_id');
|
||||
|
||||
if (!$this->employee->has_grant('reports_sales', $personId)) {
|
||||
return redirect()->to('no_access/sales/reports_sales');
|
||||
}
|
||||
|
||||
$data = [];
|
||||
|
||||
$sale_info = $this->sale->get_info($sale_id)->getRowArray();
|
||||
$data['selected_customer_id'] = $sale_info['customer_id'];
|
||||
$data['selected_customer_name'] = $sale_info['customer_name'];
|
||||
$employee_info = $this->employee->get_info($sale_info['employee_id']);
|
||||
$data['selected_employee_id'] = $sale_info['employee_id'];
|
||||
$data['selected_employee_name'] = $employee_info->first_name . ' ' . $employee_info->last_name;
|
||||
$data['sale_info'] = $sale_info;
|
||||
$balance_due = round($sale_info['amount_due'] - $sale_info['amount_tendered'] + $sale_info['cash_refund'], totals_decimals(), PHP_ROUND_HALF_UP);
|
||||
$saleInfo = $this->sale->get_info($saleId)->getRowArray();
|
||||
$data['selected_customer_id'] = $saleInfo['customer_id'];
|
||||
$data['selected_customer_name'] = $saleInfo['customer_name'];
|
||||
$employeeInfo = $this->employee->get_info($saleInfo['employee_id']);
|
||||
$data['selected_employee_id'] = $saleInfo['employee_id'];
|
||||
$data['selected_employee_name'] = $employeeInfo->first_name . ' ' . $employeeInfo->last_name;
|
||||
$data['sale_info'] = $saleInfo;
|
||||
$balanceDue = round($saleInfo['amount_due'] - $saleInfo['amount_tendered'] + $saleInfo['cash_refund'], totals_decimals(), PHP_ROUND_HALF_UP);
|
||||
|
||||
if (!$this->sale_lib->reset_cash_rounding() && $balance_due < 0) {
|
||||
$balance_due = 0;
|
||||
if (!$this->sale_lib->reset_cash_rounding() && $balanceDue < 0) {
|
||||
$balanceDue = 0;
|
||||
}
|
||||
|
||||
$data['payments'] = [];
|
||||
|
||||
foreach ($this->sale->get_sale_payments($sale_id)->getResult() as $payment) {
|
||||
foreach ($this->sale->get_sale_payments($saleId)->getResult() as $payment) {
|
||||
foreach (get_object_vars($payment) as $property => $value) {
|
||||
$payment->$property = $value;
|
||||
}
|
||||
@@ -1380,24 +1499,24 @@ class Sales extends Secure_Controller
|
||||
}
|
||||
|
||||
$data['payment_type_new'] = PAYMENT_TYPE_UNASSIGNED;
|
||||
$data['payment_amount_new'] = $balance_due;
|
||||
$data['payment_amount_new'] = $balanceDue;
|
||||
|
||||
$data['balance_due'] = $balance_due != 0;
|
||||
$data['balance_due'] = $balanceDue != 0;
|
||||
|
||||
// Don't allow gift card to be a payment option in a sale transaction edit because it's a complex change
|
||||
$payment_options = $this->sale->get_payment_options(false);
|
||||
$paymentOptions = $this->sale->get_payment_options(false);
|
||||
|
||||
if ($this->sale_lib->reset_cash_rounding()) {
|
||||
$payment_options[lang('Sales.cash_adjustment')] = lang('Sales.cash_adjustment');
|
||||
$paymentOptions[lang('Sales.cash_adjustment')] = lang('Sales.cash_adjustment');
|
||||
}
|
||||
|
||||
$data['payment_options'] = $payment_options;
|
||||
$data['payment_options'] = $paymentOptions;
|
||||
$data['reference_code_payment_types'] = get_reference_code_payment_types();
|
||||
|
||||
// Set up a slightly modified list of payment types for new payment entry
|
||||
$payment_options["--"] = lang('Common.none_selected_text');
|
||||
$paymentOptions["--"] = lang('Common.none_selected_text');
|
||||
|
||||
$data['new_payment_options'] = $payment_options;
|
||||
$data['new_payment_options'] = $paymentOptions;
|
||||
|
||||
return view('sales/form', $data);
|
||||
}
|
||||
@@ -1413,7 +1532,7 @@ class Sales extends Secure_Controller
|
||||
$has_grant = $this->employee->has_grant('sales_delete', $employee_id);
|
||||
|
||||
if (!$has_grant) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => lang('Sales.not_authorized')]);
|
||||
return $this->response->setStatusCode(403)->setJSON(['success' => false, 'message' => lang('Sales.not_authorized')]);
|
||||
} else {
|
||||
$sale_ids = $sale_id == NEW_ENTRY ? $this->request->getPost('ids', FILTER_SANITIZE_NUMBER_INT) : [$sale_id];
|
||||
|
||||
@@ -1440,7 +1559,7 @@ class Sales extends Secure_Controller
|
||||
$has_grant = $this->employee->has_grant('sales_delete', $employee_id);
|
||||
|
||||
if (!$has_grant) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => lang('Sales.not_authorized')]);
|
||||
return $this->response->setStatusCode(403)->setJSON(['success' => false, 'message' => lang('Sales.not_authorized')]);
|
||||
} else {
|
||||
$sale_ids = $sale_id == NEW_ENTRY ? $this->request->getPost('ids', FILTER_SANITIZE_NUMBER_INT) : [$sale_id];
|
||||
|
||||
@@ -1459,20 +1578,26 @@ class Sales extends Secure_Controller
|
||||
/**
|
||||
* This saves the sale from the update sale view (sales/form).
|
||||
* It only updates the sales table and payments.
|
||||
* @param int $sale_id
|
||||
* @param int $saleId
|
||||
* @return ResponseInterface
|
||||
* @throws ReflectionException
|
||||
*/
|
||||
public function postSave(int $sale_id = NEW_ENTRY): ResponseInterface
|
||||
public function postSave(int $saleId = NEW_ENTRY): ResponseInterface
|
||||
{
|
||||
$newdate = $this->request->getPost('date', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$employee_id = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
$inventory = model(Inventory::class);
|
||||
$date_formatter = date_create_from_format($this->config['dateformat'] . ' ' . $this->config['timeformat'], $newdate);
|
||||
$sale_time = $date_formatter->format('Y-m-d H:i:s');
|
||||
$personId = $this->session->get('person_id');
|
||||
|
||||
$sale_data = [
|
||||
'sale_time' => $sale_time,
|
||||
if (!$this->employee->has_grant('reports_sales', $personId)) {
|
||||
return $this->response->setStatusCode(403)->setJSON(['success' => false, 'message' => lang('Sales.not_authorized')]);
|
||||
}
|
||||
|
||||
$newdate = $this->request->getPost('date', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
$inventory = model(Inventory::class);
|
||||
$dateFormatter = date_create_from_format($this->config['dateformat'] . ' ' . $this->config['timeformat'], $newdate);
|
||||
$saleTime = $dateFormatter->format('Y-m-d H:i:s');
|
||||
|
||||
$saleData = [
|
||||
'sale_time' => $saleTime,
|
||||
'customer_id' => $this->request->getPost('customer_id') != '' ? $this->request->getPost('customer_id', FILTER_SANITIZE_NUMBER_INT) : null,
|
||||
'employee_id' => $this->request->getPost('employee_id') != '' ? $this->request->getPost('employee_id', FILTER_SANITIZE_NUMBER_INT) : null,
|
||||
'comment' => $this->request->getPost('comment', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
|
||||
@@ -1480,10 +1605,10 @@ class Sales extends Secure_Controller
|
||||
];
|
||||
|
||||
// Validate reference_code for the new payment if applicable
|
||||
$payment_type_new_check = $this->request->getPost('payment_type_new', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$payment_amount_new_check = $this->request->getPost('payment_amount_new');
|
||||
if ($payment_type_new_check != PAYMENT_TYPE_UNASSIGNED && !empty($payment_amount_new_check)
|
||||
&& in_array($payment_type_new_check, get_reference_code_payment_types())) {
|
||||
$paymentTypeNewCheck = $this->request->getPost('payment_type_new', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$paymentAmountNewCheck = $this->request->getPost('payment_amount_new');
|
||||
if ($paymentTypeNewCheck != PAYMENT_TYPE_UNASSIGNED && !empty($paymentAmountNewCheck)
|
||||
&& in_array($paymentTypeNewCheck, get_reference_code_payment_types())) {
|
||||
$min = (int)($this->config['payment_reference_code_min'] ?? 3);
|
||||
$max = (int)($this->config['payment_reference_code_max'] ?? 40);
|
||||
$rules = [
|
||||
@@ -1499,82 +1624,82 @@ class Sales extends Secure_Controller
|
||||
];
|
||||
if (!$this->validate($rules, $messages)) {
|
||||
$errors = $this->validator->getErrors();
|
||||
return $this->response->setJSON(['success' => false, 'message' => reset($errors), 'id' => $sale_id]);
|
||||
return $this->response->setJSON(['success' => false, 'message' => reset($errors), 'id' => $saleId]);
|
||||
}
|
||||
}
|
||||
|
||||
// In order to maintain tradition the only element that can change on prior payments is the payment type
|
||||
$amount_tendered = 0;
|
||||
$number_of_payments = $this->request->getPost('number_of_payments', FILTER_SANITIZE_NUMBER_INT);
|
||||
for ($i = 0; $i < $number_of_payments; ++$i) {
|
||||
$payment_id = $this->request->getPost("payment_id_$i", FILTER_SANITIZE_NUMBER_INT);
|
||||
$payment_type = $this->request->getPost("payment_type_$i", FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$payment_amount = parse_decimals($this->request->getPost("payment_amount_$i"));
|
||||
$refund_type = $this->request->getPost("refund_type_$i", FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$cash_refund = parse_decimals($this->request->getPost("refund_amount_$i"));
|
||||
$reference_code = $this->request->getPost("reference_code_$i", FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?: null;
|
||||
$amountTendered = 0;
|
||||
$numberOfPayments = $this->request->getPost('number_of_payments', FILTER_SANITIZE_NUMBER_INT);
|
||||
for ($i = 0; $i < $numberOfPayments; ++$i) {
|
||||
$paymentId = $this->request->getPost("payment_id_$i", FILTER_SANITIZE_NUMBER_INT);
|
||||
$paymentType = $this->request->getPost("payment_type_$i", FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$paymentAmount = parse_decimals($this->request->getPost("payment_amount_$i"));
|
||||
$refundType = $this->request->getPost("refund_type_$i", FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$cashRefund = parse_decimals($this->request->getPost("refund_amount_$i"));
|
||||
$referenceCode = $this->request->getPost("reference_code_$i", FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?: null;
|
||||
|
||||
$cash_adjustment = $payment_type == lang('Sales.cash_adjustment') ? CASH_ADJUSTMENT_TRUE : CASH_ADJUSTMENT_FALSE;
|
||||
$cashAdjustment = $paymentType == lang('Sales.cash_adjustment') ? CASH_ADJUSTMENT_TRUE : CASH_ADJUSTMENT_FALSE;
|
||||
|
||||
if (!$cash_adjustment) {
|
||||
$amount_tendered += $payment_amount - $cash_refund;
|
||||
if (!$cashAdjustment) {
|
||||
$amountTendered += $paymentAmount - $cashRefund;
|
||||
}
|
||||
|
||||
// Non-cash positive refund amounts
|
||||
if (empty(strstr($refund_type, lang('Sales.cash'))) && $cash_refund > 0) { // TODO: This if and the one below can be combined.
|
||||
if (empty(strstr($refundType, lang('Sales.cash'))) && $cashRefund > 0) { // TODO: This if and the one below can be combined.
|
||||
// Change it to be a new negative payment (a "non-cash refund")
|
||||
$payment_type = $refund_type;
|
||||
$payment_amount = $payment_amount - $cash_refund;
|
||||
$cash_refund = 0.00;
|
||||
$paymentType = $refundType;
|
||||
$paymentAmount = $paymentAmount - $cashRefund;
|
||||
$cashRefund = 0.00;
|
||||
}
|
||||
|
||||
$sale_data['payments'][] = [
|
||||
'payment_id' => $payment_id,
|
||||
'payment_type' => $payment_type,
|
||||
'payment_amount' => $payment_amount,
|
||||
'cash_refund' => $cash_refund,
|
||||
'cash_adjustment' => $cash_adjustment,
|
||||
'employee_id' => $employee_id,
|
||||
'reference_code' => $reference_code,
|
||||
$saleData['payments'][] = [
|
||||
'payment_id' => $paymentId,
|
||||
'payment_type' => $paymentType,
|
||||
'payment_amount' => $paymentAmount,
|
||||
'cash_refund' => $cashRefund,
|
||||
'cash_adjustment' => $cashAdjustment,
|
||||
'employee_id' => $employeeId,
|
||||
'reference_code' => $referenceCode,
|
||||
];
|
||||
}
|
||||
|
||||
$payment_id = NEW_ENTRY;
|
||||
$payment_amount_new = $this->request->getPost('payment_amount_new');
|
||||
$payment_type = $this->request->getPost('payment_type_new', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$reference_code_new = $this->request->getPost('reference_code_new', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?: null;
|
||||
$paymentId = NEW_ENTRY;
|
||||
$paymentAmountNew = $this->request->getPost('payment_amount_new');
|
||||
$paymentType = $this->request->getPost('payment_type_new', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$referenceCodeNew = $this->request->getPost('reference_code_new', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?: null;
|
||||
|
||||
if ($payment_type != PAYMENT_TYPE_UNASSIGNED && !empty($payment_amount_new)) {
|
||||
$payment_amount = parse_decimals($payment_amount_new);
|
||||
$cash_refund = 0;
|
||||
if ($payment_type == lang('Sales.cash_adjustment')) {
|
||||
$cash_adjustment = CASH_ADJUSTMENT_TRUE;
|
||||
if ($paymentType != PAYMENT_TYPE_UNASSIGNED && !empty($paymentAmountNew)) {
|
||||
$paymentAmount = parse_decimals($paymentAmountNew);
|
||||
$cashRefund = 0;
|
||||
if ($paymentType == lang('Sales.cash_adjustment')) {
|
||||
$cashAdjustment = CASH_ADJUSTMENT_TRUE;
|
||||
} else {
|
||||
$cash_adjustment = CASH_ADJUSTMENT_FALSE;
|
||||
$amount_tendered += $payment_amount;
|
||||
$sale_info = $this->sale->get_info($sale_id)->getRowArray();
|
||||
$cashAdjustment = CASH_ADJUSTMENT_FALSE;
|
||||
$amountTendered += $paymentAmount;
|
||||
$saleInfo = $this->sale->get_info($saleId)->getRowArray();
|
||||
|
||||
if ($amount_tendered > $sale_info['amount_due']) {
|
||||
$cash_refund = $amount_tendered - $sale_info['amount_due'];
|
||||
if ($amountTendered > $saleInfo['amount_due']) {
|
||||
$cashRefund = $amountTendered - $saleInfo['amount_due'];
|
||||
}
|
||||
}
|
||||
|
||||
$sale_data['payments'][] = [
|
||||
'payment_id' => $payment_id,
|
||||
'payment_type' => $payment_type,
|
||||
'payment_amount' => $payment_amount,
|
||||
'cash_refund' => $cash_refund,
|
||||
'cash_adjustment' => $cash_adjustment,
|
||||
'employee_id' => $employee_id,
|
||||
'reference_code' => $reference_code_new,
|
||||
$saleData['payments'][] = [
|
||||
'payment_id' => $paymentId,
|
||||
'payment_type' => $paymentType,
|
||||
'payment_amount' => $paymentAmount,
|
||||
'cash_refund' => $cashRefund,
|
||||
'cash_adjustment' => $cashAdjustment,
|
||||
'employee_id' => $employeeId,
|
||||
'reference_code' => $referenceCodeNew,
|
||||
];
|
||||
}
|
||||
|
||||
$inventory->update('POS ' . $sale_id, ['trans_date' => $sale_time]); // TODO: Reflection Exception
|
||||
if ($this->sale->update($sale_id, $sale_data)) {
|
||||
return $this->response->setJSON(['success' => true, 'message' => lang('Sales.successfully_updated'), 'id' => $sale_id]);
|
||||
$inventory->update('POS ' . $saleId, ['trans_date' => $saleTime]); // TODO: Reflection Exception
|
||||
if ($this->sale->update($saleId, $saleData)) {
|
||||
return $this->response->setJSON(['success' => true, 'message' => lang('Sales.successfully_updated'), 'id' => $saleId]);
|
||||
} else {
|
||||
return $this->response->setJSON(['success' => false, 'message' => lang('Sales.unsuccessfully_updated'), 'id' => $sale_id]);
|
||||
return $this->response->setJSON(['success' => false, 'message' => lang('Sales.unsuccessfully_updated'), 'id' => $saleId]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1592,7 +1717,7 @@ class Sales extends Secure_Controller
|
||||
{
|
||||
$sale_id = $this->sale_lib->get_sale_id();
|
||||
if ($sale_id != NEW_ENTRY && $sale_id != '') {
|
||||
$sale_type = $this->sale_lib->get_sale_type();
|
||||
$sale_type = $this->sale_lib->getSaleType();
|
||||
|
||||
if ($this->config['dinner_table_enable']) {
|
||||
$dinner_table = $this->sale_lib->get_dinner_table();
|
||||
@@ -1646,7 +1771,7 @@ class Sales extends Secure_Controller
|
||||
$invoice_number = $this->sale_lib->get_invoice_number();
|
||||
$work_order_number = $this->sale_lib->get_work_order_number();
|
||||
$quote_number = $this->sale_lib->get_quote_number();
|
||||
$sale_type = $this->sale_lib->get_sale_type();
|
||||
$sale_type = $this->sale_lib->getSaleType();
|
||||
|
||||
if ($sale_type == '') {
|
||||
$sale_type = SALE_TYPE_POS;
|
||||
@@ -1658,14 +1783,20 @@ class Sales extends Secure_Controller
|
||||
$data = [];
|
||||
$sales_taxes = [[], []];
|
||||
|
||||
if ($this->sale->save_value($sale_id, $sale_status, $cart, $customer_id, $employee_id, $comment, $invoice_number, $work_order_number, $quote_number, $sale_type, $payments, $dinner_table, $sales_taxes) == '-1') {
|
||||
$saleIdNum = $this->sale->save_value($sale_id, $sale_status, $cart, $customer_id, $employee_id, $comment, $invoice_number, $work_order_number, $quote_number, $sale_type, $payments, $dinner_table, $sales_taxes);
|
||||
|
||||
if ($saleIdNum === INSUFFICIENT_GIFTCARD_BALANCE) {
|
||||
$data['error'] = lang('Sales.insufficient_giftcard_balance');
|
||||
} elseif ($saleIdNum === INSUFFICIENT_REWARD_POINTS) {
|
||||
$data['error'] = lang('Sales.insufficient_reward_points');
|
||||
} elseif ($saleIdNum === NEW_ENTRY) {
|
||||
$data['error'] = lang('Sales.unsuccessfully_suspended_sale');
|
||||
$this->sale_lib->clear_all();
|
||||
} else {
|
||||
$data['success'] = lang('Sales.successfully_suspended_sale');
|
||||
$this->sale_lib->clear_all();
|
||||
}
|
||||
|
||||
$this->sale_lib->clear_all();
|
||||
|
||||
return $this->reload($data);
|
||||
}
|
||||
|
||||
@@ -1690,15 +1821,22 @@ class Sales extends Secure_Controller
|
||||
*/
|
||||
public function postUnsuspend(): ResponseInterface|string
|
||||
{
|
||||
$sale_id = $this->request->getPost('suspended_sale_id', FILTER_SANITIZE_NUMBER_INT);
|
||||
$this->sale_lib->clear_all();
|
||||
$personId = $this->session->get('person_id');
|
||||
|
||||
if ($sale_id > 0) {
|
||||
$this->sale_lib->copy_entire_sale($sale_id);
|
||||
if (!$this->employee->has_grant('reports_sales', $personId)) {
|
||||
return $this->response->setStatusCode(403)
|
||||
->setJSON(['success' => false, 'message' => lang('Sales.not_authorized')]);
|
||||
}
|
||||
|
||||
$saleId = $this->request->getPost('suspended_sale_id', FILTER_SANITIZE_NUMBER_INT);
|
||||
|
||||
if ($saleId > 0 && $this->sale->getSaleStatus($saleId) == SUSPENDED) {
|
||||
$this->sale_lib->clear_all();
|
||||
$this->sale_lib->copy_entire_sale($saleId);
|
||||
}
|
||||
|
||||
// Set current register mode to reflect that of unsuspended order type
|
||||
$this->change_register_mode($this->sale_lib->get_sale_type());
|
||||
$this->change_register_mode($this->sale_lib->getSaleType());
|
||||
|
||||
return $this->reload();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controllers;
|
||||
|
||||
use App\Models\Employee;
|
||||
use App\Models\Module;
|
||||
use CodeIgniter\HTTP\Exceptions\RedirectException;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use CodeIgniter\Model;
|
||||
use CodeIgniter\Session\Session;
|
||||
@@ -39,18 +40,12 @@ class Secure_Controller extends BaseController
|
||||
$config = config(OSPOS::class)->settings;
|
||||
$validation = Services::validation();
|
||||
|
||||
if (!$this->employee->is_logged_in()) {
|
||||
header("Location:" . base_url('login'));
|
||||
exit();
|
||||
}
|
||||
|
||||
$logged_in_employee_info = $this->employee->get_logged_in_employee_info();
|
||||
if (
|
||||
!$this->employee->has_module_grant($module_id, $logged_in_employee_info->person_id)
|
||||
|| (isset($submodule_id) && !$this->employee->has_module_grant($submodule_id, $logged_in_employee_info->person_id))
|
||||
) {
|
||||
header("Location:" . base_url("no_access/$module_id/$submodule_id"));
|
||||
exit();
|
||||
throw new RedirectException("no_access/$module_id/$submodule_id");
|
||||
}
|
||||
|
||||
// Load up global global_view_data visible to all the loaded views
|
||||
@@ -80,7 +75,36 @@ class Secure_Controller extends BaseController
|
||||
|
||||
public function sanitizeSortColumn($headers, $field, $default): string
|
||||
{
|
||||
return $field != null && in_array($field, array_keys(array_merge(...$headers))) ? $field : $default;
|
||||
if ($field === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
// Flatten keys directly as array_merge() renumbers numeric string keys
|
||||
$validColumns = [];
|
||||
foreach ($headers as $header) {
|
||||
$validColumns[] = (string) array_key_first($header);
|
||||
}
|
||||
|
||||
return in_array((string) $field, $validColumns, true) ? $field : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the given rules and, on failure, returns a JSON error response.
|
||||
*
|
||||
* @param array $rules
|
||||
* @param array $messages
|
||||
* @param mixed $id
|
||||
* @return ResponseInterface|null
|
||||
*/
|
||||
protected function validateFields(array $rules, array $messages, $id = NEW_ENTRY): ?ResponseInterface
|
||||
{
|
||||
if (!$this->validate($rules, $messages)) {
|
||||
$errors = $this->validator->getErrors();
|
||||
|
||||
return $this->response->setJSON(['success' => false, 'message' => reset($errors), 'id' => $id]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,9 +168,9 @@ class Secure_Controller extends BaseController
|
||||
|
||||
/**
|
||||
* @param int $data_item_id
|
||||
* @return false
|
||||
* @return ResponseInterface|false
|
||||
*/
|
||||
public function postSave(int $data_item_id = -1)
|
||||
public function postSave(int $data_item_id = -1): ResponseInterface|false
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -13,48 +13,56 @@ class Migration_Initial_Schema extends Migration
|
||||
|
||||
/**
|
||||
* Perform a migration step.
|
||||
* Only runs on fresh installs - skips if database already has tables.
|
||||
*
|
||||
* For testing: CI4's DatabaseTestTrait with $refresh=true handles table
|
||||
* cleanup/creation automatically. This migration only loads initial schema
|
||||
* on fresh databases where no application tables exist.
|
||||
* Deterministically (re)applies the base 3.0.2 schema. Down() is
|
||||
* responsible for clearing the application tables first, so this method
|
||||
* always runs the initial schema script. We intentionally do NOT skip on
|
||||
* "tables already exist": CodeIgniter's listTables() result is cached on
|
||||
* the connection, so a prior down()/regress() cycle in the same process
|
||||
* can leave a stale table list and a naive "skip if present" check would
|
||||
* refuse to rebuild tables that were just dropped.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Check if core application tables exist (existing install)
|
||||
// Note: migrations table may exist even on fresh DB due to migration tracking
|
||||
$tables = $this->db->listTables();
|
||||
|
||||
// Check for a core application table, not just migrations table
|
||||
foreach ($tables as $table) {
|
||||
// Strip prefix if present for comparison
|
||||
$tableName = str_replace($this->db->getPrefix(), '', $table);
|
||||
if (in_array($tableName, ['app_config', 'items', 'employees', 'people'])) {
|
||||
// Database already populated - skip initial schema
|
||||
// This is an existing installation upgrading from older version
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh install - load initial schema
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/initial_schema.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/initial_schema.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert a migration step.
|
||||
* Cannot revert initial schema - would lose all data.
|
||||
*
|
||||
* Drops the base application tables (and the migrations tracking table)
|
||||
* so the next up() re-creates a clean 3.0.2 baseline. Disables FK checks
|
||||
* so drop order doesn't matter.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Cannot safely revert initial schema
|
||||
// Would require dropping all tables which would lose all data
|
||||
$this->db->query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
|
||||
foreach ($this->db->listTables() as $table) {
|
||||
$this->db->query('DROP TABLE IF EXISTS `' . $table . '`');
|
||||
// Query the table list straight from the database. We must NOT use
|
||||
// $this->db->listTables(): CI4 caches the result in dataCache and, in a
|
||||
// long-lived test process, that cached list is stale (populated by a
|
||||
// prior test class's connection), so drops would be skipped and the
|
||||
// next up() would hit "Table ... already exists".
|
||||
$db = $this->db;
|
||||
$result = $db->query('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?', [$db->database]);
|
||||
|
||||
$tables = $result ? array_column($result->getResultArray(), 'TABLE_NAME') : [];
|
||||
|
||||
// Preserve the migrations tracking table: MigrationRunner::regress()
|
||||
// calls removeHistory() AFTER down(), so dropping it here would raise
|
||||
// "Table '...ospos_migrations' doesn't exist".
|
||||
$migrationsTable = $db->getPrefix() . 'migrations';
|
||||
|
||||
$db->query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
foreach ($tables as $table) {
|
||||
if ($table === $migrationsTable) {
|
||||
continue;
|
||||
}
|
||||
$db->query('DROP TABLE IF EXISTS `' . $table . '`');
|
||||
}
|
||||
|
||||
$this->db->query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
$db->query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
|
||||
// Invalidate the stale in-connection table-name cache so a subsequent
|
||||
// listTables() in the same process sees the fresh state.
|
||||
$db->dataCache['table_names'] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ class Migration_Upgrade_To_3_1_1 extends Migration
|
||||
}
|
||||
}
|
||||
|
||||
if (!execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.0.2_to_3.1.1.sql')) {
|
||||
if (!executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.0.2_to_3.1.1.sql')) {
|
||||
throw new DatabaseException('Migration script 3.0.2_to_3.1.1.sql failed. Check logs for details.');
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_Upgrade_To_3_2_0 extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.1.1_to_3.2.0.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.1.1_to_3.2.0.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_Upgrade_To_3_2_1 extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.2.0_to_3.2.1.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.2.0_to_3.2.1.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_Attributes extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_attributes.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_attributes.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_Upgrade_To_3_3_0 extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.2.1_to_3.3.0.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.2.1_to_3.3.0.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,7 @@ class Migration_IndiaGST extends Migration
|
||||
|
||||
// If number of entries is greater than zero then the tax data needs to be migrated
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_indiagst.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_indiagst.sql');
|
||||
|
||||
$count_of_tax_codes = $this->get_count_of_tax_code_entries();
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_IndiaGST1 extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_indiagst1.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_indiagst1.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_IndiaGST2 extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_indiagst2.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_indiagst2.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_decimal_attribute_type extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_decimal_attribute_type.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_decimal_attribute_type.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_add_iso_4217 extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_add_iso_4217.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_add_iso_4217.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_PaymentTracking extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_paymenttracking.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_paymenttracking.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_RefundTracking extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper(['migration', 'locale']);
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_refundtracking.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_refundtracking.sql');
|
||||
|
||||
// Add missing cash_refund amounts to payments table
|
||||
$decimals = totals_decimals();
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_DBFix extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_dbfix.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_dbfix.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_fix_attribute_datetime extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_fix_attribute_datetime.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_fix_attribute_datetime.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_PaymentDateFix extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.2_paymentdatefix.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.2_paymentdatefix.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_SalesChangePrice extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.2_saleschangeprice.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.2_saleschangeprice.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_modify_attr_links_constraint extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.2_modify_attr_links_constraint.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.2_modify_attr_links_constraint.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_add_item_kit_number extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.3_add_kits_item_number.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.3_add_kits_item_number.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_modify_session_datatype extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.4_modify_session_datatype.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.3.4_modify_session_datatype.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -113,7 +113,7 @@ class Migration_database_optimizations extends Migration
|
||||
}
|
||||
$this->db->transComplete();
|
||||
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.4.0_database_optimizations.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.4.0_database_optimizations.sql');
|
||||
log_message('info', 'Finished migrating database optimizations.');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use App\Libraries\CI3SecretConverter;
|
||||
use CodeIgniter\Database\Exceptions\DatabaseException;
|
||||
use CodeIgniter\Database\Forge;
|
||||
use CodeIgniter\Database\Migration;
|
||||
use CodeIgniter\HTTP\Exceptions\RedirectException;
|
||||
use RuntimeException;
|
||||
|
||||
class ConvertToCI4 extends Migration
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct(?Forge $forge = null)
|
||||
{
|
||||
parent::__construct($forge);
|
||||
helper('security');
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a migration step.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
|
||||
if (!executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.4.0_CI4Conversion.sql')) {
|
||||
throw new DatabaseException('Migration script 3.4.0_CI4Conversion.sql failed. Check logs for details.');
|
||||
}
|
||||
|
||||
$existingKey = (string) config('Encryption')->key;
|
||||
|
||||
// A valid CI4 key requires no write — just confirm it is usable.
|
||||
if ($existingKey !== '' && strlen($existingKey) >= 64) {
|
||||
checkEncryption();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Every branch below writes to .env. If the runtime user cannot write
|
||||
// it (e.g. Docker/Compose with a read-only .env mount), fail with an
|
||||
// actionable message instead of a raw fopen() error deep in the writer.
|
||||
if (!envFileIsWritable()) {
|
||||
log_message('critical', 'Encryption key not provisioned and .env is not writable. Run `php spark env:provision` to generate one.');
|
||||
|
||||
throw new RuntimeException(lang('Error.encryption_key_not_provisioned'));
|
||||
}
|
||||
|
||||
if ($existingKey !== '' && strlen($existingKey) < 64) {
|
||||
// Old CI3-era key: decrypt, rotate, re-encrypt, persist — all under
|
||||
// a single .env lock (see convertCI3EncryptedData).
|
||||
$this->convertCI3EncryptedData($existingKey);
|
||||
} else {
|
||||
// No key at all: provision a fresh one (single atomic write), then
|
||||
// drop the incidental pre-write backup left behind by the rotation.
|
||||
rotateEncryptionKey(null);
|
||||
removeBackup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert a migration step.
|
||||
*/
|
||||
public function down(): void {}
|
||||
|
||||
/**
|
||||
* Decrypts legacy CI3-encrypted secrets with the old key, rotates to a
|
||||
* fresh CI4 key, re-encrypts under the new key, verifies the round trip,
|
||||
* and persists the result.
|
||||
*
|
||||
* The actual decryption/encryption is delegated to CI3SecretConverter so
|
||||
* the docker startup command (env:provision) shares the same code path.
|
||||
*
|
||||
* @param string $oldKey the CI3-era key currently in .env
|
||||
* @throws RedirectException
|
||||
*/
|
||||
private function convertCI3EncryptedData(string $oldKey): void
|
||||
{
|
||||
$converter = new CI3SecretConverter();
|
||||
|
||||
// DB read, safe outside the .env lock.
|
||||
$plain = $converter->decryptAll($oldKey);
|
||||
|
||||
// Backup -> rotate -> re-encrypt -> verify -> persist under one .env
|
||||
// lock. On any failure the transaction restores the pre-rotation .env
|
||||
// (still holding the lock); on success it drops the backup.
|
||||
rotateEncryptionKeyTransaction($oldKey, static function () use ($plain, $converter): void {
|
||||
$encrypted = $converter->encryptAll($plain);
|
||||
|
||||
// Verify the round trip before committing so we never lose data.
|
||||
if (array_diff_assoc($plain, $converter->verifyAll($encrypted)) !== []) {
|
||||
throw new RedirectException('login'); // TODO: Need to figure out how to pass the error to the Login controller so that it gets displayed.
|
||||
}
|
||||
|
||||
$converter->saveAll($encrypted);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use App\Models\Appconfig;
|
||||
use CodeIgniter\Database\Forge;
|
||||
use CodeIgniter\Database\Migration;
|
||||
use CodeIgniter\HTTP\RedirectResponse;
|
||||
use CodeIgniter\HTTP\Exceptions\RedirectException;
|
||||
use Config\Encryption;
|
||||
use Config\Services;
|
||||
use ReflectionException;
|
||||
|
||||
class Convert_to_ci4 extends Migration
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct(?Forge $forge = null)
|
||||
{
|
||||
parent::__construct($forge);
|
||||
helper('security');
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a migration step.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.4.0_ci4_conversion.sql');
|
||||
|
||||
if (!empty(config('Encryption')->key)) {
|
||||
$this->convert_ci3_encrypted_data();
|
||||
} else {
|
||||
check_encryption();
|
||||
}
|
||||
|
||||
remove_backup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert a migration step.
|
||||
*/
|
||||
public function down(): void {}
|
||||
|
||||
/**
|
||||
* @return RedirectResponse|void
|
||||
* @throws ReflectionException
|
||||
*/
|
||||
private function convert_ci3_encrypted_data()
|
||||
{
|
||||
$appconfig = model(Appconfig::class);
|
||||
|
||||
$ci3_encrypted_data = [
|
||||
'clcdesq_api_key' => '',
|
||||
'clcdesq_api_url' => '',
|
||||
'mailchimp_api_key' => '',
|
||||
'mailchimp_list_id' => '',
|
||||
'smtp_pass' => ''
|
||||
];
|
||||
|
||||
foreach ($ci3_encrypted_data as $key => $value) {
|
||||
$ci3_encrypted_data[$key] = $appconfig->get_value($key);
|
||||
}
|
||||
|
||||
$decrypted_data = $this->decrypt_ci3_data($ci3_encrypted_data);
|
||||
|
||||
check_encryption();
|
||||
|
||||
try {
|
||||
$ci4_encrypted_data = $this->encrypt_data($decrypted_data);
|
||||
|
||||
$success = empty(array_diff_assoc($decrypted_data, $this->decrypt_data($ci4_encrypted_data)));
|
||||
if (!$success) {
|
||||
abort_encryption_conversion();
|
||||
remove_backup();
|
||||
throw new RedirectException('login');
|
||||
}
|
||||
|
||||
$appconfig->batch_save($ci4_encrypted_data);
|
||||
} catch (RedirectException $e) {
|
||||
return redirect()->to('login'); // TODO: Need to figure out how to pass the error to the Login controller so that it gets displayed.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts CI3 encrypted data and returns the plaintext values.
|
||||
*
|
||||
* @param array $encrypted_data Data encrypted using CI3 methodology.
|
||||
* @return array Plaintext, unencrypted data.
|
||||
*/
|
||||
private function decrypt_ci3_data(array $encrypted_data): array
|
||||
{
|
||||
$config = new Encryption();
|
||||
$config->driver = 'OpenSSL';
|
||||
$config->key = config('Encryption')->key;
|
||||
$config->cipher = 'AES-128-CBC';
|
||||
$config->rawData = false;
|
||||
$config->encryptKeyInfo = 'encryption';
|
||||
$config->authKeyInfo = 'authentication';
|
||||
|
||||
$encrypter = Services::encrypter($config);
|
||||
|
||||
$decrypted_data = [];
|
||||
foreach ($encrypted_data as $key => $value) {
|
||||
$decrypted_data[$key] = !empty($value) ? $encrypter->decrypt($value) : '';
|
||||
}
|
||||
|
||||
return $decrypted_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts data using CI4 algorithms.
|
||||
*
|
||||
* @param array $plain_data Data to be encrypted.
|
||||
* @return array Encrypted data.
|
||||
*/
|
||||
private function encrypt_data(array $plain_data): array
|
||||
{
|
||||
$encrypter = Services::encrypter();
|
||||
|
||||
$encrypted_data = [];
|
||||
foreach ($plain_data as $key => $value) {
|
||||
$encrypted_data[$key] = !empty($value) ? $encrypter->encrypt($value) : '';
|
||||
}
|
||||
|
||||
return $encrypted_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts data using CI4 algorithms.
|
||||
*
|
||||
* @param array $encrypted_data Data to be decrypted.
|
||||
* @return array Decrypted data.
|
||||
*/
|
||||
private function decrypt_data(array $encrypted_data): array
|
||||
{
|
||||
$encrypter = Services::encrypter();
|
||||
|
||||
$decrypted_data = [];
|
||||
foreach ($encrypted_data as $key => $value) {
|
||||
$decrypted_data[$key] = !empty($value) ? $encrypter->decrypt($value) : '';
|
||||
}
|
||||
|
||||
return $decrypted_data;
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class Migration_fix_keys_for_db_upgrade extends Migration
|
||||
$foreignKeyExists = $this->db->query($checkSql)->getRow();
|
||||
|
||||
if ($foreignKeyExists) {
|
||||
$this->db->query('ALTER TABLE ' . $this->db->prefixTable('sales_items_taxes') . ' DROP CONSTRAINT ospos_sales_items_taxes_ibfk_1');
|
||||
$this->db->query('ALTER TABLE ' . $this->db->prefixTable('sales_items_taxes') . ' DROP FOREIGN KEY ospos_sales_items_taxes_ibfk_1');
|
||||
}
|
||||
|
||||
$this->db->query('ALTER TABLE ' . $this->db->prefixTable('sales_items_taxes')
|
||||
|
||||
@@ -28,7 +28,7 @@ class fix_duplicate_attributes extends Migration
|
||||
|
||||
dropForeignKeyConstraints($foreignKeys, 'attribute_links');
|
||||
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.4.0_attribute_links_unique_constraint.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.4.0_attribute_links_unique_constraint.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_sessions_migration extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.4.1_migrate_sessions_table.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.4.1_migrate_sessions_table.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,7 +21,7 @@ class AttributeLinksUniqueConstraint extends Migration
|
||||
dropForeignKeyConstraints($foreignKeys, 'attribute_links');
|
||||
dropColumnIfExists('ospos_attribute_links', 'generated_unique_column');
|
||||
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.4.1_attribute_links_unique_constraint.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.4.1_attribute_links_unique_constraint.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_MissingConfigKeys extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
executeScriptWithTransaction(APPPATH . 'Database/Migrations/sqlscripts/3.4.2_missing_config_keys.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.4.2_missing_config_keys.sql', true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_NullableTaxCategoryId extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.4.2_nullable_tax_category_id.sql');
|
||||
executeScript(APPPATH . 'Database/Migrations/sqlscripts/3.4.2_nullable_tax_category_id.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File renamed without changes.
@@ -7,7 +7,7 @@ use Config\Database;
|
||||
|
||||
class TestDatabaseBootstrapSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
public static function reset(): void
|
||||
{
|
||||
if (ENVIRONMENT !== 'testing') {
|
||||
throw new \RuntimeException('TestDatabaseBootstrapSeeder can only run in the testing environment.');
|
||||
@@ -33,5 +33,22 @@ class TestDatabaseBootstrapSeeder extends Seeder
|
||||
|
||||
$serverConn->query("DROP DATABASE IF EXISTS `{$dbName}`");
|
||||
$serverConn->query("CREATE DATABASE IF NOT EXISTS `{$dbName}`");
|
||||
$serverConn->close();
|
||||
|
||||
// The application's shared 'tests' connection caches listTables()
|
||||
// results in dataCache['table_names'']. Because we dropped and
|
||||
// recreated the schema on a SEPARATE server connection above, that
|
||||
// cache is now stale. Reset it so subsequent listTables()/tableExists()
|
||||
// calls re-query the server instead of trusting a dropped schema's table
|
||||
// list (which previously caused "ospos_migrations doesn't exist" and
|
||||
// "column ... doesn't exist" errors for every class that followed a
|
||||
// bootstrap-reset class in the suite).
|
||||
$shared = Database::connect('tests');
|
||||
$shared->resetDataCache();
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
self::reset();
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,10 @@ class Load_config
|
||||
|
||||
private function languageExists(string $languageCode): bool
|
||||
{
|
||||
if (strpbrk($languageCode, '/\\') !== false || str_contains($languageCode, '..') || str_contains($languageCode, "\0")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return file_exists(APPPATH . 'Language/' . $languageCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use App\Models\Employee;
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\Exceptions\RedirectException;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class IsLoggedIn implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
$employee = model(Employee::class);
|
||||
|
||||
if (!$employee->is_logged_in()) {
|
||||
throw new RedirectException('login');
|
||||
}
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Services;
|
||||
|
||||
/**
|
||||
* Rate limits login/migrate POST attempts by IP and submitted username to
|
||||
* mitigate brute-force/credential-stuffing. Backed by CodeIgniter's
|
||||
* cache-based Throttler, so limits are per-server (not shared on file cache).
|
||||
*
|
||||
* Tunable via `throttle.capacity` (default 5; 0 disables) and
|
||||
* `throttle.seconds` (window, default 60) in .env.
|
||||
*/
|
||||
class Throttle implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
if ($request->getMethod() !== 'POST') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Non-positive integer = explicit disable; missing/non-numeric falls
|
||||
// back to the default so a typo (e.g. "five") cannot bypass lockout.
|
||||
$capacity = filter_var(env('throttle.capacity'), FILTER_VALIDATE_INT);
|
||||
if ($capacity === false) {
|
||||
$capacity = 5;
|
||||
}
|
||||
if ($capacity <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$seconds = filter_var(env('throttle.seconds'), FILTER_VALIDATE_INT);
|
||||
if ($seconds === false) {
|
||||
$seconds = 60;
|
||||
}
|
||||
$seconds = max(1, $seconds);
|
||||
|
||||
helper('security');
|
||||
|
||||
$throttler = Services::throttler();
|
||||
$secret = checkThrottleEncryption();
|
||||
|
||||
$ipKey = 'login-ip-' . hash_hmac('sha256', $request->getIPAddress(), $secret);
|
||||
$rawUsername = $request->getPost('username');
|
||||
$username = is_scalar($rawUsername) ? strtolower((string) $rawUsername) : '';
|
||||
$usernameKey = $username !== '' ? 'login-user-' . hash_hmac('sha256', $username, $secret) : null;
|
||||
|
||||
$ipOk = $throttler->check($ipKey, $capacity, $seconds);
|
||||
$usernameOk = $usernameKey === null || $throttler->check($usernameKey, $capacity, $seconds);
|
||||
|
||||
if (!$ipOk || !$usernameOk) {
|
||||
log_message('warning', 'Login throttled for IP {ip} (username: {username})', [
|
||||
'ip' => $request->getIPAddress(),
|
||||
'username' => $username !== '' ? $username : '(none)',
|
||||
]);
|
||||
|
||||
return service('response')
|
||||
->setStatusCode(429)
|
||||
->setJSON([
|
||||
'success' => false,
|
||||
'message' => lang('Login.too_many_attempts'),
|
||||
]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -98,3 +98,32 @@ function bom_exists(&$file_handle): bool
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the parsed item import rows expose the columns the current
|
||||
* template requires. A file whose header row does not match the template
|
||||
* (for example when the "Id" column is missing, an attribute column was
|
||||
* renamed, or a stock location was added since the file was last downloaded)
|
||||
* would otherwise trigger "Undefined array key" errors while building each
|
||||
* item. The required columns are derived from generate_import_items_csv(),
|
||||
* the same function that builds the downloadable template, so location and
|
||||
* attribute columns stay in sync automatically.
|
||||
*
|
||||
* @param array $csv_rows Rows returned by get_csv_file().
|
||||
* @param array $stock_locations Stock locations, as returned by Stock_location::get_allowed_locations().
|
||||
* @param array $attribute_names Attribute definition names, as returned by Attribute::get_definition_names().
|
||||
* @return bool True when the required columns are present.
|
||||
*/
|
||||
function csvImportHasRequiredItemHeaders(array $csv_rows, array $stock_locations, array $attribute_names): bool
|
||||
{
|
||||
if (empty($csv_rows)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$template_header_line = substr(generate_import_items_csv($stock_locations, $attribute_names), 3);
|
||||
$required_headers = str_getcsv($template_header_line);
|
||||
|
||||
$present_headers = array_keys(reset($csv_rows));
|
||||
|
||||
return empty(array_diff($required_headers, $present_headers));
|
||||
}
|
||||
@@ -485,7 +485,7 @@ function parse_decimals(string $number, ?int $decimals = null): mixed
|
||||
$fmt = new NumberFormatter($config['number_locale'], NumberFormatter::DECIMAL);
|
||||
|
||||
if (!$decimals) {
|
||||
$decimals = intVal($config['currency_decimals']);
|
||||
$decimals = intVal($config['currency_decimals'] ?? 2);
|
||||
$fmt->setAttribute(NumberFormatter::FRACTION_DIGITS, $decimals);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,77 +3,43 @@
|
||||
use Config\Database;
|
||||
|
||||
/**
|
||||
* Migration helper.
|
||||
* Executes a SQL migration script.
|
||||
* @param string $path Path to migration script.
|
||||
* @param bool $withTransaction Whether to wrap execution in a transaction.
|
||||
* @return bool Whether the migration executed successfully.
|
||||
*/
|
||||
function execute_script(string $path): bool
|
||||
function executeScript(string $path, bool $withTransaction = false): bool
|
||||
{
|
||||
$version = preg_replace("/(.*_)?(.*).sql/", "$2", $path);
|
||||
$version = explode('_', basename($path, '.sql'), 2)[0];
|
||||
log_message('info', "Migrating to $version (file: $path)");
|
||||
|
||||
$sql = file_get_contents($path);
|
||||
$sqls = explode(';', $sql);
|
||||
$sqls = explode(';', file_get_contents($path));
|
||||
array_pop($sqls);
|
||||
|
||||
$db = Database::connect();
|
||||
|
||||
if ($withTransaction) {
|
||||
$db->transStart();
|
||||
}
|
||||
|
||||
$success = true; // whether *all* queries succeeded
|
||||
foreach ($sqls as $statement) {
|
||||
$statement = "$statement;";
|
||||
$hadError = !$db->simpleQuery($statement);
|
||||
|
||||
if ($hadError) {
|
||||
$success = false;
|
||||
foreach ($db->error() as $error) {
|
||||
log_message('error', "error: $error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($success) {
|
||||
log_message('info', "Successfully migrated to $version");
|
||||
}
|
||||
else {
|
||||
log_message('info', "Could not migrate to $version.");
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration helper that uses a transaction.
|
||||
* @param string $path Path to migration script.
|
||||
* @return bool Whether the migration executed successfully.
|
||||
*/
|
||||
function executeScriptWithTransaction(string $path): bool
|
||||
{
|
||||
$version = preg_replace("/(.*_)?(.*).sql/", "$2", $path);
|
||||
log_message('info', "Migrating to $version (file: $path) with transaction");
|
||||
|
||||
$sql = file_get_contents($path);
|
||||
$sqls = explode(';', $sql);
|
||||
array_pop($sqls);
|
||||
|
||||
$db = Database::connect();
|
||||
$db->transStart();
|
||||
|
||||
$success = true;
|
||||
try {
|
||||
foreach ($sqls as $statement) {
|
||||
$statement = "$statement;";
|
||||
$hadError = !$db->query($statement);
|
||||
$hadError = $withTransaction ? !$db->query($statement) : !$db->simpleQuery($statement);
|
||||
|
||||
if ($hadError) {
|
||||
$success = false;
|
||||
foreach ($db->error() as $error) {
|
||||
log_message('info', "error: $error");
|
||||
log_message('error', "error: $error");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
log_message('info', "Could not migrate to $version: " . $e->getMessage());
|
||||
$db->transRollback();
|
||||
log_message('error', "Could not migrate to $version: " . $e->getMessage());
|
||||
if ($withTransaction) {
|
||||
$db->transRollback();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -83,7 +49,10 @@ function executeScriptWithTransaction(string $path): bool
|
||||
log_message('info', "Could not migrate to $version.");
|
||||
}
|
||||
|
||||
$db->transComplete();
|
||||
if ($withTransaction) {
|
||||
$db->transComplete();
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
|
||||
+595
-55
@@ -1,95 +1,635 @@
|
||||
<?php
|
||||
|
||||
use App\Libraries\CI3SecretConverter;
|
||||
use CodeIgniter\Encryption\Encryption;
|
||||
use Config\Services;
|
||||
use Random\RandomException;
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
* Opens (creating if needed) and exclusively locks a dedicated mutex file
|
||||
* for coordinating .env writes. Windows can't rename/delete a file while
|
||||
* any handle to it is open, so the mutex must be a separate file from
|
||||
* .env itself — never fopen()/flock() .env directly.
|
||||
*
|
||||
* @return resource
|
||||
*/
|
||||
function check_encryption(): bool
|
||||
function lockEnvFile()
|
||||
{
|
||||
$old_key = config('Encryption')->key;
|
||||
$lockPath = config('SecurityEnv')->lockPath;
|
||||
|
||||
if ((empty($old_key)) || (strlen($old_key) < 64)) {
|
||||
$encryption = new Encryption();
|
||||
$key = bin2hex($encryption->createKey());
|
||||
config('Encryption')->key = $key;
|
||||
$handle = @fopen($lockPath, 'c+');
|
||||
if ($handle === false) {
|
||||
$reason = error_get_last()['message'] ?? 'unknown error';
|
||||
log_message('critical', "Unable to open $lockPath: $reason");
|
||||
|
||||
$config_path = ROOTPATH . '.env';
|
||||
$backup_path = WRITEPATH . '/backup/.env.bak';
|
||||
$backup_folder = WRITEPATH . '/backup';
|
||||
throw new RuntimeException(lang('Error.unable_to_open_lock_file', ['filePath' => $lockPath, 'reason' => $reason]));
|
||||
}
|
||||
|
||||
if (!file_exists($backup_folder)) {
|
||||
@mkdir($backup_folder, 0750, true);
|
||||
if (!flock($handle, LOCK_EX)) {
|
||||
fclose($handle);
|
||||
|
||||
$reason = error_get_last()['message'] ?? 'unknown error';
|
||||
log_message('critical', "Unable to lock $lockPath: $reason");
|
||||
|
||||
throw new RuntimeException(lang('Error.unable_to_lock_file', ['filePath' => $lockPath, 'reason' => $reason]));
|
||||
}
|
||||
|
||||
return $handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resource $handle
|
||||
* @return void
|
||||
*/
|
||||
function unlockEnvFile($handle): void
|
||||
{
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies .env.example to .env (or creates a stub) if .env does not exist yet.
|
||||
*
|
||||
* @param string $configPath
|
||||
* @return bool true if $configPath exists (already did, or was just created)
|
||||
*/
|
||||
function initializeEnvFile(string $configPath): bool
|
||||
{
|
||||
if (!file_exists($configPath)) {
|
||||
$examplePath = ROOTPATH . '.env.example';
|
||||
if (file_exists($examplePath)) {
|
||||
@copy($examplePath, $configPath);
|
||||
} else {
|
||||
@file_put_contents($configPath, "# OSPOS Configuration\n\n");
|
||||
}
|
||||
@chmod($configPath, 0640);
|
||||
}
|
||||
|
||||
return file_exists($configPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces or inserts a single `key='value'` line in .env
|
||||
*
|
||||
* @param string $envKey
|
||||
* @param string $value
|
||||
* @return bool true on success, false if the write could not be completed
|
||||
*/
|
||||
function writeEnvKey(string $envKey, string $value): bool
|
||||
{
|
||||
$configPath = config('SecurityEnv')->envPath;
|
||||
|
||||
if (!initializeEnvFile($configPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lock = lockEnvFile();
|
||||
|
||||
try {
|
||||
$configFile = @file_get_contents($configPath);
|
||||
if ($configFile === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file_exists($config_path)) {
|
||||
$example_path = ROOTPATH . '.env.example';
|
||||
if (file_exists($example_path)) {
|
||||
@copy($example_path, $config_path);
|
||||
} else {
|
||||
@file_put_contents($config_path, "# OSPOS Configuration\n\n");
|
||||
}
|
||||
@chmod($config_path, 0640);
|
||||
$updated = applyEnvKeyReplacement($configFile, $envKey, $value);
|
||||
if ($updated === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file_exists($config_path)) {
|
||||
@copy($config_path, $backup_path);
|
||||
@chmod($backup_path, 0640);
|
||||
@chmod($config_path, 0640);
|
||||
return atomicWriteFile($configPath, $updated);
|
||||
} finally {
|
||||
unlockEnvFile($lock);
|
||||
}
|
||||
}
|
||||
|
||||
$config_file = file_get_contents($config_path);
|
||||
function applyEnvKeyReplacement(string $configFile, string $envKey, string $value): ?string
|
||||
{
|
||||
$pattern = '/^\s*' . preg_quote($envKey, '/') . '\s*=.*/m';
|
||||
$escapedValue = str_replace(['\\', '$'], ['\\\\', '\\$'], $value);
|
||||
|
||||
if (preg_match('/^\s*encryption\.key\s*=/m', $config_file)) {
|
||||
$config_file = preg_replace("/^(\s*encryption\.key\s*=\s*).*/m", "\$1'$key'", $config_file, 1);
|
||||
} else {
|
||||
$config_file .= "\nencryption.key = '$key'\n";
|
||||
}
|
||||
if (preg_match($pattern, $configFile)) {
|
||||
return preg_replace_callback($pattern, static fn () => "$envKey='$escapedValue'", $configFile, 1);
|
||||
}
|
||||
|
||||
if (!empty($old_key)) {
|
||||
$old_line = "# encryption.key = '$old_key' REMOVE IF UNNEEDED\r\n";
|
||||
if (preg_match('/^encryption\.key\s*=/m', $config_file, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
$config_file = substr_replace($config_file, $old_line, $matches[0][1], 0);
|
||||
}
|
||||
}
|
||||
// New keys insert right after encryption.key so it stays first for easy backup/rotation.
|
||||
if (preg_match('/^encryption\.key\s*=.*$/m', $configFile, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
$insertAt = (int) $matches[0][1] + strlen($matches[0][0]);
|
||||
|
||||
@file_put_contents($config_path, $config_file);
|
||||
@chmod($config_path, 0640);
|
||||
return substr_replace($configFile, "\n$envKey='$escapedValue'", $insertAt, 0);
|
||||
}
|
||||
|
||||
log_message('info', "Updated encryption key in $config_path");
|
||||
return $configFile . "\n$envKey='$escapedValue'\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes $contents to a temp file in the same directory as $path, then
|
||||
* renames it onto $path so readers never observe a partially written file.
|
||||
*
|
||||
* @param string $path
|
||||
* @param string $contents
|
||||
* @return bool
|
||||
* @throws RandomException
|
||||
*/
|
||||
function atomicWriteFile(string $path, string $contents): bool
|
||||
{
|
||||
$tmpPath = $path . '.tmp.' . bin2hex(random_bytes(8));
|
||||
|
||||
$handle = @fopen($tmpPath, 'x');
|
||||
if ($handle === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!@chmod($tmpPath, 0640)) {
|
||||
fclose($handle);
|
||||
@unlink($tmpPath);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$written = fwrite($handle, $contents);
|
||||
if ($written === false || $written !== strlen($contents) || !fflush($handle)) {
|
||||
fclose($handle);
|
||||
@unlink($tmpPath);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (function_exists('fsync') && !fsync($handle)) {
|
||||
fclose($handle);
|
||||
@unlink($tmpPath);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
// On Windows rename() does not overwrite an existing destination. Fall back to unlink()+rename().
|
||||
if (!@rename($tmpPath, $path)) {
|
||||
if (PHP_OS_FAMILY !== 'Windows' || !@unlink($path) || !@rename($tmpPath, $path)) {
|
||||
@unlink($tmpPath);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@chmod($path, 0640);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies $configPath to $backupPath, creating the backup folder if needed.
|
||||
*
|
||||
* @param string $configPath
|
||||
* @param string $backupPath
|
||||
* @return bool true when the backup exists and is readable, false otherwise
|
||||
*/
|
||||
function backupEnvFile(string $configPath, string $backupPath): bool
|
||||
{
|
||||
$backupFolder = dirname($backupPath);
|
||||
|
||||
if (!file_exists($backupFolder) && !@mkdir($backupFolder, 0750, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!@copy($configPath, $backupPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is_readable($backupPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (@chmod($backupPath, 0640) !== true || @chmod($configPath, 0640) !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* Applies the new encryption.key to $configFile, preserving $oldKey as a
|
||||
* commented-out backup line immediately before it.
|
||||
*
|
||||
* @param string $configFile
|
||||
* @param string $key
|
||||
* @param string $oldKey
|
||||
* @return string|null updated file contents, or null if the replacement failed
|
||||
*/
|
||||
function abort_encryption_conversion(): void
|
||||
function writeNewEncryptionKey(string $configFile, string $key, string $oldKey): ?string
|
||||
{
|
||||
$config_path = ROOTPATH . '.env';
|
||||
$backup_path = WRITEPATH . '/backup/.env.bak';
|
||||
|
||||
if (!file_exists($backup_path)) {
|
||||
return;
|
||||
$updated = applyEnvKeyReplacement($configFile, 'encryption.key', $key);
|
||||
if ($updated === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@chmod($config_path, 0640);
|
||||
$config_file = file_get_contents($backup_path);
|
||||
@file_put_contents($config_path, $config_file);
|
||||
log_message('info', "Restored $config_path from backup");
|
||||
if (!empty($oldKey)) {
|
||||
$oldLine = "# encryption.key='$oldKey' REMOVE IF UNNEEDED\r\n";
|
||||
if (preg_match('/^encryption\.key\s*=/m', $updated, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
$updated = substr_replace($updated, $oldLine, $matches[0][1], 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the current process can write to (or create) .env.
|
||||
*
|
||||
* The write path (temp file + lock, then rename) needs write permission on
|
||||
* the DIRECTORY, not on .env itself — a bind-mounted .env can be writable
|
||||
* while the dir (or a root-owned .env.lock) is not, so .env's own mode is not
|
||||
* a reliable signal.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
function envFileIsWritable(): bool
|
||||
{
|
||||
$configPath = config('SecurityEnv')->envPath;
|
||||
$lockPath = config('SecurityEnv')->lockPath;
|
||||
$dir = dirname($configPath);
|
||||
|
||||
if (!is_writable($dir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Windows-only: rename() can't replace a read-only destination, so an
|
||||
// existing .env must itself be writable (POSIX rename() can, if the dir is).
|
||||
if (PHP_OS_FAMILY === 'Windows'
|
||||
&& file_exists($configPath)
|
||||
&& !is_writable($configPath)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// An existing mutex file (e.g. left by a prior root env:provision) must be writable.
|
||||
if (file_exists($lockPath) && !is_writable($lockPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a usable CI4 encryption key is available.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Key present and >= 64 characters: returns true immediately (no I/O).
|
||||
* - Key missing or CI3-era (< 64 chars) and .env IS writable:
|
||||
* - Short key: decrypts legacy CI3 secrets, rotates to a fresh CI4 key,
|
||||
* re-encrypts under the new key, verifies the round-trip, persists result.
|
||||
* - Missing key: generates a fresh key and persists it.
|
||||
* - Key missing or CI3-era (< 64 chars) and .env NOT writable:
|
||||
* throws — the key was presumably already provisioned externally
|
||||
* (e.g. `php spark env:provision` at container startup); the in-memory
|
||||
* config simply hasn't been reloaded yet.
|
||||
*
|
||||
* @param CI3SecretConverter|null $converter injectable converter used to
|
||||
* decrypt/re-encrypt legacy CI3
|
||||
* secrets in the short-key branch;
|
||||
* defaults to the real converter
|
||||
* (tests may pass a fake to avoid
|
||||
* hitting the database)
|
||||
* @return bool true when a valid key is available
|
||||
* @throws Throwable when .env is not writable or the CI3 -> CI4 conversion fails
|
||||
*/
|
||||
function checkEncryption(?CI3SecretConverter $converter = null): bool
|
||||
{
|
||||
$key = (string) config('Encryption')->key;
|
||||
|
||||
if ($key !== '' && strlen($key) >= 64) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!envFileIsWritable()) {
|
||||
log_message('critical', 'Encryption key not provisioned and .env is not writable. Run `php spark env:provision` to generate one.');
|
||||
|
||||
throw new RuntimeException(lang('Error.encryption_key_not_provisioned'));
|
||||
}
|
||||
|
||||
if ($key !== '') {
|
||||
$converter = $converter ?? new CI3SecretConverter();
|
||||
|
||||
// Legacy plaintext is a DB read; safe outside the .env lock (the lock
|
||||
// only guards the .env file write, which the rotation performs).
|
||||
$plain = $converter->decryptAll($key);
|
||||
|
||||
$nonEmpty = false;
|
||||
foreach ($plain as $value) {
|
||||
if ((string) $value !== '') {
|
||||
$nonEmpty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Run the whole backup -> rotate -> re-encrypt -> verify -> persist unit
|
||||
// under a single .env lock. On any failure, the pre-rotation backup is
|
||||
// restored (also in-lock); on success the backup is removed (in-lock).
|
||||
rotateEncryptionKeyTransaction($key, static function () use ($plain, $converter, $nonEmpty): void {
|
||||
$encrypted = $converter->encryptAll($plain);
|
||||
|
||||
if (array_diff_assoc($plain, $converter->verifyAll($encrypted)) !== []) {
|
||||
throw new RuntimeException(lang('Error.unable_to_persist_encryption_key', ['filePath' => config('SecurityEnv')->envPath]));
|
||||
}
|
||||
|
||||
if ($nonEmpty) {
|
||||
$converter->saveAll($encrypted);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fresh key (no old key to decrypt): a single atomic write suffices.
|
||||
rotateEncryptionKey(null);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the persistent HMAC secret used to hash login-throttle cache keys.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Key already present (throttle.key or THROTTLE_KEY): returned immediately (no I/O).
|
||||
* - Key missing and .env IS writable: generates a fresh key and persists it.
|
||||
* - Key missing and .env NOT writable:
|
||||
* throws — the key was presumably already provisioned externally
|
||||
* (e.g. `php spark env:provision` at container startup).
|
||||
*
|
||||
* THROTTLE_KEY is a real environment-variable fallback (mirroring ENCRYPTION_KEY):
|
||||
* it is consulted only when throttle.key is empty, so it can be supplied via
|
||||
* Docker/Compose without writing a shared secret into .env.
|
||||
*
|
||||
* @return string the throttle key
|
||||
* @throws RuntimeException if the key cannot be provisioned
|
||||
*/
|
||||
function checkThrottleEncryption(): string
|
||||
{
|
||||
$key = (string) env('throttle.key', '');
|
||||
|
||||
if ($key === '') {
|
||||
$envKey = getenv('THROTTLE_KEY');
|
||||
$key = $envKey === false ? '' : $envKey;
|
||||
}
|
||||
|
||||
if ($key !== '') {
|
||||
return $key;
|
||||
}
|
||||
|
||||
if (!envFileIsWritable()) {
|
||||
log_message('critical', 'Throttle key not provisioned and .env is not writable. Run `php spark env:provision` to generate one.');
|
||||
|
||||
throw new RuntimeException(lang('Error.throttle_key_not_provisioned'));
|
||||
}
|
||||
|
||||
return provisionThrottleKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a strong encryption key, persists it to `.env` (rotating in place
|
||||
* so callers can re-encrypt CI3 data that was sealed with the old key), and
|
||||
* applies it to the running config instance.
|
||||
*
|
||||
* Standalone key-write path: acquires and holds the `.env` mutex for the
|
||||
* duration of the write, so concurrent callers see atomic key writes. Callers
|
||||
* that also run a longer multi-step conversion (re-encrypt, verify, persist)
|
||||
* must use rotateEncryptionKeyTransaction() instead, so a single lock is held
|
||||
* for the whole backup -> rotate -> re-encrypt -> persist unit and the
|
||||
* key-write does not take a nested lock on the same mutex (which would
|
||||
* deadlock).
|
||||
*
|
||||
* @param string|null $oldKey current key to rotate from; when non-empty it is
|
||||
* preserved as a commented backup line for the
|
||||
* CI3->CI4 re-encryption pass
|
||||
* @return string the newly generated key
|
||||
* @throws RuntimeException if the key could not be generated or persisted
|
||||
* @throws RandomException
|
||||
*/
|
||||
function rotateEncryptionKey(?string $oldKey = null): string
|
||||
{
|
||||
$lock = lockEnvFile();
|
||||
|
||||
try {
|
||||
return rotateEncryptionKeyUnlock($oldKey);
|
||||
} finally {
|
||||
unlockEnvFile($lock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock-free core of rotateEncryptionKey(): generates a key, backs up .env,
|
||||
* writes the new key (atomic), and applies it to the running config.
|
||||
*
|
||||
* Do not call directly from context that does not already hold the lock —
|
||||
* that leaves a window in which another worker can read or write .env before
|
||||
* the atomic write lands. The two callers, rotateEncryptionKey() and
|
||||
* rotateEncryptionKeyTransaction(), each acquire the lock first and pass the
|
||||
* lock-free core through.
|
||||
*
|
||||
* @param string|null $oldKey current key to rotate from
|
||||
* @return string the newly generated key
|
||||
* @throws RuntimeException if the key could not be generated or persisted
|
||||
* @throws RandomException
|
||||
*/
|
||||
function rotateEncryptionKeyUnlock(?string $oldKey): string
|
||||
{
|
||||
$encryption = new Encryption();
|
||||
$key = bin2hex($encryption->createKey());
|
||||
|
||||
$configPath = config('SecurityEnv')->envPath;
|
||||
$backupPath = config('SecurityEnv')->backupPath;
|
||||
|
||||
if (!initializeEnvFile($configPath)) {
|
||||
throw new RuntimeException(lang('Error.unable_to_create_env_file', ['filePath' => $configPath]));
|
||||
}
|
||||
|
||||
if (file_exists($configPath)) {
|
||||
if (!backupEnvFile($configPath, $backupPath)) {
|
||||
log_message('critical', "Unable to back up $configPath to $backupPath before rotation; aborting.");
|
||||
|
||||
throw new RuntimeException(lang('Error.unable_to_persist_encryption_key', ['filePath' => $configPath]));
|
||||
}
|
||||
}
|
||||
|
||||
$configFile = @file_get_contents($configPath);
|
||||
if ($configFile === false) {
|
||||
log_message('critical', "Unable to read $configPath before rotation; aborting.");
|
||||
|
||||
throw new RuntimeException(lang('Error.unable_to_read_env_file', ['filePath' => $configPath]));
|
||||
}
|
||||
|
||||
$updated = writeNewEncryptionKey($configFile, $key, (string) $oldKey);
|
||||
if ($updated === null) {
|
||||
throw new RuntimeException(lang('Error.unable_to_persist_encryption_key', ['filePath' => $configPath]));
|
||||
}
|
||||
|
||||
if (!atomicWriteFile($configPath, $updated)) {
|
||||
throw new RuntimeException(lang('Error.unable_to_persist_encryption_key', ['filePath' => $configPath]));
|
||||
}
|
||||
|
||||
config('Encryption')->key = $key;
|
||||
|
||||
log_message('info', "Rotated encryption key in $configPath");
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the CI3 -> CI4 key conversion as a single transaction on the `.env`
|
||||
* mutex.
|
||||
*
|
||||
* The lock is acquired BEFORE the .env backup and released only AFTER
|
||||
* $conversion has run, so the entire unit (backup -> rotate -> re-encrypt ->
|
||||
* verify -> persist) is atomic with respect to any other worker that also
|
||||
* takes the lock. The $conversion callback must throw on failure; the caller
|
||||
* is expected to catch the exception and roll back via
|
||||
* abortEncryptionConversion().
|
||||
*
|
||||
* Calls the lock-free rotateEncryptionKeyUnlock() internally so the
|
||||
* transaction lock is not re-acquired on the same mutex (which would deadlock)
|
||||
* — the mutex is held across the backup, the key write, the re-encryption,
|
||||
* verification, and persistence.
|
||||
*
|
||||
* @param string|null $oldKey current key to rotate from
|
||||
* @param callable $conversion callback(): void, called after the new key
|
||||
* has been persisted and applied to the running
|
||||
* config; expected to perform re-encryption,
|
||||
* verification, and persistence. Must throw on
|
||||
* failure; the pre-rotation backup is restored
|
||||
* before the exception propagates.
|
||||
* @return string the newly generated key
|
||||
* @throws Throwable from $conversion (the lock is released in `finally`)
|
||||
* @throws RuntimeException
|
||||
* @throws RandomException
|
||||
*/
|
||||
function rotateEncryptionKeyTransaction(?string $oldKey, callable $conversion): string
|
||||
{
|
||||
// Hold one lock for the entire transaction (backup -> rotate -> re-encrypt
|
||||
// -> verify -> persist -> cleanup). If we cannot acquire it, we fail
|
||||
// rather than silently run without synchronisation — the invariant (no
|
||||
// interleaved key + ciphertext writes) requires it.
|
||||
$lock = lockEnvFile();
|
||||
|
||||
try {
|
||||
// Lock-free inner write: the outer lock IS the transaction lock, so no
|
||||
// nested re-acquire on the same mutex (which would deadlock).
|
||||
$newKey = rotateEncryptionKeyUnlock($oldKey);
|
||||
|
||||
// Multi-step conversion (re-encrypt -> verify -> persist) runs while
|
||||
// still holding the lock, so a concurrent worker cannot interleave its
|
||||
// key write between the rotation and this ciphertext save.
|
||||
$conversion();
|
||||
|
||||
// Success: drop the pre-rotation backup, in-lock.
|
||||
removeBackup();
|
||||
|
||||
return $newKey;
|
||||
} catch (Throwable $e) {
|
||||
// Failure (CI4 EncryptionException / ReflectionException from saveAll,
|
||||
// or a failed round-trip verify, or the rotation itself failed):
|
||||
// restore the pre-rotation .env while still holding the lock, then
|
||||
// rethrow. catch runs before finally, so the restore is atomic with
|
||||
// the held lock.
|
||||
abortEncryptionConversion();
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
unlockEnvFile($lock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a persistent throttle secret exists, generating and persisting one
|
||||
* to `.env` if it is missing. Idempotent: safe to call on every startup.
|
||||
*
|
||||
* @return string the throttle key
|
||||
* @throws RuntimeException if the key could not be created or persisted
|
||||
*/
|
||||
function provisionThrottleKey(): string
|
||||
{
|
||||
$configPath = config('SecurityEnv')->envPath;
|
||||
|
||||
if (!initializeEnvFile($configPath)) {
|
||||
throw new RuntimeException(lang('Error.unable_to_create_env_file', ['filePath' => $configPath]));
|
||||
}
|
||||
|
||||
$lock = lockEnvFile();
|
||||
|
||||
try {
|
||||
$configFile = @file_get_contents($configPath);
|
||||
if ($configFile === false) {
|
||||
log_message('critical', "Unable to read $configPath while provisioning throttle key; aborting.");
|
||||
|
||||
throw new RuntimeException(lang('Error.unable_to_read_env_file', ['filePath' => $configPath]));
|
||||
}
|
||||
|
||||
$key = '';
|
||||
if (preg_match('/^\s*throttle\.key\s*=\s*[\'"]?([^\'"\r\n]*)/m', $configFile, $matches)) {
|
||||
$existing = trim($matches[1]);
|
||||
if ($existing !== '') {
|
||||
$key = $existing;
|
||||
}
|
||||
}
|
||||
|
||||
if ($key === '') {
|
||||
$key = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
$updated = applyEnvKeyReplacement($configFile, 'throttle.key', $key);
|
||||
if ($updated === null || !atomicWriteFile($configPath, $updated)) {
|
||||
throw new RuntimeException(lang('Error.unable_to_persist_throttle_key', ['filePath' => $configPath]));
|
||||
}
|
||||
} finally {
|
||||
unlockEnvFile($lock);
|
||||
}
|
||||
|
||||
putenv("throttle.key=$key");
|
||||
$_ENV['throttle.key'] = $key;
|
||||
$_SERVER['throttle.key'] = $key;
|
||||
|
||||
log_message('info', 'Provisioned throttle key in ' . config('SecurityEnv')->envPath);
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
function remove_backup(): void
|
||||
function abortEncryptionConversion(): void
|
||||
{
|
||||
$backup_path = WRITEPATH . '/backup/.env.bak';
|
||||
if (!file_exists($backup_path)) {
|
||||
$configPath = config('SecurityEnv')->envPath;
|
||||
$backupPath = config('SecurityEnv')->backupPath;
|
||||
|
||||
if (!file_exists($backupPath)) {
|
||||
return;
|
||||
}
|
||||
@unlink($backup_path);
|
||||
log_message('info', "Removed $backup_path");
|
||||
|
||||
// A backup exists, so the restore must succeed or fail loudly; a silent
|
||||
// failure would leave .env holding the new key while the DB still holds the
|
||||
// old ciphertext, making the data undecryptable after the next restart.
|
||||
if (!is_file($backupPath) || !is_readable($backupPath)) {
|
||||
throw new RuntimeException(lang('Error.unable_to_read_env_file', ['filePath' => $backupPath]));
|
||||
}
|
||||
|
||||
$configFile = file_get_contents($backupPath);
|
||||
if ($configFile === false) {
|
||||
throw new RuntimeException(lang('Error.unable_to_read_env_file', ['filePath' => $backupPath]));
|
||||
}
|
||||
|
||||
if (!atomicWriteFile($configPath, $configFile)) {
|
||||
throw new RuntimeException(lang('Error.unable_to_persist_encryption_key', ['filePath' => $configPath]));
|
||||
}
|
||||
|
||||
log_message('info', "Restored $configPath from backup");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
function removeBackup(): void
|
||||
{
|
||||
$backupPath = config('SecurityEnv')->backupPath;
|
||||
if (!file_exists($backupPath)) {
|
||||
return;
|
||||
}
|
||||
@unlink($backupPath);
|
||||
log_message('info', "Removed $backupPath");
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
use App\Models\Attribute;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Item_taxes;
|
||||
use App\Models\Tax_category;
|
||||
use CodeIgniter\Database\ResultInterface;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
use CodeIgniter\Session\Session;
|
||||
@@ -61,7 +59,7 @@ function transform_headers(array $headers, bool $readonly = false, bool $editabl
|
||||
}
|
||||
|
||||
|
||||
function sales_headers(): array
|
||||
function salesHeaders(): array
|
||||
{
|
||||
return [
|
||||
['sale_id' => lang('Common.id')],
|
||||
@@ -79,7 +77,7 @@ function sales_headers(): array
|
||||
*/
|
||||
function get_sales_manage_table_headers(): string
|
||||
{
|
||||
$headers = sales_headers();
|
||||
$headers = salesHeaders();
|
||||
$config = config(OSPOS::class)->settings;
|
||||
|
||||
if ($config['invoice_enable']) {
|
||||
@@ -95,7 +93,7 @@ function get_sales_manage_table_headers(): string
|
||||
/**
|
||||
* Get the html data row for the sales
|
||||
*/
|
||||
function get_sale_data_row(object $sale): array
|
||||
function getSaleDataRow(object $sale): array
|
||||
{
|
||||
$uri = current_url(true);
|
||||
$controller = $uri->getSegment(1);
|
||||
@@ -145,7 +143,7 @@ function get_sale_data_row(object $sale): array
|
||||
/**
|
||||
* Get the html data last row for the sales
|
||||
*/
|
||||
function get_sale_data_last_row(ResultInterface $sales): array
|
||||
function getSaleDataLastRow(ResultInterface $sales): array
|
||||
{
|
||||
$sum_amount_due = 0;
|
||||
$sum_amount_tendered = 0;
|
||||
@@ -169,7 +167,7 @@ function get_sale_data_last_row(ResultInterface $sales): array
|
||||
/**
|
||||
* Get the sales payments summary
|
||||
*/
|
||||
function get_sales_manage_payments_summary(array $payments): string
|
||||
function getSalesManagePaymentsSummary(array $payments): string
|
||||
{
|
||||
$table = '<div id="report_summary">';
|
||||
$total = 0;
|
||||
@@ -402,14 +400,37 @@ function item_headers(): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sortable column keys for items table, including dynamic attribute columns.
|
||||
*
|
||||
* @param array|null $definitionIds Attribute definition IDs to append as sortable columns.
|
||||
* If null, resolved via a query against SHOW_IN_ITEMS.
|
||||
* @return array Array of column headers in the format sanitizeSortColumn() expects
|
||||
*/
|
||||
function itemSortColumns(?array $definitionIds = null): array
|
||||
{
|
||||
if ($definitionIds === null) {
|
||||
$attribute = model(Attribute::class);
|
||||
$definitionIds = array_keys($attribute->getDefinitionsByFlags($attribute::SHOW_IN_ITEMS));
|
||||
}
|
||||
|
||||
$headers = item_headers();
|
||||
|
||||
foreach ($definitionIds as $definitionId) {
|
||||
$headers[] = [(string) $definitionId => ''];
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the header for the items tabular view
|
||||
*/
|
||||
function get_items_manage_table_headers(): string
|
||||
function getItemsManageTableHeaders(): string
|
||||
{
|
||||
$attribute = model(Attribute::class);
|
||||
$config = config(OSPOS::class)->settings;
|
||||
$definitionsWithTypes = $attribute->get_definitions_by_flags($attribute::SHOW_IN_ITEMS, true);
|
||||
$definitionsWithTypes = $attribute->getDefinitionsByFlags($attribute::SHOW_IN_ITEMS, true);
|
||||
|
||||
$headers = item_headers();
|
||||
|
||||
@@ -421,8 +442,8 @@ function get_items_manage_table_headers(): string
|
||||
|
||||
$headers[] = ['item_pic' => lang('Items.image'), 'sortable' => false];
|
||||
|
||||
foreach ($definitionsWithTypes as $definition_id => $definitionInfo) {
|
||||
$headers[] = [$definition_id => $definitionInfo['name'], 'sortable' => false];
|
||||
foreach ($definitionsWithTypes as $definitionId => $definitionInfo) {
|
||||
$headers[] = [$definitionId => $definitionInfo['name'], 'sortable' => true];
|
||||
}
|
||||
|
||||
$headers[] = ['inventory' => '', 'escape' => false];
|
||||
@@ -433,32 +454,17 @@ function get_items_manage_table_headers(): string
|
||||
|
||||
/**
|
||||
* Get the html data row for the item
|
||||
*
|
||||
* @param object $item
|
||||
* @param array $definitionNames Attribute definitions with types, keyed by definition_id (see Attribute::getDefinitionsByFlags(..., true))
|
||||
* @param array $taxPercentsByItemId Pre-computed tax percent strings, keyed by item_id (see Items::buildTaxPercentsByItem())
|
||||
* @return array
|
||||
*/
|
||||
function get_item_data_row(object $item): array
|
||||
function getItemDataRow(object $item, array $definitionNames, array $taxPercentsByItemId): array
|
||||
{
|
||||
$attribute = model(Attribute::class);
|
||||
$item_taxes = model(Item_taxes::class);
|
||||
$tax_category = model(Tax_category::class);
|
||||
$config = config(OSPOS::class)->settings;
|
||||
|
||||
if ($config['use_destination_based_tax']) {
|
||||
if ($item->tax_category_id == null) { // TODO: === ?
|
||||
$tax_percents = '-';
|
||||
} else {
|
||||
$tax_category_info = $tax_category->get_info($item->tax_category_id);
|
||||
$tax_percents = $tax_category_info->tax_category;
|
||||
}
|
||||
} else {
|
||||
$item_tax_info = $item_taxes->get_info($item->item_id);
|
||||
$tax_percents = '';
|
||||
foreach ($item_tax_info as $tax_info) {
|
||||
$tax_percents .= to_tax_decimals($tax_info['percent']) . '%, ';
|
||||
}
|
||||
|
||||
// Remove ', ' from last item
|
||||
$tax_percents = substr($tax_percents, 0, -2);
|
||||
$tax_percents = !$tax_percents ? '-' : $tax_percents;
|
||||
}
|
||||
$taxPercents = $taxPercentsByItemId[$item->item_id] ?? '-';
|
||||
|
||||
$controller = get_controller();
|
||||
|
||||
@@ -471,8 +477,8 @@ function get_item_data_row(object $item): array
|
||||
: glob("./uploads/item_pics/$item->pic_filename");
|
||||
|
||||
if (sizeof($images) > 0) {
|
||||
$image_path = ltrim($images[0], './');
|
||||
$image .= '<a class="rollover" href="' . base_url(implode('/', array_map('rawurlencode', explode('/', $image_path)))) . '"><img alt="Image thumbnail" src="' . site_url('items/PicThumb/' . rawurlencode(pathinfo($images[0], PATHINFO_BASENAME))) . '"></a>';
|
||||
$imagePath = ltrim($images[0], './');
|
||||
$image .= '<a class="rollover" href="' . base_url(implode('/', array_map('rawurlencode', explode('/', $imagePath)))) . '"><img alt="Image thumbnail" src="' . site_url('items/PicThumb/' . rawurlencode(pathinfo($images[0], PATHINFO_BASENAME))) . '"></a>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,8 +486,6 @@ function get_item_data_row(object $item): array
|
||||
$item->name .= NAME_SEPARATOR . $item->pack_name;
|
||||
}
|
||||
|
||||
$definition_names = $attribute->get_definitions_by_flags($attribute::SHOW_IN_ITEMS, true);
|
||||
|
||||
$columns = [
|
||||
'items.item_id' => $item->item_id,
|
||||
'item_number' => $item->item_number,
|
||||
@@ -491,7 +495,7 @@ function get_item_data_row(object $item): array
|
||||
'cost_price' => to_currency($item->cost_price),
|
||||
'unit_price' => to_currency($item->unit_price),
|
||||
'quantity' => to_quantity_decimals($item->quantity),
|
||||
'tax_percents' => !$tax_percents ? '-' : $tax_percents,
|
||||
'tax_percents' => !$taxPercents ? '-' : $taxPercents,
|
||||
'item_pic' => $image
|
||||
];
|
||||
|
||||
@@ -524,7 +528,7 @@ function get_item_data_row(object $item): array
|
||||
)
|
||||
];
|
||||
|
||||
return $columns + expand_attribute_values($definition_names, (array) $item) + $icons;
|
||||
return $columns + expand_attribute_values($definitionNames, (array) $item) + $icons;
|
||||
}
|
||||
|
||||
function giftcard_headers(): array
|
||||
@@ -699,7 +703,7 @@ function get_attribute_definition_data_row(object $attribute_row): array
|
||||
$attribute = model(Attribute::class);
|
||||
$controller = get_controller();
|
||||
|
||||
if (count($attribute->get_definition_flags()) == 0) {
|
||||
if (count($attribute->getDefinitionFlags()) == 0) {
|
||||
$definition_flags = lang('Common.none_selected_text');
|
||||
} elseif ($attribute->definition_type == GROUP) {
|
||||
$definition_flags = "-";
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
"attribute_value_invalid_chars" => "الميزات لا يمكن أن تحتوي على ':' أو'|'",
|
||||
"confirm_delete" => "هل أنت متأكد من أنك تريد حذف الميزات المحددة ؟",
|
||||
"confirm_restore" => "هل أنت متأكد من أنك تريد استعادة السمة (السمات) المحددة؟",
|
||||
"definition_cannot_be_deleted" => "لا يمكن حذف السمات المحددة",
|
||||
"definition_invalid_group" => "المجموعة المحددة غير موجودة أو غير صالحة.",
|
||||
"definition_error_adding_updating" => "لا يمكن إضافة السمة {0} أو تحديثها. يرجى التحقق من سجل الخطأ.",
|
||||
"definition_flags" => "رؤية الميزات",
|
||||
"definition_group" => "المجموعة",
|
||||
"definition_id" => "كود",
|
||||
"definition_name" => "إضافة ميزة",
|
||||
"definition_name_required" => "اسم الميزة هي خانة اجبارية",
|
||||
"definition_one_or_multiple" => "ميزة/ميزات",
|
||||
"definition_successful_adding" => "لقد تم إضافة صنف بنجاح",
|
||||
"definition_successful_deleted" => "لقد تم حذف ميزة بنجاح",
|
||||
"definition_successful_updating" => "تم تعديل الميزة بنجاح",
|
||||
"definition_type" => "نوع الميزة",
|
||||
"definition_type_required" => "نوع الميزة هي خانة إجبارية",
|
||||
"definition_unit" => "وحدة قياس",
|
||||
"definition_values" => "قيمة الميزة",
|
||||
"new" => "اضافة ميزة جديده",
|
||||
"no_attributes_to_display" => "لا يوجد اصناف للعرض",
|
||||
"receipt_visibility" => "وصل",
|
||||
"show_in_items" => "اظهار في الصنف",
|
||||
"show_in_items_visibility" => "الصنف",
|
||||
"show_in_receipt" => "اظهار على الوصل",
|
||||
"show_in_receivings" => "اظهار في استلام البضائع",
|
||||
"show_in_receivings_visibility" => "استلام البضائع",
|
||||
"show_in_sales" => "اظهار خلال البيع",
|
||||
"show_in_sales_visibility" => "البيع",
|
||||
"update" => "تحديث الميزات",
|
||||
'attribute_value_invalid_chars' => 'الميزات لا يمكن أن تحتوي على \':\' أو\'|\'',
|
||||
'confirm_delete' => 'هل أنت متأكد من أنك تريد حذف الميزات المحددة ؟',
|
||||
'confirm_restore' => 'هل أنت متأكد من أنك تريد استعادة السمة (السمات) المحددة؟',
|
||||
'definition_cannot_be_deleted' => 'لا يمكن حذف السمات المحددة',
|
||||
'definition_invalid_group' => 'المجموعة المحددة غير موجودة أو غير صالحة.',
|
||||
'definition_error_adding_updating' => 'لا يمكن إضافة السمة {0} أو تحديثها. يرجى التحقق من سجل الخطأ.',
|
||||
'definition_flags' => 'رؤية الميزات',
|
||||
'definition_group' => 'المجموعة',
|
||||
'definition_id' => 'كود',
|
||||
'definition_name' => 'إضافة ميزة',
|
||||
'definition_name_required' => 'اسم الميزة هي خانة اجبارية',
|
||||
'definition_one_or_multiple' => 'ميزة/ميزات',
|
||||
'definition_successful_adding' => 'لقد تم إضافة صنف بنجاح',
|
||||
'definition_successful_deleted' => 'لقد تم حذف ميزة بنجاح',
|
||||
'definition_successful_updating' => 'تم تعديل الميزة بنجاح',
|
||||
'definition_type' => 'نوع الميزة',
|
||||
'definition_type_required' => 'نوع الميزة هي خانة إجبارية',
|
||||
'definition_unit' => 'وحدة قياس',
|
||||
'definition_values' => 'قيمة الميزة',
|
||||
'new' => 'اضافة ميزة جديده',
|
||||
'no_attributes_to_display' => 'لا يوجد اصناف للعرض',
|
||||
'receipt_visibility' => 'وصل',
|
||||
'show_in_items' => 'اظهار في الصنف',
|
||||
'show_in_items_visibility' => 'الصنف',
|
||||
'show_in_receipt' => 'اظهار على الوصل',
|
||||
'show_in_receivings' => 'اظهار في استلام البضائع',
|
||||
'show_in_receivings_visibility' => 'استلام البضائع',
|
||||
'show_in_sales' => 'اظهار خلال البيع',
|
||||
'show_in_sales_visibility' => 'البيع',
|
||||
'show_in_search' => 'اظهار في البحث',
|
||||
'show_in_search_visibility' => 'بحث',
|
||||
'update' => 'تحديث الميزات',
|
||||
];
|
||||
@@ -210,7 +210,6 @@ return [
|
||||
'msg_msg' => 'الرسائل النصية المحفوظة',
|
||||
'msg_msg_placeholder' => 'إذا أردت إستخدام قالب للرسائل القصيرة احفظه هنا. عدا ذلك أترك هذا الحقل فارغ.',
|
||||
'msg_pwd' => 'SMS-API كلمة السر لـ',
|
||||
'msg_pwd_required' => 'مطلوب SMS-API كلمة السر لـ',
|
||||
'msg_src' => 'SMS-API كود المرسل لـ',
|
||||
'msg_src_required' => 'مطلوب SMS-API كود المرسل لـ',
|
||||
'msg_uid' => 'SMS-API اسم المستخدم لـ',
|
||||
@@ -285,7 +284,7 @@ return [
|
||||
'right' => 'يمين',
|
||||
'sales_invoice_format' => 'شكل فاتورة البيع',
|
||||
'sales_quote_format' => 'شكل فاتورة عرض الاسعار',
|
||||
'mailpath_invalid' => '',
|
||||
'mailpath_invalid' => 'مسار sendmail غير صالح. يُسمح فقط بالحروف والأرقام والشرطات والشرطات السفلية والشرطات المائلة والشرطات المائلة العكسية والنقطتين الرأسيتين والمسافات والنقاط.',
|
||||
'saved_successfully' => 'تم حفظ التهيئة بنجاح.',
|
||||
'saved_unsuccessfully' => 'لم يتم حفظ التهيئة بنجاح.',
|
||||
'security_issue' => 'تحذير من ثغرة أمنية',
|
||||
|
||||
@@ -1,47 +1,50 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
"administrator" => "",
|
||||
"basic_information" => "المعلومات الأساسية للموظف",
|
||||
"cannot_be_deleted" => "لايمكن حذف الموظفين ، واحد أو أكثر من المظفين قام بإجراء مبيعات :).",
|
||||
"change_employee" => "",
|
||||
"change_password" => "تغيير كلمة المرور",
|
||||
"clerk" => "",
|
||||
"commission" => "",
|
||||
"confirm_delete" => "هل أنت متأكد أنك تريد حذف الموظفين المختارين؟",
|
||||
"confirm_restore" => "هل انت متاكد من استعادة الموظفين المحددين؟",
|
||||
"current_password" => "كلمة المرور الحالية",
|
||||
"current_password_invalid" => "كلمة المرور الحالية غير صحيحة.",
|
||||
"employee" => "موظف",
|
||||
"error_adding_updating" => "خطاء فى إضافة/تعديل موظف.",
|
||||
"error_deleting_admin" => "",
|
||||
"error_updating_admin" => "",
|
||||
"error_deleting_demo_admin" => "لايمكن حذف المستخدم admin الخاص بنسخة العرض.",
|
||||
"error_updating_demo_admin" => "لايمكن تغيير بيانات المستخدم admin الخاص بنسخة العرض.",
|
||||
"language" => "اللغة",
|
||||
"login_info" => "معلومات دخول الموظف",
|
||||
"manager" => "",
|
||||
"new" => "موظف جديد",
|
||||
"none_selected" => "لم تختار أى من الموظفين للحذف.",
|
||||
"one_or_multiple" => "موظف/موظفين",
|
||||
"password" => "كلمة السر",
|
||||
"password_minlength" => "كلمة السر يجب أن تكون 8 حروف على الأقل.",
|
||||
"password_must_match" => "كلمتى السر لا تتطابقان.",
|
||||
"password_not_must_match" => "كلمة المرور الحالية والجديدة يجب ان يكونو فريدين.",
|
||||
"password_required" => "كلمة السر مطلوبة.",
|
||||
"permission_desc" => "قم بإضافة الصلاحيات بلإختيار من الأسفل.",
|
||||
"permission_info" => "اذونات المستخدمين",
|
||||
"repeat_password" => "كلمة السر مرة اخرى",
|
||||
"subpermission_required" => "يجب إختيار صلاحية واحدة على الأقل لكل قسم.",
|
||||
"successful_adding" => "لقد تم إضافة الموظف بنجاح.",
|
||||
"successful_change_password" => "تم تغيير كلمة المرور بنجاح.",
|
||||
"successful_deleted" => "لقد تم حذف الموظف بنجاح",
|
||||
"successful_updating" => "لقد تم تحديث بيانات الموظف بنجاح",
|
||||
"system_language" => "لغة النظام",
|
||||
"unsuccessful_change_password" => "فشل في تغيير كلمة المرور.",
|
||||
"update" => "تحديث بيانات موظف",
|
||||
"username" => "اسم المستخدم",
|
||||
"username_duplicate" => "حساب المحدد هو موجود في قاعدة البيانات. نرجوا استخدام اسم حساب مختلف.",
|
||||
"username_minlength" => "اسم المستخدم يجب أن يكون 5 حروف على الأقل.",
|
||||
"username_required" => "اسم المستخدم مطلوب.",
|
||||
'administrator' => '',
|
||||
'basic_information' => 'المعلومات الأساسية للموظف',
|
||||
'cannot_be_deleted' => 'لايمكن حذف الموظفين ، واحد أو أكثر من المظفين قام بإجراء مبيعات :).',
|
||||
'change_employee' => '',
|
||||
'change_password' => 'تغيير كلمة المرور',
|
||||
'clerk' => '',
|
||||
'commission' => '',
|
||||
'confirm_delete' => 'هل أنت متأكد أنك تريد حذف الموظفين المختارين؟',
|
||||
'confirm_restore' => 'هل انت متاكد من استعادة الموظفين المحددين؟',
|
||||
'current_password' => 'كلمة المرور الحالية',
|
||||
'current_password_invalid' => 'كلمة المرور الحالية غير صحيحة.',
|
||||
'employee' => 'موظف',
|
||||
'error_adding_updating' => 'خطاء فى إضافة/تعديل موظف.',
|
||||
'error_cannot_remove_own_minimum_grant' => 'لا يمكنك إزالة الحد الأدنى من صلاحيات الوصول للوحدات الخاصة بك.',
|
||||
'error_deleting_admin' => '',
|
||||
'error_deleting_demo_admin' => 'لايمكن حذف المستخدم admin الخاص بنسخة العرض.',
|
||||
'error_grant_change_disallowed' => 'تم تعطيل تغييرات المنح في هذه النسخة التجريبية.',
|
||||
'error_password_change_disallowed' => 'تم تعطيل تغييرات كلمة المرور في هذه النسخة التجريبية.',
|
||||
'error_updating_admin' => '',
|
||||
'error_updating_demo_admin' => 'لايمكن تغيير بيانات المستخدم admin الخاص بنسخة العرض.',
|
||||
'language' => 'اللغة',
|
||||
'login_info' => 'معلومات دخول الموظف',
|
||||
'manager' => '',
|
||||
'new' => 'موظف جديد',
|
||||
'none_selected' => 'لم تختار أى من الموظفين للحذف.',
|
||||
'one_or_multiple' => 'موظف/موظفين',
|
||||
'password' => 'كلمة السر',
|
||||
'password_minlength' => 'كلمة السر يجب أن تكون 8 حروف على الأقل.',
|
||||
'password_must_match' => 'كلمتى السر لا تتطابقان.',
|
||||
'password_not_must_match' => 'كلمة المرور الحالية والجديدة يجب ان يكونو فريدين.',
|
||||
'password_required' => 'كلمة السر مطلوبة.',
|
||||
'permission_desc' => 'قم بإضافة الصلاحيات بلإختيار من الأسفل.',
|
||||
'permission_info' => 'اذونات المستخدمين',
|
||||
'repeat_password' => 'كلمة السر مرة اخرى',
|
||||
'subpermission_required' => 'يجب إختيار صلاحية واحدة على الأقل لكل قسم.',
|
||||
'successful_adding' => 'لقد تم إضافة الموظف بنجاح.',
|
||||
'successful_change_password' => 'تم تغيير كلمة المرور بنجاح.',
|
||||
'successful_deleted' => 'لقد تم حذف الموظف بنجاح',
|
||||
'successful_updating' => 'لقد تم تحديث بيانات الموظف بنجاح',
|
||||
'system_language' => 'لغة النظام',
|
||||
'unsuccessful_change_password' => 'فشل في تغيير كلمة المرور.',
|
||||
'update' => 'تحديث بيانات موظف',
|
||||
'username' => 'اسم المستخدم',
|
||||
'username_duplicate' => 'حساب المحدد هو موجود في قاعدة البيانات. نرجوا استخدام اسم حساب مختلف.',
|
||||
'username_minlength' => 'اسم المستخدم يجب أن يكون 5 حروف على الأقل.',
|
||||
'username_required' => 'اسم المستخدم مطلوب.',
|
||||
];
|
||||
@@ -1,6 +1,14 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
"no_permission_module" => "ليس لديك صلاحيات للوصول لهذا القسم",
|
||||
"unknown" => "غير معروف",
|
||||
'no_permission_module' => 'ليس لديك صلاحيات للوصول لهذا القسم',
|
||||
'unable_to_create_env_file' => 'تعذر إنشاء {filePath} لتوفير throttle.key',
|
||||
'unable_to_lock_file' => 'تعذر قفل {filePath}: {reason}',
|
||||
'unable_to_open_lock_file' => 'تعذر فتح {filePath}: {reason}',
|
||||
'unable_to_persist_throttle_key' => 'تعذر حفظ throttle.key في {filePath}',
|
||||
'unable_to_persist_encryption_key' => '',
|
||||
'encryption_key_not_provisioned' => '',
|
||||
'throttle_key_not_provisioned' => '',
|
||||
'unable_to_read_env_file' => 'تعذر قراءة {filePath} لتوفير throttle.key',
|
||||
'unknown' => 'غير معروف',
|
||||
];
|
||||
+120
-118
@@ -1,122 +1,124 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
"add_minus" => "الكمية المطلوب خصمها أو اضافتها.",
|
||||
"allow_alt_description" => "السماح بوصف بديل",
|
||||
"amount_entry" => "إدخال القيمة",
|
||||
"bulk_edit" => "تحرير جماعى",
|
||||
"buy_price_required" => "سعر الشراء مطلوب.",
|
||||
"cannot_be_deleted" => "لايمكن حذف الأصناف المختارة، واحد أو أكثر منها لديه مبيعات.",
|
||||
"cannot_find_item" => "لايمكن ايجاد معلومات حول الصنف.",
|
||||
"categories" => "",
|
||||
"category" => "التصنيف",
|
||||
"category_new" => "",
|
||||
"category_required" => "التصنيف مطلوب.",
|
||||
"change_all_to_allow_alt_desc" => "السماح بوصف بديل للجميع.",
|
||||
"change_all_to_not_allow_allow_desc" => "عدم السماح بوصف بديل للجميع.",
|
||||
"change_all_to_serialized" => "تغير الجميع لمتسلسل",
|
||||
"change_all_to_unserialized" => "تغيير الجميع لغير متسلسل",
|
||||
"change_image" => "تغيير الصورة",
|
||||
"confirm_bulk_edit" => "هل أنت متأكد انك تريد تحرير كل الأصناف المختارة؟",
|
||||
"confirm_bulk_edit_wipe_taxes" => "سوف يتم مسح معلومات الضرائب لجميع الأصناف.",
|
||||
"confirm_delete" => "هل أنت متأكد انك تريد حذف كل الأصناف المختارة؟",
|
||||
"confirm_restore" => "هل انت متاكد من إستعادة الاصناف المخطارة؟",
|
||||
"cost_price" => "سعر التكلفة",
|
||||
"cost_price_number" => "سعر التكلفة يجب أن يكون رقم.",
|
||||
"cost_price_required" => "سعر التكلفة مطلوب.",
|
||||
"count" => "تحديث المخزون",
|
||||
"csv_import_failed" => "فشل الإستيراد من اكسل",
|
||||
"csv_import_invalid_location" => "",
|
||||
"csv_import_nodata_wrongformat" => "الملف الذى رفعته إما فارغ أو أنه مختلف البنية.",
|
||||
"csv_import_partially_failed" => "يوجد خطأ بنسبة {0} في استيراد الاصناف في السطر: {1}. لم يتم استيرادهم.",
|
||||
"csv_import_success" => "تم استيراد الأصناف بنجاح.",
|
||||
"current_quantity" => "الكمية الحالية",
|
||||
"default_pack_name" => "كل",
|
||||
"description" => "الوصف",
|
||||
"details_count" => "حركة الصنف",
|
||||
"do_nothing" => "لاتفعل شئ",
|
||||
"edit" => "",
|
||||
"edit_fields_you_want_to_update" => "قم بتحديث الحقول المراد تحديثها لكل الأصناف المختارة.",
|
||||
"edit_multiple_items" => "تحريد مجموعة اصناف",
|
||||
"empty_upc_items" => "تفريغ اصناف UPC",
|
||||
"error_adding_updating" => "خطاء فى إضافة/تحديث صنف",
|
||||
"error_updating_multiple" => "خطاء فى تحديث بيانات أصناف",
|
||||
"generate_barcodes" => "توليد أكواد الباركود",
|
||||
"hsn_code" => "تسميات النظام المنسق",
|
||||
"image" => "صورة",
|
||||
"import_items_csv" => "استيراد من اكسل",
|
||||
"info_provided_by" => "المعلومات مقدمة بواسطة",
|
||||
"inventory" => "المخزن",
|
||||
"inventory_CSV_import_quantity" => "العدد المستورد من ملفات ال CSV",
|
||||
"inventory_comments" => "تعليقات",
|
||||
"inventory_data_tracking" => "تتبع بيانات المخزون",
|
||||
"inventory_date" => "التاريخ",
|
||||
"inventory_employee" => "الموظف",
|
||||
"inventory_in_out_quantity" => "+- كمية",
|
||||
"inventory_remarks" => "ملاحظات",
|
||||
"is_deleted" => "حذفت",
|
||||
"is_printed" => "",
|
||||
"is_serialized" => "الصنف له رقم مسلسل",
|
||||
"item" => "صنف",
|
||||
"item_id" => "",
|
||||
"item_number" => "الباركود UPC/EAN/ISBN",
|
||||
"item_number_duplicate" => "رقم الصنف موجود فى قاعدة البيانات من قبل.",
|
||||
"kit" => "مجموعة اصناف",
|
||||
"location" => "مكان",
|
||||
"low_inventory_items" => "الأصناف التى لا يوجد لها مخزون",
|
||||
"low_sell_item" => "الصنف المركب",
|
||||
"manually_editing_of_quantity" => "تحرير الكمية يدوياً",
|
||||
"markup" => "",
|
||||
"name" => "اسم الصنف",
|
||||
"name_required" => "اسم الصنف مطلوب.",
|
||||
"new" => "صنف جديد",
|
||||
"no_description_items" => "الأصناف التى بلا وصف",
|
||||
"no_items_to_display" => "لايوجد أصناف لعرضها.",
|
||||
"none" => "لاشىء",
|
||||
"none_selected" => "لم تختار أى أصناف لتحريرها",
|
||||
"nonstock" => "غير مخزنة",
|
||||
"number_information" => "رقم الصنف",
|
||||
"number_required" => "مطلوب UPC/EAN/ISBN رقم الباركود.",
|
||||
"one_or_multiple" => "صنف/أصناف",
|
||||
"pack_name" => "اسم الحزمة",
|
||||
"qty_per_pack" => "العدد بكل الحزمة",
|
||||
"quantity" => "الكمية",
|
||||
"quantity_number" => "الكمية يجب أن تكون رقم.",
|
||||
"quantity_required" => "الكمية مطلوبة.",
|
||||
"receiving_quantity" => "الكمية المستلمة",
|
||||
"remove_image" => "ازالة الصورة",
|
||||
"reorder_level" => "نقطة إعادة الطلب",
|
||||
"reorder_level_number" => "نقطة إعادة الطلب يجب أن تكون رقم.",
|
||||
"reorder_level_required" => "نقطة إعادة الطلب مطلوبة.",
|
||||
"retrive_item_info" => "الحصول على بيانات الصنف",
|
||||
"sales_tax_1" => "ضريبة المبيعات",
|
||||
"sales_tax_2" => "ضريبة المبيعات 2",
|
||||
"search_attributes" => "بحث الحقول المخصصة",
|
||||
"select_image" => "اختار صورة",
|
||||
"serialized_items" => "أصناف مسلسلة",
|
||||
"standard" => "اساسي",
|
||||
"stock" => "مخزن",
|
||||
"stock_location" => "مكان المخزون",
|
||||
"stock_type" => "نوع المخزن",
|
||||
"successful_adding" => "لقد تم إضافة صنف بنجاح",
|
||||
"successful_bulk_edit" => "لقد تم تحديث بيانات صنف/أصناف بنجاح",
|
||||
"successful_deleted" => "لقد تم الحذف بنجاح",
|
||||
"successful_updating" => "لقد تم تحديث بيانات صنف بنجاح",
|
||||
"supplier" => "المورد",
|
||||
"tax_1" => "الضريبة 1",
|
||||
"tax_2" => "الضريبة 2",
|
||||
"tax_3" => "",
|
||||
"tax_category" => "الفئة الضريبية",
|
||||
"tax_percent" => "نسبة الضريبة",
|
||||
"tax_percent_number" => "نسبة الضريبة يجب ان تكون رقم",
|
||||
"tax_percent_required" => "نسبة الضريبة مطلوبة.",
|
||||
"tax_percents" => "نسبة/نسب الضريبة",
|
||||
"temp" => "مؤقت",
|
||||
"type" => "نوع الصنف",
|
||||
"unit_price" => "السعر",
|
||||
"unit_price_number" => "السعر يجب أن يكون رقم.",
|
||||
"unit_price_required" => "السعر مطلوب.",
|
||||
"upc_database" => "UPC قاعدة بيانات",
|
||||
"update" => "تحديث بيانات صنف",
|
||||
"use_inventory_menu" => "استخدام تحديث المخزن",
|
||||
'add_minus' => 'الكمية المطلوب خصمها أو اضافتها.',
|
||||
'allow_alt_description' => 'السماح بوصف بديل',
|
||||
'amount_entry' => 'إدخال القيمة',
|
||||
'bulk_edit' => 'تحرير جماعى',
|
||||
'buy_price_required' => 'سعر الشراء مطلوب.',
|
||||
'cannot_be_deleted' => 'لايمكن حذف الأصناف المختارة، واحد أو أكثر منها لديه مبيعات.',
|
||||
'cannot_find_item' => 'لايمكن ايجاد معلومات حول الصنف.',
|
||||
'categories' => '',
|
||||
'category' => 'التصنيف',
|
||||
'category_new' => '',
|
||||
'category_required' => 'التصنيف مطلوب.',
|
||||
'change_all_to_allow_alt_desc' => 'السماح بوصف بديل للجميع.',
|
||||
'change_all_to_not_allow_allow_desc' => 'عدم السماح بوصف بديل للجميع.',
|
||||
'change_all_to_serialized' => 'تغير الجميع لمتسلسل',
|
||||
'change_all_to_unserialized' => 'تغيير الجميع لغير متسلسل',
|
||||
'change_image' => 'تغيير الصورة',
|
||||
'confirm_bulk_edit' => 'هل أنت متأكد انك تريد تحرير كل الأصناف المختارة؟',
|
||||
'confirm_bulk_edit_wipe_taxes' => 'سوف يتم مسح معلومات الضرائب لجميع الأصناف.',
|
||||
'confirm_delete' => 'هل أنت متأكد انك تريد حذف كل الأصناف المختارة؟',
|
||||
'confirm_restore' => 'هل انت متاكد من إستعادة الاصناف المخطارة؟',
|
||||
'cost_price' => 'سعر التكلفة',
|
||||
'cost_price_number' => 'سعر التكلفة يجب أن يكون رقم.',
|
||||
'cost_price_required' => 'سعر التكلفة مطلوب.',
|
||||
'count' => 'تحديث المخزون',
|
||||
'csv_import_failed' => 'فشل الإستيراد من اكسل',
|
||||
'csv_import_invalid_location' => '',
|
||||
'csv_import_nodata_wrongformat' => 'الملف الذى رفعته إما فارغ أو أنه مختلف البنية.',
|
||||
'csv_import_partially_failed' => 'يوجد خطأ بنسبة {0} في استيراد الاصناف في السطر: {1}. لم يتم استيرادهم.',
|
||||
'csv_import_success' => 'تم استيراد الأصناف بنجاح.',
|
||||
'current_quantity' => 'الكمية الحالية',
|
||||
'default_pack_name' => 'كل',
|
||||
'description' => 'الوصف',
|
||||
'details_count' => 'حركة الصنف',
|
||||
'do_nothing' => 'لاتفعل شئ',
|
||||
'edit' => '',
|
||||
'edit_fields_you_want_to_update' => 'قم بتحديث الحقول المراد تحديثها لكل الأصناف المختارة.',
|
||||
'edit_multiple_items' => 'تحريد مجموعة اصناف',
|
||||
'empty_upc_items' => 'تفريغ اصناف UPC',
|
||||
'error_adding_updating' => 'خطاء فى إضافة/تحديث صنف',
|
||||
'error_updating_multiple' => 'خطاء فى تحديث بيانات أصناف',
|
||||
'generate_barcodes' => 'توليد أكواد الباركود',
|
||||
'hsn_code' => 'تسميات النظام المنسق',
|
||||
'image' => 'صورة',
|
||||
'import_items_csv' => 'استيراد من اكسل',
|
||||
'info_provided_by' => 'المعلومات مقدمة بواسطة',
|
||||
'inventory' => 'المخزن',
|
||||
'inventory_CSV_import_quantity' => 'العدد المستورد من ملفات ال CSV',
|
||||
'inventory_comments' => 'تعليقات',
|
||||
'inventory_data_tracking' => 'تتبع بيانات المخزون',
|
||||
'inventory_date' => 'التاريخ',
|
||||
'inventory_employee' => 'الموظف',
|
||||
'inventory_in_out_quantity' => '+- كمية',
|
||||
'inventory_remarks' => 'ملاحظات',
|
||||
'is_deleted' => 'حذفت',
|
||||
'is_printed' => '',
|
||||
'is_serialized' => 'الصنف له رقم مسلسل',
|
||||
'item' => 'صنف',
|
||||
'item_id' => '',
|
||||
'item_number' => 'الباركود UPC/EAN/ISBN',
|
||||
'item_number_duplicate' => 'رقم الصنف موجود فى قاعدة البيانات من قبل.',
|
||||
'item_number_invalid' => 'رقم الصنف يحتوي على أحرف غير مسموح بها.',
|
||||
'kit' => 'مجموعة اصناف',
|
||||
'location' => 'مكان',
|
||||
'low_inventory_items' => 'الأصناف التى لا يوجد لها مخزون',
|
||||
'low_sell_item' => 'الصنف المركب',
|
||||
'manually_editing_of_quantity' => 'تحرير الكمية يدوياً',
|
||||
'markup' => '',
|
||||
'name' => 'اسم الصنف',
|
||||
'name_required' => 'اسم الصنف مطلوب.',
|
||||
'new' => 'صنف جديد',
|
||||
'no_description_items' => 'الأصناف التى بلا وصف',
|
||||
'no_items_to_display' => 'لايوجد أصناف لعرضها.',
|
||||
'none' => 'لاشىء',
|
||||
'none_selected' => 'لم تختار أى أصناف لتحريرها',
|
||||
'nonstock' => 'غير مخزنة',
|
||||
'number_information' => 'رقم الصنف',
|
||||
'number_required' => 'مطلوب UPC/EAN/ISBN رقم الباركود.',
|
||||
'one_or_multiple' => 'صنف/أصناف',
|
||||
'pack_name' => 'اسم الحزمة',
|
||||
'qty_per_pack' => 'العدد بكل الحزمة',
|
||||
'quantity' => 'الكمية',
|
||||
'quantity_number' => 'الكمية يجب أن تكون رقم.',
|
||||
'quantity_required' => 'الكمية مطلوبة.',
|
||||
'receiving_quantity' => 'الكمية المستلمة',
|
||||
'remove_image' => 'ازالة الصورة',
|
||||
'reorder_level' => 'نقطة إعادة الطلب',
|
||||
'reorder_level_number' => 'نقطة إعادة الطلب يجب أن تكون رقم.',
|
||||
'reorder_level_required' => 'نقطة إعادة الطلب مطلوبة.',
|
||||
'retrive_item_info' => 'الحصول على بيانات الصنف',
|
||||
'sales_tax_1' => 'ضريبة المبيعات',
|
||||
'sales_tax_2' => 'ضريبة المبيعات 2',
|
||||
'search_attributes' => 'بحث الحقول المخصصة',
|
||||
'select_image' => 'اختار صورة',
|
||||
'serialized_items' => 'أصناف مسلسلة',
|
||||
'standard' => 'اساسي',
|
||||
'stock' => 'مخزن',
|
||||
'stock_location' => 'مكان المخزون',
|
||||
'stock_type' => 'نوع المخزن',
|
||||
'successful_adding' => 'لقد تم إضافة صنف بنجاح',
|
||||
'successful_bulk_edit' => 'لقد تم تحديث بيانات صنف/أصناف بنجاح',
|
||||
'successful_deleted' => 'لقد تم الحذف بنجاح',
|
||||
'successful_updating' => 'لقد تم تحديث بيانات صنف بنجاح',
|
||||
'supplier' => 'المورد',
|
||||
'tax_1' => 'الضريبة 1',
|
||||
'tax_2' => 'الضريبة 2',
|
||||
'tax_3' => '',
|
||||
'tax_category' => 'الفئة الضريبية',
|
||||
'tax_name_invalid' => 'اسم الضريبة يحتوي على أحرف غير مسموح بها.',
|
||||
'tax_percent' => 'نسبة الضريبة',
|
||||
'tax_percent_number' => 'نسبة الضريبة يجب ان تكون رقم',
|
||||
'tax_percent_required' => 'نسبة الضريبة مطلوبة.',
|
||||
'tax_percents' => 'نسبة/نسب الضريبة',
|
||||
'temp' => 'مؤقت',
|
||||
'type' => 'نوع الصنف',
|
||||
'unit_price' => 'السعر',
|
||||
'unit_price_number' => 'السعر يجب أن يكون رقم.',
|
||||
'unit_price_required' => 'السعر مطلوب.',
|
||||
'upc_database' => 'UPC قاعدة بيانات',
|
||||
'update' => 'تحديث بيانات صنف',
|
||||
'use_inventory_menu' => 'استخدام تحديث المخزن',
|
||||
];
|
||||
@@ -25,6 +25,7 @@ return [
|
||||
"migration_running" => "",
|
||||
"password" => "كلمة السر",
|
||||
"required_username" => "",
|
||||
'too_many_attempts' => 'تم تجاوز عدد المحاولات المسموح بها. يرجى الانتظار قليلاً ثم المحاولة مرة أخرى.',
|
||||
"username" => "اسم المستخدم",
|
||||
"welcome" => "مرحباً بك في{0}!"
|
||||
];
|
||||
@@ -128,6 +128,7 @@ return [
|
||||
"tax_rate" => "معدل الضريبة",
|
||||
"taxes" => "الضرائب",
|
||||
"taxes_summary_report" => "تقرير ملخص الضرائب",
|
||||
"toggle_cost_and_profit" => "",
|
||||
"total" => "الإجمالى",
|
||||
"total_inventory_value" => "إجمالى قيمة المخزن",
|
||||
"total_low_sell_quantity" => "مجموع الكميات حسب الصنف الاولي",
|
||||
|
||||
@@ -4,6 +4,7 @@ return [
|
||||
'account_number' => 'حساب',
|
||||
'add_payment' => 'إضافة دفع',
|
||||
'amount_due' => 'المبلغ المطلوب',
|
||||
'amount_due_not_covered' => 'المدفوعات لا تغطي المبلغ المستحق.',
|
||||
'amount_tendered' => 'المبلغ المدفوع',
|
||||
'authorized_signature' => 'توقيع معتمد',
|
||||
'cancel_sale' => 'الغاء عملية البيع',
|
||||
@@ -84,6 +85,8 @@ return [
|
||||
'hsn' => 'رمز نظام منسق',
|
||||
'id' => 'كود عملية البيع',
|
||||
'include_prices' => 'يشمل الاسعار؟',
|
||||
'insufficient_giftcard_balance' => 'رصيد بطاقة الهدية غير كافي لهذه الدفعة.',
|
||||
'insufficient_reward_points' => 'نقاط المكافأة غير كافية لهذه الدفعة.',
|
||||
'invoice' => 'فاتورة',
|
||||
'invoice_confirm' => 'هذه الفاتورة سوف ترسل إلى',
|
||||
'invoice_enable' => 'إنشاء فاتورة',
|
||||
@@ -129,6 +132,7 @@ return [
|
||||
'must_enter_numeric' => 'يجب إدخال رقم للمبلغ الفعلى المدفوع.',
|
||||
'must_enter_numeric_giftcard' => 'رقم بطاقة الهدية يجب أن يكون رقم.',
|
||||
'must_enter_reference_code' => 'يجب إدخال رقم المرجع/الاسترداد.',
|
||||
'negative_amount_tendered' => 'لا يمكن أن يكون المبلغ المدفوع سالبًا.',
|
||||
'negative_discount_invalid' => '',
|
||||
'negative_price_invalid' => '',
|
||||
'negative_quantity_invalid' => '',
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
"attribute_value_invalid_chars" => "الميزات لا يمكن أن تحتوي على ':' أو'|'",
|
||||
"confirm_delete" => "هل أنت متأكد من أنك تريد حذف الميزات المحددة ؟",
|
||||
"confirm_restore" => "هل أنت متأكد من أنك تريد استعادة السمة (السمات) المحددة؟",
|
||||
"definition_cannot_be_deleted" => "لا يمكن حذف السمات المحددة",
|
||||
"definition_invalid_group" => "المجموعة المحددة غير موجودة أو غير صالحة.",
|
||||
"definition_error_adding_updating" => "لا يمكن إضافة السمة {0} أو تحديثها. يرجى التحقق من سجل الخطأ.",
|
||||
"definition_flags" => "رؤية الميزات",
|
||||
"definition_group" => "المجموعة",
|
||||
"definition_id" => "كود",
|
||||
"definition_name" => "إضافة ميزة",
|
||||
"definition_name_required" => "اسم الميزة هي خانة اجبارية",
|
||||
"definition_one_or_multiple" => "ميزة/ميزات",
|
||||
"definition_successful_adding" => "لقد تم إضافة صنف بنجاح",
|
||||
"definition_successful_deleted" => "لقد تم حذف ميزة بنجاح",
|
||||
"definition_successful_updating" => "تم تعديل الميزة بنجاح",
|
||||
"definition_type" => "نوع الميزة",
|
||||
"definition_type_required" => "نوع الميزة هي خانة إجبارية",
|
||||
"definition_unit" => "وحدة قياس",
|
||||
"definition_values" => "قيمة الميزة",
|
||||
"new" => "اضافة ميزة جديده",
|
||||
"no_attributes_to_display" => "لا يوجد اصناف للعرض",
|
||||
"receipt_visibility" => "وصل",
|
||||
"show_in_items" => "اظهار في الصنف",
|
||||
"show_in_items_visibility" => "الصنف",
|
||||
"show_in_receipt" => "اظهار على الوصل",
|
||||
"show_in_receivings" => "اظهار في استلام البضائع",
|
||||
"show_in_receivings_visibility" => "استلام البضائع",
|
||||
"show_in_sales" => "اظهار خلال البيع",
|
||||
"show_in_sales_visibility" => "البيع",
|
||||
"update" => "تحديث الميزات",
|
||||
'attribute_value_invalid_chars' => 'الميزات لا يمكن أن تحتوي على \':\' أو\'|\'',
|
||||
'confirm_delete' => 'هل أنت متأكد من أنك تريد حذف الميزات المحددة ؟',
|
||||
'confirm_restore' => 'هل أنت متأكد من أنك تريد استعادة السمة (السمات) المحددة؟',
|
||||
'definition_cannot_be_deleted' => 'لا يمكن حذف السمات المحددة',
|
||||
'definition_invalid_group' => 'المجموعة المحددة غير موجودة أو غير صالحة.',
|
||||
'definition_error_adding_updating' => 'لا يمكن إضافة السمة {0} أو تحديثها. يرجى التحقق من سجل الخطأ.',
|
||||
'definition_flags' => 'رؤية الميزات',
|
||||
'definition_group' => 'المجموعة',
|
||||
'definition_id' => 'كود',
|
||||
'definition_name' => 'إضافة ميزة',
|
||||
'definition_name_required' => 'اسم الميزة هي خانة اجبارية',
|
||||
'definition_one_or_multiple' => 'ميزة/ميزات',
|
||||
'definition_successful_adding' => 'لقد تم إضافة صنف بنجاح',
|
||||
'definition_successful_deleted' => 'لقد تم حذف ميزة بنجاح',
|
||||
'definition_successful_updating' => 'تم تعديل الميزة بنجاح',
|
||||
'definition_type' => 'نوع الميزة',
|
||||
'definition_type_required' => 'نوع الميزة هي خانة إجبارية',
|
||||
'definition_unit' => 'وحدة قياس',
|
||||
'definition_values' => 'قيمة الميزة',
|
||||
'new' => 'اضافة ميزة جديده',
|
||||
'no_attributes_to_display' => 'لا يوجد اصناف للعرض',
|
||||
'receipt_visibility' => 'وصل',
|
||||
'show_in_items' => 'اظهار في الصنف',
|
||||
'show_in_items_visibility' => 'الصنف',
|
||||
'show_in_receipt' => 'اظهار على الوصل',
|
||||
'show_in_receivings' => 'اظهار في استلام البضائع',
|
||||
'show_in_receivings_visibility' => 'استلام البضائع',
|
||||
'show_in_sales' => 'اظهار خلال البيع',
|
||||
'show_in_sales_visibility' => 'البيع',
|
||||
'show_in_search' => 'اظهار في البحث',
|
||||
'show_in_search_visibility' => 'بحث',
|
||||
'update' => 'تحديث الميزات',
|
||||
];
|
||||
@@ -210,7 +210,6 @@ return [
|
||||
'msg_msg' => 'الرسائل النصية المحفوظة',
|
||||
'msg_msg_placeholder' => 'إذا أردت إستخدام قالب للرسائل القصيرة احفظه هنا. عدا ذلك أترك هذا الحقل فارغ.',
|
||||
'msg_pwd' => 'SMS-API كلمة السر لـ',
|
||||
'msg_pwd_required' => 'مطلوب SMS-API كلمة السر لـ',
|
||||
'msg_src' => 'SMS-API كود المرسل لـ',
|
||||
'msg_src_required' => 'مطلوب SMS-API كود المرسل لـ',
|
||||
'msg_uid' => 'SMS-API اسم المستخدم لـ',
|
||||
@@ -285,7 +284,7 @@ return [
|
||||
'right' => 'يمين',
|
||||
'sales_invoice_format' => 'شكل فاتورة البيع',
|
||||
'sales_quote_format' => 'شكل فاتورة عرض الاسعار',
|
||||
'mailpath_invalid' => '',
|
||||
'mailpath_invalid' => 'مسار sendmail غير صالح. يُسمح فقط بالحروف والأرقام والشرطات والشرطات السفلية والشرطات المائلة والشرطات المائلة العكسية والنقطتين الرأسيتين والمسافات والنقاط.',
|
||||
'saved_successfully' => 'تم حفظ التهيئة بنجاح.',
|
||||
'saved_unsuccessfully' => 'لم يتم حفظ التهيئة بنجاح.',
|
||||
'security_issue' => 'تحذير من ثغرة أمنية',
|
||||
|
||||
@@ -1,47 +1,50 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
"administrator" => "",
|
||||
"basic_information" => "المعلومات الأساسية للموظف",
|
||||
"cannot_be_deleted" => "لايمكن حذف الموظفين ، واحد أو أكثر من المظفين قام بإجراء مبيعات :).",
|
||||
"change_employee" => "",
|
||||
"change_password" => "تغيير كلمة المرور",
|
||||
"clerk" => "",
|
||||
"commission" => "",
|
||||
"confirm_delete" => "هل أنت متأكد أنك تريد حذف الموظفين المختارين؟",
|
||||
"confirm_restore" => "هل انت متاكد من استعادة الموظفين المحددين؟",
|
||||
"current_password" => "كلمة المرور الحالية",
|
||||
"current_password_invalid" => "كلمة المرور الحالية غير صحيحة.",
|
||||
"employee" => "موظف",
|
||||
"error_adding_updating" => "خطاء فى إضافة/تعديل موظف.",
|
||||
"error_deleting_admin" => "",
|
||||
"error_updating_admin" => "",
|
||||
"error_deleting_demo_admin" => "لايمكن حذف المستخدم admin الخاص بنسخة العرض.",
|
||||
"error_updating_demo_admin" => "لايمكن تغيير بيانات المستخدم admin الخاص بنسخة العرض.",
|
||||
"language" => "اللغة",
|
||||
"login_info" => "معلومات دخول الموظف",
|
||||
"manager" => "",
|
||||
"new" => "موظف جديد",
|
||||
"none_selected" => "لم تختار أى من الموظفين للحذف.",
|
||||
"one_or_multiple" => "موظف/موظفين",
|
||||
"password" => "كلمة السر",
|
||||
"password_minlength" => "كلمة السر يجب أن تكون 8 حروف على الأقل.",
|
||||
"password_must_match" => "كلمتى السر لا تتطابقان.",
|
||||
"password_not_must_match" => "كلمة المرور الحالية والجديدة يجب ان يكونو فريدين.",
|
||||
"password_required" => "كلمة السر مطلوبة.",
|
||||
"permission_desc" => "قم بإضافة الصلاحيات بلإختيار من الأسفل.",
|
||||
"permission_info" => "اذونات المستخدمين",
|
||||
"repeat_password" => "كلمة السر مرة اخرى",
|
||||
"subpermission_required" => "يجب إختيار صلاحية واحدة على الأقل لكل قسم.",
|
||||
"successful_adding" => "لقد تم إضافة الموظف بنجاح.",
|
||||
"successful_change_password" => "تم تغيير كلمة المرور بنجاح.",
|
||||
"successful_deleted" => "لقد تم حذف الموظف بنجاح",
|
||||
"successful_updating" => "لقد تم تحديث بيانات الموظف بنجاح",
|
||||
"system_language" => "لغة النظام",
|
||||
"unsuccessful_change_password" => "فشل في تغيير كلمة المرور.",
|
||||
"update" => "تحديث بيانات موظف",
|
||||
"username" => "اسم المستخدم",
|
||||
"username_duplicate" => "حساب المحدد هو موجود في قاعدة البيانات. نرجوا استخدام اسم حساب مختلف.",
|
||||
"username_minlength" => "اسم المستخدم يجب أن يكون 5 حروف على الأقل.",
|
||||
"username_required" => "اسم المستخدم مطلوب.",
|
||||
'administrator' => '',
|
||||
'basic_information' => 'المعلومات الأساسية للموظف',
|
||||
'cannot_be_deleted' => 'لايمكن حذف الموظفين ، واحد أو أكثر من المظفين قام بإجراء مبيعات :).',
|
||||
'change_employee' => '',
|
||||
'change_password' => 'تغيير كلمة المرور',
|
||||
'clerk' => '',
|
||||
'commission' => '',
|
||||
'confirm_delete' => 'هل أنت متأكد أنك تريد حذف الموظفين المختارين؟',
|
||||
'confirm_restore' => 'هل انت متاكد من استعادة الموظفين المحددين؟',
|
||||
'current_password' => 'كلمة المرور الحالية',
|
||||
'current_password_invalid' => 'كلمة المرور الحالية غير صحيحة.',
|
||||
'employee' => 'موظف',
|
||||
'error_adding_updating' => 'خطاء فى إضافة/تعديل موظف.',
|
||||
'error_cannot_remove_own_minimum_grant' => 'ما فيك تشيل الحد الأدنى من صلاحيات الوصول للموديولات تبعتك.',
|
||||
'error_deleting_admin' => '',
|
||||
'error_deleting_demo_admin' => 'لايمكن حذف المستخدم admin الخاص بنسخة العرض.',
|
||||
'error_grant_change_disallowed' => 'تم تعطيل تغييرات المنح في هذه النسخة التجريبية.',
|
||||
'error_password_change_disallowed' => 'تم تعطيل تغييرات كلمة المرور في هذه النسخة التجريبية.',
|
||||
'error_updating_admin' => '',
|
||||
'error_updating_demo_admin' => 'لايمكن تغيير بيانات المستخدم admin الخاص بنسخة العرض.',
|
||||
'language' => 'اللغة',
|
||||
'login_info' => 'معلومات دخول الموظف',
|
||||
'manager' => '',
|
||||
'new' => 'موظف جديد',
|
||||
'none_selected' => 'لم تختار أى من الموظفين للحذف.',
|
||||
'one_or_multiple' => 'موظف/موظفين',
|
||||
'password' => 'كلمة السر',
|
||||
'password_minlength' => 'كلمة السر يجب أن تكون 8 حروف على الأقل.',
|
||||
'password_must_match' => 'كلمتى السر لا تتطابقان.',
|
||||
'password_not_must_match' => 'كلمة المرور الحالية والجديدة يجب ان يكونو فريدين.',
|
||||
'password_required' => 'كلمة السر مطلوبة.',
|
||||
'permission_desc' => 'قم بإضافة الصلاحيات بلإختيار من الأسفل.',
|
||||
'permission_info' => 'اذونات المستخدمين',
|
||||
'repeat_password' => 'كلمة السر مرة اخرى',
|
||||
'subpermission_required' => 'يجب إختيار صلاحية واحدة على الأقل لكل قسم.',
|
||||
'successful_adding' => 'لقد تم إضافة الموظف بنجاح.',
|
||||
'successful_change_password' => 'تم تغيير كلمة المرور بنجاح.',
|
||||
'successful_deleted' => 'لقد تم حذف الموظف بنجاح',
|
||||
'successful_updating' => 'لقد تم تحديث بيانات الموظف بنجاح',
|
||||
'system_language' => 'لغة النظام',
|
||||
'unsuccessful_change_password' => 'فشل في تغيير كلمة المرور.',
|
||||
'update' => 'تحديث بيانات موظف',
|
||||
'username' => 'اسم المستخدم',
|
||||
'username_duplicate' => 'حساب المحدد هو موجود في قاعدة البيانات. نرجوا استخدام اسم حساب مختلف.',
|
||||
'username_minlength' => 'اسم المستخدم يجب أن يكون 5 حروف على الأقل.',
|
||||
'username_required' => 'اسم المستخدم مطلوب.',
|
||||
];
|
||||
@@ -1,6 +1,14 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
"no_permission_module" => "ليس لديك صلاحيات للوصول لهذا القسم",
|
||||
"unknown" => "غير معروف",
|
||||
'no_permission_module' => 'ليس لديك صلاحيات للوصول لهذا القسم',
|
||||
'unable_to_create_env_file' => 'تعذر إنشاء {filePath} لتوفير throttle.key',
|
||||
'unable_to_lock_file' => 'تعذر قفل {filePath}: {reason}',
|
||||
'unable_to_open_lock_file' => 'تعذر فتح {filePath}: {reason}',
|
||||
'unable_to_persist_throttle_key' => 'تعذر حفظ throttle.key في {filePath}',
|
||||
'unable_to_persist_encryption_key' => '',
|
||||
'encryption_key_not_provisioned' => '',
|
||||
'throttle_key_not_provisioned' => '',
|
||||
'unable_to_read_env_file' => 'تعذر قراءة {filePath} لتوفير throttle.key',
|
||||
'unknown' => 'غير معروف',
|
||||
];
|
||||
+120
-118
@@ -1,122 +1,124 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
"add_minus" => "الكمية المطلوب خصمها أو اضافتها.",
|
||||
"allow_alt_description" => "السماح بوصف بديل",
|
||||
"amount_entry" => "إدخال القيمة",
|
||||
"bulk_edit" => "تحرير جماعى",
|
||||
"buy_price_required" => "سعر الشراء مطلوب.",
|
||||
"cannot_be_deleted" => "لايمكن حذف الأصناف المختارة، واحد أو أكثر منها لديه مبيعات.",
|
||||
"cannot_find_item" => "لايمكن ايجاد معلومات حول الصنف.",
|
||||
"categories" => "",
|
||||
"category" => "التصنيف",
|
||||
"category_new" => "",
|
||||
"category_required" => "التصنيف مطلوب.",
|
||||
"change_all_to_allow_alt_desc" => "السماح بوصف بديل للجميع.",
|
||||
"change_all_to_not_allow_allow_desc" => "عدم السماح بوصف بديل للجميع.",
|
||||
"change_all_to_serialized" => "تغير الجميع لمتسلسل",
|
||||
"change_all_to_unserialized" => "تغيير الجميع لغير متسلسل",
|
||||
"change_image" => "تغيير الصورة",
|
||||
"confirm_bulk_edit" => "هل أنت متأكد انك تريد تحرير كل الأصناف المختارة؟",
|
||||
"confirm_bulk_edit_wipe_taxes" => "سوف يتم مسح معلومات الضرائب لجميع الأصناف.",
|
||||
"confirm_delete" => "هل أنت متأكد انك تريد حذف كل الأصناف المختارة؟",
|
||||
"confirm_restore" => "هل انت متاكد من إستعادة الاصناف المخطارة؟",
|
||||
"cost_price" => "سعر التكلفة",
|
||||
"cost_price_number" => "سعر التكلفة يجب أن يكون رقم.",
|
||||
"cost_price_required" => "سعر التكلفة مطلوب.",
|
||||
"count" => "تحديث المخزون",
|
||||
"csv_import_failed" => "فشل الإستيراد من اكسل",
|
||||
"csv_import_invalid_location" => "",
|
||||
"csv_import_nodata_wrongformat" => "الملف الذى رفعته إما فارغ أو أنه مختلف البنية.",
|
||||
"csv_import_partially_failed" => "يوجد خطأ بنسبة {0} في استيراد الاصناف في السطر: {1}. لم يتم استيرادهم.",
|
||||
"csv_import_success" => "تم استيراد الأصناف بنجاح.",
|
||||
"current_quantity" => "الكمية الحالية",
|
||||
"default_pack_name" => "كل",
|
||||
"description" => "الوصف",
|
||||
"details_count" => "حركة الصنف",
|
||||
"do_nothing" => "لاتفعل شئ",
|
||||
"edit" => "",
|
||||
"edit_fields_you_want_to_update" => "قم بتحديث الحقول المراد تحديثها لكل الأصناف المختارة.",
|
||||
"edit_multiple_items" => "تحريد مجموعة اصناف",
|
||||
"empty_upc_items" => "تفريغ اصناف UPC",
|
||||
"error_adding_updating" => "خطاء فى إضافة/تحديث صنف",
|
||||
"error_updating_multiple" => "خطاء فى تحديث بيانات أصناف",
|
||||
"generate_barcodes" => "توليد أكواد الباركود",
|
||||
"hsn_code" => "تسميات النظام المنسق",
|
||||
"image" => "صورة",
|
||||
"import_items_csv" => "استيراد من اكسل",
|
||||
"info_provided_by" => "المعلومات مقدمة بواسطة",
|
||||
"inventory" => "المخزن",
|
||||
"inventory_CSV_import_quantity" => "العدد المستورد من ملفات ال CSV",
|
||||
"inventory_comments" => "تعليقات",
|
||||
"inventory_data_tracking" => "تتبع بيانات المخزون",
|
||||
"inventory_date" => "التاريخ",
|
||||
"inventory_employee" => "الموظف",
|
||||
"inventory_in_out_quantity" => "+- كمية",
|
||||
"inventory_remarks" => "ملاحظات",
|
||||
"is_deleted" => "حذفت",
|
||||
"is_printed" => "",
|
||||
"is_serialized" => "الصنف له رقم مسلسل",
|
||||
"item" => "صنف",
|
||||
"item_id" => "",
|
||||
"item_number" => "الباركود UPC/EAN/ISBN",
|
||||
"item_number_duplicate" => "رقم الصنف موجود فى قاعدة البيانات من قبل.",
|
||||
"kit" => "مجموعة اصناف",
|
||||
"location" => "مكان",
|
||||
"low_inventory_items" => "الأصناف التى لا يوجد لها مخزون",
|
||||
"low_sell_item" => "الصنف المركب",
|
||||
"manually_editing_of_quantity" => "تحرير الكمية يدوياً",
|
||||
"markup" => "",
|
||||
"name" => "اسم الصنف",
|
||||
"name_required" => "اسم الصنف مطلوب.",
|
||||
"new" => "صنف جديد",
|
||||
"no_description_items" => "الأصناف التى بلا وصف",
|
||||
"no_items_to_display" => "لايوجد أصناف لعرضها.",
|
||||
"none" => "لاشىء",
|
||||
"none_selected" => "لم تختار أى أصناف لتحريرها",
|
||||
"nonstock" => "غير مخزنة",
|
||||
"number_information" => "رقم الصنف",
|
||||
"number_required" => "مطلوب UPC/EAN/ISBN رقم الباركود.",
|
||||
"one_or_multiple" => "صنف/أصناف",
|
||||
"pack_name" => "اسم الحزمة",
|
||||
"qty_per_pack" => "العدد بكل الحزمة",
|
||||
"quantity" => "الكمية",
|
||||
"quantity_number" => "الكمية يجب أن تكون رقم.",
|
||||
"quantity_required" => "الكمية مطلوبة.",
|
||||
"receiving_quantity" => "الكمية المستلمة",
|
||||
"remove_image" => "ازالة الصورة",
|
||||
"reorder_level" => "نقطة إعادة الطلب",
|
||||
"reorder_level_number" => "نقطة إعادة الطلب يجب أن تكون رقم.",
|
||||
"reorder_level_required" => "نقطة إعادة الطلب مطلوبة.",
|
||||
"retrive_item_info" => "الحصول على بيانات الصنف",
|
||||
"sales_tax_1" => "ضريبة المبيعات",
|
||||
"sales_tax_2" => "ضريبة المبيعات 2",
|
||||
"search_attributes" => "بحث الحقول المخصصة",
|
||||
"select_image" => "اختار صورة",
|
||||
"serialized_items" => "أصناف مسلسلة",
|
||||
"standard" => "اساسي",
|
||||
"stock" => "مخزن",
|
||||
"stock_location" => "مكان المخزون",
|
||||
"stock_type" => "نوع المخزن",
|
||||
"successful_adding" => "لقد تم إضافة صنف بنجاح",
|
||||
"successful_bulk_edit" => "لقد تم تحديث بيانات صنف/أصناف بنجاح",
|
||||
"successful_deleted" => "لقد تم الحذف بنجاح",
|
||||
"successful_updating" => "لقد تم تحديث بيانات صنف بنجاح",
|
||||
"supplier" => "المورد",
|
||||
"tax_1" => "الضريبة 1",
|
||||
"tax_2" => "الضريبة 2",
|
||||
"tax_3" => "",
|
||||
"tax_category" => "الفئة الضريبية",
|
||||
"tax_percent" => "نسبة الضريبة",
|
||||
"tax_percent_number" => "نسبة الضريبة يجب ان تكون رقم",
|
||||
"tax_percent_required" => "نسبة الضريبة مطلوبة.",
|
||||
"tax_percents" => "نسبة/نسب الضريبة",
|
||||
"temp" => "مؤقت",
|
||||
"type" => "نوع الصنف",
|
||||
"unit_price" => "السعر",
|
||||
"unit_price_number" => "السعر يجب أن يكون رقم.",
|
||||
"unit_price_required" => "السعر مطلوب.",
|
||||
"upc_database" => "UPC قاعدة بيانات",
|
||||
"update" => "تحديث بيانات صنف",
|
||||
"use_inventory_menu" => "استخدام تحديث المخزن",
|
||||
'add_minus' => 'الكمية المطلوب خصمها أو اضافتها.',
|
||||
'allow_alt_description' => 'السماح بوصف بديل',
|
||||
'amount_entry' => 'إدخال القيمة',
|
||||
'bulk_edit' => 'تحرير جماعى',
|
||||
'buy_price_required' => 'سعر الشراء مطلوب.',
|
||||
'cannot_be_deleted' => 'لايمكن حذف الأصناف المختارة، واحد أو أكثر منها لديه مبيعات.',
|
||||
'cannot_find_item' => 'لايمكن ايجاد معلومات حول الصنف.',
|
||||
'categories' => '',
|
||||
'category' => 'التصنيف',
|
||||
'category_new' => '',
|
||||
'category_required' => 'التصنيف مطلوب.',
|
||||
'change_all_to_allow_alt_desc' => 'السماح بوصف بديل للجميع.',
|
||||
'change_all_to_not_allow_allow_desc' => 'عدم السماح بوصف بديل للجميع.',
|
||||
'change_all_to_serialized' => 'تغير الجميع لمتسلسل',
|
||||
'change_all_to_unserialized' => 'تغيير الجميع لغير متسلسل',
|
||||
'change_image' => 'تغيير الصورة',
|
||||
'confirm_bulk_edit' => 'هل أنت متأكد انك تريد تحرير كل الأصناف المختارة؟',
|
||||
'confirm_bulk_edit_wipe_taxes' => 'سوف يتم مسح معلومات الضرائب لجميع الأصناف.',
|
||||
'confirm_delete' => 'هل أنت متأكد انك تريد حذف كل الأصناف المختارة؟',
|
||||
'confirm_restore' => 'هل انت متاكد من إستعادة الاصناف المخطارة؟',
|
||||
'cost_price' => 'سعر التكلفة',
|
||||
'cost_price_number' => 'سعر التكلفة يجب أن يكون رقم.',
|
||||
'cost_price_required' => 'سعر التكلفة مطلوب.',
|
||||
'count' => 'تحديث المخزون',
|
||||
'csv_import_failed' => 'فشل الإستيراد من اكسل',
|
||||
'csv_import_invalid_location' => '',
|
||||
'csv_import_nodata_wrongformat' => 'الملف الذى رفعته إما فارغ أو أنه مختلف البنية.',
|
||||
'csv_import_partially_failed' => 'يوجد خطأ بنسبة {0} في استيراد الاصناف في السطر: {1}. لم يتم استيرادهم.',
|
||||
'csv_import_success' => 'تم استيراد الأصناف بنجاح.',
|
||||
'current_quantity' => 'الكمية الحالية',
|
||||
'default_pack_name' => 'كل',
|
||||
'description' => 'الوصف',
|
||||
'details_count' => 'حركة الصنف',
|
||||
'do_nothing' => 'لاتفعل شئ',
|
||||
'edit' => '',
|
||||
'edit_fields_you_want_to_update' => 'قم بتحديث الحقول المراد تحديثها لكل الأصناف المختارة.',
|
||||
'edit_multiple_items' => 'تحريد مجموعة اصناف',
|
||||
'empty_upc_items' => 'تفريغ اصناف UPC',
|
||||
'error_adding_updating' => 'خطاء فى إضافة/تحديث صنف',
|
||||
'error_updating_multiple' => 'خطاء فى تحديث بيانات أصناف',
|
||||
'generate_barcodes' => 'توليد أكواد الباركود',
|
||||
'hsn_code' => 'تسميات النظام المنسق',
|
||||
'image' => 'صورة',
|
||||
'import_items_csv' => 'استيراد من اكسل',
|
||||
'info_provided_by' => 'المعلومات مقدمة بواسطة',
|
||||
'inventory' => 'المخزن',
|
||||
'inventory_CSV_import_quantity' => 'العدد المستورد من ملفات ال CSV',
|
||||
'inventory_comments' => 'تعليقات',
|
||||
'inventory_data_tracking' => 'تتبع بيانات المخزون',
|
||||
'inventory_date' => 'التاريخ',
|
||||
'inventory_employee' => 'الموظف',
|
||||
'inventory_in_out_quantity' => '+- كمية',
|
||||
'inventory_remarks' => 'ملاحظات',
|
||||
'is_deleted' => 'حذفت',
|
||||
'is_printed' => '',
|
||||
'is_serialized' => 'الصنف له رقم مسلسل',
|
||||
'item' => 'صنف',
|
||||
'item_id' => '',
|
||||
'item_number' => 'الباركود UPC/EAN/ISBN',
|
||||
'item_number_duplicate' => 'رقم الصنف موجود فى قاعدة البيانات من قبل.',
|
||||
'item_number_invalid' => 'رقم الصنف يحتوي على أحرف غير مسموح بها.',
|
||||
'kit' => 'مجموعة اصناف',
|
||||
'location' => 'مكان',
|
||||
'low_inventory_items' => 'الأصناف التى لا يوجد لها مخزون',
|
||||
'low_sell_item' => 'الصنف المركب',
|
||||
'manually_editing_of_quantity' => 'تحرير الكمية يدوياً',
|
||||
'markup' => '',
|
||||
'name' => 'اسم الصنف',
|
||||
'name_required' => 'اسم الصنف مطلوب.',
|
||||
'new' => 'صنف جديد',
|
||||
'no_description_items' => 'الأصناف التى بلا وصف',
|
||||
'no_items_to_display' => 'لايوجد أصناف لعرضها.',
|
||||
'none' => 'لاشىء',
|
||||
'none_selected' => 'لم تختار أى أصناف لتحريرها',
|
||||
'nonstock' => 'غير مخزنة',
|
||||
'number_information' => 'رقم الصنف',
|
||||
'number_required' => 'مطلوب UPC/EAN/ISBN رقم الباركود.',
|
||||
'one_or_multiple' => 'صنف/أصناف',
|
||||
'pack_name' => 'اسم الحزمة',
|
||||
'qty_per_pack' => 'العدد بكل الحزمة',
|
||||
'quantity' => 'الكمية',
|
||||
'quantity_number' => 'الكمية يجب أن تكون رقم.',
|
||||
'quantity_required' => 'الكمية مطلوبة.',
|
||||
'receiving_quantity' => 'الكمية المستلمة',
|
||||
'remove_image' => 'ازالة الصورة',
|
||||
'reorder_level' => 'نقطة إعادة الطلب',
|
||||
'reorder_level_number' => 'نقطة إعادة الطلب يجب أن تكون رقم.',
|
||||
'reorder_level_required' => 'نقطة إعادة الطلب مطلوبة.',
|
||||
'retrive_item_info' => 'الحصول على بيانات الصنف',
|
||||
'sales_tax_1' => 'ضريبة المبيعات',
|
||||
'sales_tax_2' => 'ضريبة المبيعات 2',
|
||||
'search_attributes' => 'بحث الحقول المخصصة',
|
||||
'select_image' => 'اختار صورة',
|
||||
'serialized_items' => 'أصناف مسلسلة',
|
||||
'standard' => 'اساسي',
|
||||
'stock' => 'مخزن',
|
||||
'stock_location' => 'مكان المخزون',
|
||||
'stock_type' => 'نوع المخزن',
|
||||
'successful_adding' => 'لقد تم إضافة صنف بنجاح',
|
||||
'successful_bulk_edit' => 'لقد تم تحديث بيانات صنف/أصناف بنجاح',
|
||||
'successful_deleted' => 'لقد تم الحذف بنجاح',
|
||||
'successful_updating' => 'لقد تم تحديث بيانات صنف بنجاح',
|
||||
'supplier' => 'المورد',
|
||||
'tax_1' => 'الضريبة 1',
|
||||
'tax_2' => 'الضريبة 2',
|
||||
'tax_3' => '',
|
||||
'tax_category' => 'الفئة الضريبية',
|
||||
'tax_name_invalid' => 'اسم الضريبة يحتوي على أحرف غير مسموح بها.',
|
||||
'tax_percent' => 'نسبة الضريبة',
|
||||
'tax_percent_number' => 'نسبة الضريبة يجب ان تكون رقم',
|
||||
'tax_percent_required' => 'نسبة الضريبة مطلوبة.',
|
||||
'tax_percents' => 'نسبة/نسب الضريبة',
|
||||
'temp' => 'مؤقت',
|
||||
'type' => 'نوع الصنف',
|
||||
'unit_price' => 'السعر',
|
||||
'unit_price_number' => 'السعر يجب أن يكون رقم.',
|
||||
'unit_price_required' => 'السعر مطلوب.',
|
||||
'upc_database' => 'UPC قاعدة بيانات',
|
||||
'update' => 'تحديث بيانات صنف',
|
||||
'use_inventory_menu' => 'استخدام تحديث المخزن',
|
||||
];
|
||||
@@ -25,6 +25,7 @@ return [
|
||||
"migration_running" => "",
|
||||
"password" => "كلمة السر",
|
||||
"required_username" => "خانة أسم المستخدم مطلوبة.",
|
||||
'too_many_attempts' => 'عدد كبير جدًا من المحاولات. يرجى الانتظار لحظة والمحاولة مرة أخرى.',
|
||||
"username" => "اسم المستخدم",
|
||||
"welcome" => "مرحباً بك في{0}!"
|
||||
];
|
||||
@@ -128,6 +128,7 @@ return [
|
||||
"tax_rate" => "معدل الضريبة",
|
||||
"taxes" => "الضرائب",
|
||||
"taxes_summary_report" => "تقرير ملخص الضرائب",
|
||||
"toggle_cost_and_profit" => "",
|
||||
"total" => "الإجمالى",
|
||||
"total_inventory_value" => "إجمالى قيمة المخزن",
|
||||
"total_low_sell_quantity" => "مجموع الكميات حسب الصنف الاولي",
|
||||
|
||||
@@ -4,6 +4,7 @@ return [
|
||||
'account_number' => 'حساب',
|
||||
'add_payment' => 'إضافة دفع',
|
||||
'amount_due' => 'المبلغ المطلوب',
|
||||
'amount_due_not_covered' => 'المدفوعات لا تغطي المبلغ المستحق.',
|
||||
'amount_tendered' => 'المبلغ المدفوع',
|
||||
'authorized_signature' => 'توقيع معتمد',
|
||||
'cancel_sale' => 'الغاء عملية البيع',
|
||||
@@ -84,6 +85,8 @@ return [
|
||||
'hsn' => 'رمز نظام منسق',
|
||||
'id' => 'رقم عملية البيع',
|
||||
'include_prices' => 'يشمل الاسعار؟',
|
||||
'insufficient_giftcard_balance' => 'رصيد بطاقة الهدية غير كافي لهذه الدفعة.',
|
||||
'insufficient_reward_points' => 'نقاط المكافأة غير كافية لهذه الدفعة.',
|
||||
'invoice' => 'فاتورة',
|
||||
'invoice_confirm' => 'هذه الفاتورة سوف ترسل إلى',
|
||||
'invoice_enable' => 'رقم فاتورة',
|
||||
@@ -129,6 +132,7 @@ return [
|
||||
'must_enter_numeric' => 'يجب إدخال رقم للمبلغ الفعلى المدفوع.',
|
||||
'must_enter_numeric_giftcard' => 'رمز بطاقة الهدية يجب أن يكتون ارقام فقط.',
|
||||
'must_enter_reference_code' => 'يجب إدخال رقم المرجع/الاسترداد.',
|
||||
'negative_amount_tendered' => 'لا يمكن أن يكون المبلغ المدفوع سالبًا.',
|
||||
'negative_discount_invalid' => '',
|
||||
'negative_price_invalid' => '',
|
||||
'negative_quantity_invalid' => '',
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
"attribute_value_invalid_chars" => "Atribut dəyəri bu ':' və ya '|' olmaz",
|
||||
"confirm_delete" => "Seçilmiş Atributları silmək istədiyinizdən əminsinizmi?",
|
||||
"confirm_restore" => "Seçilmiş atributları bərpa etmək istədiyinizə əminsinizmi?",
|
||||
"definition_cannot_be_deleted" => "Seçilmiş xüsusiyyətləri silmək olmadı",
|
||||
"definition_invalid_group" => "",
|
||||
"definition_error_adding_updating" => "{0} -in atributları əlavə oluna və yenilənə bilmədi. Lütfən XƏTA loq faylını yoxlayın.",
|
||||
"definition_flags" => "Atribut görünüşü",
|
||||
"definition_group" => "Qrup",
|
||||
"definition_id" => "Id",
|
||||
"definition_name" => "Atribut əlavə et",
|
||||
"definition_name_required" => "Atribut adı zəruri bir sahədir",
|
||||
"definition_one_or_multiple" => "atribut (lar)",
|
||||
"definition_successful_adding" => "Malı uğurla əlavə etdiniz",
|
||||
"definition_successful_deleted" => "Siz uğurla sildiniz",
|
||||
"definition_successful_updating" => "Atributunuzu müvəffəqiyyətlə yenilədiniz",
|
||||
"definition_type" => "Atribut Tipi",
|
||||
"definition_type_required" => "Atribut növü tələb olunan sahədir",
|
||||
"definition_unit" => "Ölçü vahidi",
|
||||
"definition_values" => "Atribut dəyərləri",
|
||||
"new" => "Yeni atributlar",
|
||||
"no_attributes_to_display" => "Göstərmək üçün heç bir element yoxdur",
|
||||
"receipt_visibility" => "Qəbz",
|
||||
"show_in_items" => "Malları göstər",
|
||||
"show_in_items_visibility" => "Mallar",
|
||||
"show_in_receipt" => "Qəbzi göstərin",
|
||||
"show_in_receivings" => "Alacaqlarda göstərin",
|
||||
"show_in_receivings_visibility" => "Alınanlar",
|
||||
"show_in_sales" => "Satışda göstərin",
|
||||
"show_in_sales_visibility" => "Satışlar",
|
||||
"update" => "Atributları yenilə",
|
||||
'attribute_value_invalid_chars' => 'Atribut dəyəri bu \':\' və ya \'|\' olmaz',
|
||||
'confirm_delete' => 'Seçilmiş Atributları silmək istədiyinizdən əminsinizmi?',
|
||||
'confirm_restore' => 'Seçilmiş atributları bərpa etmək istədiyinizə əminsinizmi?',
|
||||
'definition_cannot_be_deleted' => 'Seçilmiş xüsusiyyətləri silmək olmadı',
|
||||
'definition_invalid_group' => '',
|
||||
'definition_error_adding_updating' => '{0} -in atributları əlavə oluna və yenilənə bilmədi. Lütfən XƏTA loq faylını yoxlayın.',
|
||||
'definition_flags' => 'Atribut görünüşü',
|
||||
'definition_group' => 'Qrup',
|
||||
'definition_id' => 'Id',
|
||||
'definition_name' => 'Atribut əlavə et',
|
||||
'definition_name_required' => 'Atribut adı zəruri bir sahədir',
|
||||
'definition_one_or_multiple' => 'atribut (lar)',
|
||||
'definition_successful_adding' => 'Malı uğurla əlavə etdiniz',
|
||||
'definition_successful_deleted' => 'Siz uğurla sildiniz',
|
||||
'definition_successful_updating' => 'Atributunuzu müvəffəqiyyətlə yenilədiniz',
|
||||
'definition_type' => 'Atribut Tipi',
|
||||
'definition_type_required' => 'Atribut növü tələb olunan sahədir',
|
||||
'definition_unit' => 'Ölçü vahidi',
|
||||
'definition_values' => 'Atribut dəyərləri',
|
||||
'new' => 'Yeni atributlar',
|
||||
'no_attributes_to_display' => 'Göstərmək üçün heç bir element yoxdur',
|
||||
'receipt_visibility' => 'Qəbz',
|
||||
'show_in_items' => 'Malları göstər',
|
||||
'show_in_items_visibility' => 'Mallar',
|
||||
'show_in_receipt' => 'Qəbzi göstərin',
|
||||
'show_in_receivings' => 'Alacaqlarda göstərin',
|
||||
'show_in_receivings_visibility' => 'Alınanlar',
|
||||
'show_in_sales' => 'Satışda göstərin',
|
||||
'show_in_sales_visibility' => 'Satışlar',
|
||||
'show_in_search' => 'Axtarışda göstər',
|
||||
'show_in_search_visibility' => 'Axtarış',
|
||||
'update' => 'Atributları yenilə',
|
||||
];
|
||||
@@ -210,7 +210,6 @@ return [
|
||||
'msg_msg' => 'Saxlanılan Mətn Mesajı',
|
||||
'msg_msg_placeholder' => 'SMS şablonunu istifadə etmək istəyirsinizsə, mesajınızı buraya qeyd edin, əks halda qutunu boş buraxın.',
|
||||
'msg_pwd' => 'SMS-API Şifrəsi',
|
||||
'msg_pwd_required' => 'SMS-API şifrəsi tələb olunan bir sahədir',
|
||||
'msg_src' => 'SMS-API Göndərici ID',
|
||||
'msg_src_required' => 'SMS-API Göndərici ID tələb olunan sahədir',
|
||||
'msg_uid' => 'SMS-API İstifadəçi adı',
|
||||
@@ -285,7 +284,7 @@ return [
|
||||
'right' => 'Sağ',
|
||||
'sales_invoice_format' => 'Satış Fatura Formatı',
|
||||
'sales_quote_format' => 'Satış Sitat Formati',
|
||||
'mailpath_invalid' => '',
|
||||
'mailpath_invalid' => 'Yanlış sendmail yolu. Yalnız hərflərə, rəqəmlərə, tirelərə, alt xətlərə, əyri xətlərə, tərs əyri xətlərə, iki nöqtəyə, boşluqlara və nöqtələrə icazə verilir.',
|
||||
'saved_successfully' => 'Konfiqurasiya uğurla saxlanıldı.',
|
||||
'saved_unsuccessfully' => 'Konfiqurasiyanı saxlamq mümkün olmadı.',
|
||||
'security_issue' => 'Təhlükəsizlik açığı xəbərdarlığı',
|
||||
|
||||
@@ -1,47 +1,50 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
"administrator" => "",
|
||||
"basic_information" => "Məlumat",
|
||||
"cannot_be_deleted" => "Seçilmiş əməkdaş (lar) silinə bilməz, bir və ya birdən çox əməkdaş satışlar edib, əks halda siz öz heasabınızı silməyə çalışırsınız.",
|
||||
"change_employee" => "",
|
||||
"change_password" => "Şifrəni Dəyiş",
|
||||
"clerk" => "",
|
||||
"commission" => "",
|
||||
"confirm_delete" => "Siz əminsiniz ki seçilmiş əməkdaşları silmək istəyirsiniz?",
|
||||
"confirm_restore" => "Seçilmiş əməkdaşı (lar) yenidən bərpa etməyinizə əminsinizmi?",
|
||||
"current_password" => "İndiki Şifrə",
|
||||
"current_password_invalid" => "Hazirki Şifrə düzgün deyil.",
|
||||
"employee" => "Əməkdaş",
|
||||
"error_adding_updating" => "Əməkdaş əlavə etməsk və ya yeniləməsi baş vermədi.",
|
||||
"error_deleting_admin" => "",
|
||||
"error_updating_admin" => "",
|
||||
"error_deleting_demo_admin" => "Demo administrator istifadəçisini silə bilməzsiniz.",
|
||||
"error_updating_demo_admin" => "Demo administrator istifadəçisini dəyişə bilməzsiniz.",
|
||||
"language" => "Dil",
|
||||
"login_info" => "Daxil Ol",
|
||||
"manager" => "",
|
||||
"new" => "Yeni Əməkdaş",
|
||||
"none_selected" => "Silmək üçün heç bir işçi (lər) seçməmisiniz.",
|
||||
"one_or_multiple" => "İşçi (lər)",
|
||||
"password" => "Şifrə",
|
||||
"password_minlength" => "Şifrə ən azı 8 simvol olmalıdır.",
|
||||
"password_must_match" => "Şifrələr uyğun gəlmir.",
|
||||
"password_not_must_match" => "Hazırki şifrə və yeni şifrə unikal olmalıdır.",
|
||||
"password_required" => "Şifrə tələb olunur.",
|
||||
"permission_desc" => "Modullara giriş imkanı vermək üçün aşağıdakı qutuları yoxlayın.",
|
||||
"permission_info" => "İcazələr",
|
||||
"repeat_password" => "Şifrəni yenidən təkrar edin",
|
||||
"subpermission_required" => "Hər bir modul üçün ən azı bir qrant əlavə edin.",
|
||||
"successful_adding" => "Əməkdaş müvəffəqiyyətə əlavə olundu.",
|
||||
"successful_change_password" => "Şifrə müvəffəqiyyətlə dəyişildi.",
|
||||
"successful_deleted" => "Siz uğurla sildiniz",
|
||||
"successful_updating" => "Siz uğurla əməkdaşı yenilədiniz",
|
||||
"system_language" => "Sistem Dili",
|
||||
"unsuccessful_change_password" => "Şifrə dəyişməsi uğursuz oldu.",
|
||||
"update" => "İşçini yeniləyin",
|
||||
"username" => "İstifadəçi Adı",
|
||||
"username_duplicate" => "",
|
||||
"username_minlength" => "İstifadəçi adı ən azı 5 simvol olmalıdır.",
|
||||
"username_required" => "İstifadəçi adı tələb olunan sahədir.",
|
||||
'administrator' => '',
|
||||
'basic_information' => 'Məlumat',
|
||||
'cannot_be_deleted' => 'Seçilmiş əməkdaş (lar) silinə bilməz, bir və ya birdən çox əməkdaş satışlar edib, əks halda siz öz hesabınızı silməyə çalışırsınız.',
|
||||
'change_employee' => '',
|
||||
'change_password' => 'Şifrəni Dəyiş',
|
||||
'clerk' => '',
|
||||
'commission' => '',
|
||||
'confirm_delete' => 'Siz əminsiniz ki seçilmiş əməkdaşları silmək istəyirsiniz?',
|
||||
'confirm_restore' => 'Seçilmiş əməkdaşı (lar) yenidən bərpa etməyinizə əminsinizmi?',
|
||||
'current_password' => 'İndiki Şifrə',
|
||||
'current_password_invalid' => 'Hazirki Şifrə düzgün deyil.',
|
||||
'employee' => 'Əməkdaş',
|
||||
'error_adding_updating' => 'Əməkdaş əlavə etməsk və ya yeniləməsi baş vermədi.',
|
||||
'error_cannot_remove_own_minimum_grant' => 'Öz minimum modul giriş qrantlarınızı silə bilməzsiniz.',
|
||||
'error_deleting_admin' => '',
|
||||
'error_deleting_demo_admin' => 'Demo administrator istifadəçisini silə bilməzsiniz.',
|
||||
'error_grant_change_disallowed' => 'Bu demodan icazə dəyişiklikləri deaktivdir.',
|
||||
'error_password_change_disallowed' => 'Bu demodan şifrə dəyişiklikləri deaktivdir.',
|
||||
'error_updating_admin' => '',
|
||||
'error_updating_demo_admin' => 'Demo administrator istifadəçisini dəyişə bilməzsiniz.',
|
||||
'language' => 'Dil',
|
||||
'login_info' => 'Daxil Ol',
|
||||
'manager' => '',
|
||||
'new' => 'Yeni Əməkdaş',
|
||||
'none_selected' => 'Silmək üçün heç bir işçi (lər) seçməmisiniz.',
|
||||
'one_or_multiple' => 'İşçi (lər)',
|
||||
'password' => 'Şifrə',
|
||||
'password_minlength' => 'Şifrə ən azı 8 simvol olmalıdır.',
|
||||
'password_must_match' => 'Şifrələr uyğun gəlmir.',
|
||||
'password_not_must_match' => 'Hazırki şifrə və yeni şifrə unikal olmalıdır.',
|
||||
'password_required' => 'Şifrə tələb olunur.',
|
||||
'permission_desc' => 'Modullara giriş imkanı vermək üçün aşağıdakı qutuları yoxlayın.',
|
||||
'permission_info' => 'İcazələr',
|
||||
'repeat_password' => 'Şifrəni yenidən təkrar edin',
|
||||
'subpermission_required' => 'Hər bir modul üçün ən azı bir qrant əlavə edin.',
|
||||
'successful_adding' => 'Əməkdaş müvəffəqiyyətə əlavə olundu.',
|
||||
'successful_change_password' => 'Şifrə müvəffəqiyyətlə dəyişildi.',
|
||||
'successful_deleted' => 'Siz uğurla sildiniz',
|
||||
'successful_updating' => 'Siz uğurla əməkdaşı yenilədiniz',
|
||||
'system_language' => 'Sistem Dili',
|
||||
'unsuccessful_change_password' => 'Şifrə dəyişməsi uğursuz oldu.',
|
||||
'update' => 'İşçini yeniləyin',
|
||||
'username' => 'İstifadəçi Adı',
|
||||
'username_duplicate' => '',
|
||||
'username_minlength' => 'İstifadəçi adı ən azı 5 simvol olmalıdır.',
|
||||
'username_required' => 'İstifadəçi adı tələb olunan sahədir.',
|
||||
];
|
||||
@@ -1,6 +1,14 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
"no_permission_module" => "sizin icazəniz yoxdur",
|
||||
"unknown" => "naməlum",
|
||||
'no_permission_module' => 'sizin icazəniz yoxdur',
|
||||
'unable_to_create_env_file' => 'throttle.key təmin etmək üçün {filePath} yaradıla bilmədi',
|
||||
'unable_to_lock_file' => '{filePath} kilidlənə bilmədi: {reason}',
|
||||
'unable_to_open_lock_file' => '{filePath} açıla bilmədi: {reason}',
|
||||
'unable_to_persist_throttle_key' => 'throttle.key {filePath} faylına yazıla bilmədi',
|
||||
'unable_to_persist_encryption_key' => '',
|
||||
'encryption_key_not_provisioned' => '',
|
||||
'throttle_key_not_provisioned' => '',
|
||||
'unable_to_read_env_file' => 'throttle.key təmin etmək üçün {filePath} oxuna bilmədi',
|
||||
'unknown' => 'naməlum',
|
||||
];
|
||||
+120
-118
@@ -1,122 +1,124 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
"add_minus" => "İnventar + Topla və ya - Çıx.",
|
||||
"allow_alt_description" => "Alternativ təsvirə icazə verin",
|
||||
"amount_entry" => "Məbləğ Əlavəsi",
|
||||
"bulk_edit" => "Kütləvi Redaktə",
|
||||
"buy_price_required" => "Alış qiyməti tələb olunan sahədir.",
|
||||
"cannot_be_deleted" => "Seçilmiş Mal (lar) silmək mükün deyil, bir və ya birdən çox Mallarda Satışlar var.",
|
||||
"cannot_find_item" => "Mal Tapılmadı.",
|
||||
"categories" => "",
|
||||
"category" => "Bolmə",
|
||||
"category_new" => "",
|
||||
"category_required" => "Bölmə sahəsi boş qala bilməz.",
|
||||
"change_all_to_allow_alt_desc" => "Hamısı üçün Alternativ Təsvirə İcazə Verin.",
|
||||
"change_all_to_not_allow_allow_desc" => "Hamısı üçün Alternativ Təsvirə İcazə Verməyin.",
|
||||
"change_all_to_serialized" => "Hamısını Seriyalaşdırılmışa görə dəyişin",
|
||||
"change_all_to_unserialized" => "Qeyri Seriyalara görə dəyiş",
|
||||
"change_image" => "Şəkili Dəyiş",
|
||||
"confirm_bulk_edit" => "Siz əminsinizmi ki işarələnmiş butun malları redaktə etmək istəyirsiniz?",
|
||||
"confirm_bulk_edit_wipe_taxes" => "Bütün Məhsul Vergisi məlumatları dəyişdiriləcək.",
|
||||
"confirm_delete" => "Seçilmiş element(ləri) silmək istədiyinizə əminsiniz?",
|
||||
"confirm_restore" => "Seçilmiş element (lər) bərpa etmək istədiyinizə əminsinizmi?",
|
||||
"cost_price" => "Topdan Satış qiyməti",
|
||||
"cost_price_number" => "Topdansatış qiyməti rəqəm olmalıdır.",
|
||||
"cost_price_required" => "Topdan satiış - doldurulması vacib sahə.",
|
||||
"count" => "inventorun yenilənməsi",
|
||||
"csv_import_failed" => "səhv csv import",
|
||||
"csv_import_invalid_location" => "",
|
||||
"csv_import_nodata_wrongformat" => "Yüklənmiş faylda məlumat yoxdur və ya düzgün formatlanmır.",
|
||||
"csv_import_partially_failed" => "Xətlərdə {0} element idxalı uğursuzluq (lar) var: {1}. Heç bir sıra idxal edilmədi.",
|
||||
"csv_import_success" => "Malların İdxalı Uğurla Həyata Keçdi.",
|
||||
"current_quantity" => "indiki say",
|
||||
"default_pack_name" => "Hər biri",
|
||||
"description" => "təsvir",
|
||||
"details_count" => "detallı inventor sayı",
|
||||
"do_nothing" => "heç nə etməmək",
|
||||
"edit" => "",
|
||||
"edit_fields_you_want_to_update" => "Seçilmiş məhsullar üçün tələb olunan sahələri redaktə edin.",
|
||||
"edit_multiple_items" => "bir neçə malın redaktəsi",
|
||||
"empty_upc_items" => "Boş Barkod Malları",
|
||||
"error_adding_updating" => "mal əlavə etməkdə və ya yeniləməkdə səhv var",
|
||||
"error_updating_multiple" => "malların yenilənməsində səhv var",
|
||||
"generate_barcodes" => "şifrə yarat",
|
||||
"hsn_code" => "Harmonize Sistem Nomenklaturası",
|
||||
"image" => "Avatar",
|
||||
"import_items_csv" => "CSVdən Malları İdxal Et",
|
||||
"info_provided_by" => "Məlumat təqdim olundu",
|
||||
"inventory" => "inventor",
|
||||
"inventory_CSV_import_quantity" => "Miqdarı CSV-dən idxal olunur",
|
||||
"inventory_comments" => "komentariyalar",
|
||||
"inventory_data_tracking" => "İnventarlaşdırma Üçün Məlumat İzləmə",
|
||||
"inventory_date" => "Tarix",
|
||||
"inventory_employee" => "Əmakdaş",
|
||||
"inventory_in_out_quantity" => "Daxili və Xarici Say",
|
||||
"inventory_remarks" => "Qeydlər",
|
||||
"is_deleted" => "Silindi",
|
||||
"is_printed" => "",
|
||||
"is_serialized" => "Malın Seriya Nömrəsi var",
|
||||
"item" => "Mal",
|
||||
"item_id" => "",
|
||||
"item_number" => "Barkod",
|
||||
"item_number_duplicate" => "Malın Nömrəsi artiq Məlumat bazasında var.",
|
||||
"kit" => "Dəst",
|
||||
"location" => "Yer",
|
||||
"low_inventory_items" => "Bitmış Mallar",
|
||||
"low_sell_item" => "Malların aşağı qiy. satışı",
|
||||
"manually_editing_of_quantity" => "Sayı Əl ilə Redeaktə Etmək",
|
||||
"markup" => "",
|
||||
"name" => "Malın Adı",
|
||||
"name_required" => "Malın adı olan sahə boş qala bilməz.",
|
||||
"new" => "Yeni Mal",
|
||||
"no_description_items" => "Mallar haqqında Təsvir Yoxdur",
|
||||
"no_items_to_display" => "Mallar Göstərilmir.",
|
||||
"none" => "Heçnə",
|
||||
"none_selected" => "Siz redaktə etmək üçün hər hansı bir mal seçməmisiniz",
|
||||
"nonstock" => "Əldə Olmayan",
|
||||
"number_information" => "Malın Nömrəsi",
|
||||
"number_required" => "Barkod tələb olunan sahədir.",
|
||||
"one_or_multiple" => "Mal (lar)",
|
||||
"pack_name" => "Paket Adı",
|
||||
"qty_per_pack" => "Paketin miqdarı",
|
||||
"quantity" => "Sayı",
|
||||
"quantity_number" => "Miqdarı mütləq rəqəmlə olmalıdır.",
|
||||
"quantity_required" => "Miqyas tələb olunan sahədir.",
|
||||
"receiving_quantity" => "Qəbul Olunan Miqdar",
|
||||
"remove_image" => "Şəkili Sil",
|
||||
"reorder_level" => "Səviyyani yenidən düzəlt",
|
||||
"reorder_level_number" => "Yenidən sıralama səviyyəsi rəqəmlə olmalıdır.",
|
||||
"reorder_level_required" => "Düzəliş Səviyyəsi zəruri bir sahədir.",
|
||||
"retrive_item_info" => "Mal Haqqında Məlumat",
|
||||
"sales_tax_1" => "Satış Vergisi",
|
||||
"sales_tax_2" => "Satış Vergisi 2",
|
||||
"search_attributes" => "Atributları Axtar",
|
||||
"select_image" => "Şəkil Seç",
|
||||
"serialized_items" => "Seriyalı Mallar",
|
||||
"standard" => "Standart",
|
||||
"stock" => "Ehtiyyat",
|
||||
"stock_location" => "Ehtiyyatın Yerləşdiyi Yer",
|
||||
"stock_type" => "Ehtiyyatda olanın Növü",
|
||||
"successful_adding" => "Siz uğurla malı əlavə etdiniz",
|
||||
"successful_bulk_edit" => "Siz uğurla bütun mallari yenilədiniz",
|
||||
"successful_deleted" => "Siz uğurla sildiniz",
|
||||
"successful_updating" => "Siz uğurla malı yenilədiniz",
|
||||
"supplier" => "Təchizatcı",
|
||||
"tax_1" => "Vergi 1",
|
||||
"tax_2" => "Vergi 2",
|
||||
"tax_3" => "",
|
||||
"tax_category" => "Vergi Bolməsi",
|
||||
"tax_percent" => "Vergi Faizi",
|
||||
"tax_percent_number" => "Vergi Faizi rəqəmli dəyər vahidi olmalıdir",
|
||||
"tax_percent_required" => "Vergi Faizi tələb olunan sahədir.",
|
||||
"tax_percents" => "Vergi Faiz(lər) i",
|
||||
"temp" => "Müvəqqəti",
|
||||
"type" => "Malın Növü",
|
||||
"unit_price" => "Pərakəndə Satış",
|
||||
"unit_price_number" => "Vahid qiyməti rəqəmlə olmalıdır.",
|
||||
"unit_price_required" => "Pərakəndə qiymət - vacib sahədir.",
|
||||
"upc_database" => "Barkod Məlumat Bazası",
|
||||
"update" => "Malı Yenilə",
|
||||
"use_inventory_menu" => "İnvertizasiya Menyusundan İstifadə Et",
|
||||
'add_minus' => 'İnventar + Topla və ya - Çıx.',
|
||||
'allow_alt_description' => 'Alternativ təsvirə icazə verin',
|
||||
'amount_entry' => 'Məbləğ Əlavəsi',
|
||||
'bulk_edit' => 'Kütləvi Redaktə',
|
||||
'buy_price_required' => 'Alış qiyməti tələb olunan sahədir.',
|
||||
'cannot_be_deleted' => 'Seçilmiş Mal (lar) silmək mükün deyil, bir və ya birdən çox Mallarda Satışlar var.',
|
||||
'cannot_find_item' => 'Mal Tapılmadı.',
|
||||
'categories' => '',
|
||||
'category' => 'Bolmə',
|
||||
'category_new' => '',
|
||||
'category_required' => 'Bölmə sahəsi boş qala bilməz.',
|
||||
'change_all_to_allow_alt_desc' => 'Hamısı üçün Alternativ Təsvirə İcazə Verin.',
|
||||
'change_all_to_not_allow_allow_desc' => 'Hamısı üçün Alternativ Təsvirə İcazə Verməyin.',
|
||||
'change_all_to_serialized' => 'Hamısını Seriyalaşdırılmışa görə dəyişin',
|
||||
'change_all_to_unserialized' => 'Qeyri Seriyalara görə dəyiş',
|
||||
'change_image' => 'Şəkili Dəyiş',
|
||||
'confirm_bulk_edit' => 'Siz əminsinizmi ki işarələnmiş butun malları redaktə etmək istəyirsiniz?',
|
||||
'confirm_bulk_edit_wipe_taxes' => 'Bütün Məhsul Vergisi məlumatları dəyişdiriləcək.',
|
||||
'confirm_delete' => 'Seçilmiş element(ləri) silmək istədiyinizə əminsiniz?',
|
||||
'confirm_restore' => 'Seçilmiş element (lər) bərpa etmək istədiyinizə əminsinizmi?',
|
||||
'cost_price' => 'Topdan Satış qiyməti',
|
||||
'cost_price_number' => 'Topdansatış qiyməti rəqəm olmalıdır.',
|
||||
'cost_price_required' => 'Topdan satiış - doldurulması vacib sahə.',
|
||||
'count' => 'inventorun yenilənməsi',
|
||||
'csv_import_failed' => 'səhv csv import',
|
||||
'csv_import_invalid_location' => '',
|
||||
'csv_import_nodata_wrongformat' => 'Yüklənmiş faylda məlumat yoxdur və ya düzgün formatlanmır.',
|
||||
'csv_import_partially_failed' => 'Xətlərdə {0} element idxalı uğursuzluq (lar) var: {1}. Heç bir sıra idxal edilmədi.',
|
||||
'csv_import_success' => 'Malların İdxalı Uğurla Həyata Keçdi.',
|
||||
'current_quantity' => 'indiki say',
|
||||
'default_pack_name' => 'Hər biri',
|
||||
'description' => 'təsvir',
|
||||
'details_count' => 'detallı inventor sayı',
|
||||
'do_nothing' => 'heç nə etməmək',
|
||||
'edit' => '',
|
||||
'edit_fields_you_want_to_update' => 'Seçilmiş məhsullar üçün tələb olunan sahələri redaktə edin.',
|
||||
'edit_multiple_items' => 'bir neçə malın redaktəsi',
|
||||
'empty_upc_items' => 'Boş Barkod Malları',
|
||||
'error_adding_updating' => 'mal əlavə etməkdə və ya yeniləməkdə səhv var',
|
||||
'error_updating_multiple' => 'malların yenilənməsində səhv var',
|
||||
'generate_barcodes' => 'şifrə yarat',
|
||||
'hsn_code' => 'Harmonize Sistem Nomenklaturası',
|
||||
'image' => 'Avatar',
|
||||
'import_items_csv' => 'CSVdən Malları İdxal Et',
|
||||
'info_provided_by' => 'Məlumat təqdim olundu',
|
||||
'inventory' => 'inventor',
|
||||
'inventory_CSV_import_quantity' => 'Miqdarı CSV-dən idxal olunur',
|
||||
'inventory_comments' => 'komentariyalar',
|
||||
'inventory_data_tracking' => 'İnventarlaşdırma Üçün Məlumat İzləmə',
|
||||
'inventory_date' => 'Tarix',
|
||||
'inventory_employee' => 'Əmakdaş',
|
||||
'inventory_in_out_quantity' => 'Daxili və Xarici Say',
|
||||
'inventory_remarks' => 'Qeydlər',
|
||||
'is_deleted' => 'Silindi',
|
||||
'is_printed' => '',
|
||||
'is_serialized' => 'Malın Seriya Nömrəsi var',
|
||||
'item' => 'Mal',
|
||||
'item_id' => '',
|
||||
'item_number' => 'Barkod',
|
||||
'item_number_duplicate' => 'Malın Nömrəsi artiq Məlumat bazasında var.',
|
||||
'item_number_invalid' => 'Malın Nömrəsində icazə verilməyən simvollar var.',
|
||||
'kit' => 'Dəst',
|
||||
'location' => 'Yer',
|
||||
'low_inventory_items' => 'Bitmış Mallar',
|
||||
'low_sell_item' => 'Malların aşağı qiy. satışı',
|
||||
'manually_editing_of_quantity' => 'Sayı Əl ilə Redeaktə Etmək',
|
||||
'markup' => '',
|
||||
'name' => 'Malın Adı',
|
||||
'name_required' => 'Malın adı olan sahə boş qala bilməz.',
|
||||
'new' => 'Yeni Mal',
|
||||
'no_description_items' => 'Mallar haqqında Təsvir Yoxdur',
|
||||
'no_items_to_display' => 'Mallar Göstərilmir.',
|
||||
'none' => 'Heçnə',
|
||||
'none_selected' => 'Siz redaktə etmək üçün hər hansı bir mal seçməmisiniz',
|
||||
'nonstock' => 'Əldə Olmayan',
|
||||
'number_information' => 'Malın Nömrəsi',
|
||||
'number_required' => 'Barkod tələb olunan sahədir.',
|
||||
'one_or_multiple' => 'Mal (lar)',
|
||||
'pack_name' => 'Paket Adı',
|
||||
'qty_per_pack' => 'Paketin miqdarı',
|
||||
'quantity' => 'Sayı',
|
||||
'quantity_number' => 'Miqdarı mütləq rəqəmlə olmalıdır.',
|
||||
'quantity_required' => 'Miqyas tələb olunan sahədir.',
|
||||
'receiving_quantity' => 'Qəbul Olunan Miqdar',
|
||||
'remove_image' => 'Şəkili Sil',
|
||||
'reorder_level' => 'Səviyyani yenidən düzəlt',
|
||||
'reorder_level_number' => 'Yenidən sıralama səviyyəsi rəqəmlə olmalıdır.',
|
||||
'reorder_level_required' => 'Düzəliş Səviyyəsi zəruri bir sahədir.',
|
||||
'retrive_item_info' => 'Mal Haqqında Məlumat',
|
||||
'sales_tax_1' => 'Satış Vergisi',
|
||||
'sales_tax_2' => 'Satış Vergisi 2',
|
||||
'search_attributes' => 'Atributları Axtar',
|
||||
'select_image' => 'Şəkil Seç',
|
||||
'serialized_items' => 'Seriyalı Mallar',
|
||||
'standard' => 'Standart',
|
||||
'stock' => 'Ehtiyyat',
|
||||
'stock_location' => 'Ehtiyyatın Yerləşdiyi Yer',
|
||||
'stock_type' => 'Ehtiyyatda olanın Növü',
|
||||
'successful_adding' => 'Siz uğurla malı əlavə etdiniz',
|
||||
'successful_bulk_edit' => 'Siz uğurla bütun mallari yenilədiniz',
|
||||
'successful_deleted' => 'Siz uğurla sildiniz',
|
||||
'successful_updating' => 'Siz uğurla malı yenilədiniz',
|
||||
'supplier' => 'Təchizatcı',
|
||||
'tax_1' => 'Vergi 1',
|
||||
'tax_2' => 'Vergi 2',
|
||||
'tax_3' => '',
|
||||
'tax_category' => 'Vergi Bolməsi',
|
||||
'tax_name_invalid' => 'Vergi adında icazə verilməyən simvollar var.',
|
||||
'tax_percent' => 'Vergi Faizi',
|
||||
'tax_percent_number' => 'Vergi Faizi rəqəmli dəyər vahidi olmalıdir',
|
||||
'tax_percent_required' => 'Vergi Faizi tələb olunan sahədir.',
|
||||
'tax_percents' => 'Vergi Faiz(lər) i',
|
||||
'temp' => 'Müvəqqəti',
|
||||
'type' => 'Malın Növü',
|
||||
'unit_price' => 'Pərakəndə Satış',
|
||||
'unit_price_number' => 'Vahid qiyməti rəqəmlə olmalıdır.',
|
||||
'unit_price_required' => 'Pərakəndə qiymət - vacib sahədir.',
|
||||
'upc_database' => 'Barkod Məlumat Bazası',
|
||||
'update' => 'Malı Yenilə',
|
||||
'use_inventory_menu' => 'İnvertizasiya Menyusundan İstifadə Et',
|
||||
];
|
||||
@@ -25,6 +25,7 @@ return [
|
||||
"migration_running" => "",
|
||||
"password" => "Şifrə",
|
||||
"required_username" => "",
|
||||
'too_many_attempts' => 'Həddindən artıq cəhd. Zəhmət olmasa bir az gözləyib yenidən cəhd edin.',
|
||||
"username" => "İstifadəçi",
|
||||
"welcome" => "{0} -ə xoş gəlmisiniz!"
|
||||
];
|
||||
@@ -4,6 +4,7 @@ return [
|
||||
'account_number' => 'Hesab #',
|
||||
'add_payment' => 'Ödəniş Əlavə Etmək',
|
||||
'amount_due' => 'Qalıq',
|
||||
'amount_due_not_covered' => 'Ödənişlər ödəniləcək məbləği əhatə etmir.',
|
||||
'amount_tendered' => 'Ödənilən məbləğ',
|
||||
'authorized_signature' => 'Səlahiyyətli İmza',
|
||||
'cancel_sale' => 'İmtina',
|
||||
@@ -84,6 +85,8 @@ return [
|
||||
'hsn' => 'HSN',
|
||||
'id' => 'Satış İD',
|
||||
'include_prices' => 'Qiymətlər daxildir?',
|
||||
'insufficient_giftcard_balance' => 'Bu ödeme için yetersiz hediye kartı bakiyesi.',
|
||||
'insufficient_reward_points' => 'Bu ödeme için yetersiz ödül puanları.',
|
||||
'invoice' => 'Qaimə',
|
||||
'invoice_confirm' => 'Bu qaimə göndəriləcək',
|
||||
'invoice_enable' => 'Qaimə Yarat',
|
||||
@@ -129,6 +132,7 @@ return [
|
||||
'must_enter_numeric' => 'Ödəniş məbləği rəqəm ilə olmalıdır.',
|
||||
'must_enter_numeric_giftcard' => 'Hədiyyə Kartın nömrəsi rəqəmlə olmalıdır.',
|
||||
'must_enter_reference_code' => 'İstinad/Axtarış nömrəsi daxil edilməlidir.',
|
||||
'negative_amount_tendered' => 'Ödənilən məbləğ mənfi ola bilməz.',
|
||||
'negative_discount_invalid' => '',
|
||||
'negative_price_invalid' => '',
|
||||
'negative_quantity_invalid' => '',
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
"attribute_value_invalid_chars" => "",
|
||||
"confirm_delete" => "",
|
||||
"confirm_restore" => "",
|
||||
"definition_cannot_be_deleted" => "",
|
||||
"definition_invalid_group" => "",
|
||||
"definition_error_adding_updating" => "",
|
||||
"definition_flags" => "",
|
||||
"definition_group" => "",
|
||||
"definition_id" => "",
|
||||
"definition_name" => "",
|
||||
"definition_name_required" => "",
|
||||
"definition_one_or_multiple" => "",
|
||||
"definition_successful_adding" => "",
|
||||
"definition_successful_deleted" => "",
|
||||
"definition_successful_updating" => "",
|
||||
"definition_type" => "",
|
||||
"definition_type_required" => "",
|
||||
"definition_unit" => "",
|
||||
"definition_values" => "",
|
||||
"new" => "",
|
||||
"no_attributes_to_display" => "",
|
||||
"receipt_visibility" => "",
|
||||
"show_in_items" => "",
|
||||
"show_in_items_visibility" => "",
|
||||
"show_in_receipt" => "",
|
||||
"show_in_receivings" => "",
|
||||
"show_in_receivings_visibility" => "",
|
||||
"show_in_sales" => "",
|
||||
"show_in_sales_visibility" => "",
|
||||
"update" => "",
|
||||
'attribute_value_invalid_chars' => '',
|
||||
'confirm_delete' => '',
|
||||
'confirm_restore' => '',
|
||||
'definition_cannot_be_deleted' => '',
|
||||
'definition_invalid_group' => '',
|
||||
'definition_error_adding_updating' => '',
|
||||
'definition_flags' => '',
|
||||
'definition_group' => '',
|
||||
'definition_id' => '',
|
||||
'definition_name' => '',
|
||||
'definition_name_required' => '',
|
||||
'definition_one_or_multiple' => '',
|
||||
'definition_successful_adding' => '',
|
||||
'definition_successful_deleted' => '',
|
||||
'definition_successful_updating' => '',
|
||||
'definition_type' => '',
|
||||
'definition_type_required' => '',
|
||||
'definition_unit' => '',
|
||||
'definition_values' => '',
|
||||
'new' => '',
|
||||
'no_attributes_to_display' => '',
|
||||
'receipt_visibility' => '',
|
||||
'show_in_items' => '',
|
||||
'show_in_items_visibility' => '',
|
||||
'show_in_receipt' => '',
|
||||
'show_in_receivings' => '',
|
||||
'show_in_receivings_visibility' => '',
|
||||
'show_in_sales' => '',
|
||||
'show_in_sales_visibility' => '',
|
||||
'show_in_search' => 'Показване в търсенето',
|
||||
'show_in_search_visibility' => 'Търсене',
|
||||
'update' => '',
|
||||
];
|
||||
Loaded 100 of 511 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user