merge: 合并远程仓库历史,保留本地开发改动
This commit is contained in:
commit
752f26bf30
122
deploy/activate-incremental-release.sh
Normal file
122
deploy/activate-incremental-release.sh
Normal file
@ -0,0 +1,122 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
REGISTRY_IMAGE="${1:?REGISTRY_IMAGE is required}"
|
||||
IMAGE_TAG="${2:?IMAGE_TAG is required}"
|
||||
REMOTE_DIR="${3:-/opt/caiwuxitong}"
|
||||
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
|
||||
RUN_MIGRATIONS="${5:-0}"
|
||||
KEEP_IMAGES="${6:-2}"
|
||||
IMAGE_NAME="caiwu_akaunting"
|
||||
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
|
||||
REGISTRY_API="http://127.0.0.1:5000/v2/caiwuxitong/app"
|
||||
TAG_HISTORY="$REMOTE_DIR/.registry-tags"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "activate-incremental-release.sh must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
|
||||
echo "Missing $REMOTE_DIR/runtime/.env; run bootstrap-server.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
echo "Another CaiWuXiTong deployment is already running" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
|
||||
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
|
||||
|
||||
docker pull "$REGISTRY_IMAGE"
|
||||
docker tag "$REGISTRY_IMAGE" "$IMAGE_NAME:$IMAGE_TAG"
|
||||
|
||||
LOADED_SHA="$(docker image inspect "$IMAGE_NAME:$IMAGE_TAG" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')"
|
||||
if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
|
||||
echo "Image revision mismatch: expected $DEPLOY_SHA, got $LOADED_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$REMOTE_DIR"
|
||||
export IMAGE_TAG RUN_MIGRATIONS
|
||||
docker compose -f compose.jiangling.yml up -d --remove-orphans
|
||||
|
||||
healthy=0
|
||||
attempt=1
|
||||
while [ "$attempt" -le 30 ]; do
|
||||
if curl -fsS -o /dev/null http://127.0.0.1:18088/; then
|
||||
healthy=1
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
if [ "$healthy" -ne 1 ]; then
|
||||
docker compose -f compose.jiangling.yml logs --tail 120 app >&2 || true
|
||||
|
||||
case "$PREVIOUS_IMAGE" in
|
||||
"$IMAGE_NAME":*)
|
||||
IMAGE_TAG="${PREVIOUS_IMAGE#*:}"
|
||||
RUN_MIGRATIONS=0
|
||||
export IMAGE_TAG RUN_MIGRATIONS
|
||||
docker compose -f compose.jiangling.yml up -d app || true
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Release failed health check; previous image was restored when available" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.deployed-sha.tmp"
|
||||
mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
|
||||
|
||||
docker image ls "$IMAGE_NAME" --format '{{.Repository}}:{{.Tag}}' \
|
||||
| awk '!seen[$0]++' \
|
||||
| tail -n "+$((KEEP_IMAGES + 1))" \
|
||||
| while IFS= read -r old_image; do
|
||||
[ -n "$old_image" ] && docker image rm "$old_image" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
docker image prune -f --filter 'label=com.caiwu.project=CaiWuXiTong' >/dev/null || true
|
||||
|
||||
# Track tags by successful deployment order, retain only current + previous,
|
||||
# delete older manifests, and reclaim their unreferenced blobs.
|
||||
touch "$TAG_HISTORY"
|
||||
awk -v tag="$IMAGE_TAG" '$0 != tag && NF { print }' "$TAG_HISTORY" > "$TAG_HISTORY.tmp"
|
||||
printf '%s\n' "$IMAGE_TAG" >> "$TAG_HISTORY.tmp"
|
||||
mv "$TAG_HISTORY.tmp" "$TAG_HISTORY"
|
||||
|
||||
deleted_registry_tag=0
|
||||
while [ "$(wc -l < "$TAG_HISTORY")" -gt "$KEEP_IMAGES" ]; do
|
||||
OLD_TAG="$(sed -n '1p' "$TAG_HISTORY")"
|
||||
DIGEST="$(curl -fsSI \
|
||||
-H 'Accept: application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \
|
||||
"$REGISTRY_API/manifests/$OLD_TAG" \
|
||||
| awk -F ': ' 'tolower($1) == "docker-content-digest" { gsub("\r", "", $2); print $2 }')"
|
||||
|
||||
if [ -n "$DIGEST" ]; then
|
||||
curl -fsS -X DELETE "$REGISTRY_API/manifests/$DIGEST" >/dev/null
|
||||
deleted_registry_tag=1
|
||||
fi
|
||||
docker image rm "127.0.0.1:5000/caiwuxitong/app:$OLD_TAG" >/dev/null 2>&1 || true
|
||||
sed '1d' "$TAG_HISTORY" > "$TAG_HISTORY.tmp"
|
||||
mv "$TAG_HISTORY.tmp" "$TAG_HISTORY"
|
||||
done
|
||||
|
||||
if [ "$deleted_registry_tag" -eq 1 ]; then
|
||||
systemctl stop docker-registry
|
||||
if ! docker-registry garbage-collect --delete-untagged /etc/docker/registry/config.yml; then
|
||||
echo "Registry garbage collection failed; the service will still be restarted" >&2
|
||||
fi
|
||||
systemctl start docker-registry
|
||||
fi
|
||||
|
||||
docker compose -f compose.jiangling.yml ps
|
||||
echo "DEPLOYED_SHA=$DEPLOY_SHA"
|
||||
|
||||
: "$PREVIOUS_SHA"
|
||||
94
deploy/activate-release.sh
Normal file
94
deploy/activate-release.sh
Normal file
@ -0,0 +1,94 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
IMAGE_ARCHIVE="${1:?IMAGE_ARCHIVE is required}"
|
||||
IMAGE_TAG="${2:?IMAGE_TAG is required}"
|
||||
REMOTE_DIR="${3:-/opt/caiwuxitong}"
|
||||
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
|
||||
RUN_MIGRATIONS="${5:-0}"
|
||||
KEEP_IMAGES="${6:-2}"
|
||||
IMAGE_NAME="caiwu_akaunting"
|
||||
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$IMAGE_ARCHIVE"
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "activate-release.sh must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
|
||||
echo "Missing $REMOTE_DIR/runtime/.env; run bootstrap-server.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
echo "Another CaiWuXiTong deployment is already running" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
|
||||
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
|
||||
|
||||
docker load --input "$IMAGE_ARCHIVE"
|
||||
rm -f "$IMAGE_ARCHIVE"
|
||||
|
||||
LOADED_SHA="$(docker image inspect "$IMAGE_NAME:$IMAGE_TAG" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')"
|
||||
if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
|
||||
echo "Image revision mismatch: expected $DEPLOY_SHA, got $LOADED_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$REMOTE_DIR"
|
||||
export IMAGE_TAG RUN_MIGRATIONS
|
||||
docker compose -f compose.jiangling.yml up -d --remove-orphans
|
||||
|
||||
healthy=0
|
||||
attempt=1
|
||||
while [ "$attempt" -le 30 ]; do
|
||||
if curl -fsS -o /dev/null http://127.0.0.1:18088/; then
|
||||
healthy=1
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
if [ "$healthy" -ne 1 ]; then
|
||||
docker compose -f compose.jiangling.yml logs --tail 120 app >&2 || true
|
||||
|
||||
case "$PREVIOUS_IMAGE" in
|
||||
"$IMAGE_NAME":*)
|
||||
IMAGE_TAG="${PREVIOUS_IMAGE#*:}"
|
||||
RUN_MIGRATIONS=0
|
||||
export IMAGE_TAG RUN_MIGRATIONS
|
||||
docker compose -f compose.jiangling.yml up -d app || true
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Release failed health check; previous image was restored when available" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.deployed-sha.tmp"
|
||||
mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
|
||||
|
||||
docker image ls "$IMAGE_NAME" --format '{{.Repository}}:{{.Tag}}' \
|
||||
| awk '!seen[$0]++' \
|
||||
| tail -n "+$((KEEP_IMAGES + 1))" \
|
||||
| while IFS= read -r old_image; do
|
||||
[ -n "$old_image" ] && docker image rm "$old_image" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
docker image prune -f --filter 'label=com.caiwu.project=CaiWuXiTong' >/dev/null || true
|
||||
docker compose -f compose.jiangling.yml ps
|
||||
echo "DEPLOYED_SHA=$DEPLOY_SHA"
|
||||
|
||||
# Keep the variable for rollback diagnostics without printing any secret.
|
||||
: "$PREVIOUS_SHA"
|
||||
72
deploy/bootstrap-server.sh
Normal file
72
deploy/bootstrap-server.sh
Normal file
@ -0,0 +1,72 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
APP_URL="${1:?APP_URL is required}"
|
||||
REMOTE_DIR="${2:-/opt/caiwuxitong}"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "bootstrap-server.sh must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ca-certificates openssl docker.io docker-compose-v2
|
||||
systemctl enable --now docker
|
||||
|
||||
if ! swapon --show=NAME --noheadings | grep -q .; then
|
||||
if [ ! -f /swapfile ]; then
|
||||
fallocate -l 2G /swapfile
|
||||
chmod 600 /swapfile
|
||||
mkswap /swapfile >/dev/null
|
||||
fi
|
||||
|
||||
swapon /swapfile
|
||||
|
||||
if ! grep -q '^/swapfile ' /etc/fstab; then
|
||||
printf '%s\n' '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
fi
|
||||
fi
|
||||
|
||||
install -d -m 700 "$REMOTE_DIR/runtime"
|
||||
|
||||
if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
|
||||
DB_PASSWORD="$(openssl rand -hex 24)"
|
||||
DB_ROOT_PASSWORD="$(openssl rand -hex 24)"
|
||||
APP_KEY="$(openssl rand -base64 32 | tr -d '\n')"
|
||||
|
||||
umask 077
|
||||
{
|
||||
printf '%s\n' \
|
||||
'APP_NAME=CaiWuXiTong' \
|
||||
'APP_ENV=production' \
|
||||
"APP_KEY=base64:$APP_KEY" \
|
||||
'APP_DEBUG=false' \
|
||||
'APP_INSTALLED=false' \
|
||||
"APP_URL=$APP_URL" \
|
||||
'APP_LOCALE=zh-CN' \
|
||||
'APP_FALLBACK_LOCALE=en-GB' \
|
||||
'APP_TIMEZONE=Asia/Shanghai' \
|
||||
'LOG_CHANNEL=stack' \
|
||||
'DB_CONNECTION=mysql' \
|
||||
'DB_HOST=db' \
|
||||
'DB_PORT=3306' \
|
||||
'DB_DATABASE=caiwu' \
|
||||
'DB_USERNAME=caiwu' \
|
||||
"DB_PASSWORD=$DB_PASSWORD" \
|
||||
'DB_PREFIX=ak_' \
|
||||
'CACHE_DRIVER=file' \
|
||||
'SESSION_DRIVER=file' \
|
||||
'QUEUE_CONNECTION=sync' \
|
||||
'MARIADB_DATABASE=caiwu' \
|
||||
'MARIADB_USER=caiwu' \
|
||||
"MARIADB_PASSWORD=$DB_PASSWORD" \
|
||||
"MARIADB_ROOT_PASSWORD=$DB_ROOT_PASSWORD" \
|
||||
'MARIADB_AUTO_UPGRADE=1'
|
||||
} > "$REMOTE_DIR/runtime/.env"
|
||||
chmod 600 "$REMOTE_DIR/runtime/.env"
|
||||
fi
|
||||
|
||||
docker --version
|
||||
docker compose version
|
||||
echo "Server runtime is ready: $REMOTE_DIR"
|
||||
2
deploy/buildkitd.toml
Normal file
2
deploy/buildkitd.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[registry."docker.io"]
|
||||
mirrors = ["m.daocloud.io/docker.io"]
|
||||
24
deploy/host-nginx-http.conf
Normal file
24
deploy/host-nginx-http.conf
Normal file
@ -0,0 +1,24 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name 118.196.84.79;
|
||||
|
||||
client_max_body_size 12m;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/letsencrypt;
|
||||
default_type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:18088;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto http;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
24
deploy/registry-config.yml
Normal file
24
deploy/registry-config.yml
Normal file
@ -0,0 +1,24 @@
|
||||
version: 0.1
|
||||
|
||||
log:
|
||||
fields:
|
||||
service: caiwuxitong-registry
|
||||
|
||||
storage:
|
||||
cache:
|
||||
blobdescriptor: inmemory
|
||||
filesystem:
|
||||
rootdirectory: /var/lib/docker-registry
|
||||
delete:
|
||||
enabled: true
|
||||
|
||||
http:
|
||||
addr: 127.0.0.1:5000
|
||||
headers:
|
||||
X-Content-Type-Options: [nosniff]
|
||||
|
||||
health:
|
||||
storagedriver:
|
||||
enabled: true
|
||||
interval: 10s
|
||||
threshold: 3
|
||||
96
deploy/setup-https.sh
Normal file
96
deploy/setup-https.sh
Normal file
@ -0,0 +1,96 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SERVER_IP="${1:?SERVER_IP is required}"
|
||||
REMOTE_DIR="${2:-/opt/caiwuxitong}"
|
||||
CERTBOT_DIR="/opt/caiwuxitong-certbot"
|
||||
CERT_NAME="caiwuxitong-ip"
|
||||
NGINX_SITE="/etc/nginx/sites-available/caiwuxitong"
|
||||
WEBROOT="/var/www/letsencrypt"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "setup-https.sh must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$SERVER_IP" != "118.196.84.79" ]; then
|
||||
echo "Refusing to configure an unexpected server IP: $SERVER_IP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$REMOTE_DIR/host-nginx-http.conf"
|
||||
test -f "$REMOTE_DIR/host-nginx.conf"
|
||||
curl -fsS -o /dev/null http://127.0.0.1:18088/
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends nginx python3-venv ca-certificates
|
||||
|
||||
install -d -m 755 "$WEBROOT/.well-known/acme-challenge"
|
||||
install -m 644 "$REMOTE_DIR/host-nginx-http.conf" "$NGINX_SITE"
|
||||
ln -sfn "$NGINX_SITE" /etc/nginx/sites-enabled/caiwuxitong
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
nginx -t
|
||||
systemctl enable --now nginx
|
||||
|
||||
if [ ! -x "$CERTBOT_DIR/bin/certbot" ]; then
|
||||
python3 -m venv "$CERTBOT_DIR"
|
||||
"$CERTBOT_DIR/bin/pip" install --disable-pip-version-check --no-cache-dir 'certbot==5.4.0'
|
||||
fi
|
||||
|
||||
"$CERTBOT_DIR/bin/certbot" --version
|
||||
|
||||
if [ ! -s "/etc/letsencrypt/live/$CERT_NAME/fullchain.pem" ]; then
|
||||
"$CERTBOT_DIR/bin/certbot" certonly \
|
||||
--non-interactive \
|
||||
--agree-tos \
|
||||
--register-unsafely-without-email \
|
||||
--preferred-profile shortlived \
|
||||
--webroot \
|
||||
--webroot-path "$WEBROOT" \
|
||||
--ip-address "$SERVER_IP" \
|
||||
--cert-name "$CERT_NAME"
|
||||
fi
|
||||
|
||||
install -m 644 "$REMOTE_DIR/host-nginx.conf" "$NGINX_SITE"
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
cat > /usr/local/sbin/caiwuxitong-certbot-deploy-hook <<'EOF'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
EOF
|
||||
chmod 700 /usr/local/sbin/caiwuxitong-certbot-deploy-hook
|
||||
|
||||
cat > /etc/systemd/system/caiwuxitong-certbot-renew.service <<EOF
|
||||
[Unit]
|
||||
Description=Renew CaiWuXiTong short-lived IP certificate
|
||||
After=network-online.target nginx.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=$CERTBOT_DIR/bin/certbot renew --quiet --deploy-hook /usr/local/sbin/caiwuxitong-certbot-deploy-hook
|
||||
EOF
|
||||
|
||||
cat > /etc/systemd/system/caiwuxitong-certbot-renew.timer <<'EOF'
|
||||
[Unit]
|
||||
Description=Check CaiWuXiTong IP certificate renewal every 8 hours
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 00,08,16:15:00
|
||||
RandomizedDelaySec=30m
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now caiwuxitong-certbot-renew.timer
|
||||
|
||||
curl -fsS -o /dev/null "https://$SERVER_IP/"
|
||||
openssl x509 -in "/etc/letsencrypt/live/$CERT_NAME/fullchain.pem" -noout -subject -issuer -dates
|
||||
systemctl list-timers caiwuxitong-certbot-renew.timer --no-pager
|
||||
echo "HTTPS_READY=https://$SERVER_IP"
|
||||
56
deploy/setup-incremental-registry.sh
Normal file
56
deploy/setup-incremental-registry.sh
Normal file
@ -0,0 +1,56 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
REMOTE_DIR="${1:-/opt/caiwuxitong}"
|
||||
CONFIG_SOURCE="${2:-$REMOTE_DIR/registry-config.yml}"
|
||||
REGISTRY_CONFIG="/etc/docker/registry/config.yml"
|
||||
REGISTRY_REPOSITORY="127.0.0.1:5000/caiwuxitong/app"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "setup-incremental-registry.sh must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$CONFIG_SOURCE" ]; then
|
||||
echo "Missing registry config: $CONFIG_SOURCE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker-registry >/dev/null 2>&1; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends docker-registry
|
||||
fi
|
||||
|
||||
install -d -m 755 "$(dirname "$REGISTRY_CONFIG")"
|
||||
install -m 644 "$CONFIG_SOURCE" "$REGISTRY_CONFIG"
|
||||
systemctl enable docker-registry >/dev/null
|
||||
systemctl restart docker-registry
|
||||
|
||||
attempt=1
|
||||
while [ "$attempt" -le 20 ]; do
|
||||
if curl -fsS -o /dev/null http://127.0.0.1:5000/v2/; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
curl -fsS -o /dev/null http://127.0.0.1:5000/v2/
|
||||
|
||||
# Seed the registry from the image already on this server. This makes the first
|
||||
# incremental release reuse existing layers instead of uploading them again.
|
||||
CURRENT_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
|
||||
case "$CURRENT_IMAGE" in
|
||||
caiwu_akaunting:*)
|
||||
CURRENT_TAG="${CURRENT_IMAGE#*:}"
|
||||
docker tag "$CURRENT_IMAGE" "$REGISTRY_REPOSITORY:$CURRENT_TAG"
|
||||
docker push "$REGISTRY_REPOSITORY:$CURRENT_TAG"
|
||||
touch "$REMOTE_DIR/.registry-tags"
|
||||
if ! grep -Fxq "$CURRENT_TAG" "$REMOTE_DIR/.registry-tags"; then
|
||||
printf '%s\n' "$CURRENT_TAG" >> "$REMOTE_DIR/.registry-tags"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "REGISTRY_READY=127.0.0.1:5000"
|
||||
37
deploy/update-runtime-url.sh
Normal file
37
deploy/update-runtime-url.sh
Normal file
@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
APP_URL="${1:?APP_URL is required}"
|
||||
REMOTE_DIR="${2:-/opt/caiwuxitong}"
|
||||
ENV_FILE="$REMOTE_DIR/runtime/.env"
|
||||
TEMP_FILE="$ENV_FILE.tmp"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "update-runtime-url.sh must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "Missing $ENV_FILE; run bootstrap-server.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
umask 077
|
||||
awk -v app_url="$APP_URL" '
|
||||
BEGIN { updated = 0 }
|
||||
/^APP_URL=/ {
|
||||
print "APP_URL=" app_url
|
||||
updated = 1
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (!updated) {
|
||||
print "APP_URL=" app_url
|
||||
}
|
||||
}
|
||||
' "$ENV_FILE" > "$TEMP_FILE"
|
||||
|
||||
chown --reference="$ENV_FILE" "$TEMP_FILE"
|
||||
chmod --reference="$ENV_FILE" "$TEMP_FILE"
|
||||
mv "$TEMP_FILE" "$ENV_FILE"
|
||||
157
docs/开发与部署SOP.md
Normal file
157
docs/开发与部署SOP.md
Normal file
@ -0,0 +1,157 @@
|
||||
# CaiWuXiTong 开发与部署 SOP
|
||||
|
||||
## 1. 这套流程解决什么问题
|
||||
|
||||
本项目由 6 位新人使用 Codex 协作开发。项目负责人只确认方向、验收结果和高风险动作,不负责日常命令操作。
|
||||
|
||||
固定链路如下:
|
||||
|
||||
```text
|
||||
任务分支开发 -> 本机验证 -> PR -> 合并 main
|
||||
-> 开发机复用缓存构建镜像
|
||||
-> 经 SSH 隧道只上传变化的镜像层
|
||||
-> 2GB 服务器只拉取本机 Registry 并切换镜像
|
||||
-> 本机后端检查 + 公网 HTTPS 验收
|
||||
-> 保留当前与上一个镜像,清理其余本项目产物
|
||||
```
|
||||
|
||||
服务器永远不构建源码。
|
||||
|
||||
## 2. 每台开发机只做一次的设置
|
||||
|
||||
1. 安装并启动 Docker Desktop,使用 WSL2 后端。
|
||||
2. 在 Docker Desktop 的 `Settings -> Resources -> Advanced` 中:
|
||||
- Docker 虚拟磁盘上限建议 60GB;
|
||||
- Windows 磁盘至少保留 15GB 可用空间。
|
||||
3. SSH 私钥放到个人目录,例如 `C:\Users\你的用户名\.ssh\caiwu.pem`,不要放进 Git。
|
||||
4. 只使用仓库里的发布脚本。不要自行执行 `docker system prune -a`,也不要重置 Docker Desktop。
|
||||
|
||||
说明:Docker 虚拟磁盘清理后,Windows 上看到的 `.vhdx` 文件可能不会立刻变小,但内部可用空间已经释放。不要为了让文件数字变小而删除虚拟磁盘。
|
||||
|
||||
## 3. 为什么后续不会反复全量下载和上传
|
||||
|
||||
项目使用专属构建器 `caiwu-builder`,缓存与其他项目分开:
|
||||
|
||||
- 前端在 Windows 主机使用项目专用 Node 20 构建,避开 Docker 虚拟机内存上限;
|
||||
- 前端输入指纹未变化时直接复用上次产物,纯后端改动不重复打包前端;
|
||||
- PHP 扩展层只在 Dockerfile 的基础环境变化时重建;
|
||||
- Composer 只在 `composer.lock` 变化时重新解析依赖,并复用下载缓存;
|
||||
- npm 优先使用本机下载缓存,`node_modules` 原位刷新,不会逐版本堆积;
|
||||
- 业务代码变化只重新执行前端资源构建和后面的代码复制;
|
||||
- 日常命令不带 `--pull` 和 `--no-cache`。
|
||||
- 首次初始化才上传应用和 MariaDB 的全量包;日常发布通过 SSH 隧道连接服务器本机 Registry。
|
||||
- Registry 按内容哈希判断镜像层是否已经存在,基础环境、PHP 依赖和未变化的前端层不会重复上传。
|
||||
- 日常传输使用 BuildKit 原生 OCI 分层包,保持未变化层的压缩摘要稳定;`docker save` 全量包只用于首次初始化。
|
||||
- Registry 只监听服务器 `127.0.0.1:5000`,公网无法直接访问;发布脚本退出时会关闭 SSH 隧道。
|
||||
- 每次发布后只保留当前和上一个 Registry 版本,并回收旧版本不再引用的镜像层。
|
||||
|
||||
首次构建和首次上传必然较慢;后续构建应大量显示 `CACHED`,上传量由本次实际变化的镜像层决定。
|
||||
|
||||
## 4. 首次初始化服务器
|
||||
|
||||
首次发布由 Codex 或当班开发者在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
.\scripts\deploy-from-workstation.ps1 `
|
||||
-Server 118.196.84.79 `
|
||||
-KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" `
|
||||
-BootstrapServer
|
||||
```
|
||||
|
||||
初始化脚本会:
|
||||
|
||||
- 使用服务器现有 Ubuntu 安全更新源安装并启动 Docker 与 Compose;
|
||||
- 为 2GB 服务器增加 2GB swap(交换空间),仅用于运行时缓冲;
|
||||
- 创建 `/opt/caiwuxitong/runtime/.env`;
|
||||
- 在服务器本地生成数据库密码和 Laravel `APP_KEY`,不会打印出来;
|
||||
- 创建持久化数据库卷和上传文件卷。
|
||||
- 上传应用与 MariaDB 首次全量包,并初始化仅限本机访问的增量 Registry。
|
||||
|
||||
云平台安全组必须放行 `TCP 80` 和 `TCP 443`。应用容器只绑定服务器本机 `127.0.0.1:18088`,公网由轻量 Nginx 提供 HTTPS;Registry 的 `5000` 端口仍禁止对公网开放。
|
||||
|
||||
首次部署后执行一次 HTTPS 配置:
|
||||
|
||||
```powershell
|
||||
.\scripts\deploy-from-workstation.ps1 `
|
||||
-Server 118.196.84.79 `
|
||||
-KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" `
|
||||
-ConfigureHttps
|
||||
```
|
||||
|
||||
该步骤在服务器安装轻量 Nginx 和独立 Certbot 环境,为公网 IP 申请 Let’s Encrypt 短期证书,并启用每 8 小时检查一次的自动续期。服务器仍不构建任何应用源码或镜像。
|
||||
|
||||
首次打开 `https://118.196.84.79` 后,由负责人或指定人员在网页安装向导中设置管理员账号和密码。
|
||||
|
||||
## 5. 日常正式发布
|
||||
|
||||
前提:PR 已审核并合并,当前为干净的 `main`。
|
||||
|
||||
```powershell
|
||||
git switch main
|
||||
git pull --ff-only
|
||||
|
||||
.\scripts\deploy-from-workstation.ps1 `
|
||||
-Server 118.196.84.79 `
|
||||
-KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem"
|
||||
```
|
||||
|
||||
脚本自动完成:
|
||||
|
||||
1. 检查分支和未提交改动;
|
||||
2. 使用项目专用 Node 20 和本机 npm 缓存构建前端资源;
|
||||
3. 使用提交 SHA 构建镜像并复用项目专属缓存;
|
||||
4. 将应用镜像导出到临时文件,通过 SSH 隧道只上传 Registry 缺少的镜像层;
|
||||
5. 服务器从本机 Registry 拉取新版本并启动;
|
||||
6. 检查服务器本机后端页面;
|
||||
7. 失败时恢复上一个镜像;
|
||||
8. 使用服务器互斥锁阻止两个人同时切换版本;
|
||||
9. 删除本地临时镜像包并关闭 SSH 隧道;
|
||||
10. 开发机、服务器 Docker 和服务器 Registry 均仅保留当前和上一个本项目版本;
|
||||
11. 清理 7 天前的本项目构建缓存,并将总量压到最多 8GB。
|
||||
|
||||
## 6. 数据库迁移
|
||||
|
||||
默认禁止自动迁移。脚本发现上次部署后新增了 `database/migrations` 文件时会停止。
|
||||
|
||||
只有负责人明确批准、PR 已写清影响与回滚思路后,才允许:
|
||||
|
||||
```powershell
|
||||
.\scripts\deploy-from-workstation.ps1 `
|
||||
-Server 118.196.84.79 `
|
||||
-KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" `
|
||||
-AllowMigrations
|
||||
```
|
||||
|
||||
镜像回滚不能自动撤销已经执行的数据库结构变更,因此迁移授权必须单独处理。
|
||||
|
||||
## 7. 测试分支发布
|
||||
|
||||
正式服务器默认只允许 `main`。确需临时测试时,必须得到明确授权,并添加:
|
||||
|
||||
```powershell
|
||||
-AllowNonMain
|
||||
```
|
||||
|
||||
测试完成后仍要回到 `main` 再正式发布,不能把测试分支当长期生产版本。
|
||||
|
||||
## 8. 禁止事项
|
||||
|
||||
- 禁止登录服务器运行 `docker build` 或 `docker compose build`;
|
||||
- 禁止在服务器运行 Composer、npm 或前端打包;
|
||||
- 禁止把 `.pem`、`.env`、密码、数据库文件或 `image.tar` 提交到 Git;
|
||||
- 禁止把服务器 Registry 的 `5000` 端口开放到公网;
|
||||
- 禁止为了省空间删除 Docker 卷;
|
||||
- 禁止全局清空 Docker,因为同一台电脑可能还有其他项目;
|
||||
- 禁止跳过健康检查后口头说“部署完成”。
|
||||
|
||||
## 9. 发布完成时应报告
|
||||
|
||||
- 发布分支和完整 Git SHA;
|
||||
- 镜像标签;
|
||||
- 服务器 `caiwu_app`、`caiwu_db` 状态;
|
||||
- `https://118.196.84.79` 的实际返回结果和证书有效期;
|
||||
- 是否执行数据库迁移;
|
||||
- 本地临时包是否删除;
|
||||
- 本地/服务器保留了几个本项目镜像;
|
||||
- 本次增量新增镜像层大小和传输耗时;
|
||||
- BuildKit 缓存当前占用。
|
||||
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Http\Controllers\Api;
|
||||
|
||||
use App\Abstracts\Http\ApiController;
|
||||
use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository;
|
||||
|
||||
/**
|
||||
* 货款对账 API
|
||||
*
|
||||
* 提供供应商应付-货款对账页面的全部数据接口:
|
||||
* GET /api/ecommerce-finance/goods-reconciliations 列表(分页 + 筛选)
|
||||
* GET /api/ecommerce-finance/goods-reconciliations/summary 统计卡片
|
||||
* GET /api/ecommerce-finance/goods-reconciliations/options 筛选器选项
|
||||
*/
|
||||
class GoodsReconciliations extends ApiController
|
||||
{
|
||||
private GoodsReconciliationRepository $repo;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->repo = new GoodsReconciliationRepository();
|
||||
}
|
||||
|
||||
/**
|
||||
* 货款对账列表(分页 + 筛选)
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$filters = $this->buildFilters();
|
||||
$page = max((int) request('page', 1), 1);
|
||||
$limit = $this->limit();
|
||||
|
||||
$result = $this->repo->list($filters, $page, $limit);
|
||||
|
||||
return response()->json([
|
||||
'data' => $result['data'],
|
||||
'total' => $result['total'],
|
||||
'page' => $page,
|
||||
'per_page' => $limit,
|
||||
'last_page' => (int) ceil($result['total'] / $limit),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总统计卡片
|
||||
*/
|
||||
public function summary()
|
||||
{
|
||||
$filters = $this->buildFilters();
|
||||
$summary = $this->repo->summary($filters);
|
||||
|
||||
return response()->json($summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选器选项(供应商/仓库下拉列表等)
|
||||
*/
|
||||
public function options()
|
||||
{
|
||||
$options = $this->repo->filterOptions();
|
||||
|
||||
return response()->json($options);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Helpers
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* 从请求参数构建筛选条件
|
||||
*/
|
||||
private function buildFilters(): array
|
||||
{
|
||||
$filters = [];
|
||||
|
||||
// 记录类型
|
||||
if ($type = request('record_type')) {
|
||||
$filters['record_type'] = $type; // purchase | sale | all
|
||||
}
|
||||
|
||||
// 文本筛选
|
||||
$textFields = ['supplier', 'stock_no', 'purchase_no', 'product_code', 'express_no', 'warehouse'];
|
||||
foreach ($textFields as $field) {
|
||||
if ($val = request($field)) {
|
||||
$filters[$field] = trim($val);
|
||||
}
|
||||
}
|
||||
|
||||
// 日期范围
|
||||
$dateFields = ['send_time_from', 'send_time_to', 'create_time_from', 'create_time_to'];
|
||||
foreach ($dateFields as $field) {
|
||||
if ($val = request($field)) {
|
||||
$filters[$field] = $val;
|
||||
}
|
||||
}
|
||||
|
||||
// 发出状态
|
||||
if (($sent = request('sent_status')) && $sent !== 'all') {
|
||||
$filters['sent_status'] = $sent; // sent | unsent
|
||||
}
|
||||
|
||||
// 是否有差异
|
||||
if (request('has_diff')) {
|
||||
$filters['has_diff'] = true;
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
private function limit(): int
|
||||
{
|
||||
return min(max((int) request('limit', 25), 1), 100);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Http\Controllers\Api;
|
||||
|
||||
use App\Abstracts\Http\ApiController;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
|
||||
|
||||
/**
|
||||
* 供应商应付 - 子功能页面 API
|
||||
*
|
||||
* 为 5 个子页面提供数据接口:
|
||||
* 退货对账 : /return-reconciliations + /return-reconciliations/summary + /return-reconciliations/options
|
||||
* 发票扣款 : /invoice-deductions + /invoice-deductions/summary + /invoice-deductions/options
|
||||
* 赔偿明细 : /compensation-details + /compensation-details/summary + /compensation-details/options
|
||||
* 借支明细 : /advance-details + /advance-details/summary + /advance-details/options
|
||||
* 样品扣款 : /sample-deductions + /sample-deductions/summary + /sample-deductions/options
|
||||
*/
|
||||
class SupplierPayables extends ApiController
|
||||
{
|
||||
private SupplierPayableRepository $repo;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->repo = new SupplierPayableRepository();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 退货对账
|
||||
// ============================================================
|
||||
public function returnReconciliationsIndex()
|
||||
{
|
||||
$filters = $this->filters('return');
|
||||
$result = $this->repo->returnReconciliations($filters, $this->page(), $this->limit());
|
||||
|
||||
return response()->json([
|
||||
'data' => $result['data'],
|
||||
'total' => $result['total'],
|
||||
'page' => $this->page(),
|
||||
'per_page' => $this->limit(),
|
||||
'last_page' => (int) ceil(max($result['total'], 0) / $this->limit()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function returnReconciliationsSummary()
|
||||
{
|
||||
return response()->json($this->repo->returnReconciliationSummary($this->filters('return')));
|
||||
}
|
||||
|
||||
public function returnReconciliationsOptions()
|
||||
{
|
||||
return response()->json($this->repo->filterOptionsForReturn());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 发票扣款
|
||||
// ============================================================
|
||||
public function invoiceDeductionsIndex()
|
||||
{
|
||||
$filters = $this->filters('invoice');
|
||||
$result = $this->repo->invoiceDeductions($filters, $this->page(), $this->limit());
|
||||
|
||||
return response()->json([
|
||||
'data' => $result['data'],
|
||||
'total' => $result['total'],
|
||||
'page' => $this->page(),
|
||||
'per_page' => $this->limit(),
|
||||
'last_page' => (int) ceil(max($result['total'], 0) / $this->limit()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function invoiceDeductionsSummary()
|
||||
{
|
||||
return response()->json($this->repo->invoiceDeductionSummary($this->filters('invoice')));
|
||||
}
|
||||
|
||||
public function invoiceDeductionsOptions()
|
||||
{
|
||||
return response()->json($this->repo->filterOptionsForInvoice());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 赔偿明细
|
||||
// ============================================================
|
||||
public function compensationDetailsIndex()
|
||||
{
|
||||
$filters = $this->filters('compensation');
|
||||
$result = $this->repo->compensationDetails($filters, $this->page(), $this->limit());
|
||||
|
||||
return response()->json([
|
||||
'data' => $result['data'],
|
||||
'total' => $result['total'],
|
||||
'page' => $this->page(),
|
||||
'per_page' => $this->limit(),
|
||||
'last_page' => (int) ceil(max($result['total'], 0) / $this->limit()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function compensationDetailsSummary()
|
||||
{
|
||||
return response()->json($this->repo->compensationDetailSummary($this->filters('compensation')));
|
||||
}
|
||||
|
||||
public function compensationDetailsOptions()
|
||||
{
|
||||
return response()->json($this->repo->filterOptionsForCompensation());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 借支明细
|
||||
// ============================================================
|
||||
public function advanceDetailsIndex()
|
||||
{
|
||||
$filters = $this->filters('advance');
|
||||
$result = $this->repo->advanceDetails($filters, $this->page(), $this->limit());
|
||||
|
||||
return response()->json([
|
||||
'data' => $result['data'],
|
||||
'total' => $result['total'],
|
||||
'page' => $this->page(),
|
||||
'per_page' => $this->limit(),
|
||||
'last_page' => (int) ceil(max($result['total'], 0) / $this->limit()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function advanceDetailsSummary()
|
||||
{
|
||||
return response()->json($this->repo->advanceDetailSummary($this->filters('advance')));
|
||||
}
|
||||
|
||||
public function advanceDetailsOptions()
|
||||
{
|
||||
return response()->json($this->repo->filterOptionsForAdvance());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 样品扣款
|
||||
// ============================================================
|
||||
public function sampleDeductionsIndex()
|
||||
{
|
||||
$filters = $this->filters('sample');
|
||||
$result = $this->repo->sampleDeductions($filters, $this->page(), $this->limit());
|
||||
|
||||
return response()->json([
|
||||
'data' => $result['data'],
|
||||
'total' => $result['total'],
|
||||
'page' => $this->page(),
|
||||
'per_page' => $this->limit(),
|
||||
'last_page' => (int) ceil(max($result['total'], 0) / $this->limit()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function sampleDeductionsSummary()
|
||||
{
|
||||
return response()->json($this->repo->sampleDeductionSummary($this->filters('sample')));
|
||||
}
|
||||
|
||||
public function sampleDeductionsOptions()
|
||||
{
|
||||
return response()->json($this->repo->filterOptionsForSample());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
private function filters(string $type): array
|
||||
{
|
||||
$filters = [];
|
||||
|
||||
// 通用文本字段
|
||||
$text = ['supplier', 'warehouse', 'sku_id', 'express_no', 'creator', 'department'];
|
||||
foreach ($text as $field) {
|
||||
if ($val = request($field)) {
|
||||
$filters[$field] = trim($val);
|
||||
}
|
||||
}
|
||||
|
||||
// 日期
|
||||
foreach (['date_from', 'date_to', 'month_from', 'month_to'] as $field) {
|
||||
if ($val = request($field)) {
|
||||
$filters[$field] = $val;
|
||||
}
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
private function page(): int
|
||||
{
|
||||
return max((int) request('page', 1), 1);
|
||||
}
|
||||
|
||||
private function limit(): int
|
||||
{
|
||||
return min(max((int) request('limit', 25), 1), 100);
|
||||
}
|
||||
}
|
||||
27
modules/EcommerceFinance/Models/JianSun/AverageUnitPrice.php
Normal file
27
modules/EcommerceFinance/Models/JianSun/AverageUnitPrice.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Models\JianSun;
|
||||
|
||||
/**
|
||||
* 加权平均单价表
|
||||
* pm_averageUnitPrice
|
||||
*
|
||||
* 按货号+供应商存储的加权平均单价(262条)。
|
||||
* 用于计算"预估金额"。
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $number 货号
|
||||
* @property string $supplier 供应商名称
|
||||
* @property float $price 加权平均单价(页面表头第11列来源)
|
||||
* @property string $name 商品名称
|
||||
*/
|
||||
class AverageUnitPrice extends JianSunModel
|
||||
{
|
||||
protected $table = 'pm_averageUnitPrice';
|
||||
|
||||
protected $fillable = ['number', 'supplier', 'price', 'name'];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:4',
|
||||
];
|
||||
}
|
||||
27
modules/EcommerceFinance/Models/JianSun/JianSunModel.php
Normal file
27
modules/EcommerceFinance/Models/JianSun/JianSunModel.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Models\JianSun;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Base Model for JianSun SQL Server database tables.
|
||||
* All models extending this will automatically use the 'jiansun' connection.
|
||||
*/
|
||||
abstract class JianSunModel extends Model
|
||||
{
|
||||
/** @var string */
|
||||
protected $connection = 'jiansun';
|
||||
|
||||
/** @var bool JianSun tables don't use Laravel timestamps */
|
||||
public $timestamps = false;
|
||||
|
||||
/** @var string No prefix on JianSun tables */
|
||||
protected $prefix = '';
|
||||
|
||||
/** @var string */
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/** @var bool */
|
||||
public $incrementing = true;
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Models\JianSun;
|
||||
|
||||
/**
|
||||
* 订单快递详情表(代发记录)
|
||||
* jst_order_express_details
|
||||
*
|
||||
* 存储所有代发订单的快递信息(2,812,915条)。
|
||||
* 页面中"代发记录"的快递名称和快递单号来源于此表。
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $so_id 订单编号
|
||||
* @property string $logistic_id 快递单号(页面表头第18列)
|
||||
* @property string $logistic_company_name 快递名称(页面表头第17列)
|
||||
* @property string $logistic_status_name 物流状态
|
||||
* @property string $warehouse_name 仓库名称
|
||||
* @property string $send_time 发货时间(页面表头第3列)
|
||||
* @property string $sign_time 签收时间
|
||||
* @property float $weight 重量
|
||||
*/
|
||||
class JstOrderExpressDetails extends JianSunModel
|
||||
{
|
||||
protected $table = 'jst_order_express_details';
|
||||
|
||||
protected $fillable = [
|
||||
'createdAt', 'updatedAt', 'inner_order_id', 'so_id', 'logistic_id',
|
||||
'logistic_company_name', 'logistic_status_name', 'logistic_return_flag',
|
||||
'order_status_name', 'first_question_type', 'first_question_time',
|
||||
'aftersale_status_name', 'order_labels', 'remark', 'buyer_message',
|
||||
'shop_name', 'platform_name', 'dis_co_name', 'warehouse_name',
|
||||
'receiver_address', 'pay_time', 'first_oa_confirm_time',
|
||||
'last_oa_confirm_time', 'send_time', 'fetch_time', 'sign_time',
|
||||
'logistic_time', 'weight', 'f_weight', 'ship_time_by_pdh',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'send_time' => 'datetime',
|
||||
'sign_time' => 'datetime',
|
||||
'pay_time' => 'datetime',
|
||||
'fetch_time' => 'datetime',
|
||||
'weight' => 'decimal:4',
|
||||
'f_weight' => 'decimal:4',
|
||||
];
|
||||
}
|
||||
31
modules/EcommerceFinance/Models/JianSun/JstSupplier.php
Normal file
31
modules/EcommerceFinance/Models/JianSun/JstSupplier.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Models\JianSun;
|
||||
|
||||
/**
|
||||
* 聚水潭供应商表
|
||||
* jst_supplier
|
||||
*
|
||||
* 聚水潭ERP系统的供应商数据(2,075条)。
|
||||
*
|
||||
* @property int $Id
|
||||
* @property int $supplier_id 供应商ID
|
||||
* @property string $supplier_code 供应商编码
|
||||
* @property string $name 供应商名称
|
||||
* @property string $modified 修改时间
|
||||
* @property string $remark 备注
|
||||
* @property string $group 分组
|
||||
* @property int $enabled 是否启用
|
||||
*/
|
||||
class JstSupplier extends JianSunModel
|
||||
{
|
||||
protected $table = 'jst_supplier';
|
||||
|
||||
protected $primaryKey = 'Id';
|
||||
|
||||
protected $fillable = [
|
||||
'supplier_id', 'supplier_code', 'name', 'modified', 'remark',
|
||||
'remark2', 'remark3', 'group', 'enabled', 'depositbank',
|
||||
'bankacount', 'acountnumber',
|
||||
];
|
||||
}
|
||||
45
modules/EcommerceFinance/Models/JianSun/PurchaseIn.php
Normal file
45
modules/EcommerceFinance/Models/JianSun/PurchaseIn.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Models\JianSun;
|
||||
|
||||
/**
|
||||
* 采购入库单主表
|
||||
* pm_purchaseIn
|
||||
*
|
||||
* 每次采购入库的汇总记录(52,219条)。
|
||||
* 通过 id 关联 pm_purchaseInItem 获取明细。
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $hno 入库单号(页面表头第1列)
|
||||
* @property string $whcode 仓库编码
|
||||
* @property string $sourceno 来源单号
|
||||
* @property string $title 标题
|
||||
* @property string $scmname 供应商名称
|
||||
* @property string $scmcode 供应商编码
|
||||
* @property int $scmid 供应商ID
|
||||
* @property int $incount 入库总数
|
||||
* @property string $created 创建时间(页面表头第16列)
|
||||
* @property string $memo 备注(页面表头第14列)
|
||||
* @property string $expcode 快递编码
|
||||
* @property string $expname 快递名称
|
||||
*/
|
||||
class PurchaseIn extends JianSunModel
|
||||
{
|
||||
protected $table = 'pm_purchaseIn';
|
||||
|
||||
protected $fillable = [
|
||||
'whcode', 'hno', 'sourceno', 'title', 'scmname', 'scmcode', 'scmid',
|
||||
'incount', 'created', 'memo', 'expcode', 'expname', 'postcode', 'postfee',
|
||||
'creater', 'createrid', 'inwhdate', 'finishdate', 'procode', 'proname',
|
||||
'skucode', 'skuname',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'incount' => 'integer',
|
||||
'scmid' => 'integer',
|
||||
'postfee' => 'decimal:2',
|
||||
'created' => 'datetime',
|
||||
'inwhdate' => 'datetime',
|
||||
'finishdate' => 'datetime',
|
||||
];
|
||||
}
|
||||
39
modules/EcommerceFinance/Models/JianSun/PurchaseInItem.php
Normal file
39
modules/EcommerceFinance/Models/JianSun/PurchaseInItem.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Models\JianSun;
|
||||
|
||||
/**
|
||||
* 采购入库单明细表
|
||||
* pm_purchaseInItem
|
||||
*
|
||||
* 每次采购入库的商品明细(223,382条)。
|
||||
* 通过 hid 关联 pm_purchaseIn.id。
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $hid 关联 PurchaseIn.id
|
||||
* @property string $skucode 商品编码(页面表头第6列)
|
||||
* @property string $skuname 规格名称(页面表头第7列)
|
||||
* @property string $skuoid 外部商品编码
|
||||
* @property int $incount 入库数量(页面表头第8列)
|
||||
* @property int $skucount SKU计数
|
||||
* @property float $payment 单价
|
||||
* @property string $created 创建时间
|
||||
*/
|
||||
class PurchaseInItem extends JianSunModel
|
||||
{
|
||||
protected $table = 'pm_purchaseInItem';
|
||||
|
||||
protected $fillable = [
|
||||
'hid', 'skucode', 'skuname', 'skuoid', 'incount', 'skucount',
|
||||
'payment', 'created', 'procode', 'proname', 'batchCode',
|
||||
'inventoryType', 'productDate', 'expireDate', 'produceCode',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'hid' => 'integer',
|
||||
'incount' => 'integer',
|
||||
'skucount' => 'integer',
|
||||
'payment' => 'decimal:2',
|
||||
'created' => 'datetime',
|
||||
];
|
||||
}
|
||||
29
modules/EcommerceFinance/Models/JianSun/SkuInfoManage.php
Normal file
29
modules/EcommerceFinance/Models/JianSun/SkuInfoManage.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Models\JianSun;
|
||||
|
||||
/**
|
||||
* SKU信息管理表
|
||||
* js_sku_info_manage
|
||||
*
|
||||
* 商品SKU管理信息(8,394条),包含货号、商品编码等。
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $sku_id 商品编码(页面表头第6列)
|
||||
* @property string $sku 货号(页面表头第5列)
|
||||
* @property string $spu SPU
|
||||
* @property string $sku_manager SKU负责人
|
||||
* @property string $operation_dept_manager 运营部门负责人
|
||||
* @property string $designer 设计师
|
||||
* @property string $designer_dept 设计部门
|
||||
*/
|
||||
class SkuInfoManage extends JianSunModel
|
||||
{
|
||||
protected $table = 'js_sku_info_manage';
|
||||
|
||||
protected $fillable = [
|
||||
'sku_id', 'sku', 'spu', 'sku_manager',
|
||||
'operation_dept_manager', 'sku_business_unit_manager',
|
||||
'designer', 'designer_dept', 'create_time', 'update_time',
|
||||
];
|
||||
}
|
||||
40
modules/EcommerceFinance/Models/JianSun/Supplier.php
Normal file
40
modules/EcommerceFinance/Models/JianSun/Supplier.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Models\JianSun;
|
||||
|
||||
/**
|
||||
* 供应商基础信息表
|
||||
* js_supplier
|
||||
*
|
||||
* 供应商主数据(1,479条),包含供应商编码、名称、公司等。
|
||||
*
|
||||
* @property int $Id
|
||||
* @property string $scmcode 供应商编码
|
||||
* @property string $scmname 供应商名称
|
||||
* @property string $scmtype 供应商类型
|
||||
* @property string $company 公司全称
|
||||
* @property string $paycyclesgn 结算周期标记
|
||||
* @property string $reconciliter 对账人
|
||||
* @property string $buyer 采购员
|
||||
* @property string $coopersgn 合作状态标记
|
||||
* @property string $levelname 等级名称
|
||||
* @property string $memo 备注
|
||||
* @property string $enterpriseType 企业类型
|
||||
* @property string $creditcode 统一社会信用代码
|
||||
*/
|
||||
class Supplier extends JianSunModel
|
||||
{
|
||||
protected $table = 'js_supplier';
|
||||
|
||||
protected $primaryKey = 'Id';
|
||||
|
||||
protected $fillable = [
|
||||
'scmcode', 'scmname', 'scmtype', 'tradebody', 'company',
|
||||
'paycyclesgn', 'accountbankno', 'corpmobile', 'tradebtime', 'tradeetime',
|
||||
'coopersgn', 'cooperbtime', 'cooperetime', 'dsperid', 'dsper',
|
||||
'reconciliter', 'levelname', 'buyerid', 'buyer', 'memo',
|
||||
'guid', 'craftType', 'outline', 'capacity', 'freereason', 'area',
|
||||
'stocker', 'region', 'sindou', 'follower', 'presc', 'strategy',
|
||||
'enterpriseType', 'creditcode', 'handlePerson', 'legalPerson',
|
||||
];
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Models\JianSun;
|
||||
|
||||
/**
|
||||
* 供应商采购结算明细表
|
||||
* fys_supplierGoods_sttlement_purchase
|
||||
*
|
||||
* 采购入库结算明细,包含每个SKU的结算数量和价格。
|
||||
* 是货款对账页面"入库记录"的核心数据源(228,039条)。
|
||||
*
|
||||
* @property int $Id
|
||||
* @property string $created 创建时间
|
||||
* @property int $stock 入库数量
|
||||
* @property int $settlementStock 结算数量
|
||||
* @property float $singlePrice 单价
|
||||
* @property float $settlementPrice 结算价格
|
||||
* @property string $priceType 价格类型
|
||||
* @property string $ownername 货主
|
||||
* @property string $scmname 供应商名称
|
||||
* @property string $procode 货号
|
||||
* @property string $whname 仓库名称
|
||||
* @property string $skuoid 商品编码
|
||||
* @property string $skuname 规格名称
|
||||
* @property string $bizpo 业务采购单号
|
||||
* @property string $spo 采购单号
|
||||
* @property string $remark 备注
|
||||
*/
|
||||
class SupplierGoodsSttlementPurchase extends JianSunModel
|
||||
{
|
||||
protected $table = 'fys_supplierGoods_sttlement_purchase';
|
||||
|
||||
protected $primaryKey = 'Id';
|
||||
|
||||
protected $fillable = [
|
||||
'created', 'stock', 'settlementStock', 'singlePrice', 'settlementPrice',
|
||||
'priceType', 'ownername', 'scmname', 'procode', 'whname',
|
||||
'skuoid', 'skuname', 'bizpo', 'spo', 'remark', 'createTime',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'stock' => 'integer',
|
||||
'settlementStock' => 'integer',
|
||||
'singlePrice' => 'decimal:2',
|
||||
'settlementPrice' => 'decimal:2',
|
||||
'created' => 'datetime',
|
||||
'createTime' => 'datetime',
|
||||
];
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Models\JianSun;
|
||||
|
||||
/**
|
||||
* 供应商付款明细表
|
||||
* fys_supplierPaymentDetail
|
||||
*
|
||||
* @property int $Id
|
||||
* @property string $created 创建时间
|
||||
* @property string $month 月份
|
||||
* @property string $date 日期
|
||||
* @property string $scmname 供应商名称
|
||||
* @property string $scmcode 供应商编码
|
||||
* @property float $payment 付款金额
|
||||
* @property string $reason 原因
|
||||
* @property string $remark 备注
|
||||
* @property int $billstatus 单据状态
|
||||
*/
|
||||
class SupplierPaymentDetail extends JianSunModel
|
||||
{
|
||||
protected $table = 'fys_supplierPaymentDetail';
|
||||
|
||||
protected $primaryKey = 'Id';
|
||||
|
||||
protected $fillable = [
|
||||
'created', 'month', 'date', 'scmname', 'scmcode',
|
||||
'payment', 'reason', 'remark', 'billstatus',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payment' => 'decimal:2',
|
||||
'created' => 'datetime',
|
||||
'date' => 'date',
|
||||
];
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Models\JianSun;
|
||||
|
||||
/**
|
||||
* 供应商付款汇总表
|
||||
* fys_supplierPaymentSummary
|
||||
*
|
||||
* 核心对账数据源,每条记录代表一个供应商在某月份的应付汇总
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $company 公司
|
||||
* @property string $supplier 供应商名称
|
||||
* @property string $checker 对账人
|
||||
* @property string $settleway 结算方式
|
||||
* @property string $Month 对账月份
|
||||
* @property float $ExpFieght 快递费
|
||||
* @property float $bginMonthPay 月初应付
|
||||
* @property float $unpriced 未定价
|
||||
* @property float $advloan 预付
|
||||
* @property float $loan 借款
|
||||
* @property float $refund 退款
|
||||
* @property float $procurement 采购
|
||||
* @property float $invoiceDct 发票扣款
|
||||
* @property float $boardDct 板材扣款
|
||||
* @property float $specimenDct 样品扣款
|
||||
* @property float $Totalindemnity 总赔偿
|
||||
* @property float $other 其他
|
||||
* @property float $supplySup 供应商补贴
|
||||
* @property float $ShouldPay 应付金额
|
||||
* @property float $factPay 实付金额
|
||||
* @property float $guessPrice 预估金额
|
||||
* @property float $toCorporate 到公账
|
||||
* @property float $toPrivate 到私账
|
||||
* @property string $remarks 备注
|
||||
* @property string $reason 原因
|
||||
* @property string $guid GUID
|
||||
* @property int $isExamine 是否审核
|
||||
* @property int $isPayment 是否付款
|
||||
* @property string $scmcode 供应商编码
|
||||
* @property int $supid 供应商ID
|
||||
* @property string $createTime 创建时间
|
||||
*/
|
||||
class SupplierPaymentSummary extends JianSunModel
|
||||
{
|
||||
protected $table = 'fys_supplierPaymentSummary';
|
||||
|
||||
protected $fillable = [
|
||||
'company', 'supplier', 'checker', 'settleway', 'Month',
|
||||
'ExpFieght', 'bginMonthPay', 'unpriced', 'advloan', 'loan',
|
||||
'refund', 'procurement', 'invoiceDct', 'boardDct', 'specimenDct',
|
||||
'Totalindemnity', 'other', 'supplySup', 'ShouldPay', 'factPay',
|
||||
'guessPrice', 'toCorporate', 'toPrivate', 'remarks', 'reason',
|
||||
'mark', 'guid', 'isExamine', 'isPayment', 'Month',
|
||||
'billstatus', 'opstatus', 'elstatu', 'taskid', 'supid',
|
||||
'depositMoney', 'depositType', 'cashierbypaid', 'push', 'scmcode',
|
||||
'billRemaining',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'ExpFieght' => 'decimal:2',
|
||||
'bginMonthPay' => 'decimal:2',
|
||||
'unpriced' => 'decimal:2',
|
||||
'advloan' => 'decimal:2',
|
||||
'loan' => 'decimal:2',
|
||||
'refund' => 'decimal:2',
|
||||
'procurement' => 'decimal:2',
|
||||
'invoiceDct' => 'decimal:2',
|
||||
'boardDct' => 'decimal:2',
|
||||
'specimenDct' => 'decimal:2',
|
||||
'Totalindemnity' => 'decimal:2',
|
||||
'other' => 'decimal:2',
|
||||
'supplySup' => 'decimal:2',
|
||||
'ShouldPay' => 'decimal:2',
|
||||
'factPay' => 'decimal:2',
|
||||
'guessPrice' => 'decimal:2',
|
||||
'toCorporate' => 'decimal:2',
|
||||
'toPrivate' => 'decimal:2',
|
||||
'depositMoney' => 'decimal:2',
|
||||
'cashierbypaid' => 'decimal:2',
|
||||
'billRemaining' => 'decimal:2',
|
||||
'createTime' => 'datetime',
|
||||
'modifiedTime' => 'datetime',
|
||||
];
|
||||
}
|
||||
43
modules/EcommerceFinance/Models/JianSun/UnOrderDetails.php
Normal file
43
modules/EcommerceFinance/Models/JianSun/UnOrderDetails.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Models\JianSun;
|
||||
|
||||
/**
|
||||
* 未发出订单明细表
|
||||
* pm_unOrderDetails
|
||||
*
|
||||
* 记录已采购但尚未发出的商品明细。
|
||||
*
|
||||
* @property int $Id
|
||||
* @property string $date 日期
|
||||
* @property string $purchase 采购单号
|
||||
* @property string $warehouse 仓库
|
||||
* @property string $supplier 供应商(页面表头第4列)
|
||||
* @property string $transactionNo 交易号
|
||||
* @property string $distributionNo 配送号
|
||||
* @property string $expressNo 快递单号(页面表头第18列)
|
||||
* @property string $sku 货号
|
||||
* @property string $barCode 条码
|
||||
* @property string $skuName 规格名称
|
||||
* @property string $distributionDate 配送日期
|
||||
* @property string $shipDate 发货日期
|
||||
* @property string $expressName 快递名称(页面表头第17列)
|
||||
*/
|
||||
class UnOrderDetails extends JianSunModel
|
||||
{
|
||||
protected $table = 'pm_unOrderDetails';
|
||||
|
||||
protected $primaryKey = 'Id';
|
||||
|
||||
protected $fillable = [
|
||||
'date', 'purchase', 'warehouse', 'supplier', 'transactionNo',
|
||||
'distributionNo', 'expressNo', 'sku', 'barCode', 'skuName',
|
||||
'distributionDate', 'shipDate', 'expressName',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'datetime',
|
||||
'distributionDate' => 'datetime',
|
||||
'shipDate' => 'datetime',
|
||||
];
|
||||
}
|
||||
@ -0,0 +1,370 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Modules\EcommerceFinance\Services\JianSunConnection;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* 货款对账数据仓库
|
||||
*
|
||||
* 数据来源:
|
||||
* - 入库记录:fys_supplierGoods_sttlement_purchase(~228K)+ pm_purchaseIn + pm_averageUnitPrice
|
||||
* - 代发记录:fys_supplierGoods_sttlement_sale(~2.8M)+ pm_averageUnitPrice
|
||||
*
|
||||
* 性能策略:入库/代发分开查询,各自执行分页,避免全量 UNION ALL。
|
||||
*/
|
||||
class GoodsReconciliationRepository
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pdo = JianSunConnection::get();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// 汇总统计(轻量:只用 COUNT + SUM,不做复杂 JOIN)
|
||||
// ===================================================================
|
||||
|
||||
public function summary(array $filters = []): array
|
||||
{
|
||||
$purchase = $this->purchaseSummary($filters);
|
||||
$sale = $this->saleSummary($filters);
|
||||
|
||||
return [
|
||||
'totalAmount' => round($purchase['totalAmount'] + $sale['totalAmount'], 2),
|
||||
'totalGuessAmount' => round($purchase['totalGuessAmount'] + $sale['totalGuessAmount'], 2),
|
||||
'sentCount' => $purchase['sentCount'] + $sale['sentCount'],
|
||||
'unsentCount' => $purchase['unsentCount'] + $sale['unsentCount'],
|
||||
'totalCount' => $purchase['totalCount'] + $sale['totalCount'],
|
||||
];
|
||||
}
|
||||
|
||||
private function purchaseSummary(array $filters): array
|
||||
{
|
||||
$where = $this->buildPurchaseWhere($filters);
|
||||
$params = $this->buildPurchaseParams($filters);
|
||||
|
||||
// 按需 JOIN,减少不必要的全表扫描
|
||||
$joinA = $joinU = '';
|
||||
if (strpos($where, 'a.') !== false) {
|
||||
$joinA = ' LEFT JOIN pm_averageUnitPrice a ON s.procode = a.number AND s.scmname = a.supplier';
|
||||
}
|
||||
if (strpos($where, 'u.') !== false) {
|
||||
$joinU = ' LEFT JOIN pm_unOrderDetails u ON s.spo = u.purchase AND s.skuoid = u.sku';
|
||||
}
|
||||
|
||||
$sql = "SELECT
|
||||
COUNT(*) as totalCount,
|
||||
ISNULL(SUM(s.settlementPrice), 0) as totalAmount,
|
||||
0 as totalGuessAmount,
|
||||
COUNT(*) as sentCount,
|
||||
0 as unsentCount
|
||||
FROM fys_supplierGoods_sttlement_purchase s
|
||||
{$joinA}{$joinU}
|
||||
WHERE 1=1 {$where}";
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: ['totalCount' => 0, 'totalAmount' => 0, 'totalGuessAmount' => 0, 'sentCount' => 0, 'unsentCount' => 0];
|
||||
}
|
||||
|
||||
private function saleSummary(array $filters): array
|
||||
{
|
||||
$where = $this->buildSaleWhere($filters);
|
||||
$params = $this->buildSaleParams($filters);
|
||||
|
||||
$joinA = '';
|
||||
if (strpos($where, 'a.') !== false) {
|
||||
$joinA = ' LEFT JOIN pm_averageUnitPrice a ON s.procode = a.number AND s.supplier = a.supplier';
|
||||
}
|
||||
|
||||
$sql = "SELECT
|
||||
COUNT(*) as totalCount,
|
||||
ISNULL(SUM(s.settlementPrice), 0) as totalAmount,
|
||||
0 as totalGuessAmount,
|
||||
ISNULL(SUM(CASE WHEN s.expressNo IS NOT NULL AND s.expressNo != '' THEN 1 ELSE 0 END), 0) as sentCount,
|
||||
ISNULL(SUM(CASE WHEN s.expressNo IS NULL OR s.expressNo = '' THEN 1 ELSE 0 END), 0) as unsentCount
|
||||
FROM fys_supplierGoods_sttlement_sale s
|
||||
{$joinA}
|
||||
WHERE 1=1 {$where}";
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: ['totalCount' => 0, 'totalAmount' => 0, 'totalGuessAmount' => 0, 'sentCount' => 0, 'unsentCount' => 0];
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// 列表查询(核心 — 分别查入库/代发再合并)
|
||||
// ===================================================================
|
||||
|
||||
public function list(array $filters = [], int $page = 1, int $perPage = 25): array
|
||||
{
|
||||
$type = $filters['record_type'] ?? 'all';
|
||||
|
||||
// 分别查询(同一分页参数,各自内部分页)
|
||||
$purchaseResult = null;
|
||||
$saleResult = null;
|
||||
$totalPurchase = 0;
|
||||
$totalSale = 0;
|
||||
|
||||
if ($type === 'all' || $type === 'purchase') {
|
||||
$where = $this->buildPurchaseWhere($filters);
|
||||
$params = $this->buildPurchaseParams($filters);
|
||||
$totalPurchase = $this->countPurchase($where, $params);
|
||||
$purchaseResult = $this->fetchPurchase($where, $params, $page, $perPage);
|
||||
}
|
||||
|
||||
if ($type === 'all' || $type === 'sale') {
|
||||
$where = $this->buildSaleWhere($filters);
|
||||
$params = $this->buildSaleParams($filters);
|
||||
$totalSale = $this->countSale($where, $params);
|
||||
$saleResult = $this->fetchSale($where, $params, $page, $perPage);
|
||||
}
|
||||
|
||||
// 合并并按 createTime 排序,取前 perPage 条
|
||||
$merged = array_merge($purchaseResult ?? [], $saleResult ?? []);
|
||||
|
||||
// 按 createTime DESC 排序
|
||||
usort($merged, function ($a, $b) {
|
||||
return strcmp($b['createTime'] ?? '', $a['createTime'] ?? '');
|
||||
});
|
||||
|
||||
// 截取当前页
|
||||
$merged = array_slice($merged, 0, $perPage);
|
||||
|
||||
// 格式化金额
|
||||
foreach ($merged as &$row) {
|
||||
$row['amount'] = round((float) $row['amount'], 2);
|
||||
$row['guessAmount'] = round((float) $row['guessAmount'], 2);
|
||||
$row['unitPrice'] = round((float) $row['unitPrice'], 2);
|
||||
$row['guessUnitPrice'] = round((float) ($row['guessUnitPrice'] ?? 0), 2);
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $merged,
|
||||
'total' => $totalPurchase + $totalSale,
|
||||
];
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// 筛选器选项
|
||||
// ===================================================================
|
||||
|
||||
public function filterOptions(): array
|
||||
{
|
||||
$sql = "SELECT DISTINCT scmname FROM fys_supplierGoods_sttlement_purchase WHERE scmname IS NOT NULL AND scmname != ''
|
||||
UNION
|
||||
SELECT DISTINCT supplier FROM fys_supplierGoods_sttlement_sale WHERE supplier IS NOT NULL AND supplier != ''
|
||||
ORDER BY scmname";
|
||||
$stmt = $this->pdo->query($sql);
|
||||
$suppliers = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
$sql = "SELECT DISTINCT whname FROM fys_supplierGoods_sttlement_purchase WHERE whname IS NOT NULL AND whname != ''
|
||||
UNION
|
||||
SELECT DISTINCT whName FROM fys_supplierGoods_sttlement_sale WHERE whName IS NOT NULL AND whName != ''
|
||||
ORDER BY whname";
|
||||
$stmt = $this->pdo->query($sql);
|
||||
$warehouses = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
return [
|
||||
'suppliers' => $suppliers,
|
||||
'warehouses' => $warehouses,
|
||||
];
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// 入库查询
|
||||
// ===================================================================
|
||||
|
||||
private function countPurchase(string $where, array $params): int
|
||||
{
|
||||
// 仅当筛选条件引用关联表字段时才 JOIN,否则纯主表 COUNT
|
||||
$joins = '';
|
||||
if (strpos($where, 'p.') !== false) {
|
||||
$joins .= ' LEFT JOIN pm_purchaseIn p ON s.spo = p.sourceno';
|
||||
}
|
||||
if (strpos($where, 'a.') !== false) {
|
||||
$joins .= ' LEFT JOIN pm_averageUnitPrice a ON s.procode = a.number AND s.scmname = a.supplier';
|
||||
}
|
||||
if (strpos($where, 'u.') !== false) {
|
||||
$joins .= ' LEFT JOIN pm_unOrderDetails u ON s.spo = u.purchase AND s.skuoid = u.sku';
|
||||
}
|
||||
$sql = "SELECT COUNT(*) as cnt FROM fys_supplierGoods_sttlement_purchase s{$joins} WHERE 1=1 {$where}";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return (int) $stmt->fetch()['cnt'];
|
||||
}
|
||||
|
||||
private function fetchPurchase(string $where, array $params, int $page, int $perPage): array
|
||||
{
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$sql = "SELECT
|
||||
'purchase' AS recordType,
|
||||
ISNULL(p.hno, '') AS stockNo,
|
||||
ISNULL(s.spo, '') AS purchaseNo,
|
||||
FORMAT(ISNULL(s.created, s.createTime), 'yyyy-MM-dd HH:mm:ss') AS sendTime,
|
||||
ISNULL(s.scmname, '') AS supplier,
|
||||
ISNULL(s.skuoid, '') AS goodsNo,
|
||||
ISNULL(s.procode, '') AS productCode,
|
||||
ISNULL(s.skuname, '') AS specName,
|
||||
ISNULL(s.settlementStock, 0) AS qty,
|
||||
ISNULL(s.singlePrice, 0) AS unitPrice,
|
||||
ISNULL(s.settlementPrice, 0) AS amount,
|
||||
ISNULL(a.price, 0) AS guessUnitPrice,
|
||||
ISNULL(s.settlementStock * ISNULL(a.price, 0), 0) AS guessAmount,
|
||||
'' AS supplierGoodsCode,
|
||||
ISNULL(s.remark, '') AS remark,
|
||||
ISNULL(s.whname, '') AS warehouseName,
|
||||
FORMAT(s.createTime, 'yyyy-MM-dd HH:mm:ss') AS createTime,
|
||||
'' AS expressName,
|
||||
'' AS expressNo,
|
||||
CASE WHEN u.Id IS NOT NULL THEN 1 ELSE 0 END AS isUnsent,
|
||||
s.id AS sourceId
|
||||
FROM fys_supplierGoods_sttlement_purchase s
|
||||
LEFT JOIN pm_purchaseIn p ON s.spo = p.sourceno
|
||||
LEFT JOIN pm_averageUnitPrice a ON s.procode = a.number AND s.scmname = a.supplier
|
||||
LEFT JOIN pm_unOrderDetails u ON s.spo = u.purchase AND s.skuoid = u.sku
|
||||
WHERE 1=1 {$where}
|
||||
ORDER BY s.createTime DESC
|
||||
OFFSET {$offset} ROWS FETCH NEXT {$perPage} ROWS ONLY";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// 代发查询
|
||||
// ===================================================================
|
||||
|
||||
private function countSale(string $where, array $params): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*) as cnt FROM fys_supplierGoods_sttlement_sale s
|
||||
LEFT JOIN pm_averageUnitPrice a ON s.procode = a.number AND s.supplier = a.supplier
|
||||
WHERE 1=1 {$where}";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return (int) $stmt->fetch()['cnt'];
|
||||
}
|
||||
|
||||
private function fetchSale(string $where, array $params, int $page, int $perPage): array
|
||||
{
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$sql = "SELECT
|
||||
'sale' AS recordType,
|
||||
'' AS stockNo,
|
||||
'' AS purchaseNo,
|
||||
FORMAT(s.sendTime, 'yyyy-MM-dd HH:mm:ss') AS sendTime,
|
||||
ISNULL(s.supplier, '') AS supplier,
|
||||
ISNULL(s.skucode, '') AS goodsNo,
|
||||
ISNULL(s.procode, '') AS productCode,
|
||||
ISNULL(s.skuname, '') AS specName,
|
||||
ISNULL(s.settlementStock, 0) AS qty,
|
||||
ISNULL(s.singlePrice, 0) AS unitPrice,
|
||||
ISNULL(s.settlementPrice, 0) AS amount,
|
||||
ISNULL(a.price, 0) AS guessUnitPrice,
|
||||
ISNULL(s.settlementStock * ISNULL(a.price, 0), 0) AS guessAmount,
|
||||
'' AS supplierGoodsCode,
|
||||
ISNULL(s.remark, '') AS remark,
|
||||
ISNULL(s.whName, '') AS warehouseName,
|
||||
FORMAT(s.createTime, 'yyyy-MM-dd HH:mm:ss') AS createTime,
|
||||
ISNULL(s.expressName, '') AS expressName,
|
||||
ISNULL(s.expressNo, '') AS expressNo,
|
||||
CASE WHEN s.expressNo IS NULL OR s.expressNo = '' THEN 1 ELSE 0 END AS isUnsent,
|
||||
s.Id AS sourceId
|
||||
FROM fys_supplierGoods_sttlement_sale s
|
||||
LEFT JOIN pm_averageUnitPrice a ON s.procode = a.number AND s.supplier = a.supplier
|
||||
WHERE 1=1 {$where}
|
||||
ORDER BY s.createTime DESC
|
||||
OFFSET {$offset} ROWS FETCH NEXT {$perPage} ROWS ONLY";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// WHERE 与参数构建
|
||||
// ===================================================================
|
||||
|
||||
private function like(string $v): string
|
||||
{
|
||||
return '%' . $v . '%';
|
||||
}
|
||||
|
||||
// --- 入库 WHERE ---
|
||||
private function buildPurchaseWhere(array $filters): string
|
||||
{
|
||||
$c = [];
|
||||
if (!empty($filters['supplier'])) $c[] = 'AND s.scmname LIKE :supplier';
|
||||
if (!empty($filters['stock_no'])) $c[] = 'AND p.hno LIKE :stock_no';
|
||||
if (!empty($filters['purchase_no'])) $c[] = 'AND s.spo LIKE :purchase_no';
|
||||
if (!empty($filters['product_code'])) $c[] = 'AND s.procode LIKE :product_code';
|
||||
if (!empty($filters['warehouse'])) $c[] = 'AND s.whname LIKE :warehouse';
|
||||
if (!empty($filters['send_time_from'])) $c[] = 'AND s.created >= :send_time_from';
|
||||
if (!empty($filters['send_time_to'])) $c[] = 'AND s.created <= :send_time_to';
|
||||
if (!empty($filters['create_time_from'])) $c[] = 'AND s.createTime >= :create_time_from';
|
||||
if (!empty($filters['create_time_to'])) $c[] = 'AND s.createTime <= :create_time_to';
|
||||
if (!empty($filters['sent_status'])) {
|
||||
if ($filters['sent_status'] === 'sent') $c[] = 'AND u.Id IS NULL';
|
||||
if ($filters['sent_status'] === 'unsent') $c[] = 'AND u.Id IS NOT NULL';
|
||||
}
|
||||
if (!empty($filters['has_diff'])) {
|
||||
$c[] = 'AND ABS(ISNULL(s.settlementPrice, 0) - ISNULL(s.settlementStock * ISNULL(a.price, 0), 0)) > 0.01';
|
||||
}
|
||||
return $c ? ' ' . implode(' ', $c) : '';
|
||||
}
|
||||
|
||||
private function buildPurchaseParams(array $filters): array
|
||||
{
|
||||
$p = [];
|
||||
if (!empty($filters['supplier'])) $p[':supplier'] = $this->like($filters['supplier']);
|
||||
if (!empty($filters['stock_no'])) $p[':stock_no'] = $this->like($filters['stock_no']);
|
||||
if (!empty($filters['purchase_no'])) $p[':purchase_no'] = $this->like($filters['purchase_no']);
|
||||
if (!empty($filters['product_code'])) $p[':product_code'] = $this->like($filters['product_code']);
|
||||
if (!empty($filters['warehouse'])) $p[':warehouse'] = $this->like($filters['warehouse']);
|
||||
if (!empty($filters['send_time_from'])) $p[':send_time_from'] = $filters['send_time_from'];
|
||||
if (!empty($filters['send_time_to'])) $p[':send_time_to'] = $filters['send_time_to'];
|
||||
if (!empty($filters['create_time_from'])) $p[':create_time_from'] = $filters['create_time_from'];
|
||||
if (!empty($filters['create_time_to'])) $p[':create_time_to'] = $filters['create_time_to'];
|
||||
return $p;
|
||||
}
|
||||
|
||||
// --- 代发 WHERE ---
|
||||
private function buildSaleWhere(array $filters): string
|
||||
{
|
||||
$c = [];
|
||||
if (!empty($filters['supplier'])) $c[] = 'AND s.supplier LIKE :supplier_s';
|
||||
if (!empty($filters['product_code'])) $c[] = 'AND s.procode LIKE :product_code_s';
|
||||
if (!empty($filters['express_no'])) $c[] = 'AND s.expressNo LIKE :express_no';
|
||||
if (!empty($filters['warehouse'])) $c[] = 'AND s.whName LIKE :warehouse_s';
|
||||
if (!empty($filters['send_time_from'])) $c[] = 'AND s.sendTime >= :send_time_from_s';
|
||||
if (!empty($filters['send_time_to'])) $c[] = 'AND s.sendTime <= :send_time_to_s';
|
||||
if (!empty($filters['create_time_from'])) $c[] = 'AND s.createTime >= :create_time_from_s';
|
||||
if (!empty($filters['create_time_to'])) $c[] = 'AND s.createTime <= :create_time_to_s';
|
||||
if (!empty($filters['sent_status'])) {
|
||||
if ($filters['sent_status'] === 'sent') $c[] = "AND s.expressNo IS NOT NULL AND s.expressNo != ''";
|
||||
if ($filters['sent_status'] === 'unsent') $c[] = "AND (s.expressNo IS NULL OR s.expressNo = '')";
|
||||
}
|
||||
if (!empty($filters['has_diff'])) {
|
||||
$c[] = 'AND ABS(ISNULL(s.settlementPrice, 0) - ISNULL(s.settlementStock * ISNULL(a.price, 0), 0)) > 0.01';
|
||||
}
|
||||
return $c ? ' ' . implode(' ', $c) : '';
|
||||
}
|
||||
|
||||
private function buildSaleParams(array $filters): array
|
||||
{
|
||||
$p = [];
|
||||
if (!empty($filters['supplier'])) $p[':supplier_s'] = $this->like($filters['supplier']);
|
||||
if (!empty($filters['product_code'])) $p[':product_code_s'] = $this->like($filters['product_code']);
|
||||
if (!empty($filters['express_no'])) $p[':express_no'] = $this->like($filters['express_no']);
|
||||
if (!empty($filters['warehouse'])) $p[':warehouse_s'] = $this->like($filters['warehouse']);
|
||||
if (!empty($filters['send_time_from'])) $p[':send_time_from_s'] = $filters['send_time_from'];
|
||||
if (!empty($filters['send_time_to'])) $p[':send_time_to_s'] = $filters['send_time_to'];
|
||||
if (!empty($filters['create_time_from'])) $p[':create_time_from_s'] = $filters['create_time_from'];
|
||||
if (!empty($filters['create_time_to'])) $p[':create_time_to_s'] = $filters['create_time_to'];
|
||||
return $p;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,540 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Modules\EcommerceFinance\Services\JianSunConnection;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* 供应商应付 - 子功能页面数据仓库
|
||||
*
|
||||
* 封装 5 个子页面的数据访问逻辑:
|
||||
* - returnReconciliations() 退货对账
|
||||
* - invoiceDeductions() 发票扣款
|
||||
* - compensationDetails() 赔偿明细(UNION 纸板 + 压板)
|
||||
* - advanceDetails() 借支明细
|
||||
* - sampleDeductions() 样品扣款
|
||||
*
|
||||
* 所有方法返回统一结构: ['data' => array<int,array>, 'total' => int]
|
||||
*/
|
||||
class SupplierPayableRepository
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = JianSunConnection::get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用工具:带筛选 + 分页的查询
|
||||
*/
|
||||
private function runQuery(string $sql, array $params, int $page, int $pageSize, int $countThreshold = 5000): array
|
||||
{
|
||||
$offset = ($page - 1) * $pageSize;
|
||||
|
||||
// 1. 总数(先查总数,超阈值再用近似)
|
||||
$countSql = 'SELECT COUNT(*) AS cnt FROM (' . $sql . ') AS sub';
|
||||
$total = 0;
|
||||
try {
|
||||
$stmt = $this->db->prepare($countSql);
|
||||
$stmt->execute($params);
|
||||
$total = (int) $stmt->fetchColumn();
|
||||
} catch (\Throwable $e) {
|
||||
// 极端情况:表太大查不到总数时退回到 -1
|
||||
$total = -1;
|
||||
}
|
||||
|
||||
// 2. 分页数据(用 TOP + OFFSET/FETCH 不可行,统一使用 ROW_NUMBER() OVER 包装)
|
||||
$pagedSql = 'SELECT * FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY (SELECT 0)) AS rn FROM (' . $sql . ') AS src) AS t WHERE rn BETWEEN ? AND ?';
|
||||
$paramsWithRange = array_merge($params, [$offset + 1, $offset + $pageSize]);
|
||||
|
||||
$stmt = $this->db->prepare($pagedSql);
|
||||
$stmt->execute($paramsWithRange);
|
||||
|
||||
return [
|
||||
'data' => $stmt->fetchAll(),
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算分页的 SELECT TOP N(无 COUNT),加速大表查询
|
||||
*/
|
||||
private function runQueryFast(string $sql, array $params, int $page, int $pageSize, int $total = -1): array
|
||||
{
|
||||
$offset = ($page - 1) * $pageSize;
|
||||
$pagedSql = 'SELECT * FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY (SELECT 0)) AS rn FROM (' . $sql . ') AS src) AS t WHERE rn BETWEEN ? AND ?';
|
||||
$paramsWithRange = array_merge($params, [$offset + 1, $offset + $pageSize]);
|
||||
$stmt = $this->db->prepare($pagedSql);
|
||||
$stmt->execute($paramsWithRange);
|
||||
|
||||
return [
|
||||
'data' => $stmt->fetchAll(),
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 1. 退货对账 — finance_purchaseReturn (36791行)
|
||||
// ============================================================
|
||||
public function returnReconciliations(array $filters, int $page, int $pageSize): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if (!empty($filters['supplier'])) {
|
||||
$where[] = 'supplierName LIKE ?';
|
||||
$params[] = '%' . $filters['supplier'] . '%';
|
||||
}
|
||||
if (!empty($filters['warehouse'])) {
|
||||
$where[] = 'warehouseName LIKE ?';
|
||||
$params[] = '%' . $filters['warehouse'] . '%';
|
||||
}
|
||||
if (!empty($filters['sku_id'])) {
|
||||
$where[] = 'sku_id LIKE ?';
|
||||
$params[] = '%' . $filters['sku_id'] . '%';
|
||||
}
|
||||
if (!empty($filters['express_no'])) {
|
||||
$where[] = 'expressNo LIKE ?';
|
||||
$params[] = '%' . $filters['express_no'] . '%';
|
||||
}
|
||||
if (!empty($filters['date_from'])) {
|
||||
$where[] = 'date >= ?';
|
||||
$params[] = $filters['date_from'];
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$where[] = 'date <= ?';
|
||||
$params[] = $filters['date_to'];
|
||||
}
|
||||
|
||||
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$sql = "SELECT
|
||||
CONVERT(varchar(10), date, 120) AS send_time,
|
||||
supplierName AS supplier,
|
||||
sku_id AS barcode,
|
||||
name AS sku_name,
|
||||
CAST(qty AS decimal(18,2)) AS qty,
|
||||
CAST(price AS decimal(18,2)) AS unit_price,
|
||||
CAST(price * qty AS decimal(18,2)) AS amount,
|
||||
CAST(priceB AS decimal(18,2)) AS guess_unit_price,
|
||||
CAST(priceB * qty AS decimal(18,2)) AS guess_amount,
|
||||
remark,
|
||||
io_id AS wms_pick_no,
|
||||
warehouseName AS warehouse,
|
||||
expressNo AS express_no,
|
||||
expressName AS express_name,
|
||||
CONVERT(varchar(10), io_date, 120) AS io_date,
|
||||
labels,
|
||||
creator_name AS creator,
|
||||
'' AS title,
|
||||
'' AS supplier_barcode,
|
||||
'' AS original_barcode,
|
||||
'' AS reconciler
|
||||
FROM finance_purchaseReturn
|
||||
$whereSql";
|
||||
|
||||
$result = $this->runQuery($sql, $params, $page, $pageSize);
|
||||
|
||||
return [
|
||||
'data' => $result['data'],
|
||||
'total' => $result['total'],
|
||||
];
|
||||
}
|
||||
|
||||
public function returnReconciliationSummary(array $filters): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
if (!empty($filters['supplier'])) {
|
||||
$where[] = 'supplierName LIKE ?';
|
||||
$params[] = '%' . $filters['supplier'] . '%';
|
||||
}
|
||||
if (!empty($filters['date_from'])) {
|
||||
$where[] = 'date >= ?';
|
||||
$params[] = $filters['date_from'];
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$where[] = 'date <= ?';
|
||||
$params[] = $filters['date_to'];
|
||||
}
|
||||
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$sql = "SELECT
|
||||
ISNULL(SUM(price * qty), 0) AS total_amount,
|
||||
ISNULL(SUM(priceB * qty), 0) AS total_guess_amount,
|
||||
COUNT(*) AS total_count
|
||||
FROM finance_purchaseReturn $whereSql";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return [
|
||||
'totalAmount' => (float) $row['total_amount'],
|
||||
'totalGuessAmount' => (float) $row['total_guess_amount'],
|
||||
'totalCount' => (int) $row['total_count'],
|
||||
];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. 发票扣款 — finance_invoiceDeduction (195行)
|
||||
// ============================================================
|
||||
public function invoiceDeductions(array $filters, int $page, int $pageSize): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if (!empty($filters['supplier'])) {
|
||||
$where[] = 'supplier LIKE ?';
|
||||
$params[] = '%' . $filters['supplier'] . '%';
|
||||
}
|
||||
if (!empty($filters['month_from'])) {
|
||||
$where[] = 'month >= ?';
|
||||
$params[] = $filters['month_from'];
|
||||
}
|
||||
if (!empty($filters['month_to'])) {
|
||||
$where[] = 'month <= ?';
|
||||
$params[] = $filters['month_to'];
|
||||
}
|
||||
|
||||
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$sql = "SELECT
|
||||
CONVERT(varchar(7), month, 120) AS month_str,
|
||||
'' AS category,
|
||||
supplier AS payee,
|
||||
supplier AS receiver,
|
||||
'' AS board_factory,
|
||||
CAST(0 AS decimal(18,2)) AS subsidy_fee,
|
||||
remark,
|
||||
CAST(price AS decimal(18,2)) AS price,
|
||||
remark AS detail,
|
||||
'' AS note,
|
||||
personCharge AS person_charge,
|
||||
CONVERT(varchar(19), createTime, 120) AS create_time,
|
||||
creator
|
||||
FROM finance_invoiceDeduction
|
||||
$whereSql";
|
||||
|
||||
return $this->runQuery($sql, $params, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function invoiceDeductionSummary(array $filters): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
if (!empty($filters['supplier'])) {
|
||||
$where[] = 'supplier LIKE ?';
|
||||
$params[] = '%' . $filters['supplier'] . '%';
|
||||
}
|
||||
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$sql = "SELECT ISNULL(SUM(price), 0) AS total_price, COUNT(*) AS total_count FROM finance_invoiceDeduction $whereSql";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return [
|
||||
'totalPrice' => (float) $row['total_price'],
|
||||
'totalCount' => (int) $row['total_count'],
|
||||
];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. 赔偿明细 — fys_paper_factory + fys_pressing_factory UNION
|
||||
// ============================================================
|
||||
public function compensationDetails(array $filters, int $page, int $pageSize): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if (!empty($filters['supplier'])) {
|
||||
$where[] = 'supplier LIKE ?';
|
||||
$params[] = '%' . $filters['supplier'] . '%';
|
||||
}
|
||||
if (!empty($filters['month_from'])) {
|
||||
$where[] = 'month >= ?';
|
||||
$params[] = $filters['month_from'];
|
||||
}
|
||||
if (!empty($filters['month_to'])) {
|
||||
$where[] = 'month <= ?';
|
||||
$params[] = $filters['month_to'];
|
||||
}
|
||||
|
||||
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$sql = "SELECT
|
||||
month,
|
||||
supplier,
|
||||
CAST(compensation_amount AS decimal(18,2)) AS amount,
|
||||
reason,
|
||||
'' AS remark,
|
||||
creator,
|
||||
CONVERT(varchar(19), create_time, 120) AS create_time,
|
||||
'纸板厂' AS factory_type
|
||||
FROM (
|
||||
SELECT
|
||||
month,
|
||||
factory_subject_name AS supplier,
|
||||
compensation_amount,
|
||||
reason,
|
||||
creator,
|
||||
create_time
|
||||
FROM fys_paper_factory_compensation_detail
|
||||
UNION ALL
|
||||
SELECT
|
||||
month,
|
||||
factory_subject_name AS supplier,
|
||||
compensation_amount,
|
||||
reason,
|
||||
creator,
|
||||
create_time
|
||||
FROM fys_pressing_factory_compensation_detail
|
||||
) AS t
|
||||
$whereSql
|
||||
ORDER BY month DESC, create_time DESC";
|
||||
|
||||
return $this->runQuery($sql, $params, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function compensationDetailSummary(array $filters): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
if (!empty($filters['supplier'])) {
|
||||
$where[] = 'supplier LIKE ?';
|
||||
$params[] = '%' . $filters['supplier'] . '%';
|
||||
}
|
||||
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$sql = "SELECT
|
||||
ISNULL(SUM(compensation_amount), 0) AS total_amount,
|
||||
COUNT(*) AS total_count
|
||||
FROM (
|
||||
SELECT compensation_amount, factory_subject_name AS supplier FROM fys_paper_factory_compensation_detail
|
||||
UNION ALL
|
||||
SELECT compensation_amount, factory_subject_name AS supplier FROM fys_pressing_factory_compensation_detail
|
||||
) AS t
|
||||
$whereSql";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return [
|
||||
'totalAmount' => (float) $row['total_amount'],
|
||||
'totalCount' => (int) $row['total_count'],
|
||||
];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. 借支明细 — finance_advanceLoans (4行, 字段不足,部分列显示-)
|
||||
// ============================================================
|
||||
public function advanceDetails(array $filters, int $page, int $pageSize): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if (!empty($filters['supplier'])) {
|
||||
$where[] = 'supplier LIKE ?';
|
||||
$params[] = '%' . $filters['supplier'] . '%';
|
||||
}
|
||||
if (!empty($filters['month_from'])) {
|
||||
$where[] = 'month >= ?';
|
||||
$params[] = $filters['month_from'];
|
||||
}
|
||||
if (!empty($filters['month_to'])) {
|
||||
$where[] = 'month <= ?';
|
||||
$params[] = $filters['month_to'];
|
||||
}
|
||||
|
||||
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$sql = "SELECT
|
||||
supplier,
|
||||
'' AS contract_name,
|
||||
CAST(price AS decimal(18,2)) AS advance_amount,
|
||||
CAST(0 AS decimal(18,2)) AS actual_pay,
|
||||
CAST(0 AS decimal(18,2)) AS month_deduct,
|
||||
CAST(0 AS decimal(18,2)) AS should_deduct,
|
||||
'' AS deduct_start_month,
|
||||
'' AS deduct_end_month,
|
||||
0 AS remain_periods,
|
||||
CONVERT(varchar(7), month, 120) AS month_str,
|
||||
'' AS loan_term,
|
||||
remark,
|
||||
'' AS is_paid,
|
||||
CONVERT(varchar(7), month, 120) AS pay_month,
|
||||
'' AS serial_no,
|
||||
'' AS loan_agreement,
|
||||
personCharge AS person_charge,
|
||||
'' AS receipt_0,
|
||||
'' AS receipt_1,
|
||||
'' AS receipt_2,
|
||||
CONVERT(varchar(19), createTime, 120) AS create_time,
|
||||
creator
|
||||
FROM finance_advanceLoans
|
||||
$whereSql
|
||||
ORDER BY month DESC, createTime DESC";
|
||||
|
||||
return $this->runQuery($sql, $params, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function advanceDetailSummary(array $filters): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
if (!empty($filters['supplier'])) {
|
||||
$where[] = 'supplier LIKE ?';
|
||||
$params[] = '%' . $filters['supplier'] . '%';
|
||||
}
|
||||
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$sql = "SELECT ISNULL(SUM(price), 0) AS total_amount, COUNT(*) AS total_count FROM finance_advanceLoans $whereSql";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return [
|
||||
'totalAmount' => (float) $row['total_amount'],
|
||||
'totalCount' => (int) $row['total_count'],
|
||||
];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 5. 样品扣款 — finance_sampleDeduction (12行)
|
||||
// ============================================================
|
||||
public function sampleDeductions(array $filters, int $page, int $pageSize): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if (!empty($filters['supplier'])) {
|
||||
$where[] = 'supplier LIKE ?';
|
||||
$params[] = '%' . $filters['supplier'] . '%';
|
||||
}
|
||||
if (!empty($filters['department'])) {
|
||||
$where[] = 'department LIKE ?';
|
||||
$params[] = '%' . $filters['department'] . '%';
|
||||
}
|
||||
if (!empty($filters['creator'])) {
|
||||
$where[] = 'creator LIKE ?';
|
||||
$params[] = '%' . $filters['creator'] . '%';
|
||||
}
|
||||
if (!empty($filters['sku_id'])) {
|
||||
$where[] = 'sku_id LIKE ?';
|
||||
$params[] = '%' . $filters['sku_id'] . '%';
|
||||
}
|
||||
if (!empty($filters['date_from'])) {
|
||||
$where[] = 'finishTime >= ?';
|
||||
$params[] = $filters['date_from'];
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$where[] = 'finishTime <= ?';
|
||||
$params[] = $filters['date_to'];
|
||||
}
|
||||
|
||||
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$sql = "SELECT
|
||||
CONVERT(varchar(10), finishTime, 120) AS finish_time,
|
||||
department,
|
||||
creator,
|
||||
sku_id,
|
||||
skuName,
|
||||
CAST(num AS decimal(18,2)) AS num,
|
||||
supplier,
|
||||
purchase,
|
||||
productCode,
|
||||
CAST(shippingFee AS decimal(18,2)) AS shipping_fee,
|
||||
CAST(costPrice AS decimal(18,2)) AS cost_price,
|
||||
CAST(totalAmount AS decimal(18,2)) AS total_amount,
|
||||
CAST(estimatedPrice AS decimal(18,2)) AS estimated_price,
|
||||
CAST(estimatedPriceAll AS decimal(18,2)) AS estimated_price_all,
|
||||
'' AS remark,
|
||||
businessId,
|
||||
CONVERT(varchar(19), updateAt, 120) AS update_time
|
||||
FROM finance_sampleDeduction
|
||||
$whereSql
|
||||
ORDER BY finishTime DESC";
|
||||
|
||||
return $this->runQuery($sql, $params, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function sampleDeductionSummary(array $filters): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
if (!empty($filters['supplier'])) {
|
||||
$where[] = 'supplier LIKE ?';
|
||||
$params[] = '%' . $filters['supplier'] . '%';
|
||||
}
|
||||
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$sql = "SELECT
|
||||
ISNULL(SUM(totalAmount), 0) AS total_amount,
|
||||
ISNULL(SUM(estimatedPriceAll), 0) AS total_estimate,
|
||||
COUNT(*) AS total_count
|
||||
FROM finance_sampleDeduction $whereSql";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return [
|
||||
'totalAmount' => (float) $row['total_amount'],
|
||||
'totalEstimate' => (float) $row['total_estimate'],
|
||||
'totalCount' => (int) $row['total_count'],
|
||||
];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 筛选器选项
|
||||
// ============================================================
|
||||
public function filterOptionsForReturn(): array
|
||||
{
|
||||
$suppliers = $this->fetchDistinct('SELECT DISTINCT supplierName FROM finance_purchaseReturn WHERE supplierName IS NOT NULL ORDER BY supplierName');
|
||||
$warehouses = $this->fetchDistinct('SELECT DISTINCT warehouseName FROM finance_purchaseReturn WHERE warehouseName IS NOT NULL ORDER BY warehouseName');
|
||||
|
||||
return compact('suppliers', 'warehouses');
|
||||
}
|
||||
|
||||
public function filterOptionsForInvoice(): array
|
||||
{
|
||||
$suppliers = $this->fetchDistinct('SELECT DISTINCT supplier FROM finance_invoiceDeduction WHERE supplier IS NOT NULL ORDER BY supplier');
|
||||
|
||||
return ['suppliers' => $suppliers];
|
||||
}
|
||||
|
||||
public function filterOptionsForCompensation(): array
|
||||
{
|
||||
$suppliers = $this->fetchDistinct("SELECT DISTINCT supplier FROM (
|
||||
SELECT factory_subject_name AS supplier FROM fys_paper_factory_compensation_detail WHERE factory_subject_name IS NOT NULL
|
||||
UNION
|
||||
SELECT factory_subject_name AS supplier FROM fys_pressing_factory_compensation_detail WHERE factory_subject_name IS NOT NULL
|
||||
) AS t ORDER BY supplier");
|
||||
|
||||
return ['suppliers' => $suppliers];
|
||||
}
|
||||
|
||||
public function filterOptionsForAdvance(): array
|
||||
{
|
||||
$suppliers = $this->fetchDistinct('SELECT DISTINCT supplier FROM finance_advanceLoans WHERE supplier IS NOT NULL ORDER BY supplier');
|
||||
|
||||
return ['suppliers' => $suppliers];
|
||||
}
|
||||
|
||||
public function filterOptionsForSample(): array
|
||||
{
|
||||
$suppliers = $this->fetchDistinct('SELECT DISTINCT supplier FROM finance_sampleDeduction WHERE supplier IS NOT NULL ORDER BY supplier');
|
||||
$departments = $this->fetchDistinct('SELECT DISTINCT department FROM finance_sampleDeduction WHERE department IS NOT NULL ORDER BY department');
|
||||
$creators = $this->fetchDistinct('SELECT DISTINCT creator FROM finance_sampleDeduction WHERE creator IS NOT NULL ORDER BY creator');
|
||||
|
||||
return compact('suppliers', 'departments', 'creators');
|
||||
}
|
||||
|
||||
private function fetchDistinct(string $sql): array
|
||||
{
|
||||
$rows = $this->db->query($sql)->fetchAll(PDO::FETCH_COLUMN);
|
||||
return array_values(array_filter($rows, fn ($v) => $v !== null && $v !== ''));
|
||||
}
|
||||
}
|
||||
70
modules/EcommerceFinance/Services/JianSunConnection.php
Normal file
70
modules/EcommerceFinance/Services/JianSunConnection.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* JianSun SQL Server 数据库连接管理
|
||||
* 使用 PDO_ODBC 直连 SQL Server(无需 sqlsrv 扩展)
|
||||
*/
|
||||
class JianSunConnection
|
||||
{
|
||||
private static ?PDO $instance = null;
|
||||
|
||||
/**
|
||||
* 获取 PDO 连接实例(单例模式)
|
||||
*/
|
||||
public static function get(): PDO
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = self::create();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的 PDO 连接
|
||||
*/
|
||||
public static function create(): PDO
|
||||
{
|
||||
$host = env('DB_JIANSUN_HOST', '218.93.191.21');
|
||||
$port = env('DB_JIANSUN_PORT', '1433');
|
||||
$db = env('DB_JIANSUN_DATABASE', 'JianSun');
|
||||
$user = env('DB_JIANSUN_USERNAME', 'financeReader');
|
||||
$pass = env('DB_JIANSUN_PASSWORD', '');
|
||||
|
||||
$dsn = sprintf(
|
||||
'odbc:Driver={SQL Server};Server=%s,%s;Database=%s',
|
||||
$host,
|
||||
$port,
|
||||
$db
|
||||
);
|
||||
|
||||
try {
|
||||
$pdo = new PDO($dsn, $user, $pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
|
||||
return $pdo;
|
||||
} catch (PDOException $e) {
|
||||
throw new \RuntimeException(
|
||||
'JianSun 数据库连接失败: ' . $e->getMessage(),
|
||||
500,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置连接(用于长时间运行或测试场景)
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$instance = null;
|
||||
}
|
||||
}
|
||||
1
public/public
Symbolic link
1
public/public
Symbolic link
@ -0,0 +1 @@
|
||||
.
|
||||
84
resources/assets/js/views/ecommerce-finance/advance-details.js
vendored
Normal file
84
resources/assets/js/views/ecommerce-finance/advance-details.js
vendored
Normal file
@ -0,0 +1,84 @@
|
||||
require('./../../bootstrap');
|
||||
|
||||
import Vue from 'vue';
|
||||
import DashboardPlugin from './../../plugins/dashboard-plugin';
|
||||
import Global from './../../mixins/global';
|
||||
|
||||
import {
|
||||
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message
|
||||
} from 'element-ui';
|
||||
|
||||
Vue.use(DashboardPlugin);
|
||||
Vue.component(Table.name, Table);
|
||||
Vue.component(TableColumn.name, TableColumn);
|
||||
Vue.component(Pagination.name, Pagination);
|
||||
Vue.component(DatePicker.name, DatePicker);
|
||||
Vue.component(Tag.name, Tag);
|
||||
Vue.component(Input.name, Input);
|
||||
Vue.component(Button.name, Button);
|
||||
Vue.prototype.$message = Message;
|
||||
|
||||
const app = new Vue({
|
||||
el: '#advance-detail-app',
|
||||
|
||||
mixins: [Global],
|
||||
|
||||
data() {
|
||||
return {
|
||||
baseUrl: url + '/api/ecommerce-finance/supplier-payables/advance-details',
|
||||
loading: false,
|
||||
tableData: [],
|
||||
total: 0,
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
summary: { totalAmount: 0, totalCount: 0 },
|
||||
filters: { supplier: '', month_range: null }
|
||||
};
|
||||
},
|
||||
|
||||
mounted() { this.fetchData(); },
|
||||
|
||||
methods: {
|
||||
fetchData() {
|
||||
this.loading = true;
|
||||
const params = this.buildParams();
|
||||
Promise.all([
|
||||
axios.get(this.baseUrl, { params }),
|
||||
axios.get(this.baseUrl + '/summary', { params })
|
||||
]).then(([listRes, sumRes]) => {
|
||||
this.tableData = listRes.data.data || [];
|
||||
this.total = listRes.data.total || 0;
|
||||
this.currentPage = listRes.data.page || 1;
|
||||
this.pageSize = listRes.data.per_page || 25;
|
||||
this.summary = sumRes.data;
|
||||
}).catch(() => this.$message.error('获取数据失败'))
|
||||
.finally(() => { this.loading = false; });
|
||||
},
|
||||
|
||||
buildParams() {
|
||||
const params = { page: this.currentPage, limit: this.pageSize };
|
||||
if (this.filters.supplier) params.supplier = this.filters.supplier;
|
||||
if (this.filters.month_range && this.filters.month_range.length === 2) {
|
||||
params.month_from = this.filters.month_range[0];
|
||||
params.month_to = this.filters.month_range[1];
|
||||
}
|
||||
return params;
|
||||
},
|
||||
|
||||
onFilterChange() { this.currentPage = 1; this.fetchData(); },
|
||||
onPageChange(p) { this.currentPage = p; this.fetchData(); },
|
||||
onPageSizeChange(s) { this.pageSize = s; this.currentPage = 1; this.fetchData(); },
|
||||
onReset() {
|
||||
this.filters = { supplier: '', month_range: null };
|
||||
this.currentPage = 1;
|
||||
this.fetchData();
|
||||
},
|
||||
|
||||
formatMoney(v) {
|
||||
if (v == null || isNaN(v)) return '0.00';
|
||||
return '¥' + Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
84
resources/assets/js/views/ecommerce-finance/compensation-details.js
vendored
Normal file
84
resources/assets/js/views/ecommerce-finance/compensation-details.js
vendored
Normal file
@ -0,0 +1,84 @@
|
||||
require('./../../bootstrap');
|
||||
|
||||
import Vue from 'vue';
|
||||
import DashboardPlugin from './../../plugins/dashboard-plugin';
|
||||
import Global from './../../mixins/global';
|
||||
|
||||
import {
|
||||
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message
|
||||
} from 'element-ui';
|
||||
|
||||
Vue.use(DashboardPlugin);
|
||||
Vue.component(Table.name, Table);
|
||||
Vue.component(TableColumn.name, TableColumn);
|
||||
Vue.component(Pagination.name, Pagination);
|
||||
Vue.component(DatePicker.name, DatePicker);
|
||||
Vue.component(Tag.name, Tag);
|
||||
Vue.component(Input.name, Input);
|
||||
Vue.component(Button.name, Button);
|
||||
Vue.prototype.$message = Message;
|
||||
|
||||
const app = new Vue({
|
||||
el: '#compensation-detail-app',
|
||||
|
||||
mixins: [Global],
|
||||
|
||||
data() {
|
||||
return {
|
||||
baseUrl: url + '/api/ecommerce-finance/supplier-payables/compensation-details',
|
||||
loading: false,
|
||||
tableData: [],
|
||||
total: 0,
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
summary: { totalAmount: 0, totalCount: 0 },
|
||||
filters: { supplier: '', month_range: null }
|
||||
};
|
||||
},
|
||||
|
||||
mounted() { this.fetchData(); },
|
||||
|
||||
methods: {
|
||||
fetchData() {
|
||||
this.loading = true;
|
||||
const params = this.buildParams();
|
||||
Promise.all([
|
||||
axios.get(this.baseUrl, { params }),
|
||||
axios.get(this.baseUrl + '/summary', { params })
|
||||
]).then(([listRes, sumRes]) => {
|
||||
this.tableData = listRes.data.data || [];
|
||||
this.total = listRes.data.total || 0;
|
||||
this.currentPage = listRes.data.page || 1;
|
||||
this.pageSize = listRes.data.per_page || 25;
|
||||
this.summary = sumRes.data;
|
||||
}).catch(() => this.$message.error('获取数据失败'))
|
||||
.finally(() => { this.loading = false; });
|
||||
},
|
||||
|
||||
buildParams() {
|
||||
const params = { page: this.currentPage, limit: this.pageSize };
|
||||
if (this.filters.supplier) params.supplier = this.filters.supplier;
|
||||
if (this.filters.month_range && this.filters.month_range.length === 2) {
|
||||
params.month_from = this.filters.month_range[0];
|
||||
params.month_to = this.filters.month_range[1];
|
||||
}
|
||||
return params;
|
||||
},
|
||||
|
||||
onFilterChange() { this.currentPage = 1; this.fetchData(); },
|
||||
onPageChange(p) { this.currentPage = p; this.fetchData(); },
|
||||
onPageSizeChange(s) { this.pageSize = s; this.currentPage = 1; this.fetchData(); },
|
||||
onReset() {
|
||||
this.filters = { supplier: '', month_range: null };
|
||||
this.currentPage = 1;
|
||||
this.fetchData();
|
||||
},
|
||||
|
||||
formatMoney(v) {
|
||||
if (v == null || isNaN(v)) return '0.00';
|
||||
return '¥' + Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
277
resources/assets/js/views/ecommerce-finance/goods-reconciliations.js
vendored
Normal file
277
resources/assets/js/views/ecommerce-finance/goods-reconciliations.js
vendored
Normal file
@ -0,0 +1,277 @@
|
||||
require('./../../bootstrap');
|
||||
|
||||
import Vue from 'vue';
|
||||
import DashboardPlugin from './../../plugins/dashboard-plugin';
|
||||
import Global from './../../mixins/global';
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableColumn,
|
||||
Pagination,
|
||||
DatePicker,
|
||||
Tag,
|
||||
Message
|
||||
} from 'element-ui';
|
||||
|
||||
Vue.use(DashboardPlugin);
|
||||
Vue.component(Table.name, Table);
|
||||
Vue.component(TableColumn.name, TableColumn);
|
||||
Vue.component(Pagination.name, Pagination);
|
||||
Vue.component(DatePicker.name, DatePicker);
|
||||
Vue.component(Tag.name, Tag);
|
||||
Vue.prototype.$message = Message;
|
||||
|
||||
const app = new Vue({
|
||||
el: '#goods-reconciliation-app',
|
||||
|
||||
mixins: [Global],
|
||||
|
||||
data() {
|
||||
return {
|
||||
baseUrl: url + '/api/ecommerce-finance/goods-reconciliations',
|
||||
loading: false,
|
||||
tableData: [],
|
||||
total: 0,
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
activeTab: 'all',
|
||||
|
||||
summary: {
|
||||
totalAmount: 0,
|
||||
totalGuessAmount: 0,
|
||||
totalCount: 0,
|
||||
sentCount: 0,
|
||||
unsentCount: 0
|
||||
},
|
||||
|
||||
options: {
|
||||
suppliers: [],
|
||||
warehouses: []
|
||||
},
|
||||
|
||||
filters: {
|
||||
record_type: 'all',
|
||||
supplier: '',
|
||||
warehouse: '',
|
||||
stock_no: '',
|
||||
purchase_no: '',
|
||||
product_code: '',
|
||||
express_no: '',
|
||||
send_time_range: null,
|
||||
create_time_range: null,
|
||||
sent_status: 'all',
|
||||
has_diff: false
|
||||
},
|
||||
|
||||
tabs: [
|
||||
{ key: 'all', label: '全部记录' },
|
||||
{ key: 'purchase', label: '入库对账' },
|
||||
{ key: 'sale', label: '代发对账' },
|
||||
{ key: 'unsent', label: '未发出' },
|
||||
{ key: 'diff', label: '有差异' }
|
||||
],
|
||||
|
||||
sortProp: 'createTime',
|
||||
sortOrder: 'descending'
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.fetchOptions();
|
||||
this.fetchData();
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Fetch filter options (suppliers, warehouses)
|
||||
*/
|
||||
fetchOptions() {
|
||||
axios.get(this.baseUrl + '/options')
|
||||
.then(response => {
|
||||
this.options = response.data;
|
||||
})
|
||||
.catch(() => {
|
||||
this.$message.error('获取筛选选项失败');
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch data list and summary
|
||||
*/
|
||||
fetchData() {
|
||||
this.loading = true;
|
||||
|
||||
const params = this.buildParams();
|
||||
const summaryParams = this.buildSummaryParams();
|
||||
|
||||
Promise.all([
|
||||
axios.get(this.baseUrl, { params }),
|
||||
axios.get(this.baseUrl + '/summary', { params: summaryParams })
|
||||
])
|
||||
.then(([listRes, summaryRes]) => {
|
||||
this.tableData = listRes.data.data || [];
|
||||
this.total = listRes.data.total || 0;
|
||||
this.currentPage = listRes.data.page || 1;
|
||||
this.pageSize = listRes.data.per_page || 25;
|
||||
this.summary = summaryRes.data;
|
||||
})
|
||||
.catch(() => {
|
||||
this.$message.error('获取数据失败,请稍后重试');
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Build list query params
|
||||
*/
|
||||
buildParams() {
|
||||
const params = {
|
||||
page: this.currentPage,
|
||||
limit: this.pageSize,
|
||||
record_type: this.filters.record_type
|
||||
};
|
||||
|
||||
// text filters
|
||||
const textFields = ['supplier', 'warehouse', 'stock_no', 'purchase_no', 'product_code', 'express_no'];
|
||||
textFields.forEach(f => {
|
||||
if (this.filters[f]) {
|
||||
params[f] = this.filters[f];
|
||||
}
|
||||
});
|
||||
|
||||
// date range filters
|
||||
if (this.filters.send_time_range && this.filters.send_time_range.length === 2) {
|
||||
params.send_time_from = this.filters.send_time_range[0];
|
||||
params.send_time_to = this.filters.send_time_range[1];
|
||||
}
|
||||
if (this.filters.create_time_range && this.filters.create_time_range.length === 2) {
|
||||
params.create_time_from = this.filters.create_time_range[0];
|
||||
params.create_time_to = this.filters.create_time_range[1];
|
||||
}
|
||||
|
||||
// sent status
|
||||
if (this.filters.sent_status && this.filters.sent_status !== 'all') {
|
||||
params.sent_status = this.filters.sent_status;
|
||||
}
|
||||
|
||||
// has diff
|
||||
if (this.filters.has_diff) {
|
||||
params.has_diff = 1;
|
||||
}
|
||||
|
||||
return params;
|
||||
},
|
||||
|
||||
/**
|
||||
* Summary params always use 'all' for overall counts
|
||||
*/
|
||||
buildSummaryParams() {
|
||||
const params = {};
|
||||
const textFields = ['supplier', 'warehouse', 'stock_no', 'purchase_no', 'product_code', 'express_no'];
|
||||
textFields.forEach(f => {
|
||||
if (this.filters[f]) {
|
||||
params[f] = this.filters[f];
|
||||
}
|
||||
});
|
||||
if (this.filters.send_time_range && this.filters.send_time_range.length === 2) {
|
||||
params.send_time_from = this.filters.send_time_range[0];
|
||||
params.send_time_to = this.filters.send_time_range[1];
|
||||
}
|
||||
if (this.filters.create_time_range && this.filters.create_time_range.length === 2) {
|
||||
params.create_time_from = this.filters.create_time_range[0];
|
||||
params.create_time_to = this.filters.create_time_range[1];
|
||||
}
|
||||
params.record_type = this.filters.record_type;
|
||||
if (this.filters.sent_status && this.filters.sent_status !== 'all') {
|
||||
params.sent_status = this.filters.sent_status;
|
||||
}
|
||||
return params;
|
||||
},
|
||||
|
||||
/**
|
||||
* Switch tab
|
||||
*/
|
||||
switchTab(key) {
|
||||
this.activeTab = key;
|
||||
this.currentPage = 1;
|
||||
|
||||
switch (key) {
|
||||
case 'all':
|
||||
this.filters.record_type = 'all';
|
||||
this.filters.sent_status = 'all';
|
||||
this.filters.has_diff = false;
|
||||
break;
|
||||
case 'purchase':
|
||||
this.filters.record_type = 'purchase';
|
||||
this.filters.sent_status = 'all';
|
||||
this.filters.has_diff = false;
|
||||
break;
|
||||
case 'sale':
|
||||
this.filters.record_type = 'sale';
|
||||
this.filters.sent_status = 'all';
|
||||
this.filters.has_diff = false;
|
||||
break;
|
||||
case 'unsent':
|
||||
this.filters.sent_status = 'unsent';
|
||||
this.filters.has_diff = false;
|
||||
break;
|
||||
case 'diff':
|
||||
this.filters.has_diff = true;
|
||||
this.filters.sent_status = 'all';
|
||||
break;
|
||||
}
|
||||
|
||||
this.fetchData();
|
||||
},
|
||||
|
||||
/**
|
||||
* On filter change, reset to page 1
|
||||
*/
|
||||
onFilterChange() {
|
||||
this.currentPage = 1;
|
||||
this.fetchData();
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination page change
|
||||
*/
|
||||
onPageChange(page) {
|
||||
this.currentPage = page;
|
||||
this.fetchData();
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination page size change
|
||||
*/
|
||||
onPageSizeChange(size) {
|
||||
this.pageSize = size;
|
||||
this.currentPage = 1;
|
||||
this.fetchData();
|
||||
},
|
||||
|
||||
/**
|
||||
* Sort change
|
||||
*/
|
||||
onSortChange({ prop, order }) {
|
||||
this.sortProp = prop;
|
||||
this.sortOrder = order;
|
||||
// Sorting is handled server-side for now; re-fetch
|
||||
this.fetchData();
|
||||
},
|
||||
|
||||
/**
|
||||
* Format money (CNY)
|
||||
*/
|
||||
formatMoney(value) {
|
||||
if (value == null || isNaN(value)) return '0.00';
|
||||
return '¥' + Number(value).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
84
resources/assets/js/views/ecommerce-finance/invoice-deductions.js
vendored
Normal file
84
resources/assets/js/views/ecommerce-finance/invoice-deductions.js
vendored
Normal file
@ -0,0 +1,84 @@
|
||||
require('./../../bootstrap');
|
||||
|
||||
import Vue from 'vue';
|
||||
import DashboardPlugin from './../../plugins/dashboard-plugin';
|
||||
import Global from './../../mixins/global';
|
||||
|
||||
import {
|
||||
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message
|
||||
} from 'element-ui';
|
||||
|
||||
Vue.use(DashboardPlugin);
|
||||
Vue.component(Table.name, Table);
|
||||
Vue.component(TableColumn.name, TableColumn);
|
||||
Vue.component(Pagination.name, Pagination);
|
||||
Vue.component(DatePicker.name, DatePicker);
|
||||
Vue.component(Tag.name, Tag);
|
||||
Vue.component(Input.name, Input);
|
||||
Vue.component(Button.name, Button);
|
||||
Vue.prototype.$message = Message;
|
||||
|
||||
const app = new Vue({
|
||||
el: '#invoice-deduction-app',
|
||||
|
||||
mixins: [Global],
|
||||
|
||||
data() {
|
||||
return {
|
||||
baseUrl: url + '/api/ecommerce-finance/supplier-payables/invoice-deductions',
|
||||
loading: false,
|
||||
tableData: [],
|
||||
total: 0,
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
summary: { totalPrice: 0, totalCount: 0 },
|
||||
filters: { supplier: '', month_range: null }
|
||||
};
|
||||
},
|
||||
|
||||
mounted() { this.fetchData(); },
|
||||
|
||||
methods: {
|
||||
fetchData() {
|
||||
this.loading = true;
|
||||
const params = this.buildParams();
|
||||
Promise.all([
|
||||
axios.get(this.baseUrl, { params }),
|
||||
axios.get(this.baseUrl + '/summary', { params })
|
||||
]).then(([listRes, sumRes]) => {
|
||||
this.tableData = listRes.data.data || [];
|
||||
this.total = listRes.data.total || 0;
|
||||
this.currentPage = listRes.data.page || 1;
|
||||
this.pageSize = listRes.data.per_page || 25;
|
||||
this.summary = sumRes.data;
|
||||
}).catch(() => this.$message.error('获取数据失败'))
|
||||
.finally(() => { this.loading = false; });
|
||||
},
|
||||
|
||||
buildParams() {
|
||||
const params = { page: this.currentPage, limit: this.pageSize };
|
||||
if (this.filters.supplier) params.supplier = this.filters.supplier;
|
||||
if (this.filters.month_range && this.filters.month_range.length === 2) {
|
||||
params.month_from = this.filters.month_range[0];
|
||||
params.month_to = this.filters.month_range[1];
|
||||
}
|
||||
return params;
|
||||
},
|
||||
|
||||
onFilterChange() { this.currentPage = 1; this.fetchData(); },
|
||||
onPageChange(p) { this.currentPage = p; this.fetchData(); },
|
||||
onPageSizeChange(s) { this.pageSize = s; this.currentPage = 1; this.fetchData(); },
|
||||
onReset() {
|
||||
this.filters = { supplier: '', month_range: null };
|
||||
this.currentPage = 1;
|
||||
this.fetchData();
|
||||
},
|
||||
|
||||
formatMoney(v) {
|
||||
if (v == null || isNaN(v)) return '0.00';
|
||||
return '¥' + Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
113
resources/assets/js/views/ecommerce-finance/return-reconciliations.js
vendored
Normal file
113
resources/assets/js/views/ecommerce-finance/return-reconciliations.js
vendored
Normal file
@ -0,0 +1,113 @@
|
||||
require('./../../bootstrap');
|
||||
|
||||
import Vue from 'vue';
|
||||
import DashboardPlugin from './../../plugins/dashboard-plugin';
|
||||
import Global from './../../mixins/global';
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableColumn,
|
||||
Pagination,
|
||||
DatePicker,
|
||||
Tag,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Option,
|
||||
Message
|
||||
} from 'element-ui';
|
||||
|
||||
Vue.use(DashboardPlugin);
|
||||
Vue.component(Table.name, Table);
|
||||
Vue.component(TableColumn.name, TableColumn);
|
||||
Vue.component(Pagination.name, Pagination);
|
||||
Vue.component(DatePicker.name, DatePicker);
|
||||
Vue.component(Tag.name, Tag);
|
||||
Vue.component(Input.name, Input);
|
||||
Vue.component(Button.name, Button);
|
||||
Vue.component(Select.name, Select);
|
||||
Vue.component(Option.name, Option);
|
||||
Vue.prototype.$message = Message;
|
||||
|
||||
const app = new Vue({
|
||||
el: '#return-reconciliation-app',
|
||||
|
||||
mixins: [Global],
|
||||
|
||||
data() {
|
||||
return {
|
||||
baseUrl: url + '/api/ecommerce-finance/supplier-payables/return-reconciliations',
|
||||
loading: false,
|
||||
tableData: [],
|
||||
total: 0,
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
summary: { totalAmount: 0, totalGuessAmount: 0, totalCount: 0 },
|
||||
options: { suppliers: [], warehouses: [] },
|
||||
filters: {
|
||||
supplier: '',
|
||||
warehouse: '',
|
||||
sku_id: '',
|
||||
express_no: '',
|
||||
date_range: null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.fetchOptions();
|
||||
this.fetchData();
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchOptions() {
|
||||
axios.get(this.baseUrl + '/options')
|
||||
.then(r => { this.options = r.data; })
|
||||
.catch(() => this.$message.error('获取筛选选项失败'));
|
||||
},
|
||||
|
||||
fetchData() {
|
||||
this.loading = true;
|
||||
const params = this.buildParams();
|
||||
Promise.all([
|
||||
axios.get(this.baseUrl, { params }),
|
||||
axios.get(this.baseUrl + '/summary', { params })
|
||||
]).then(([listRes, sumRes]) => {
|
||||
this.tableData = listRes.data.data || [];
|
||||
this.total = listRes.data.total || 0;
|
||||
this.currentPage = listRes.data.page || 1;
|
||||
this.pageSize = listRes.data.per_page || 25;
|
||||
this.summary = sumRes.data;
|
||||
}).catch(() => this.$message.error('获取数据失败'))
|
||||
.finally(() => { this.loading = false; });
|
||||
},
|
||||
|
||||
buildParams() {
|
||||
const params = { page: this.currentPage, limit: this.pageSize };
|
||||
['supplier', 'warehouse', 'sku_id', 'express_no'].forEach(f => {
|
||||
if (this.filters[f]) params[f] = this.filters[f];
|
||||
});
|
||||
if (this.filters.date_range && this.filters.date_range.length === 2) {
|
||||
params.date_from = this.filters.date_range[0];
|
||||
params.date_to = this.filters.date_range[1];
|
||||
}
|
||||
return params;
|
||||
},
|
||||
|
||||
onFilterChange() { this.currentPage = 1; this.fetchData(); },
|
||||
onPageChange(p) { this.currentPage = p; this.fetchData(); },
|
||||
onPageSizeChange(s) { this.pageSize = s; this.currentPage = 1; this.fetchData(); },
|
||||
onReset() {
|
||||
this.filters = { supplier: '', warehouse: '', sku_id: '', express_no: '', date_range: null };
|
||||
this.currentPage = 1;
|
||||
this.fetchData();
|
||||
},
|
||||
|
||||
formatMoney(v) {
|
||||
if (v == null || isNaN(v)) return '0.00';
|
||||
return '¥' + Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
92
resources/assets/js/views/ecommerce-finance/sample-deductions.js
vendored
Normal file
92
resources/assets/js/views/ecommerce-finance/sample-deductions.js
vendored
Normal file
@ -0,0 +1,92 @@
|
||||
require('./../../bootstrap');
|
||||
|
||||
import Vue from 'vue';
|
||||
import DashboardPlugin from './../../plugins/dashboard-plugin';
|
||||
import Global from './../../mixins/global';
|
||||
|
||||
import {
|
||||
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message
|
||||
} from 'element-ui';
|
||||
|
||||
Vue.use(DashboardPlugin);
|
||||
Vue.component(Table.name, Table);
|
||||
Vue.component(TableColumn.name, TableColumn);
|
||||
Vue.component(Pagination.name, Pagination);
|
||||
Vue.component(DatePicker.name, DatePicker);
|
||||
Vue.component(Tag.name, Tag);
|
||||
Vue.component(Input.name, Input);
|
||||
Vue.component(Button.name, Button);
|
||||
Vue.prototype.$message = Message;
|
||||
|
||||
const app = new Vue({
|
||||
el: '#sample-deduction-app',
|
||||
|
||||
mixins: [Global],
|
||||
|
||||
data() {
|
||||
return {
|
||||
baseUrl: url + '/api/ecommerce-finance/supplier-payables/sample-deductions',
|
||||
loading: false,
|
||||
tableData: [],
|
||||
total: 0,
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
summary: { totalAmount: 0, totalEstimate: 0, totalCount: 0 },
|
||||
filters: {
|
||||
supplier: '',
|
||||
department: '',
|
||||
creator: '',
|
||||
sku_id: '',
|
||||
date_range: null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
mounted() { this.fetchData(); },
|
||||
|
||||
methods: {
|
||||
fetchData() {
|
||||
this.loading = true;
|
||||
const params = this.buildParams();
|
||||
Promise.all([
|
||||
axios.get(this.baseUrl, { params }),
|
||||
axios.get(this.baseUrl + '/summary', { params })
|
||||
]).then(([listRes, sumRes]) => {
|
||||
this.tableData = listRes.data.data || [];
|
||||
this.total = listRes.data.total || 0;
|
||||
this.currentPage = listRes.data.page || 1;
|
||||
this.pageSize = listRes.data.per_page || 25;
|
||||
this.summary = sumRes.data;
|
||||
}).catch(() => this.$message.error('获取数据失败'))
|
||||
.finally(() => { this.loading = false; });
|
||||
},
|
||||
|
||||
buildParams() {
|
||||
const params = { page: this.currentPage, limit: this.pageSize };
|
||||
['supplier', 'department', 'creator', 'sku_id'].forEach(f => {
|
||||
if (this.filters[f]) params[f] = this.filters[f];
|
||||
});
|
||||
if (this.filters.date_range && this.filters.date_range.length === 2) {
|
||||
params.date_from = this.filters.date_range[0];
|
||||
params.date_to = this.filters.date_range[1];
|
||||
}
|
||||
return params;
|
||||
},
|
||||
|
||||
onFilterChange() { this.currentPage = 1; this.fetchData(); },
|
||||
onPageChange(p) { this.currentPage = p; this.fetchData(); },
|
||||
onPageSizeChange(s) { this.pageSize = s; this.currentPage = 1; this.fetchData(); },
|
||||
onReset() {
|
||||
this.filters = { supplier: '', department: '', creator: '', sku_id: '', date_range: null };
|
||||
this.currentPage = 1;
|
||||
this.fetchData();
|
||||
},
|
||||
|
||||
formatMoney(v) {
|
||||
if (v == null || isNaN(v)) return '0.00';
|
||||
return '¥' + Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
@ -0,0 +1,111 @@
|
||||
<div id="advance-detail-app" class="advance-detail-page">
|
||||
<div v-cloak>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-4 text-sm text-yellow-800">
|
||||
<i class="el-icon-warning mr-1"></i>
|
||||
借支明细数据源 finance_advanceLoans 当前仅 4 行数据;表头 21 列中部分字段(如实际打款金额、分期扣款、票据等)在数据库中无对应列,已显示为占位符。
|
||||
</div>
|
||||
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">借支总金额</div>
|
||||
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">总记录数</div>
|
||||
<div class="text-2xl font-bold text-indigo-600">@{{ (summary.totalCount || 0).toLocaleString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 筛选栏 --}}
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<el-input v-model="filters.supplier" placeholder="供应商" size="small" style="width: 200px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
|
||||
<el-date-picker v-model="filters.month_range" type="monthrange"
|
||||
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
|
||||
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
|
||||
<el-button size="small" @click="onReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 数据表格 --}}
|
||||
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
|
||||
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
|
||||
<el-table-column prop="supplier" label="供应商" width="140" fixed="left" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column label="协议签订名称" width="140">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.contract_name || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="advance_amount" label="借支金额-借款协议" width="140" align="right">
|
||||
<template slot-scope="scope">
|
||||
<span :class="scope.row.advance_amount < 0 ? 'text-red-600' : 'text-gray-800'">@{{ formatMoney(scope.row.advance_amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实际打款金额" width="120" align="right">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ formatMoney(scope.row.actual_pay) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="本月扣款金额" width="120" align="right">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ formatMoney(scope.row.month_deduct) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="应扣金额" width="120" align="right">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ formatMoney(scope.row.should_deduct) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分期起始扣款月份" width="130">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.deduct_start_month || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分期最后次扣款月份" width="130">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.deduct_end_month || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remain_periods" label="分期剩余期数" width="110" align="center"></el-table-column>
|
||||
<el-table-column prop="month_str" label="借支日期" width="110"></el-table-column>
|
||||
<el-table-column label="借款期限" width="100">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.loan_term || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" width="180" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column label="是否打款" width="100">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.is_paid || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="pay_month" label="打款月份" width="110"></el-table-column>
|
||||
<el-table-column label="流水号" width="120">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.serial_no || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="借支协议" width="120">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.loan_agreement || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="person_charge" label="负责人" width="100"></el-table-column>
|
||||
<el-table-column label="票据" width="80">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.receipt_0 || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="票据1" width="80">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.receipt_1 || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="票据2" width="80">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.receipt_2 || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-100" v-if="!loading">
|
||||
<div class="text-sm text-gray-500">共 @{{ total }} 条记录</div>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, sizes, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page.sync="currentPage"
|
||||
:page-sizes="[25, 50, 100]"
|
||||
@size-change="onPageSizeChange"
|
||||
@current-change="onPageChange">
|
||||
</el-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('body_end')
|
||||
<script src="{{ asset('public/js/ecommerce-finance/advance-details.min.js') }}"></script>
|
||||
@endpush
|
||||
@ -0,0 +1,73 @@
|
||||
<div id="compensation-detail-app" class="compensation-detail-page">
|
||||
<div v-cloak>
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">赔付总金额</div>
|
||||
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalAmount) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">总记录数</div>
|
||||
<div class="text-2xl font-bold text-indigo-600">@{{ (summary.totalCount || 0).toLocaleString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 筛选栏 --}}
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<el-input v-model="filters.supplier" placeholder="供应商" size="small" style="width: 220px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
|
||||
<el-date-picker v-model="filters.month_range" type="monthrange"
|
||||
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
|
||||
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
|
||||
<el-button size="small" @click="onReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 数据表格 --}}
|
||||
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
|
||||
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
|
||||
<el-table-column prop="month" label="日期" width="100" fixed="left"></el-table-column>
|
||||
<el-table-column prop="supplier" label="供应商" width="260" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="amount" label="赔付金额" width="140" align="right">
|
||||
<template slot-scope="scope">
|
||||
<span :class="scope.row.amount < 0 ? 'text-red-600' : 'text-gray-800'">@{{ formatMoney(scope.row.amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reason" label="赔付原因" width="240" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="remark" label="备注" width="180" show-overflow-tooltip>
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.remark || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="factory_type" label="类型" width="100">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini" :type="scope.row.factory_type === '纸板厂' ? 'primary' : 'success'">@{{ scope.row.factory_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator" label="创建人" width="100"></el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-100" v-if="!loading">
|
||||
<div class="text-sm text-gray-500">共 @{{ total }} 条记录</div>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, sizes, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page.sync="currentPage"
|
||||
:page-sizes="[25, 50, 100]"
|
||||
@size-change="onPageSizeChange"
|
||||
@current-change="onPageChange">
|
||||
</el-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('body_end')
|
||||
<script src="{{ asset('public/js/ecommerce-finance/compensation-details.min.js') }}"></script>
|
||||
@endpush
|
||||
@ -0,0 +1,79 @@
|
||||
<div id="invoice-deduction-app" class="invoice-deduction-page">
|
||||
<div v-cloak>
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">扣款总金额</div>
|
||||
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalPrice) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">总记录数</div>
|
||||
<div class="text-2xl font-bold text-indigo-600">@{{ (summary.totalCount || 0).toLocaleString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 筛选栏 --}}
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<el-input v-model="filters.supplier" placeholder="供应商/打款抬头/收款方" size="small" style="width: 220px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
|
||||
<el-date-picker v-model="filters.month_range" type="monthrange"
|
||||
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
|
||||
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
|
||||
<el-button size="small" @click="onReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 数据表格 --}}
|
||||
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
|
||||
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
|
||||
<el-table-column prop="month_str" label="日期" width="100" fixed="left"></el-table-column>
|
||||
<el-table-column label="类别" width="100">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.category || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payee" label="打款抬头" width="160" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="receiver" label="收款方" width="160" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column label="供板材厂家/代扣厂家" width="180">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.board_factory || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="补贴费用" width="120" align="right">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ formatMoney(scope.row.subsidy_fee) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" width="200" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="price" label="对私打款金额" width="140" align="right">
|
||||
<template slot-scope="scope">
|
||||
<span :class="scope.row.price < 0 ? 'text-red-600' : 'text-gray-800'">@{{ formatMoney(scope.row.price) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="detail" label="明细" width="200" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column label="注明" width="100">
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.note || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="person_charge" label="负责人" width="100" show-overflow-tooltip></el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-100" v-if="!loading">
|
||||
<div class="text-sm text-gray-500">共 @{{ total }} 条记录</div>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, sizes, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page.sync="currentPage"
|
||||
:page-sizes="[25, 50, 100]"
|
||||
@size-change="onPageSizeChange"
|
||||
@current-change="onPageChange">
|
||||
</el-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('body_end')
|
||||
<script src="{{ asset('public/js/ecommerce-finance/invoice-deductions.min.js') }}"></script>
|
||||
@endpush
|
||||
@ -0,0 +1,139 @@
|
||||
<div id="goods-reconciliation-app" class="goods-reconciliation-page">
|
||||
<div v-cloak>
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">对账总金额</div>
|
||||
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">预估总金额</div>
|
||||
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalGuessAmount) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">已发出</div>
|
||||
<div class="text-2xl font-bold text-green-600">@{{ summary.sentCount.toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">未发出</div>
|
||||
<div class="text-2xl font-bold text-orange-500">@{{ summary.unsentCount.toLocaleString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 筛选栏 --}}
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-3">
|
||||
<el-select v-model="filters.record_type" placeholder="记录类型" size="small" style="width: 140px" @change="onFilterChange">
|
||||
<el-option label="全部记录" value="all"></el-option>
|
||||
<el-option label="入库对账" value="purchase"></el-option>
|
||||
<el-option label="代发对账" value="sale"></el-option>
|
||||
</el-select>
|
||||
<el-select v-model="filters.supplier" placeholder="供应商" size="small" style="width: 160px" filterable clearable @change="onFilterChange">
|
||||
<el-option v-for="s in options.suppliers" :key="s" :label="s" :value="s"></el-option>
|
||||
</el-select>
|
||||
<el-select v-model="filters.warehouse" placeholder="仓库" size="small" style="width: 140px" filterable clearable @change="onFilterChange">
|
||||
<el-option v-for="w in options.warehouses" :key="w" :label="w" :value="w"></el-option>
|
||||
</el-select>
|
||||
<el-input v-model="filters.stock_no" placeholder="入库单号" size="small" style="width: 140px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
|
||||
<el-input v-model="filters.purchase_no" placeholder="采购单号" size="small" style="width: 140px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<el-input v-model="filters.product_code" placeholder="商品编码" size="small" style="width: 140px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
|
||||
<el-input v-model="filters.express_no" placeholder="快递单号" size="small" style="width: 140px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
|
||||
<el-date-picker v-model="filters.send_time_range" type="daterange"
|
||||
start-placeholder="发货开始" end-placeholder="发货结束" size="small"
|
||||
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-date-picker v-model="filters.create_time_range" type="daterange"
|
||||
start-placeholder="创建开始" end-placeholder="创建结束" size="small"
|
||||
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-select v-model="filters.sent_status" placeholder="发出状态" size="small" style="width: 120px" clearable @change="onFilterChange">
|
||||
<el-option label="全部" value="all"></el-option>
|
||||
<el-option label="已发出" value="sent"></el-option>
|
||||
<el-option label="未发出" value="unsent"></el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Tab 切换 --}}
|
||||
<div class="flex gap-1 mb-4">
|
||||
<button v-for="tab in tabs" :key="tab.key"
|
||||
@click="switchTab(tab.key)"
|
||||
:class="['px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
activeTab === tab.key ? 'bg-indigo-600 text-white shadow-sm' : 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-50']">
|
||||
@{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 数据表格 --}}
|
||||
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small"
|
||||
:default-sort="{prop: 'createTime', order: 'descending'}"
|
||||
@sort-change="onSortChange">
|
||||
<el-table-column type="index" label="#" width="55" fixed="left"></el-table-column>
|
||||
<el-table-column prop="recordType" label="类型" width="80">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini" :type="scope.row.recordType === 'purchase' ? 'primary' : 'success'">
|
||||
@{{ scope.row.recordType === 'purchase' ? '入库' : '代发' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="stockNo" label="入库单号" width="120" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="purchaseNo" label="采购单号" width="110" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="sendTime" label="发货时间" width="145" sortable="custom"></el-table-column>
|
||||
<el-table-column prop="supplier" label="供应商" width="110" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="skuCode" label="货号" width="130" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="productCode" label="商品编码" width="120" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="skuName" label="规格名称" width="180" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="qty" label="数量" width="80" align="right"></el-table-column>
|
||||
<el-table-column label="单价" width="100" align="right">
|
||||
<template slot-scope="scope">@{{ formatMoney(scope.row.unitPrice) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="120" align="right">
|
||||
<template slot-scope="scope">@{{ formatMoney(scope.row.amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预估单价" width="100" align="right">
|
||||
<template slot-scope="scope">@{{ formatMoney(scope.row.guessUnitPrice) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预估金额" width="120" align="right">
|
||||
<template slot-scope="scope">@{{ formatMoney(scope.row.guessAmount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="supplierGoodsCode" label="供应商商品编码" width="140" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="remark" label="备注" width="150" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="warehouse" label="仓库名称" width="100" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="145" sortable="custom"></el-table-column>
|
||||
<el-table-column prop="expressName" label="快递名称" width="100" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="expressNo" label="快递单号" width="130" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column label="未发出" width="90" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.isUnsent" size="mini" type="warning">未发出</el-tag>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
{{-- 分页 --}}
|
||||
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-100" v-if="!loading">
|
||||
<div class="text-sm text-gray-500">共 @{{ total }} 条记录</div>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, sizes, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page.sync="currentPage"
|
||||
:page-sizes="[25, 50, 100]"
|
||||
@size-change="onPageSizeChange"
|
||||
@current-change="onPageChange">
|
||||
</el-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('body_end')
|
||||
<script src="{{ asset('public/js/ecommerce-finance/goods-reconciliations.min.js') }}"></script>
|
||||
@endpush
|
||||
@ -0,0 +1,107 @@
|
||||
<div id="return-reconciliation-app" class="return-reconciliation-page">
|
||||
<div v-cloak>
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">对账总金额</div>
|
||||
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">预估总金额</div>
|
||||
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalGuessAmount) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">总记录数</div>
|
||||
<div class="text-2xl font-bold text-indigo-600">@{{ (summary.totalCount || 0).toLocaleString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 筛选栏 --}}
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<el-input v-model="filters.supplier" placeholder="明细供应商" size="small" style="width: 160px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
|
||||
<el-select v-model="filters.warehouse" placeholder="仓库" size="small" style="width: 140px" filterable clearable @change="onFilterChange">
|
||||
<el-option v-for="w in options.warehouses" :key="w" :label="w" :value="w"></el-option>
|
||||
</el-select>
|
||||
<el-input v-model="filters.sku_id" placeholder="条形码" size="small" style="width: 160px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
|
||||
<el-input v-model="filters.express_no" placeholder="快递单号" size="small" style="width: 160px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
|
||||
<el-date-picker v-model="filters.date_range" type="daterange"
|
||||
start-placeholder="发货开始" end-placeholder="发货结束" size="small"
|
||||
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
|
||||
<el-button size="small" @click="onReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 数据表格 --}}
|
||||
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
|
||||
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
|
||||
<el-table-column prop="send_time" label="发货时间" width="110" fixed="left" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="supplier" label="明细供应商" width="110" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="barcode" label="条形码" width="140" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="sku_name" label="商品名称" width="220" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="qty" label="发货量" width="80" align="right"></el-table-column>
|
||||
<el-table-column label="单价" width="100" align="right">
|
||||
<template slot-scope="scope">@{{ formatMoney(scope.row.unit_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="110" align="right">
|
||||
<template slot-scope="scope">@{{ formatMoney(scope.row.amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预估单价" width="100" align="right">
|
||||
<template slot-scope="scope">@{{ formatMoney(scope.row.guess_unit_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预估金额" width="110" align="right">
|
||||
<template slot-scope="scope">@{{ formatMoney(scope.row.guess_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="supplier_barcode" label="供应商条码" width="120" show-overflow-tooltip>
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.supplier_barcode || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" width="140" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="title" label="标题" width="100" show-overflow-tooltip>
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.title || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="wms_pick_no" label="wms配货单号" width="120" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="warehouse" label="仓库名称" width="120" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="express_no" label="快递单号" width="130" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="express_name" label="快递名称" width="100" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="io_date" label="发货时间" width="110" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="original_barcode" label="原条码" width="130" show-overflow-tooltip>
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.original_barcode || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="labels" label="标记多标签" width="120" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.labels" size="mini" type="warning">@{{ scope.row.labels }}</el-tag>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator" label="创建人" width="100" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="reconciler" label="对账人" width="100" show-overflow-tooltip>
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.reconciler || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-100" v-if="!loading">
|
||||
<div class="text-sm text-gray-500">共 @{{ total }} 条记录</div>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, sizes, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page.sync="currentPage"
|
||||
:page-sizes="[25, 50, 100]"
|
||||
@size-change="onPageSizeChange"
|
||||
@current-change="onPageChange">
|
||||
</el-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('body_end')
|
||||
<script src="{{ asset('public/js/ecommerce-finance/return-reconciliations.min.js') }}"></script>
|
||||
@endpush
|
||||
@ -0,0 +1,93 @@
|
||||
<div id="sample-deduction-app" class="sample-deduction-page">
|
||||
<div v-cloak>
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">合计扣款金额</div>
|
||||
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalAmount) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">预估总金额</div>
|
||||
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalEstimate) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">总记录数</div>
|
||||
<div class="text-2xl font-bold text-indigo-600">@{{ (summary.totalCount || 0).toLocaleString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 筛选栏 --}}
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<el-input v-model="filters.supplier" placeholder="厂家" size="small" style="width: 160px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
|
||||
<el-input v-model="filters.department" placeholder="部门" size="small" style="width: 160px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
|
||||
<el-input v-model="filters.creator" placeholder="申请人" size="small" style="width: 120px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
|
||||
<el-input v-model="filters.sku_id" placeholder="商品代码" size="small" style="width: 160px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
|
||||
<el-date-picker v-model="filters.date_range" type="daterange"
|
||||
start-placeholder="开始" end-placeholder="结束" size="small"
|
||||
style="width: 240px" value-format="yyyy-MM-dd" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
|
||||
<el-button size="small" @click="onReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 数据表格 --}}
|
||||
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
|
||||
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
|
||||
<el-table-column prop="finish_time" label="日期" width="110" fixed="left"></el-table-column>
|
||||
<el-table-column prop="department" label="部门" width="200" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="creator" label="申请人" width="100"></el-table-column>
|
||||
<el-table-column prop="sku_id" label="商品代码" width="160" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="skuName" label="商品名称" width="220" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="num" label="数量" width="70" align="right"></el-table-column>
|
||||
<el-table-column prop="supplier" label="厂家" width="120" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="purchase" label="对应采购" width="200" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="productCode" label="扣款厂家商品代码" width="160" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="shipping_fee" label="运费" width="100" align="right">
|
||||
<template slot-scope="scope">@{{ formatMoney(scope.row.shipping_fee) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cost_price" label="成本价" width="110" align="right">
|
||||
<template slot-scope="scope">@{{ formatMoney(scope.row.cost_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_amount" label="合计扣款金额" width="130" align="right">
|
||||
<template slot-scope="scope">
|
||||
<span :class="scope.row.total_amount < 0 ? 'text-red-600' : 'text-gray-800'">@{{ formatMoney(scope.row.total_amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="estimated_price" label="预估价" width="100" align="right">
|
||||
<template slot-scope="scope">@{{ formatMoney(scope.row.estimated_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="estimated_price_all" label="预估金额" width="110" align="right">
|
||||
<template slot-scope="scope">@{{ formatMoney(scope.row.estimated_price_all) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" width="150" show-overflow-tooltip>
|
||||
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.remark || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="businessId" label="业务单号" width="200" show-overflow-tooltip></el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-100" v-if="!loading">
|
||||
<div class="text-sm text-gray-500">共 @{{ total }} 条记录</div>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, sizes, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page.sync="currentPage"
|
||||
:page-sizes="[25, 50, 100]"
|
||||
@size-change="onPageSizeChange"
|
||||
@current-change="onPageChange">
|
||||
</el-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('body_end')
|
||||
<script src="{{ asset('public/js/ecommerce-finance/sample-deductions.min.js') }}"></script>
|
||||
@endpush
|
||||
142
scripts/build-frontend.ps1
Normal file
142
scripts/build-frontend.ps1
Normal file
@ -0,0 +1,142 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$NodeVersion = '20.20.2',
|
||||
[int]$MaxOldSpaceSizeMB = 8192
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$toolRoot = Join-Path $env:LOCALAPPDATA 'CaiWuXiTong\tools'
|
||||
$nodeFolderName = "node-v$NodeVersion-win-x64"
|
||||
$nodeRoot = Join-Path $toolRoot $nodeFolderName
|
||||
$nodeExe = Join-Path $nodeRoot 'node.exe'
|
||||
$npmCmd = Join-Path $nodeRoot 'npm.cmd'
|
||||
$archiveName = "$nodeFolderName.zip"
|
||||
$archivePath = Join-Path $toolRoot $archiveName
|
||||
$checksumPath = Join-Path $toolRoot 'SHASUMS256.txt'
|
||||
$trackedCssPath = Join-Path $repoRoot 'public/css/app.css'
|
||||
$frontendOutputRoot = Join-Path $repoRoot '.caiwu-build/public'
|
||||
$fingerprintPath = Join-Path $repoRoot '.caiwu-build/frontend.sha'
|
||||
|
||||
function Invoke-Native {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[scriptblock]$Command,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FailureMessage
|
||||
)
|
||||
|
||||
& $Command
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw $FailureMessage
|
||||
}
|
||||
}
|
||||
|
||||
$fingerprintInputs = @(
|
||||
'HEAD:resources',
|
||||
'HEAD:package.json',
|
||||
'HEAD:package-lock.json',
|
||||
'HEAD:webpack.mix.js',
|
||||
'HEAD:tailwind.config.js'
|
||||
)
|
||||
$fingerprintHashes = @(& git -C $repoRoot rev-parse @fingerprintInputs)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw '无法计算前端输入指纹。'
|
||||
}
|
||||
$frontendFingerprint = "$NodeVersion|$($fingerprintHashes -join '|')"
|
||||
$cachedOutputs = @(
|
||||
(Join-Path $frontendOutputRoot 'js/auth/common.min.js'),
|
||||
(Join-Path $frontendOutputRoot 'css/app.css'),
|
||||
(Join-Path $frontendOutputRoot 'mix-manifest.json'),
|
||||
$fingerprintPath
|
||||
)
|
||||
$cacheComplete = -not ($cachedOutputs | Where-Object { -not (Test-Path -LiteralPath $_) })
|
||||
if ($cacheComplete -and (Get-Content -Raw -LiteralPath $fingerprintPath).Trim() -eq $frontendFingerprint) {
|
||||
Write-Host '前端输入未变化,复用已有构建产物。'
|
||||
return
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $nodeExe)) {
|
||||
New-Item -ItemType Directory -Force -Path $toolRoot | Out-Null
|
||||
|
||||
try {
|
||||
$downloadRoot = "https://nodejs.org/dist/v$NodeVersion"
|
||||
Invoke-WebRequest -Uri "$downloadRoot/$archiveName" -OutFile $archivePath
|
||||
Invoke-WebRequest -Uri "$downloadRoot/SHASUMS256.txt" -OutFile $checksumPath
|
||||
|
||||
$checksumLine = Select-String -Path $checksumPath -Pattern ([regex]::Escape($archiveName) + '$') |
|
||||
Select-Object -First 1
|
||||
if (-not $checksumLine) {
|
||||
throw 'Node 安装包缺少官方 SHA-256 校验值。'
|
||||
}
|
||||
|
||||
$expectedHash = ($checksumLine.Line -split '\s+')[0].ToLowerInvariant()
|
||||
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archivePath).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne $expectedHash) {
|
||||
throw 'Node 安装包 SHA-256 校验失败。'
|
||||
}
|
||||
|
||||
Expand-Archive -LiteralPath $archivePath -DestinationPath $toolRoot -Force
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $archivePath) {
|
||||
Remove-Item -LiteralPath $archivePath -Force
|
||||
}
|
||||
if (Test-Path -LiteralPath $checksumPath) {
|
||||
Remove-Item -LiteralPath $checksumPath -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $nodeExe) -or -not (Test-Path -LiteralPath $npmCmd)) {
|
||||
throw "项目专用 Node $NodeVersion 安装不完整。"
|
||||
}
|
||||
|
||||
$originalPath = $env:Path
|
||||
$originalNodeOptions = $env:NODE_OPTIONS
|
||||
$trackedCssBackup = if (Test-Path -LiteralPath $trackedCssPath) {
|
||||
[IO.File]::ReadAllBytes($trackedCssPath)
|
||||
}
|
||||
else {
|
||||
$null
|
||||
}
|
||||
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
$env:Path = "$nodeRoot;$originalPath"
|
||||
$env:NODE_OPTIONS = "--max-old-space-size=$MaxOldSpaceSizeMB"
|
||||
|
||||
Invoke-Native { & $nodeExe --version } '项目专用 Node 无法运行。'
|
||||
Invoke-Native {
|
||||
& $npmCmd ci --prefer-offline --no-audit --no-fund
|
||||
} '前端依赖安装失败。'
|
||||
Invoke-Native { & $npmCmd run production } '前端资源构建失败。'
|
||||
|
||||
$requiredOutputs = @(
|
||||
'public/js/auth/common.min.js',
|
||||
'public/css/app.css',
|
||||
'public/mix-manifest.json'
|
||||
)
|
||||
foreach ($output in $requiredOutputs) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $repoRoot $output))) {
|
||||
throw "前端构建缺少产物:$output"
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $frontendOutputRoot) {
|
||||
Remove-Item -LiteralPath $frontendOutputRoot -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $frontendOutputRoot 'css') | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $repoRoot 'public/js') -Destination $frontendOutputRoot -Recurse
|
||||
Copy-Item -LiteralPath (Join-Path $repoRoot 'public/css/app.css') -Destination (Join-Path $frontendOutputRoot 'css/app.css')
|
||||
Copy-Item -LiteralPath (Join-Path $repoRoot 'public/mix-manifest.json') -Destination $frontendOutputRoot
|
||||
Set-Content -LiteralPath $fingerprintPath -Value $frontendFingerprint -NoNewline
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
$env:Path = $originalPath
|
||||
$env:NODE_OPTIONS = $originalNodeOptions
|
||||
if ($null -ne $trackedCssBackup) {
|
||||
[IO.File]::WriteAllBytes($trackedCssPath, $trackedCssBackup)
|
||||
}
|
||||
}
|
||||
404
scripts/deploy-from-workstation.ps1
Normal file
404
scripts/deploy-from-workstation.ps1
Normal file
@ -0,0 +1,404 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Server,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$KeyPath,
|
||||
|
||||
[string]$SshUser = 'root',
|
||||
[string]$RemotePath = '/opt/caiwuxitong',
|
||||
[string]$AppUrl,
|
||||
[string]$BuilderName = 'caiwu-builder',
|
||||
[string]$BuildkitImage = 'm.daocloud.io/docker.io/moby/buildkit:buildx-stable-1',
|
||||
[string]$DatabaseImage = 'mariadb:11.4',
|
||||
[string]$DatabaseMirrorImage = 'm.daocloud.io/docker.io/library/mariadb:11.4',
|
||||
[int]$KeepImages = 2,
|
||||
[switch]$BootstrapServer,
|
||||
[switch]$ConfigureHttps,
|
||||
[switch]$AllowNonMain,
|
||||
[switch]$AllowMigrations
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$keyFullPath = (Resolve-Path $KeyPath).Path
|
||||
$sshTarget = "${SshUser}@${Server}"
|
||||
$imageName = 'caiwu_akaunting'
|
||||
$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("caiwu-deploy-" + [Guid]::NewGuid().ToString('N'))
|
||||
$localTar = Join-Path $tempRoot 'image.tar'
|
||||
$localArchive = Join-Path $tempRoot 'image.tar.gz'
|
||||
$remoteArchive = $null
|
||||
$bootstrapRemote = $null
|
||||
$sshTunnel = $null
|
||||
$buildkitConfig = Join-Path $repoRoot 'deploy/buildkitd.toml'
|
||||
$regctlVersion = 'v0.11.5'
|
||||
$regctlSha256 = '9b83975d464e4ee5a9f899a3ead26072ed67e9a8ed0f582897bfad8141b45938'
|
||||
|
||||
function Invoke-Native {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[scriptblock]$Command,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FailureMessage
|
||||
)
|
||||
|
||||
& $Command
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw $FailureMessage
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-OldProjectImages {
|
||||
param([string]$CurrentImage)
|
||||
|
||||
$projectImages = @(
|
||||
docker image ls $imageName --format '{{.Repository}}:{{.Tag}}' |
|
||||
Where-Object { $_ -and $_ -ne '<none>:<none>' } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
$keep = @($CurrentImage)
|
||||
foreach ($candidate in $projectImages) {
|
||||
if ($keep.Count -ge $KeepImages) { break }
|
||||
if ($candidate -notin $keep) { $keep += $candidate }
|
||||
}
|
||||
|
||||
foreach ($candidate in $projectImages) {
|
||||
if ($candidate -notin $keep) {
|
||||
docker image rm $candidate 2>$null | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Compress-Gzip {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Source,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Destination
|
||||
)
|
||||
|
||||
$inputStream = $null
|
||||
$outputStream = $null
|
||||
$gzipStream = $null
|
||||
try {
|
||||
$inputStream = [IO.File]::OpenRead($Source)
|
||||
$outputStream = [IO.File]::Create($Destination)
|
||||
$gzipStream = [IO.Compression.GZipStream]::new(
|
||||
$outputStream,
|
||||
[IO.Compression.CompressionLevel]::Fastest
|
||||
)
|
||||
$inputStream.CopyTo($gzipStream)
|
||||
}
|
||||
finally {
|
||||
if ($gzipStream) { $gzipStream.Dispose() }
|
||||
if ($outputStream) { $outputStream.Dispose() }
|
||||
if ($inputStream) { $inputStream.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RegctlPath {
|
||||
$toolRoot = Join-Path $env:LOCALAPPDATA 'CaiWuXiTong\tools'
|
||||
$regctlPath = Join-Path $toolRoot "regctl-$regctlVersion.exe"
|
||||
|
||||
if (Test-Path -LiteralPath $regctlPath) {
|
||||
$actualHash = (Get-FileHash -LiteralPath $regctlPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualHash -eq $regctlSha256) {
|
||||
return $regctlPath
|
||||
}
|
||||
Remove-Item -LiteralPath $regctlPath -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $toolRoot -Force | Out-Null
|
||||
$downloadPath = "$regctlPath.download"
|
||||
try {
|
||||
Invoke-WebRequest `
|
||||
-Uri "https://github.com/regclient/regclient/releases/download/$regctlVersion/regctl-windows-amd64.exe" `
|
||||
-OutFile $downloadPath
|
||||
$actualHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne $regctlSha256) {
|
||||
throw "regctl 校验失败:预期 $regctlSha256,实际 $actualHash。"
|
||||
}
|
||||
Move-Item -LiteralPath $downloadPath -Destination $regctlPath -Force
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $downloadPath) {
|
||||
Remove-Item -LiteralPath $downloadPath -Force
|
||||
}
|
||||
}
|
||||
|
||||
return $regctlPath
|
||||
}
|
||||
|
||||
function Get-AvailableLocalPort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
try {
|
||||
$listener.Start()
|
||||
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
|
||||
}
|
||||
finally {
|
||||
$listener.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
function Start-RegistryTunnel {
|
||||
param([int]$LocalPort)
|
||||
|
||||
$sshPath = (Get-Command ssh -ErrorAction Stop).Source
|
||||
$startInfo = [Diagnostics.ProcessStartInfo]::new()
|
||||
$startInfo.FileName = $sshPath
|
||||
$startInfo.UseShellExecute = $false
|
||||
$startInfo.CreateNoWindow = $true
|
||||
foreach ($argument in @(
|
||||
'-N',
|
||||
'-i', $keyFullPath,
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'ExitOnForwardFailure=yes',
|
||||
'-o', 'ServerAliveInterval=15',
|
||||
'-L', "127.0.0.1:${LocalPort}:127.0.0.1:5000",
|
||||
$sshTarget
|
||||
)) {
|
||||
$startInfo.ArgumentList.Add($argument)
|
||||
}
|
||||
|
||||
$process = [Diagnostics.Process]::Start($startInfo)
|
||||
$attempt = 1
|
||||
while ($attempt -le 20) {
|
||||
if ($process.HasExited) {
|
||||
throw 'SSH 增量传输隧道启动失败。'
|
||||
}
|
||||
try {
|
||||
Invoke-WebRequest -Uri "http://127.0.0.1:$LocalPort/v2/" -TimeoutSec 1 | Out-Null
|
||||
return $process
|
||||
}
|
||||
catch {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$attempt++
|
||||
}
|
||||
}
|
||||
|
||||
$process.Kill()
|
||||
$process.Dispose()
|
||||
throw 'SSH 隧道已建立,但无法访问服务器本机 Registry。'
|
||||
}
|
||||
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
$branch = (git branch --show-current).Trim()
|
||||
if (-not $AllowNonMain -and $branch -ne 'main') {
|
||||
throw "当前分支是 $branch。正式发布只允许 main;测试分支需显式添加 -AllowNonMain。"
|
||||
}
|
||||
|
||||
$trackedChanges = git status --porcelain --untracked-files=no
|
||||
if ($trackedChanges) {
|
||||
throw '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。'
|
||||
}
|
||||
|
||||
$deploySha = (git rev-parse HEAD).Trim()
|
||||
$imageTag = $deploySha.Substring(0, 12)
|
||||
$fullImage = "${imageName}:$imageTag"
|
||||
$remoteArchive = "$RemotePath/image-$imageTag.tar.gz"
|
||||
$buildDate = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||||
|
||||
if (-not $AppUrl) {
|
||||
$AppUrl = "https://${Server}"
|
||||
}
|
||||
|
||||
Invoke-Native { docker version | Out-Null } 'Docker Desktop 未启动或不可用。'
|
||||
|
||||
& (Join-Path $PSScriptRoot 'build-frontend.ps1')
|
||||
|
||||
docker buildx inspect $BuilderName 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Invoke-Native {
|
||||
docker buildx create `
|
||||
--name $BuilderName `
|
||||
--driver docker-container `
|
||||
--driver-opt "image=$BuildkitImage" `
|
||||
--buildkitd-config $buildkitConfig `
|
||||
--use | Out-Null
|
||||
} '创建项目专属 BuildKit 构建器失败。'
|
||||
}
|
||||
|
||||
Invoke-Native { docker buildx inspect $BuilderName --bootstrap | Out-Null } '启动 BuildKit 构建器失败。'
|
||||
|
||||
Invoke-Native {
|
||||
docker buildx build `
|
||||
--builder $BuilderName `
|
||||
--load `
|
||||
--tag $fullImage `
|
||||
--build-context 'frontend=.caiwu-build/public' `
|
||||
--build-arg "VCS_REF=$deploySha" `
|
||||
--build-arg "BUILD_DATE=$buildDate" `
|
||||
--label 'com.caiwu.project=CaiWuXiTong' `
|
||||
--file deploy/Dockerfile `
|
||||
.
|
||||
} '本机镜像构建失败。服务器未发生任何变更。'
|
||||
|
||||
$previousShaOutput = @(& ssh -i $keyFullPath -o BatchMode=yes -o StrictHostKeyChecking=accept-new $sshTarget "cat '$RemotePath/.deployed-sha' 2>/dev/null || true")
|
||||
$previousSha = ($previousShaOutput -join "`n").Trim()
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw '无法连接服务器。'
|
||||
}
|
||||
|
||||
$previousShaExists = $false
|
||||
if ($previousSha) {
|
||||
git cat-file -e "${previousSha}^{commit}" 2>$null
|
||||
$previousShaExists = $LASTEXITCODE -eq 0
|
||||
}
|
||||
|
||||
if ($previousSha -and -not $previousShaExists -and -not $AllowMigrations) {
|
||||
throw "本地缺少服务器当前提交 $previousSha,无法安全比较数据库迁移。请先 git fetch,或在负责人授权后使用 -AllowMigrations。"
|
||||
}
|
||||
|
||||
if ($previousSha -and $previousShaExists) {
|
||||
$migrationChanges = git diff --name-only "$previousSha..$deploySha" -- database/migrations
|
||||
if ($migrationChanges -and -not $AllowMigrations) {
|
||||
throw "检测到数据库迁移:$($migrationChanges -join ', ')。需负责人确认后添加 -AllowMigrations。"
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $tempRoot | Out-Null
|
||||
|
||||
if ($BootstrapServer) {
|
||||
$bootstrapRemote = "/tmp/caiwu-bootstrap-$imageTag.sh"
|
||||
Invoke-Native {
|
||||
scp -i $keyFullPath -o BatchMode=yes deploy/bootstrap-server.sh "${sshTarget}:$bootstrapRemote"
|
||||
} '上传服务器初始化脚本失败。'
|
||||
Invoke-Native {
|
||||
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$bootstrapRemote' && '$bootstrapRemote' '$AppUrl' '$RemotePath' && rm -f '$bootstrapRemote'"
|
||||
} '服务器初始化失败。'
|
||||
}
|
||||
|
||||
Invoke-Native {
|
||||
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'"
|
||||
} '服务器 Docker 未就绪;首次发布请添加 -BootstrapServer。'
|
||||
|
||||
$migrationFlag = if ($AllowMigrations) { '1' } else { '0' }
|
||||
Invoke-Native {
|
||||
scp -i $keyFullPath -o BatchMode=yes `
|
||||
compose.jiangling.yml `
|
||||
deploy/activate-release.sh `
|
||||
deploy/activate-incremental-release.sh `
|
||||
deploy/update-runtime-url.sh `
|
||||
deploy/setup-https.sh `
|
||||
deploy/host-nginx-http.conf `
|
||||
deploy/host-nginx.conf `
|
||||
deploy/setup-incremental-registry.sh `
|
||||
deploy/registry-config.yml `
|
||||
"${sshTarget}:$RemotePath/"
|
||||
} '上传运行配置失败。'
|
||||
|
||||
Invoke-Native {
|
||||
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/update-runtime-url.sh' && '$RemotePath/update-runtime-url.sh' '$AppUrl' '$RemotePath'"
|
||||
} '更新服务器访问地址失败。'
|
||||
|
||||
if ($BootstrapServer) {
|
||||
docker image inspect $DatabaseImage 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
docker image inspect $DatabaseMirrorImage 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Invoke-Native { docker pull $DatabaseMirrorImage } '下载数据库运行镜像失败。'
|
||||
}
|
||||
Invoke-Native { docker tag $DatabaseMirrorImage $DatabaseImage } '标记数据库运行镜像失败。'
|
||||
}
|
||||
|
||||
Invoke-Native {
|
||||
docker save --output $localTar $fullImage $DatabaseImage
|
||||
} '导出应用与数据库运行镜像失败。'
|
||||
Compress-Gzip -Source $localTar -Destination $localArchive
|
||||
Remove-Item -LiteralPath $localTar -Force
|
||||
$archiveSizeMB = [math]::Round((Get-Item -LiteralPath $localArchive).Length / 1MB, 1)
|
||||
Write-Host "首次初始化全量包:$archiveSizeMB MiB"
|
||||
Invoke-Native { scp -i $keyFullPath -o BatchMode=yes $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。'
|
||||
Invoke-Native {
|
||||
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/'*.sh && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'"
|
||||
} '服务器激活、健康检查或增量仓库初始化失败。'
|
||||
}
|
||||
else {
|
||||
Invoke-Native {
|
||||
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/'*.sh && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml')"
|
||||
} '服务器增量镜像仓库初始化失败。'
|
||||
|
||||
# docker save 导出未压缩层,每次导入重新压缩会改变摘要,导致 Registry 无法复用。
|
||||
# 直接导出 BuildKit 原生 OCI 层,让未变化层的压缩摘要跨版本保持稳定。
|
||||
Invoke-Native {
|
||||
docker buildx build `
|
||||
--builder $BuilderName `
|
||||
--output "type=oci,dest=$localTar" `
|
||||
--tag $fullImage `
|
||||
--build-context 'frontend=.caiwu-build/public' `
|
||||
--build-arg "VCS_REF=$deploySha" `
|
||||
--build-arg "BUILD_DATE=$buildDate" `
|
||||
--label 'com.caiwu.project=CaiWuXiTong' `
|
||||
--file deploy/Dockerfile `
|
||||
.
|
||||
} '导出稳定分层的 OCI 应用镜像失败。'
|
||||
$regctlPath = Get-RegctlPath
|
||||
$localRegistryPort = Get-AvailableLocalPort
|
||||
$sshTunnel = Start-RegistryTunnel -LocalPort $localRegistryPort
|
||||
$registryImage = "127.0.0.1:${localRegistryPort}/caiwuxitong/app:$imageTag"
|
||||
$serverRegistryImage = "127.0.0.1:5000/caiwuxitong/app:$imageTag"
|
||||
|
||||
$beforeBytesOutput = @(& ssh -i $keyFullPath -o BatchMode=yes $sshTarget "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1")
|
||||
$beforeBytes = [long](($beforeBytesOutput -join '').Trim())
|
||||
$uploadTimer = [Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-Native {
|
||||
& $regctlPath `
|
||||
--host "reg=127.0.0.1:${localRegistryPort},tls=disabled" `
|
||||
image import $registryImage $localTar
|
||||
} '增量镜像层上传失败。'
|
||||
$uploadTimer.Stop()
|
||||
$afterBytesOutput = @(& ssh -i $keyFullPath -o BatchMode=yes $sshTarget "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1")
|
||||
$afterBytes = [long](($afterBytesOutput -join '').Trim())
|
||||
$incrementalMiB = [math]::Round([math]::Max(0, $afterBytes - $beforeBytes) / 1MB, 2)
|
||||
Write-Host "增量新增镜像层:$incrementalMiB MiB;传输耗时:$([math]::Round($uploadTimer.Elapsed.TotalSeconds, 1)) 秒"
|
||||
|
||||
Invoke-Native {
|
||||
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "'$RemotePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'"
|
||||
} '服务器增量激活或健康检查失败。'
|
||||
}
|
||||
|
||||
if ($ConfigureHttps) {
|
||||
Invoke-Native {
|
||||
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/setup-https.sh' && '$RemotePath/setup-https.sh' '$Server' '$RemotePath'"
|
||||
} '服务器 HTTPS 配置或证书申请失败。'
|
||||
}
|
||||
|
||||
Remove-OldProjectImages -CurrentImage $fullImage
|
||||
Write-Host "发布完成:$fullImage -> $AppUrl"
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
|
||||
if (Test-Path -LiteralPath $tempRoot) {
|
||||
Remove-Item -LiteralPath $tempRoot -Recurse -Force
|
||||
}
|
||||
|
||||
if ($sshTunnel) {
|
||||
if (-not $sshTunnel.HasExited) {
|
||||
$sshTunnel.Kill()
|
||||
}
|
||||
$sshTunnel.Dispose()
|
||||
}
|
||||
|
||||
if ($remoteArchive) {
|
||||
ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$remoteArchive'" 2>$null | Out-Null
|
||||
}
|
||||
|
||||
if ($bootstrapRemote) {
|
||||
ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$bootstrapRemote'" 2>$null | Out-Null
|
||||
}
|
||||
|
||||
$pruneHelp = docker buildx prune --help 2>$null | Out-String
|
||||
docker buildx prune --builder $BuilderName --filter 'until=168h' --force 2>$null | Out-Null
|
||||
if ($pruneHelp -match '--max-used-space') {
|
||||
docker buildx prune --builder $BuilderName --max-used-space 8GB --force 2>$null | Out-Null
|
||||
}
|
||||
elseif ($pruneHelp -match '--keep-storage') {
|
||||
docker buildx prune --builder $BuilderName --keep-storage 8GB --force 2>$null | Out-Null
|
||||
}
|
||||
|
||||
docker system df
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user