diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh new file mode 100644 index 00000000..938fec2d --- /dev/null +++ b/deploy/activate-incremental-release.sh @@ -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" diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh new file mode 100644 index 00000000..e2a62501 --- /dev/null +++ b/deploy/activate-release.sh @@ -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" diff --git a/deploy/bootstrap-server.sh b/deploy/bootstrap-server.sh new file mode 100644 index 00000000..14b42581 --- /dev/null +++ b/deploy/bootstrap-server.sh @@ -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" diff --git a/deploy/buildkitd.toml b/deploy/buildkitd.toml new file mode 100644 index 00000000..fbf98a02 --- /dev/null +++ b/deploy/buildkitd.toml @@ -0,0 +1,2 @@ +[registry."docker.io"] + mirrors = ["m.daocloud.io/docker.io"] diff --git a/deploy/host-nginx-http.conf b/deploy/host-nginx-http.conf new file mode 100644 index 00000000..efec9ac4 --- /dev/null +++ b/deploy/host-nginx-http.conf @@ -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; + } +} diff --git a/deploy/registry-config.yml b/deploy/registry-config.yml new file mode 100644 index 00000000..a785efde --- /dev/null +++ b/deploy/registry-config.yml @@ -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 diff --git a/deploy/setup-https.sh b/deploy/setup-https.sh new file mode 100644 index 00000000..7db96edd --- /dev/null +++ b/deploy/setup-https.sh @@ -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 < /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" diff --git a/deploy/setup-incremental-registry.sh b/deploy/setup-incremental-registry.sh new file mode 100644 index 00000000..09887b70 --- /dev/null +++ b/deploy/setup-incremental-registry.sh @@ -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" diff --git a/deploy/update-runtime-url.sh b/deploy/update-runtime-url.sh new file mode 100644 index 00000000..4a0ad82b --- /dev/null +++ b/deploy/update-runtime-url.sh @@ -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" diff --git a/docs/开发与部署SOP.md b/docs/开发与部署SOP.md new file mode 100644 index 00000000..12db4484 --- /dev/null +++ b/docs/开发与部署SOP.md @@ -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 缓存当前占用。 diff --git a/modules/EcommerceFinance/Http/Controllers/Api/GoodsReconciliations.php b/modules/EcommerceFinance/Http/Controllers/Api/GoodsReconciliations.php new file mode 100644 index 00000000..a8c5b570 --- /dev/null +++ b/modules/EcommerceFinance/Http/Controllers/Api/GoodsReconciliations.php @@ -0,0 +1,115 @@ +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); + } +} diff --git a/modules/EcommerceFinance/Http/Controllers/Api/SupplierPayables.php b/modules/EcommerceFinance/Http/Controllers/Api/SupplierPayables.php new file mode 100644 index 00000000..90fa5159 --- /dev/null +++ b/modules/EcommerceFinance/Http/Controllers/Api/SupplierPayables.php @@ -0,0 +1,196 @@ +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); + } +} diff --git a/modules/EcommerceFinance/Models/JianSun/AverageUnitPrice.php b/modules/EcommerceFinance/Models/JianSun/AverageUnitPrice.php new file mode 100644 index 00000000..34afacc2 --- /dev/null +++ b/modules/EcommerceFinance/Models/JianSun/AverageUnitPrice.php @@ -0,0 +1,27 @@ + 'decimal:4', + ]; +} diff --git a/modules/EcommerceFinance/Models/JianSun/JianSunModel.php b/modules/EcommerceFinance/Models/JianSun/JianSunModel.php new file mode 100644 index 00000000..19dfbf02 --- /dev/null +++ b/modules/EcommerceFinance/Models/JianSun/JianSunModel.php @@ -0,0 +1,27 @@ + 'datetime', + 'sign_time' => 'datetime', + 'pay_time' => 'datetime', + 'fetch_time' => 'datetime', + 'weight' => 'decimal:4', + 'f_weight' => 'decimal:4', + ]; +} diff --git a/modules/EcommerceFinance/Models/JianSun/JstSupplier.php b/modules/EcommerceFinance/Models/JianSun/JstSupplier.php new file mode 100644 index 00000000..bf90299f --- /dev/null +++ b/modules/EcommerceFinance/Models/JianSun/JstSupplier.php @@ -0,0 +1,31 @@ + 'integer', + 'scmid' => 'integer', + 'postfee' => 'decimal:2', + 'created' => 'datetime', + 'inwhdate' => 'datetime', + 'finishdate' => 'datetime', + ]; +} diff --git a/modules/EcommerceFinance/Models/JianSun/PurchaseInItem.php b/modules/EcommerceFinance/Models/JianSun/PurchaseInItem.php new file mode 100644 index 00000000..8ca00d30 --- /dev/null +++ b/modules/EcommerceFinance/Models/JianSun/PurchaseInItem.php @@ -0,0 +1,39 @@ + 'integer', + 'incount' => 'integer', + 'skucount' => 'integer', + 'payment' => 'decimal:2', + 'created' => 'datetime', + ]; +} diff --git a/modules/EcommerceFinance/Models/JianSun/SkuInfoManage.php b/modules/EcommerceFinance/Models/JianSun/SkuInfoManage.php new file mode 100644 index 00000000..2888db8c --- /dev/null +++ b/modules/EcommerceFinance/Models/JianSun/SkuInfoManage.php @@ -0,0 +1,29 @@ + 'integer', + 'settlementStock' => 'integer', + 'singlePrice' => 'decimal:2', + 'settlementPrice' => 'decimal:2', + 'created' => 'datetime', + 'createTime' => 'datetime', + ]; +} diff --git a/modules/EcommerceFinance/Models/JianSun/SupplierPaymentDetail.php b/modules/EcommerceFinance/Models/JianSun/SupplierPaymentDetail.php new file mode 100644 index 00000000..b7d4d1cd --- /dev/null +++ b/modules/EcommerceFinance/Models/JianSun/SupplierPaymentDetail.php @@ -0,0 +1,36 @@ + 'decimal:2', + 'created' => 'datetime', + 'date' => 'date', + ]; +} diff --git a/modules/EcommerceFinance/Models/JianSun/SupplierPaymentSummary.php b/modules/EcommerceFinance/Models/JianSun/SupplierPaymentSummary.php new file mode 100644 index 00000000..40d9bb39 --- /dev/null +++ b/modules/EcommerceFinance/Models/JianSun/SupplierPaymentSummary.php @@ -0,0 +1,85 @@ + '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', + ]; +} diff --git a/modules/EcommerceFinance/Models/JianSun/UnOrderDetails.php b/modules/EcommerceFinance/Models/JianSun/UnOrderDetails.php new file mode 100644 index 00000000..5fb2e330 --- /dev/null +++ b/modules/EcommerceFinance/Models/JianSun/UnOrderDetails.php @@ -0,0 +1,43 @@ + 'datetime', + 'distributionDate' => 'datetime', + 'shipDate' => 'datetime', + ]; +} diff --git a/modules/EcommerceFinance/Repositories/GoodsReconciliationRepository.php b/modules/EcommerceFinance/Repositories/GoodsReconciliationRepository.php new file mode 100644 index 00000000..ae0d1879 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/GoodsReconciliationRepository.php @@ -0,0 +1,370 @@ +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; + } +} diff --git a/modules/EcommerceFinance/Repositories/SupplierPayableRepository.php b/modules/EcommerceFinance/Repositories/SupplierPayableRepository.php new file mode 100644 index 00000000..85d9c1e6 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/SupplierPayableRepository.php @@ -0,0 +1,540 @@ + 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 !== '')); + } +} diff --git a/modules/EcommerceFinance/Services/JianSunConnection.php b/modules/EcommerceFinance/Services/JianSunConnection.php new file mode 100644 index 00000000..f3289843 --- /dev/null +++ b/modules/EcommerceFinance/Services/JianSunConnection.php @@ -0,0 +1,70 @@ + 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; + } +} diff --git a/public/public b/public/public new file mode 120000 index 00000000..945c9b46 --- /dev/null +++ b/public/public @@ -0,0 +1 @@ +. \ No newline at end of file diff --git a/resources/assets/js/views/ecommerce-finance/advance-details.js b/resources/assets/js/views/ecommerce-finance/advance-details.js new file mode 100644 index 00000000..0d70936a --- /dev/null +++ b/resources/assets/js/views/ecommerce-finance/advance-details.js @@ -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; diff --git a/resources/assets/js/views/ecommerce-finance/compensation-details.js b/resources/assets/js/views/ecommerce-finance/compensation-details.js new file mode 100644 index 00000000..61e3bd2e --- /dev/null +++ b/resources/assets/js/views/ecommerce-finance/compensation-details.js @@ -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; diff --git a/resources/assets/js/views/ecommerce-finance/goods-reconciliations.js b/resources/assets/js/views/ecommerce-finance/goods-reconciliations.js new file mode 100644 index 00000000..41770d95 --- /dev/null +++ b/resources/assets/js/views/ecommerce-finance/goods-reconciliations.js @@ -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; diff --git a/resources/assets/js/views/ecommerce-finance/invoice-deductions.js b/resources/assets/js/views/ecommerce-finance/invoice-deductions.js new file mode 100644 index 00000000..5866abd0 --- /dev/null +++ b/resources/assets/js/views/ecommerce-finance/invoice-deductions.js @@ -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; diff --git a/resources/assets/js/views/ecommerce-finance/return-reconciliations.js b/resources/assets/js/views/ecommerce-finance/return-reconciliations.js new file mode 100644 index 00000000..29ae55c5 --- /dev/null +++ b/resources/assets/js/views/ecommerce-finance/return-reconciliations.js @@ -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; diff --git a/resources/assets/js/views/ecommerce-finance/sample-deductions.js b/resources/assets/js/views/ecommerce-finance/sample-deductions.js new file mode 100644 index 00000000..2436ea89 --- /dev/null +++ b/resources/assets/js/views/ecommerce-finance/sample-deductions.js @@ -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; diff --git a/resources/views/business/pages/supplier-payables/advance-details.blade.php b/resources/views/business/pages/supplier-payables/advance-details.blade.php new file mode 100644 index 00000000..917dd7f4 --- /dev/null +++ b/resources/views/business/pages/supplier-payables/advance-details.blade.php @@ -0,0 +1,111 @@ +
+
+
+ + 借支明细数据源 finance_advanceLoans 当前仅 4 行数据;表头 21 列中部分字段(如实际打款金额、分期扣款、票据等)在数据库中无对应列,已显示为占位符。 +
+ + {{-- 统计卡片 --}} +
+
+
借支总金额
+
@{{ formatMoney(summary.totalAmount) }}
+
+
+
总记录数
+
@{{ (summary.totalCount || 0).toLocaleString() }}
+
+
+ + {{-- 筛选栏 --}} +
+
+ + + + 查询 + 重置 +
+
+ + {{-- 数据表格 --}} +
+
+ 加载中... +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
共 @{{ total }} 条记录
+ + +
+
+
+
+ +@push('body_end') + +@endpush diff --git a/resources/views/business/pages/supplier-payables/compensation-details.blade.php b/resources/views/business/pages/supplier-payables/compensation-details.blade.php new file mode 100644 index 00000000..869e836e --- /dev/null +++ b/resources/views/business/pages/supplier-payables/compensation-details.blade.php @@ -0,0 +1,73 @@ +
+
+ {{-- 统计卡片 --}} +
+
+
赔付总金额
+
@{{ formatMoney(summary.totalAmount) }}
+
+
+
总记录数
+
@{{ (summary.totalCount || 0).toLocaleString() }}
+
+
+ + {{-- 筛选栏 --}} +
+
+ + + + 查询 + 重置 +
+
+ + {{-- 数据表格 --}} +
+
+ 加载中... +
+ + + + + + + + + + + + + + + + + +
+
共 @{{ total }} 条记录
+ + +
+
+
+
+ +@push('body_end') + +@endpush diff --git a/resources/views/business/pages/supplier-payables/invoice-deductions.blade.php b/resources/views/business/pages/supplier-payables/invoice-deductions.blade.php new file mode 100644 index 00000000..3934e82e --- /dev/null +++ b/resources/views/business/pages/supplier-payables/invoice-deductions.blade.php @@ -0,0 +1,79 @@ +
+
+ {{-- 统计卡片 --}} +
+
+
扣款总金额
+
@{{ formatMoney(summary.totalPrice) }}
+
+
+
总记录数
+
@{{ (summary.totalCount || 0).toLocaleString() }}
+
+
+ + {{-- 筛选栏 --}} +
+
+ + + + 查询 + 重置 +
+
+ + {{-- 数据表格 --}} +
+
+ 加载中... +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
共 @{{ total }} 条记录
+ + +
+
+
+
+ +@push('body_end') + +@endpush diff --git a/resources/views/business/pages/supplier-payables/payment-reconciliation.blade.php b/resources/views/business/pages/supplier-payables/payment-reconciliation.blade.php new file mode 100644 index 00000000..f73f0176 --- /dev/null +++ b/resources/views/business/pages/supplier-payables/payment-reconciliation.blade.php @@ -0,0 +1,139 @@ +
+
+ {{-- 统计卡片 --}} +
+
+
对账总金额
+
@{{ formatMoney(summary.totalAmount) }}
+
+
+
预估总金额
+
@{{ formatMoney(summary.totalGuessAmount) }}
+
+
+
已发出
+
@{{ summary.sentCount.toLocaleString() }}
+
+
+
未发出
+
@{{ summary.unsentCount.toLocaleString() }}
+
+
+ + {{-- 筛选栏 --}} +
+
+ + + + + + + + + + + + + +
+
+ + + + + + + + + + + +
+
+ + {{-- Tab 切换 --}} +
+ +
+ + {{-- 数据表格 --}} +
+
+ 加载中... +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{-- 分页 --}} +
+
共 @{{ total }} 条记录
+ + +
+
+
+
+ +@push('body_end') + +@endpush diff --git a/resources/views/business/pages/supplier-payables/return-reconciliation.blade.php b/resources/views/business/pages/supplier-payables/return-reconciliation.blade.php new file mode 100644 index 00000000..ef9cbc11 --- /dev/null +++ b/resources/views/business/pages/supplier-payables/return-reconciliation.blade.php @@ -0,0 +1,107 @@ +
+
+ {{-- 统计卡片 --}} +
+
+
对账总金额
+
@{{ formatMoney(summary.totalAmount) }}
+
+
+
预估总金额
+
@{{ formatMoney(summary.totalGuessAmount) }}
+
+
+
总记录数
+
@{{ (summary.totalCount || 0).toLocaleString() }}
+
+
+ + {{-- 筛选栏 --}} +
+
+ + + + + + + + + 查询 + 重置 +
+
+ + {{-- 数据表格 --}} +
+
+ 加载中... +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
共 @{{ total }} 条记录
+ + +
+
+
+
+ +@push('body_end') + +@endpush diff --git a/resources/views/business/pages/supplier-payables/sample-deductions.blade.php b/resources/views/business/pages/supplier-payables/sample-deductions.blade.php new file mode 100644 index 00000000..8594621e --- /dev/null +++ b/resources/views/business/pages/supplier-payables/sample-deductions.blade.php @@ -0,0 +1,93 @@ +
+
+ {{-- 统计卡片 --}} +
+
+
合计扣款金额
+
@{{ formatMoney(summary.totalAmount) }}
+
+
+
预估总金额
+
@{{ formatMoney(summary.totalEstimate) }}
+
+
+
总记录数
+
@{{ (summary.totalCount || 0).toLocaleString() }}
+
+
+ + {{-- 筛选栏 --}} +
+
+ + + + + + + 查询 + 重置 +
+
+ + {{-- 数据表格 --}} +
+
+ 加载中... +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
共 @{{ total }} 条记录
+ + +
+
+
+
+ +@push('body_end') + +@endpush diff --git a/scripts/build-frontend.ps1 b/scripts/build-frontend.ps1 new file mode 100644 index 00000000..6f7edd6e --- /dev/null +++ b/scripts/build-frontend.ps1 @@ -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) + } +} diff --git a/scripts/deploy-from-workstation.ps1 b/scripts/deploy-from-workstation.ps1 new file mode 100644 index 00000000..0aedd74f --- /dev/null +++ b/scripts/deploy-from-workstation.ps1 @@ -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 ':' } | + 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 +}