merge: 合并远程仓库历史,保留本地开发改动

This commit is contained in:
15061959991 2026-07-21 18:44:28 +08:00
commit 752f26bf30
42 changed files with 4354 additions and 0 deletions

View File

@ -0,0 +1,122 @@
#!/bin/sh
set -eu
REGISTRY_IMAGE="${1:?REGISTRY_IMAGE is required}"
IMAGE_TAG="${2:?IMAGE_TAG is required}"
REMOTE_DIR="${3:-/opt/caiwuxitong}"
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
RUN_MIGRATIONS="${5:-0}"
KEEP_IMAGES="${6:-2}"
IMAGE_NAME="caiwu_akaunting"
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
REGISTRY_API="http://127.0.0.1:5000/v2/caiwuxitong/app"
TAG_HISTORY="$REMOTE_DIR/.registry-tags"
if [ "$(id -u)" -ne 0 ]; then
echo "activate-incremental-release.sh must run as root" >&2
exit 1
fi
if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
echo "Missing $REMOTE_DIR/runtime/.env; run bootstrap-server.sh first" >&2
exit 1
fi
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "Another CaiWuXiTong deployment is already running" >&2
exit 1
fi
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
docker pull "$REGISTRY_IMAGE"
docker tag "$REGISTRY_IMAGE" "$IMAGE_NAME:$IMAGE_TAG"
LOADED_SHA="$(docker image inspect "$IMAGE_NAME:$IMAGE_TAG" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')"
if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
echo "Image revision mismatch: expected $DEPLOY_SHA, got $LOADED_SHA" >&2
exit 1
fi
cd "$REMOTE_DIR"
export IMAGE_TAG RUN_MIGRATIONS
docker compose -f compose.jiangling.yml up -d --remove-orphans
healthy=0
attempt=1
while [ "$attempt" -le 30 ]; do
if curl -fsS -o /dev/null http://127.0.0.1:18088/; then
healthy=1
break
fi
sleep 5
attempt=$((attempt + 1))
done
if [ "$healthy" -ne 1 ]; then
docker compose -f compose.jiangling.yml logs --tail 120 app >&2 || true
case "$PREVIOUS_IMAGE" in
"$IMAGE_NAME":*)
IMAGE_TAG="${PREVIOUS_IMAGE#*:}"
RUN_MIGRATIONS=0
export IMAGE_TAG RUN_MIGRATIONS
docker compose -f compose.jiangling.yml up -d app || true
;;
esac
echo "Release failed health check; previous image was restored when available" >&2
exit 1
fi
printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.deployed-sha.tmp"
mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
docker image ls "$IMAGE_NAME" --format '{{.Repository}}:{{.Tag}}' \
| awk '!seen[$0]++' \
| tail -n "+$((KEEP_IMAGES + 1))" \
| while IFS= read -r old_image; do
[ -n "$old_image" ] && docker image rm "$old_image" >/dev/null 2>&1 || true
done
docker image prune -f --filter 'label=com.caiwu.project=CaiWuXiTong' >/dev/null || true
# Track tags by successful deployment order, retain only current + previous,
# delete older manifests, and reclaim their unreferenced blobs.
touch "$TAG_HISTORY"
awk -v tag="$IMAGE_TAG" '$0 != tag && NF { print }' "$TAG_HISTORY" > "$TAG_HISTORY.tmp"
printf '%s\n' "$IMAGE_TAG" >> "$TAG_HISTORY.tmp"
mv "$TAG_HISTORY.tmp" "$TAG_HISTORY"
deleted_registry_tag=0
while [ "$(wc -l < "$TAG_HISTORY")" -gt "$KEEP_IMAGES" ]; do
OLD_TAG="$(sed -n '1p' "$TAG_HISTORY")"
DIGEST="$(curl -fsSI \
-H 'Accept: application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \
"$REGISTRY_API/manifests/$OLD_TAG" \
| awk -F ': ' 'tolower($1) == "docker-content-digest" { gsub("\r", "", $2); print $2 }')"
if [ -n "$DIGEST" ]; then
curl -fsS -X DELETE "$REGISTRY_API/manifests/$DIGEST" >/dev/null
deleted_registry_tag=1
fi
docker image rm "127.0.0.1:5000/caiwuxitong/app:$OLD_TAG" >/dev/null 2>&1 || true
sed '1d' "$TAG_HISTORY" > "$TAG_HISTORY.tmp"
mv "$TAG_HISTORY.tmp" "$TAG_HISTORY"
done
if [ "$deleted_registry_tag" -eq 1 ]; then
systemctl stop docker-registry
if ! docker-registry garbage-collect --delete-untagged /etc/docker/registry/config.yml; then
echo "Registry garbage collection failed; the service will still be restarted" >&2
fi
systemctl start docker-registry
fi
docker compose -f compose.jiangling.yml ps
echo "DEPLOYED_SHA=$DEPLOY_SHA"
: "$PREVIOUS_SHA"

View File

@ -0,0 +1,94 @@
#!/bin/sh
set -eu
IMAGE_ARCHIVE="${1:?IMAGE_ARCHIVE is required}"
IMAGE_TAG="${2:?IMAGE_TAG is required}"
REMOTE_DIR="${3:-/opt/caiwuxitong}"
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
RUN_MIGRATIONS="${5:-0}"
KEEP_IMAGES="${6:-2}"
IMAGE_NAME="caiwu_akaunting"
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
cleanup() {
rm -f "$IMAGE_ARCHIVE"
}
trap cleanup EXIT INT TERM
if [ "$(id -u)" -ne 0 ]; then
echo "activate-release.sh must run as root" >&2
exit 1
fi
if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
echo "Missing $REMOTE_DIR/runtime/.env; run bootstrap-server.sh first" >&2
exit 1
fi
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "Another CaiWuXiTong deployment is already running" >&2
exit 1
fi
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
docker load --input "$IMAGE_ARCHIVE"
rm -f "$IMAGE_ARCHIVE"
LOADED_SHA="$(docker image inspect "$IMAGE_NAME:$IMAGE_TAG" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')"
if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
echo "Image revision mismatch: expected $DEPLOY_SHA, got $LOADED_SHA" >&2
exit 1
fi
cd "$REMOTE_DIR"
export IMAGE_TAG RUN_MIGRATIONS
docker compose -f compose.jiangling.yml up -d --remove-orphans
healthy=0
attempt=1
while [ "$attempt" -le 30 ]; do
if curl -fsS -o /dev/null http://127.0.0.1:18088/; then
healthy=1
break
fi
sleep 5
attempt=$((attempt + 1))
done
if [ "$healthy" -ne 1 ]; then
docker compose -f compose.jiangling.yml logs --tail 120 app >&2 || true
case "$PREVIOUS_IMAGE" in
"$IMAGE_NAME":*)
IMAGE_TAG="${PREVIOUS_IMAGE#*:}"
RUN_MIGRATIONS=0
export IMAGE_TAG RUN_MIGRATIONS
docker compose -f compose.jiangling.yml up -d app || true
;;
esac
echo "Release failed health check; previous image was restored when available" >&2
exit 1
fi
printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.deployed-sha.tmp"
mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
docker image ls "$IMAGE_NAME" --format '{{.Repository}}:{{.Tag}}' \
| awk '!seen[$0]++' \
| tail -n "+$((KEEP_IMAGES + 1))" \
| while IFS= read -r old_image; do
[ -n "$old_image" ] && docker image rm "$old_image" >/dev/null 2>&1 || true
done
docker image prune -f --filter 'label=com.caiwu.project=CaiWuXiTong' >/dev/null || true
docker compose -f compose.jiangling.yml ps
echo "DEPLOYED_SHA=$DEPLOY_SHA"
# Keep the variable for rollback diagnostics without printing any secret.
: "$PREVIOUS_SHA"

View File

@ -0,0 +1,72 @@
#!/bin/sh
set -eu
APP_URL="${1:?APP_URL is required}"
REMOTE_DIR="${2:-/opt/caiwuxitong}"
if [ "$(id -u)" -ne 0 ]; then
echo "bootstrap-server.sh must run as root" >&2
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends ca-certificates openssl docker.io docker-compose-v2
systemctl enable --now docker
if ! swapon --show=NAME --noheadings | grep -q .; then
if [ ! -f /swapfile ]; then
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile >/dev/null
fi
swapon /swapfile
if ! grep -q '^/swapfile ' /etc/fstab; then
printf '%s\n' '/swapfile none swap sw 0 0' >> /etc/fstab
fi
fi
install -d -m 700 "$REMOTE_DIR/runtime"
if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
DB_PASSWORD="$(openssl rand -hex 24)"
DB_ROOT_PASSWORD="$(openssl rand -hex 24)"
APP_KEY="$(openssl rand -base64 32 | tr -d '\n')"
umask 077
{
printf '%s\n' \
'APP_NAME=CaiWuXiTong' \
'APP_ENV=production' \
"APP_KEY=base64:$APP_KEY" \
'APP_DEBUG=false' \
'APP_INSTALLED=false' \
"APP_URL=$APP_URL" \
'APP_LOCALE=zh-CN' \
'APP_FALLBACK_LOCALE=en-GB' \
'APP_TIMEZONE=Asia/Shanghai' \
'LOG_CHANNEL=stack' \
'DB_CONNECTION=mysql' \
'DB_HOST=db' \
'DB_PORT=3306' \
'DB_DATABASE=caiwu' \
'DB_USERNAME=caiwu' \
"DB_PASSWORD=$DB_PASSWORD" \
'DB_PREFIX=ak_' \
'CACHE_DRIVER=file' \
'SESSION_DRIVER=file' \
'QUEUE_CONNECTION=sync' \
'MARIADB_DATABASE=caiwu' \
'MARIADB_USER=caiwu' \
"MARIADB_PASSWORD=$DB_PASSWORD" \
"MARIADB_ROOT_PASSWORD=$DB_ROOT_PASSWORD" \
'MARIADB_AUTO_UPGRADE=1'
} > "$REMOTE_DIR/runtime/.env"
chmod 600 "$REMOTE_DIR/runtime/.env"
fi
docker --version
docker compose version
echo "Server runtime is ready: $REMOTE_DIR"

2
deploy/buildkitd.toml Normal file
View File

@ -0,0 +1,2 @@
[registry."docker.io"]
mirrors = ["m.daocloud.io/docker.io"]

View File

@ -0,0 +1,24 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name 118.196.84.79;
client_max_body_size 12m;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
default_type text/plain;
}
location / {
proxy_pass http://127.0.0.1:18088;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto http;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 120s;
}
}

View File

@ -0,0 +1,24 @@
version: 0.1
log:
fields:
service: caiwuxitong-registry
storage:
cache:
blobdescriptor: inmemory
filesystem:
rootdirectory: /var/lib/docker-registry
delete:
enabled: true
http:
addr: 127.0.0.1:5000
headers:
X-Content-Type-Options: [nosniff]
health:
storagedriver:
enabled: true
interval: 10s
threshold: 3

96
deploy/setup-https.sh Normal file
View File

@ -0,0 +1,96 @@
#!/bin/sh
set -eu
SERVER_IP="${1:?SERVER_IP is required}"
REMOTE_DIR="${2:-/opt/caiwuxitong}"
CERTBOT_DIR="/opt/caiwuxitong-certbot"
CERT_NAME="caiwuxitong-ip"
NGINX_SITE="/etc/nginx/sites-available/caiwuxitong"
WEBROOT="/var/www/letsencrypt"
if [ "$(id -u)" -ne 0 ]; then
echo "setup-https.sh must run as root" >&2
exit 1
fi
if [ "$SERVER_IP" != "118.196.84.79" ]; then
echo "Refusing to configure an unexpected server IP: $SERVER_IP" >&2
exit 1
fi
test -f "$REMOTE_DIR/host-nginx-http.conf"
test -f "$REMOTE_DIR/host-nginx.conf"
curl -fsS -o /dev/null http://127.0.0.1:18088/
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends nginx python3-venv ca-certificates
install -d -m 755 "$WEBROOT/.well-known/acme-challenge"
install -m 644 "$REMOTE_DIR/host-nginx-http.conf" "$NGINX_SITE"
ln -sfn "$NGINX_SITE" /etc/nginx/sites-enabled/caiwuxitong
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl enable --now nginx
if [ ! -x "$CERTBOT_DIR/bin/certbot" ]; then
python3 -m venv "$CERTBOT_DIR"
"$CERTBOT_DIR/bin/pip" install --disable-pip-version-check --no-cache-dir 'certbot==5.4.0'
fi
"$CERTBOT_DIR/bin/certbot" --version
if [ ! -s "/etc/letsencrypt/live/$CERT_NAME/fullchain.pem" ]; then
"$CERTBOT_DIR/bin/certbot" certonly \
--non-interactive \
--agree-tos \
--register-unsafely-without-email \
--preferred-profile shortlived \
--webroot \
--webroot-path "$WEBROOT" \
--ip-address "$SERVER_IP" \
--cert-name "$CERT_NAME"
fi
install -m 644 "$REMOTE_DIR/host-nginx.conf" "$NGINX_SITE"
nginx -t
systemctl reload nginx
cat > /usr/local/sbin/caiwuxitong-certbot-deploy-hook <<'EOF'
#!/bin/sh
set -eu
nginx -t
systemctl reload nginx
EOF
chmod 700 /usr/local/sbin/caiwuxitong-certbot-deploy-hook
cat > /etc/systemd/system/caiwuxitong-certbot-renew.service <<EOF
[Unit]
Description=Renew CaiWuXiTong short-lived IP certificate
After=network-online.target nginx.service
[Service]
Type=oneshot
ExecStart=$CERTBOT_DIR/bin/certbot renew --quiet --deploy-hook /usr/local/sbin/caiwuxitong-certbot-deploy-hook
EOF
cat > /etc/systemd/system/caiwuxitong-certbot-renew.timer <<'EOF'
[Unit]
Description=Check CaiWuXiTong IP certificate renewal every 8 hours
[Timer]
OnCalendar=*-*-* 00,08,16:15:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now caiwuxitong-certbot-renew.timer
curl -fsS -o /dev/null "https://$SERVER_IP/"
openssl x509 -in "/etc/letsencrypt/live/$CERT_NAME/fullchain.pem" -noout -subject -issuer -dates
systemctl list-timers caiwuxitong-certbot-renew.timer --no-pager
echo "HTTPS_READY=https://$SERVER_IP"

View File

@ -0,0 +1,56 @@
#!/bin/sh
set -eu
REMOTE_DIR="${1:-/opt/caiwuxitong}"
CONFIG_SOURCE="${2:-$REMOTE_DIR/registry-config.yml}"
REGISTRY_CONFIG="/etc/docker/registry/config.yml"
REGISTRY_REPOSITORY="127.0.0.1:5000/caiwuxitong/app"
if [ "$(id -u)" -ne 0 ]; then
echo "setup-incremental-registry.sh must run as root" >&2
exit 1
fi
if [ ! -f "$CONFIG_SOURCE" ]; then
echo "Missing registry config: $CONFIG_SOURCE" >&2
exit 1
fi
if ! command -v docker-registry >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends docker-registry
fi
install -d -m 755 "$(dirname "$REGISTRY_CONFIG")"
install -m 644 "$CONFIG_SOURCE" "$REGISTRY_CONFIG"
systemctl enable docker-registry >/dev/null
systemctl restart docker-registry
attempt=1
while [ "$attempt" -le 20 ]; do
if curl -fsS -o /dev/null http://127.0.0.1:5000/v2/; then
break
fi
sleep 1
attempt=$((attempt + 1))
done
curl -fsS -o /dev/null http://127.0.0.1:5000/v2/
# Seed the registry from the image already on this server. This makes the first
# incremental release reuse existing layers instead of uploading them again.
CURRENT_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
case "$CURRENT_IMAGE" in
caiwu_akaunting:*)
CURRENT_TAG="${CURRENT_IMAGE#*:}"
docker tag "$CURRENT_IMAGE" "$REGISTRY_REPOSITORY:$CURRENT_TAG"
docker push "$REGISTRY_REPOSITORY:$CURRENT_TAG"
touch "$REMOTE_DIR/.registry-tags"
if ! grep -Fxq "$CURRENT_TAG" "$REMOTE_DIR/.registry-tags"; then
printf '%s\n' "$CURRENT_TAG" >> "$REMOTE_DIR/.registry-tags"
fi
;;
esac
echo "REGISTRY_READY=127.0.0.1:5000"

View File

@ -0,0 +1,37 @@
#!/bin/sh
set -eu
APP_URL="${1:?APP_URL is required}"
REMOTE_DIR="${2:-/opt/caiwuxitong}"
ENV_FILE="$REMOTE_DIR/runtime/.env"
TEMP_FILE="$ENV_FILE.tmp"
if [ "$(id -u)" -ne 0 ]; then
echo "update-runtime-url.sh must run as root" >&2
exit 1
fi
if [ ! -f "$ENV_FILE" ]; then
echo "Missing $ENV_FILE; run bootstrap-server.sh first" >&2
exit 1
fi
umask 077
awk -v app_url="$APP_URL" '
BEGIN { updated = 0 }
/^APP_URL=/ {
print "APP_URL=" app_url
updated = 1
next
}
{ print }
END {
if (!updated) {
print "APP_URL=" app_url
}
}
' "$ENV_FILE" > "$TEMP_FILE"
chown --reference="$ENV_FILE" "$TEMP_FILE"
chmod --reference="$ENV_FILE" "$TEMP_FILE"
mv "$TEMP_FILE" "$ENV_FILE"

157
docs/开发与部署SOP.md Normal file
View File

@ -0,0 +1,157 @@
# CaiWuXiTong 开发与部署 SOP
## 1. 这套流程解决什么问题
本项目由 6 位新人使用 Codex 协作开发。项目负责人只确认方向、验收结果和高风险动作,不负责日常命令操作。
固定链路如下:
```text
任务分支开发 -> 本机验证 -> PR -> 合并 main
-> 开发机复用缓存构建镜像
-> 经 SSH 隧道只上传变化的镜像层
-> 2GB 服务器只拉取本机 Registry 并切换镜像
-> 本机后端检查 + 公网 HTTPS 验收
-> 保留当前与上一个镜像,清理其余本项目产物
```
服务器永远不构建源码。
## 2. 每台开发机只做一次的设置
1. 安装并启动 Docker Desktop,使用 WSL2 后端。
2. 在 Docker Desktop 的 `Settings -> Resources -> Advanced` 中:
- Docker 虚拟磁盘上限建议 60GB;
- Windows 磁盘至少保留 15GB 可用空间。
3. SSH 私钥放到个人目录,例如 `C:\Users\你的用户名\.ssh\caiwu.pem`,不要放进 Git。
4. 只使用仓库里的发布脚本。不要自行执行 `docker system prune -a`,也不要重置 Docker Desktop。
说明:Docker 虚拟磁盘清理后,Windows 上看到的 `.vhdx` 文件可能不会立刻变小,但内部可用空间已经释放。不要为了让文件数字变小而删除虚拟磁盘。
## 3. 为什么后续不会反复全量下载和上传
项目使用专属构建器 `caiwu-builder`,缓存与其他项目分开:
- 前端在 Windows 主机使用项目专用 Node 20 构建,避开 Docker 虚拟机内存上限;
- 前端输入指纹未变化时直接复用上次产物,纯后端改动不重复打包前端;
- PHP 扩展层只在 Dockerfile 的基础环境变化时重建;
- Composer 只在 `composer.lock` 变化时重新解析依赖,并复用下载缓存;
- npm 优先使用本机下载缓存,`node_modules` 原位刷新,不会逐版本堆积;
- 业务代码变化只重新执行前端资源构建和后面的代码复制;
- 日常命令不带 `--pull` 和 `--no-cache`。
- 首次初始化才上传应用和 MariaDB 的全量包;日常发布通过 SSH 隧道连接服务器本机 Registry。
- Registry 按内容哈希判断镜像层是否已经存在,基础环境、PHP 依赖和未变化的前端层不会重复上传。
- 日常传输使用 BuildKit 原生 OCI 分层包,保持未变化层的压缩摘要稳定;`docker save` 全量包只用于首次初始化。
- Registry 只监听服务器 `127.0.0.1:5000`,公网无法直接访问;发布脚本退出时会关闭 SSH 隧道。
- 每次发布后只保留当前和上一个 Registry 版本,并回收旧版本不再引用的镜像层。
首次构建和首次上传必然较慢;后续构建应大量显示 `CACHED`,上传量由本次实际变化的镜像层决定。
## 4. 首次初始化服务器
首次发布由 Codex 或当班开发者在仓库根目录执行:
```powershell
.\scripts\deploy-from-workstation.ps1 `
-Server 118.196.84.79 `
-KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" `
-BootstrapServer
```
初始化脚本会:
- 使用服务器现有 Ubuntu 安全更新源安装并启动 Docker 与 Compose;
- 为 2GB 服务器增加 2GB swap(交换空间),仅用于运行时缓冲;
- 创建 `/opt/caiwuxitong/runtime/.env`;
- 在服务器本地生成数据库密码和 Laravel `APP_KEY`,不会打印出来;
- 创建持久化数据库卷和上传文件卷。
- 上传应用与 MariaDB 首次全量包,并初始化仅限本机访问的增量 Registry。
云平台安全组必须放行 `TCP 80` 和 `TCP 443`。应用容器只绑定服务器本机 `127.0.0.1:18088`,公网由轻量 Nginx 提供 HTTPS;Registry 的 `5000` 端口仍禁止对公网开放。
首次部署后执行一次 HTTPS 配置:
```powershell
.\scripts\deploy-from-workstation.ps1 `
-Server 118.196.84.79 `
-KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" `
-ConfigureHttps
```
该步骤在服务器安装轻量 Nginx 和独立 Certbot 环境,为公网 IP 申请 Let’s Encrypt 短期证书,并启用每 8 小时检查一次的自动续期。服务器仍不构建任何应用源码或镜像。
首次打开 `https://118.196.84.79` 后,由负责人或指定人员在网页安装向导中设置管理员账号和密码。
## 5. 日常正式发布
前提:PR 已审核并合并,当前为干净的 `main`。
```powershell
git switch main
git pull --ff-only
.\scripts\deploy-from-workstation.ps1 `
-Server 118.196.84.79 `
-KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem"
```
脚本自动完成:
1. 检查分支和未提交改动;
2. 使用项目专用 Node 20 和本机 npm 缓存构建前端资源;
3. 使用提交 SHA 构建镜像并复用项目专属缓存;
4. 将应用镜像导出到临时文件,通过 SSH 隧道只上传 Registry 缺少的镜像层;
5. 服务器从本机 Registry 拉取新版本并启动;
6. 检查服务器本机后端页面;
7. 失败时恢复上一个镜像;
8. 使用服务器互斥锁阻止两个人同时切换版本;
9. 删除本地临时镜像包并关闭 SSH 隧道;
10. 开发机、服务器 Docker 和服务器 Registry 均仅保留当前和上一个本项目版本;
11. 清理 7 天前的本项目构建缓存,并将总量压到最多 8GB。
## 6. 数据库迁移
默认禁止自动迁移。脚本发现上次部署后新增了 `database/migrations` 文件时会停止。
只有负责人明确批准、PR 已写清影响与回滚思路后,才允许:
```powershell
.\scripts\deploy-from-workstation.ps1 `
-Server 118.196.84.79 `
-KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" `
-AllowMigrations
```
镜像回滚不能自动撤销已经执行的数据库结构变更,因此迁移授权必须单独处理。
## 7. 测试分支发布
正式服务器默认只允许 `main`。确需临时测试时,必须得到明确授权,并添加:
```powershell
-AllowNonMain
```
测试完成后仍要回到 `main` 再正式发布,不能把测试分支当长期生产版本。
## 8. 禁止事项
- 禁止登录服务器运行 `docker build` 或 `docker compose build`;
- 禁止在服务器运行 Composer、npm 或前端打包;
- 禁止把 `.pem`、`.env`、密码、数据库文件或 `image.tar` 提交到 Git;
- 禁止把服务器 Registry 的 `5000` 端口开放到公网;
- 禁止为了省空间删除 Docker 卷;
- 禁止全局清空 Docker,因为同一台电脑可能还有其他项目;
- 禁止跳过健康检查后口头说“部署完成”。
## 9. 发布完成时应报告
- 发布分支和完整 Git SHA;
- 镜像标签;
- 服务器 `caiwu_app`、`caiwu_db` 状态;
- `https://118.196.84.79` 的实际返回结果和证书有效期;
- 是否执行数据库迁移;
- 本地临时包是否删除;
- 本地/服务器保留了几个本项目镜像;
- 本次增量新增镜像层大小和传输耗时;
- BuildKit 缓存当前占用。

View File

@ -0,0 +1,115 @@
<?php
namespace Modules\EcommerceFinance\Http\Controllers\Api;
use App\Abstracts\Http\ApiController;
use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository;
/**
* 货款对账 API
*
* 提供供应商应付-货款对账页面的全部数据接口:
* GET /api/ecommerce-finance/goods-reconciliations 列表(分页 + 筛选)
* GET /api/ecommerce-finance/goods-reconciliations/summary 统计卡片
* GET /api/ecommerce-finance/goods-reconciliations/options 筛选器选项
*/
class GoodsReconciliations extends ApiController
{
private GoodsReconciliationRepository $repo;
public function __construct()
{
$this->repo = new GoodsReconciliationRepository();
}
/**
* 货款对账列表(分页 + 筛选)
*/
public function index()
{
$filters = $this->buildFilters();
$page = max((int) request('page', 1), 1);
$limit = $this->limit();
$result = $this->repo->list($filters, $page, $limit);
return response()->json([
'data' => $result['data'],
'total' => $result['total'],
'page' => $page,
'per_page' => $limit,
'last_page' => (int) ceil($result['total'] / $limit),
]);
}
/**
* 汇总统计卡片
*/
public function summary()
{
$filters = $this->buildFilters();
$summary = $this->repo->summary($filters);
return response()->json($summary);
}
/**
* 筛选器选项(供应商/仓库下拉列表等)
*/
public function options()
{
$options = $this->repo->filterOptions();
return response()->json($options);
}
// ===================================================================
// Helpers
// ===================================================================
/**
* 从请求参数构建筛选条件
*/
private function buildFilters(): array
{
$filters = [];
// 记录类型
if ($type = request('record_type')) {
$filters['record_type'] = $type; // purchase | sale | all
}
// 文本筛选
$textFields = ['supplier', 'stock_no', 'purchase_no', 'product_code', 'express_no', 'warehouse'];
foreach ($textFields as $field) {
if ($val = request($field)) {
$filters[$field] = trim($val);
}
}
// 日期范围
$dateFields = ['send_time_from', 'send_time_to', 'create_time_from', 'create_time_to'];
foreach ($dateFields as $field) {
if ($val = request($field)) {
$filters[$field] = $val;
}
}
// 发出状态
if (($sent = request('sent_status')) && $sent !== 'all') {
$filters['sent_status'] = $sent; // sent | unsent
}
// 是否有差异
if (request('has_diff')) {
$filters['has_diff'] = true;
}
return $filters;
}
private function limit(): int
{
return min(max((int) request('limit', 25), 1), 100);
}
}

View File

@ -0,0 +1,196 @@
<?php
namespace Modules\EcommerceFinance\Http\Controllers\Api;
use App\Abstracts\Http\ApiController;
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
/**
* 供应商应付 - 子功能页面 API
*
* 为 5 个子页面提供数据接口:
* 退货对账 : /return-reconciliations + /return-reconciliations/summary + /return-reconciliations/options
* 发票扣款 : /invoice-deductions + /invoice-deductions/summary + /invoice-deductions/options
* 赔偿明细 : /compensation-details + /compensation-details/summary + /compensation-details/options
* 借支明细 : /advance-details + /advance-details/summary + /advance-details/options
* 样品扣款 : /sample-deductions + /sample-deductions/summary + /sample-deductions/options
*/
class SupplierPayables extends ApiController
{
private SupplierPayableRepository $repo;
public function __construct()
{
$this->repo = new SupplierPayableRepository();
}
// ============================================================
// 退货对账
// ============================================================
public function returnReconciliationsIndex()
{
$filters = $this->filters('return');
$result = $this->repo->returnReconciliations($filters, $this->page(), $this->limit());
return response()->json([
'data' => $result['data'],
'total' => $result['total'],
'page' => $this->page(),
'per_page' => $this->limit(),
'last_page' => (int) ceil(max($result['total'], 0) / $this->limit()),
]);
}
public function returnReconciliationsSummary()
{
return response()->json($this->repo->returnReconciliationSummary($this->filters('return')));
}
public function returnReconciliationsOptions()
{
return response()->json($this->repo->filterOptionsForReturn());
}
// ============================================================
// 发票扣款
// ============================================================
public function invoiceDeductionsIndex()
{
$filters = $this->filters('invoice');
$result = $this->repo->invoiceDeductions($filters, $this->page(), $this->limit());
return response()->json([
'data' => $result['data'],
'total' => $result['total'],
'page' => $this->page(),
'per_page' => $this->limit(),
'last_page' => (int) ceil(max($result['total'], 0) / $this->limit()),
]);
}
public function invoiceDeductionsSummary()
{
return response()->json($this->repo->invoiceDeductionSummary($this->filters('invoice')));
}
public function invoiceDeductionsOptions()
{
return response()->json($this->repo->filterOptionsForInvoice());
}
// ============================================================
// 赔偿明细
// ============================================================
public function compensationDetailsIndex()
{
$filters = $this->filters('compensation');
$result = $this->repo->compensationDetails($filters, $this->page(), $this->limit());
return response()->json([
'data' => $result['data'],
'total' => $result['total'],
'page' => $this->page(),
'per_page' => $this->limit(),
'last_page' => (int) ceil(max($result['total'], 0) / $this->limit()),
]);
}
public function compensationDetailsSummary()
{
return response()->json($this->repo->compensationDetailSummary($this->filters('compensation')));
}
public function compensationDetailsOptions()
{
return response()->json($this->repo->filterOptionsForCompensation());
}
// ============================================================
// 借支明细
// ============================================================
public function advanceDetailsIndex()
{
$filters = $this->filters('advance');
$result = $this->repo->advanceDetails($filters, $this->page(), $this->limit());
return response()->json([
'data' => $result['data'],
'total' => $result['total'],
'page' => $this->page(),
'per_page' => $this->limit(),
'last_page' => (int) ceil(max($result['total'], 0) / $this->limit()),
]);
}
public function advanceDetailsSummary()
{
return response()->json($this->repo->advanceDetailSummary($this->filters('advance')));
}
public function advanceDetailsOptions()
{
return response()->json($this->repo->filterOptionsForAdvance());
}
// ============================================================
// 样品扣款
// ============================================================
public function sampleDeductionsIndex()
{
$filters = $this->filters('sample');
$result = $this->repo->sampleDeductions($filters, $this->page(), $this->limit());
return response()->json([
'data' => $result['data'],
'total' => $result['total'],
'page' => $this->page(),
'per_page' => $this->limit(),
'last_page' => (int) ceil(max($result['total'], 0) / $this->limit()),
]);
}
public function sampleDeductionsSummary()
{
return response()->json($this->repo->sampleDeductionSummary($this->filters('sample')));
}
public function sampleDeductionsOptions()
{
return response()->json($this->repo->filterOptionsForSample());
}
// ============================================================
// Helpers
// ============================================================
private function filters(string $type): array
{
$filters = [];
// 通用文本字段
$text = ['supplier', 'warehouse', 'sku_id', 'express_no', 'creator', 'department'];
foreach ($text as $field) {
if ($val = request($field)) {
$filters[$field] = trim($val);
}
}
// 日期
foreach (['date_from', 'date_to', 'month_from', 'month_to'] as $field) {
if ($val = request($field)) {
$filters[$field] = $val;
}
}
return $filters;
}
private function page(): int
{
return max((int) request('page', 1), 1);
}
private function limit(): int
{
return min(max((int) request('limit', 25), 1), 100);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Modules\EcommerceFinance\Models\JianSun;
/**
* 加权平均单价表
* pm_averageUnitPrice
*
* 按货号+供应商存储的加权平均单价(262条)。
* 用于计算"预估金额"。
*
* @property int $id
* @property string $number 货号
* @property string $supplier 供应商名称
* @property float $price 加权平均单价(页面表头第11列来源)
* @property string $name 商品名称
*/
class AverageUnitPrice extends JianSunModel
{
protected $table = 'pm_averageUnitPrice';
protected $fillable = ['number', 'supplier', 'price', 'name'];
protected $casts = [
'price' => 'decimal:4',
];
}

View File

@ -0,0 +1,27 @@
<?php
namespace Modules\EcommerceFinance\Models\JianSun;
use Illuminate\Database\Eloquent\Model;
/**
* Base Model for JianSun SQL Server database tables.
* All models extending this will automatically use the 'jiansun' connection.
*/
abstract class JianSunModel extends Model
{
/** @var string */
protected $connection = 'jiansun';
/** @var bool JianSun tables don't use Laravel timestamps */
public $timestamps = false;
/** @var string No prefix on JianSun tables */
protected $prefix = '';
/** @var string */
protected $primaryKey = 'id';
/** @var bool */
public $incrementing = true;
}

View File

@ -0,0 +1,45 @@
<?php
namespace Modules\EcommerceFinance\Models\JianSun;
/**
* 订单快递详情表(代发记录)
* jst_order_express_details
*
* 存储所有代发订单的快递信息(2,812,915条)。
* 页面中"代发记录"的快递名称和快递单号来源于此表。
*
* @property int $id
* @property string $so_id 订单编号
* @property string $logistic_id 快递单号(页面表头第18列)
* @property string $logistic_company_name 快递名称(页面表头第17列)
* @property string $logistic_status_name 物流状态
* @property string $warehouse_name 仓库名称
* @property string $send_time 发货时间(页面表头第3列)
* @property string $sign_time 签收时间
* @property float $weight 重量
*/
class JstOrderExpressDetails extends JianSunModel
{
protected $table = 'jst_order_express_details';
protected $fillable = [
'createdAt', 'updatedAt', 'inner_order_id', 'so_id', 'logistic_id',
'logistic_company_name', 'logistic_status_name', 'logistic_return_flag',
'order_status_name', 'first_question_type', 'first_question_time',
'aftersale_status_name', 'order_labels', 'remark', 'buyer_message',
'shop_name', 'platform_name', 'dis_co_name', 'warehouse_name',
'receiver_address', 'pay_time', 'first_oa_confirm_time',
'last_oa_confirm_time', 'send_time', 'fetch_time', 'sign_time',
'logistic_time', 'weight', 'f_weight', 'ship_time_by_pdh',
];
protected $casts = [
'send_time' => 'datetime',
'sign_time' => 'datetime',
'pay_time' => 'datetime',
'fetch_time' => 'datetime',
'weight' => 'decimal:4',
'f_weight' => 'decimal:4',
];
}

View File

@ -0,0 +1,31 @@
<?php
namespace Modules\EcommerceFinance\Models\JianSun;
/**
* 聚水潭供应商表
* jst_supplier
*
* 聚水潭ERP系统的供应商数据(2,075条)。
*
* @property int $Id
* @property int $supplier_id 供应商ID
* @property string $supplier_code 供应商编码
* @property string $name 供应商名称
* @property string $modified 修改时间
* @property string $remark 备注
* @property string $group 分组
* @property int $enabled 是否启用
*/
class JstSupplier extends JianSunModel
{
protected $table = 'jst_supplier';
protected $primaryKey = 'Id';
protected $fillable = [
'supplier_id', 'supplier_code', 'name', 'modified', 'remark',
'remark2', 'remark3', 'group', 'enabled', 'depositbank',
'bankacount', 'acountnumber',
];
}

View File

@ -0,0 +1,45 @@
<?php
namespace Modules\EcommerceFinance\Models\JianSun;
/**
* 采购入库单主表
* pm_purchaseIn
*
* 每次采购入库的汇总记录(52,219条)。
* 通过 id 关联 pm_purchaseInItem 获取明细。
*
* @property int $id
* @property string $hno 入库单号(页面表头第1列)
* @property string $whcode 仓库编码
* @property string $sourceno 来源单号
* @property string $title 标题
* @property string $scmname 供应商名称
* @property string $scmcode 供应商编码
* @property int $scmid 供应商ID
* @property int $incount 入库总数
* @property string $created 创建时间(页面表头第16列)
* @property string $memo 备注(页面表头第14列)
* @property string $expcode 快递编码
* @property string $expname 快递名称
*/
class PurchaseIn extends JianSunModel
{
protected $table = 'pm_purchaseIn';
protected $fillable = [
'whcode', 'hno', 'sourceno', 'title', 'scmname', 'scmcode', 'scmid',
'incount', 'created', 'memo', 'expcode', 'expname', 'postcode', 'postfee',
'creater', 'createrid', 'inwhdate', 'finishdate', 'procode', 'proname',
'skucode', 'skuname',
];
protected $casts = [
'incount' => 'integer',
'scmid' => 'integer',
'postfee' => 'decimal:2',
'created' => 'datetime',
'inwhdate' => 'datetime',
'finishdate' => 'datetime',
];
}

View File

@ -0,0 +1,39 @@
<?php
namespace Modules\EcommerceFinance\Models\JianSun;
/**
* 采购入库单明细表
* pm_purchaseInItem
*
* 每次采购入库的商品明细(223,382条)。
* 通过 hid 关联 pm_purchaseIn.id。
*
* @property int $id
* @property int $hid 关联 PurchaseIn.id
* @property string $skucode 商品编码(页面表头第6列)
* @property string $skuname 规格名称(页面表头第7列)
* @property string $skuoid 外部商品编码
* @property int $incount 入库数量(页面表头第8列)
* @property int $skucount SKU计数
* @property float $payment 单价
* @property string $created 创建时间
*/
class PurchaseInItem extends JianSunModel
{
protected $table = 'pm_purchaseInItem';
protected $fillable = [
'hid', 'skucode', 'skuname', 'skuoid', 'incount', 'skucount',
'payment', 'created', 'procode', 'proname', 'batchCode',
'inventoryType', 'productDate', 'expireDate', 'produceCode',
];
protected $casts = [
'hid' => 'integer',
'incount' => 'integer',
'skucount' => 'integer',
'payment' => 'decimal:2',
'created' => 'datetime',
];
}

View File

@ -0,0 +1,29 @@
<?php
namespace Modules\EcommerceFinance\Models\JianSun;
/**
* SKU信息管理表
* js_sku_info_manage
*
* 商品SKU管理信息(8,394条),包含货号、商品编码等。
*
* @property int $id
* @property string $sku_id 商品编码(页面表头第6列)
* @property string $sku 货号(页面表头第5列)
* @property string $spu SPU
* @property string $sku_manager SKU负责人
* @property string $operation_dept_manager 运营部门负责人
* @property string $designer 设计师
* @property string $designer_dept 设计部门
*/
class SkuInfoManage extends JianSunModel
{
protected $table = 'js_sku_info_manage';
protected $fillable = [
'sku_id', 'sku', 'spu', 'sku_manager',
'operation_dept_manager', 'sku_business_unit_manager',
'designer', 'designer_dept', 'create_time', 'update_time',
];
}

View File

@ -0,0 +1,40 @@
<?php
namespace Modules\EcommerceFinance\Models\JianSun;
/**
* 供应商基础信息表
* js_supplier
*
* 供应商主数据(1,479条),包含供应商编码、名称、公司等。
*
* @property int $Id
* @property string $scmcode 供应商编码
* @property string $scmname 供应商名称
* @property string $scmtype 供应商类型
* @property string $company 公司全称
* @property string $paycyclesgn 结算周期标记
* @property string $reconciliter 对账人
* @property string $buyer 采购员
* @property string $coopersgn 合作状态标记
* @property string $levelname 等级名称
* @property string $memo 备注
* @property string $enterpriseType 企业类型
* @property string $creditcode 统一社会信用代码
*/
class Supplier extends JianSunModel
{
protected $table = 'js_supplier';
protected $primaryKey = 'Id';
protected $fillable = [
'scmcode', 'scmname', 'scmtype', 'tradebody', 'company',
'paycyclesgn', 'accountbankno', 'corpmobile', 'tradebtime', 'tradeetime',
'coopersgn', 'cooperbtime', 'cooperetime', 'dsperid', 'dsper',
'reconciliter', 'levelname', 'buyerid', 'buyer', 'memo',
'guid', 'craftType', 'outline', 'capacity', 'freereason', 'area',
'stocker', 'region', 'sindou', 'follower', 'presc', 'strategy',
'enterpriseType', 'creditcode', 'handlePerson', 'legalPerson',
];
}

View File

@ -0,0 +1,49 @@
<?php
namespace Modules\EcommerceFinance\Models\JianSun;
/**
* 供应商采购结算明细表
* fys_supplierGoods_sttlement_purchase
*
* 采购入库结算明细,包含每个SKU的结算数量和价格。
* 是货款对账页面"入库记录"的核心数据源(228,039条)。
*
* @property int $Id
* @property string $created 创建时间
* @property int $stock 入库数量
* @property int $settlementStock 结算数量
* @property float $singlePrice 单价
* @property float $settlementPrice 结算价格
* @property string $priceType 价格类型
* @property string $ownername 货主
* @property string $scmname 供应商名称
* @property string $procode 货号
* @property string $whname 仓库名称
* @property string $skuoid 商品编码
* @property string $skuname 规格名称
* @property string $bizpo 业务采购单号
* @property string $spo 采购单号
* @property string $remark 备注
*/
class SupplierGoodsSttlementPurchase extends JianSunModel
{
protected $table = 'fys_supplierGoods_sttlement_purchase';
protected $primaryKey = 'Id';
protected $fillable = [
'created', 'stock', 'settlementStock', 'singlePrice', 'settlementPrice',
'priceType', 'ownername', 'scmname', 'procode', 'whname',
'skuoid', 'skuname', 'bizpo', 'spo', 'remark', 'createTime',
];
protected $casts = [
'stock' => 'integer',
'settlementStock' => 'integer',
'singlePrice' => 'decimal:2',
'settlementPrice' => 'decimal:2',
'created' => 'datetime',
'createTime' => 'datetime',
];
}

View File

@ -0,0 +1,36 @@
<?php
namespace Modules\EcommerceFinance\Models\JianSun;
/**
* 供应商付款明细表
* fys_supplierPaymentDetail
*
* @property int $Id
* @property string $created 创建时间
* @property string $month 月份
* @property string $date 日期
* @property string $scmname 供应商名称
* @property string $scmcode 供应商编码
* @property float $payment 付款金额
* @property string $reason 原因
* @property string $remark 备注
* @property int $billstatus 单据状态
*/
class SupplierPaymentDetail extends JianSunModel
{
protected $table = 'fys_supplierPaymentDetail';
protected $primaryKey = 'Id';
protected $fillable = [
'created', 'month', 'date', 'scmname', 'scmcode',
'payment', 'reason', 'remark', 'billstatus',
];
protected $casts = [
'payment' => 'decimal:2',
'created' => 'datetime',
'date' => 'date',
];
}

View File

@ -0,0 +1,85 @@
<?php
namespace Modules\EcommerceFinance\Models\JianSun;
/**
* 供应商付款汇总表
* fys_supplierPaymentSummary
*
* 核心对账数据源,每条记录代表一个供应商在某月份的应付汇总
*
* @property int $id
* @property string $company 公司
* @property string $supplier 供应商名称
* @property string $checker 对账人
* @property string $settleway 结算方式
* @property string $Month 对账月份
* @property float $ExpFieght 快递费
* @property float $bginMonthPay 月初应付
* @property float $unpriced 未定价
* @property float $advloan 预付
* @property float $loan 借款
* @property float $refund 退款
* @property float $procurement 采购
* @property float $invoiceDct 发票扣款
* @property float $boardDct 板材扣款
* @property float $specimenDct 样品扣款
* @property float $Totalindemnity 总赔偿
* @property float $other 其他
* @property float $supplySup 供应商补贴
* @property float $ShouldPay 应付金额
* @property float $factPay 实付金额
* @property float $guessPrice 预估金额
* @property float $toCorporate 到公账
* @property float $toPrivate 到私账
* @property string $remarks 备注
* @property string $reason 原因
* @property string $guid GUID
* @property int $isExamine 是否审核
* @property int $isPayment 是否付款
* @property string $scmcode 供应商编码
* @property int $supid 供应商ID
* @property string $createTime 创建时间
*/
class SupplierPaymentSummary extends JianSunModel
{
protected $table = 'fys_supplierPaymentSummary';
protected $fillable = [
'company', 'supplier', 'checker', 'settleway', 'Month',
'ExpFieght', 'bginMonthPay', 'unpriced', 'advloan', 'loan',
'refund', 'procurement', 'invoiceDct', 'boardDct', 'specimenDct',
'Totalindemnity', 'other', 'supplySup', 'ShouldPay', 'factPay',
'guessPrice', 'toCorporate', 'toPrivate', 'remarks', 'reason',
'mark', 'guid', 'isExamine', 'isPayment', 'Month',
'billstatus', 'opstatus', 'elstatu', 'taskid', 'supid',
'depositMoney', 'depositType', 'cashierbypaid', 'push', 'scmcode',
'billRemaining',
];
protected $casts = [
'ExpFieght' => 'decimal:2',
'bginMonthPay' => 'decimal:2',
'unpriced' => 'decimal:2',
'advloan' => 'decimal:2',
'loan' => 'decimal:2',
'refund' => 'decimal:2',
'procurement' => 'decimal:2',
'invoiceDct' => 'decimal:2',
'boardDct' => 'decimal:2',
'specimenDct' => 'decimal:2',
'Totalindemnity' => 'decimal:2',
'other' => 'decimal:2',
'supplySup' => 'decimal:2',
'ShouldPay' => 'decimal:2',
'factPay' => 'decimal:2',
'guessPrice' => 'decimal:2',
'toCorporate' => 'decimal:2',
'toPrivate' => 'decimal:2',
'depositMoney' => 'decimal:2',
'cashierbypaid' => 'decimal:2',
'billRemaining' => 'decimal:2',
'createTime' => 'datetime',
'modifiedTime' => 'datetime',
];
}

View File

@ -0,0 +1,43 @@
<?php
namespace Modules\EcommerceFinance\Models\JianSun;
/**
* 未发出订单明细表
* pm_unOrderDetails
*
* 记录已采购但尚未发出的商品明细。
*
* @property int $Id
* @property string $date 日期
* @property string $purchase 采购单号
* @property string $warehouse 仓库
* @property string $supplier 供应商(页面表头第4列)
* @property string $transactionNo 交易号
* @property string $distributionNo 配送号
* @property string $expressNo 快递单号(页面表头第18列)
* @property string $sku 货号
* @property string $barCode 条码
* @property string $skuName 规格名称
* @property string $distributionDate 配送日期
* @property string $shipDate 发货日期
* @property string $expressName 快递名称(页面表头第17列)
*/
class UnOrderDetails extends JianSunModel
{
protected $table = 'pm_unOrderDetails';
protected $primaryKey = 'Id';
protected $fillable = [
'date', 'purchase', 'warehouse', 'supplier', 'transactionNo',
'distributionNo', 'expressNo', 'sku', 'barCode', 'skuName',
'distributionDate', 'shipDate', 'expressName',
];
protected $casts = [
'date' => 'datetime',
'distributionDate' => 'datetime',
'shipDate' => 'datetime',
];
}

View File

@ -0,0 +1,370 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Modules\EcommerceFinance\Services\JianSunConnection;
use PDO;
/**
* 货款对账数据仓库
*
* 数据来源:
* - 入库记录:fys_supplierGoods_sttlement_purchase(~228K)+ pm_purchaseIn + pm_averageUnitPrice
* - 代发记录:fys_supplierGoods_sttlement_sale(~2.8M)+ pm_averageUnitPrice
*
* 性能策略:入库/代发分开查询,各自执行分页,避免全量 UNION ALL。
*/
class GoodsReconciliationRepository
{
private PDO $pdo;
public function __construct()
{
$this->pdo = JianSunConnection::get();
}
// ===================================================================
// 汇总统计(轻量:只用 COUNT + SUM,不做复杂 JOIN)
// ===================================================================
public function summary(array $filters = []): array
{
$purchase = $this->purchaseSummary($filters);
$sale = $this->saleSummary($filters);
return [
'totalAmount' => round($purchase['totalAmount'] + $sale['totalAmount'], 2),
'totalGuessAmount' => round($purchase['totalGuessAmount'] + $sale['totalGuessAmount'], 2),
'sentCount' => $purchase['sentCount'] + $sale['sentCount'],
'unsentCount' => $purchase['unsentCount'] + $sale['unsentCount'],
'totalCount' => $purchase['totalCount'] + $sale['totalCount'],
];
}
private function purchaseSummary(array $filters): array
{
$where = $this->buildPurchaseWhere($filters);
$params = $this->buildPurchaseParams($filters);
// 按需 JOIN,减少不必要的全表扫描
$joinA = $joinU = '';
if (strpos($where, 'a.') !== false) {
$joinA = ' LEFT JOIN pm_averageUnitPrice a ON s.procode = a.number AND s.scmname = a.supplier';
}
if (strpos($where, 'u.') !== false) {
$joinU = ' LEFT JOIN pm_unOrderDetails u ON s.spo = u.purchase AND s.skuoid = u.sku';
}
$sql = "SELECT
COUNT(*) as totalCount,
ISNULL(SUM(s.settlementPrice), 0) as totalAmount,
0 as totalGuessAmount,
COUNT(*) as sentCount,
0 as unsentCount
FROM fys_supplierGoods_sttlement_purchase s
{$joinA}{$joinU}
WHERE 1=1 {$where}";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
return $row ?: ['totalCount' => 0, 'totalAmount' => 0, 'totalGuessAmount' => 0, 'sentCount' => 0, 'unsentCount' => 0];
}
private function saleSummary(array $filters): array
{
$where = $this->buildSaleWhere($filters);
$params = $this->buildSaleParams($filters);
$joinA = '';
if (strpos($where, 'a.') !== false) {
$joinA = ' LEFT JOIN pm_averageUnitPrice a ON s.procode = a.number AND s.supplier = a.supplier';
}
$sql = "SELECT
COUNT(*) as totalCount,
ISNULL(SUM(s.settlementPrice), 0) as totalAmount,
0 as totalGuessAmount,
ISNULL(SUM(CASE WHEN s.expressNo IS NOT NULL AND s.expressNo != '' THEN 1 ELSE 0 END), 0) as sentCount,
ISNULL(SUM(CASE WHEN s.expressNo IS NULL OR s.expressNo = '' THEN 1 ELSE 0 END), 0) as unsentCount
FROM fys_supplierGoods_sttlement_sale s
{$joinA}
WHERE 1=1 {$where}";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
return $row ?: ['totalCount' => 0, 'totalAmount' => 0, 'totalGuessAmount' => 0, 'sentCount' => 0, 'unsentCount' => 0];
}
// ===================================================================
// 列表查询(核心 — 分别查入库/代发再合并)
// ===================================================================
public function list(array $filters = [], int $page = 1, int $perPage = 25): array
{
$type = $filters['record_type'] ?? 'all';
// 分别查询(同一分页参数,各自内部分页)
$purchaseResult = null;
$saleResult = null;
$totalPurchase = 0;
$totalSale = 0;
if ($type === 'all' || $type === 'purchase') {
$where = $this->buildPurchaseWhere($filters);
$params = $this->buildPurchaseParams($filters);
$totalPurchase = $this->countPurchase($where, $params);
$purchaseResult = $this->fetchPurchase($where, $params, $page, $perPage);
}
if ($type === 'all' || $type === 'sale') {
$where = $this->buildSaleWhere($filters);
$params = $this->buildSaleParams($filters);
$totalSale = $this->countSale($where, $params);
$saleResult = $this->fetchSale($where, $params, $page, $perPage);
}
// 合并并按 createTime 排序,取前 perPage 条
$merged = array_merge($purchaseResult ?? [], $saleResult ?? []);
// 按 createTime DESC 排序
usort($merged, function ($a, $b) {
return strcmp($b['createTime'] ?? '', $a['createTime'] ?? '');
});
// 截取当前页
$merged = array_slice($merged, 0, $perPage);
// 格式化金额
foreach ($merged as &$row) {
$row['amount'] = round((float) $row['amount'], 2);
$row['guessAmount'] = round((float) $row['guessAmount'], 2);
$row['unitPrice'] = round((float) $row['unitPrice'], 2);
$row['guessUnitPrice'] = round((float) ($row['guessUnitPrice'] ?? 0), 2);
}
return [
'data' => $merged,
'total' => $totalPurchase + $totalSale,
];
}
// ===================================================================
// 筛选器选项
// ===================================================================
public function filterOptions(): array
{
$sql = "SELECT DISTINCT scmname FROM fys_supplierGoods_sttlement_purchase WHERE scmname IS NOT NULL AND scmname != ''
UNION
SELECT DISTINCT supplier FROM fys_supplierGoods_sttlement_sale WHERE supplier IS NOT NULL AND supplier != ''
ORDER BY scmname";
$stmt = $this->pdo->query($sql);
$suppliers = $stmt->fetchAll(PDO::FETCH_COLUMN);
$sql = "SELECT DISTINCT whname FROM fys_supplierGoods_sttlement_purchase WHERE whname IS NOT NULL AND whname != ''
UNION
SELECT DISTINCT whName FROM fys_supplierGoods_sttlement_sale WHERE whName IS NOT NULL AND whName != ''
ORDER BY whname";
$stmt = $this->pdo->query($sql);
$warehouses = $stmt->fetchAll(PDO::FETCH_COLUMN);
return [
'suppliers' => $suppliers,
'warehouses' => $warehouses,
];
}
// ===================================================================
// 入库查询
// ===================================================================
private function countPurchase(string $where, array $params): int
{
// 仅当筛选条件引用关联表字段时才 JOIN,否则纯主表 COUNT
$joins = '';
if (strpos($where, 'p.') !== false) {
$joins .= ' LEFT JOIN pm_purchaseIn p ON s.spo = p.sourceno';
}
if (strpos($where, 'a.') !== false) {
$joins .= ' LEFT JOIN pm_averageUnitPrice a ON s.procode = a.number AND s.scmname = a.supplier';
}
if (strpos($where, 'u.') !== false) {
$joins .= ' LEFT JOIN pm_unOrderDetails u ON s.spo = u.purchase AND s.skuoid = u.sku';
}
$sql = "SELECT COUNT(*) as cnt FROM fys_supplierGoods_sttlement_purchase s{$joins} WHERE 1=1 {$where}";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int) $stmt->fetch()['cnt'];
}
private function fetchPurchase(string $where, array $params, int $page, int $perPage): array
{
$offset = ($page - 1) * $perPage;
$sql = "SELECT
'purchase' AS recordType,
ISNULL(p.hno, '') AS stockNo,
ISNULL(s.spo, '') AS purchaseNo,
FORMAT(ISNULL(s.created, s.createTime), 'yyyy-MM-dd HH:mm:ss') AS sendTime,
ISNULL(s.scmname, '') AS supplier,
ISNULL(s.skuoid, '') AS goodsNo,
ISNULL(s.procode, '') AS productCode,
ISNULL(s.skuname, '') AS specName,
ISNULL(s.settlementStock, 0) AS qty,
ISNULL(s.singlePrice, 0) AS unitPrice,
ISNULL(s.settlementPrice, 0) AS amount,
ISNULL(a.price, 0) AS guessUnitPrice,
ISNULL(s.settlementStock * ISNULL(a.price, 0), 0) AS guessAmount,
'' AS supplierGoodsCode,
ISNULL(s.remark, '') AS remark,
ISNULL(s.whname, '') AS warehouseName,
FORMAT(s.createTime, 'yyyy-MM-dd HH:mm:ss') AS createTime,
'' AS expressName,
'' AS expressNo,
CASE WHEN u.Id IS NOT NULL THEN 1 ELSE 0 END AS isUnsent,
s.id AS sourceId
FROM fys_supplierGoods_sttlement_purchase s
LEFT JOIN pm_purchaseIn p ON s.spo = p.sourceno
LEFT JOIN pm_averageUnitPrice a ON s.procode = a.number AND s.scmname = a.supplier
LEFT JOIN pm_unOrderDetails u ON s.spo = u.purchase AND s.skuoid = u.sku
WHERE 1=1 {$where}
ORDER BY s.createTime DESC
OFFSET {$offset} ROWS FETCH NEXT {$perPage} ROWS ONLY";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
// ===================================================================
// 代发查询
// ===================================================================
private function countSale(string $where, array $params): int
{
$sql = "SELECT COUNT(*) as cnt FROM fys_supplierGoods_sttlement_sale s
LEFT JOIN pm_averageUnitPrice a ON s.procode = a.number AND s.supplier = a.supplier
WHERE 1=1 {$where}";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int) $stmt->fetch()['cnt'];
}
private function fetchSale(string $where, array $params, int $page, int $perPage): array
{
$offset = ($page - 1) * $perPage;
$sql = "SELECT
'sale' AS recordType,
'' AS stockNo,
'' AS purchaseNo,
FORMAT(s.sendTime, 'yyyy-MM-dd HH:mm:ss') AS sendTime,
ISNULL(s.supplier, '') AS supplier,
ISNULL(s.skucode, '') AS goodsNo,
ISNULL(s.procode, '') AS productCode,
ISNULL(s.skuname, '') AS specName,
ISNULL(s.settlementStock, 0) AS qty,
ISNULL(s.singlePrice, 0) AS unitPrice,
ISNULL(s.settlementPrice, 0) AS amount,
ISNULL(a.price, 0) AS guessUnitPrice,
ISNULL(s.settlementStock * ISNULL(a.price, 0), 0) AS guessAmount,
'' AS supplierGoodsCode,
ISNULL(s.remark, '') AS remark,
ISNULL(s.whName, '') AS warehouseName,
FORMAT(s.createTime, 'yyyy-MM-dd HH:mm:ss') AS createTime,
ISNULL(s.expressName, '') AS expressName,
ISNULL(s.expressNo, '') AS expressNo,
CASE WHEN s.expressNo IS NULL OR s.expressNo = '' THEN 1 ELSE 0 END AS isUnsent,
s.Id AS sourceId
FROM fys_supplierGoods_sttlement_sale s
LEFT JOIN pm_averageUnitPrice a ON s.procode = a.number AND s.supplier = a.supplier
WHERE 1=1 {$where}
ORDER BY s.createTime DESC
OFFSET {$offset} ROWS FETCH NEXT {$perPage} ROWS ONLY";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
// ===================================================================
// WHERE 与参数构建
// ===================================================================
private function like(string $v): string
{
return '%' . $v . '%';
}
// --- 入库 WHERE ---
private function buildPurchaseWhere(array $filters): string
{
$c = [];
if (!empty($filters['supplier'])) $c[] = 'AND s.scmname LIKE :supplier';
if (!empty($filters['stock_no'])) $c[] = 'AND p.hno LIKE :stock_no';
if (!empty($filters['purchase_no'])) $c[] = 'AND s.spo LIKE :purchase_no';
if (!empty($filters['product_code'])) $c[] = 'AND s.procode LIKE :product_code';
if (!empty($filters['warehouse'])) $c[] = 'AND s.whname LIKE :warehouse';
if (!empty($filters['send_time_from'])) $c[] = 'AND s.created >= :send_time_from';
if (!empty($filters['send_time_to'])) $c[] = 'AND s.created <= :send_time_to';
if (!empty($filters['create_time_from'])) $c[] = 'AND s.createTime >= :create_time_from';
if (!empty($filters['create_time_to'])) $c[] = 'AND s.createTime <= :create_time_to';
if (!empty($filters['sent_status'])) {
if ($filters['sent_status'] === 'sent') $c[] = 'AND u.Id IS NULL';
if ($filters['sent_status'] === 'unsent') $c[] = 'AND u.Id IS NOT NULL';
}
if (!empty($filters['has_diff'])) {
$c[] = 'AND ABS(ISNULL(s.settlementPrice, 0) - ISNULL(s.settlementStock * ISNULL(a.price, 0), 0)) > 0.01';
}
return $c ? ' ' . implode(' ', $c) : '';
}
private function buildPurchaseParams(array $filters): array
{
$p = [];
if (!empty($filters['supplier'])) $p[':supplier'] = $this->like($filters['supplier']);
if (!empty($filters['stock_no'])) $p[':stock_no'] = $this->like($filters['stock_no']);
if (!empty($filters['purchase_no'])) $p[':purchase_no'] = $this->like($filters['purchase_no']);
if (!empty($filters['product_code'])) $p[':product_code'] = $this->like($filters['product_code']);
if (!empty($filters['warehouse'])) $p[':warehouse'] = $this->like($filters['warehouse']);
if (!empty($filters['send_time_from'])) $p[':send_time_from'] = $filters['send_time_from'];
if (!empty($filters['send_time_to'])) $p[':send_time_to'] = $filters['send_time_to'];
if (!empty($filters['create_time_from'])) $p[':create_time_from'] = $filters['create_time_from'];
if (!empty($filters['create_time_to'])) $p[':create_time_to'] = $filters['create_time_to'];
return $p;
}
// --- 代发 WHERE ---
private function buildSaleWhere(array $filters): string
{
$c = [];
if (!empty($filters['supplier'])) $c[] = 'AND s.supplier LIKE :supplier_s';
if (!empty($filters['product_code'])) $c[] = 'AND s.procode LIKE :product_code_s';
if (!empty($filters['express_no'])) $c[] = 'AND s.expressNo LIKE :express_no';
if (!empty($filters['warehouse'])) $c[] = 'AND s.whName LIKE :warehouse_s';
if (!empty($filters['send_time_from'])) $c[] = 'AND s.sendTime >= :send_time_from_s';
if (!empty($filters['send_time_to'])) $c[] = 'AND s.sendTime <= :send_time_to_s';
if (!empty($filters['create_time_from'])) $c[] = 'AND s.createTime >= :create_time_from_s';
if (!empty($filters['create_time_to'])) $c[] = 'AND s.createTime <= :create_time_to_s';
if (!empty($filters['sent_status'])) {
if ($filters['sent_status'] === 'sent') $c[] = "AND s.expressNo IS NOT NULL AND s.expressNo != ''";
if ($filters['sent_status'] === 'unsent') $c[] = "AND (s.expressNo IS NULL OR s.expressNo = '')";
}
if (!empty($filters['has_diff'])) {
$c[] = 'AND ABS(ISNULL(s.settlementPrice, 0) - ISNULL(s.settlementStock * ISNULL(a.price, 0), 0)) > 0.01';
}
return $c ? ' ' . implode(' ', $c) : '';
}
private function buildSaleParams(array $filters): array
{
$p = [];
if (!empty($filters['supplier'])) $p[':supplier_s'] = $this->like($filters['supplier']);
if (!empty($filters['product_code'])) $p[':product_code_s'] = $this->like($filters['product_code']);
if (!empty($filters['express_no'])) $p[':express_no'] = $this->like($filters['express_no']);
if (!empty($filters['warehouse'])) $p[':warehouse_s'] = $this->like($filters['warehouse']);
if (!empty($filters['send_time_from'])) $p[':send_time_from_s'] = $filters['send_time_from'];
if (!empty($filters['send_time_to'])) $p[':send_time_to_s'] = $filters['send_time_to'];
if (!empty($filters['create_time_from'])) $p[':create_time_from_s'] = $filters['create_time_from'];
if (!empty($filters['create_time_to'])) $p[':create_time_to_s'] = $filters['create_time_to'];
return $p;
}
}

View File

@ -0,0 +1,540 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Modules\EcommerceFinance\Services\JianSunConnection;
use PDO;
/**
* 供应商应付 - 子功能页面数据仓库
*
* 封装 5 个子页面的数据访问逻辑:
* - returnReconciliations() 退货对账
* - invoiceDeductions() 发票扣款
* - compensationDetails() 赔偿明细(UNION 纸板 + 压板)
* - advanceDetails() 借支明细
* - sampleDeductions() 样品扣款
*
* 所有方法返回统一结构: ['data' => array<int,array>, 'total' => int]
*/
class SupplierPayableRepository
{
private PDO $db;
public function __construct()
{
$this->db = JianSunConnection::get();
}
/**
* 通用工具:带筛选 + 分页的查询
*/
private function runQuery(string $sql, array $params, int $page, int $pageSize, int $countThreshold = 5000): array
{
$offset = ($page - 1) * $pageSize;
// 1. 总数(先查总数,超阈值再用近似)
$countSql = 'SELECT COUNT(*) AS cnt FROM (' . $sql . ') AS sub';
$total = 0;
try {
$stmt = $this->db->prepare($countSql);
$stmt->execute($params);
$total = (int) $stmt->fetchColumn();
} catch (\Throwable $e) {
// 极端情况:表太大查不到总数时退回到 -1
$total = -1;
}
// 2. 分页数据(用 TOP + OFFSET/FETCH 不可行,统一使用 ROW_NUMBER() OVER 包装)
$pagedSql = 'SELECT * FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY (SELECT 0)) AS rn FROM (' . $sql . ') AS src) AS t WHERE rn BETWEEN ? AND ?';
$paramsWithRange = array_merge($params, [$offset + 1, $offset + $pageSize]);
$stmt = $this->db->prepare($pagedSql);
$stmt->execute($paramsWithRange);
return [
'data' => $stmt->fetchAll(),
'total' => $total,
];
}
/**
* 计算分页的 SELECT TOP N(无 COUNT),加速大表查询
*/
private function runQueryFast(string $sql, array $params, int $page, int $pageSize, int $total = -1): array
{
$offset = ($page - 1) * $pageSize;
$pagedSql = 'SELECT * FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY (SELECT 0)) AS rn FROM (' . $sql . ') AS src) AS t WHERE rn BETWEEN ? AND ?';
$paramsWithRange = array_merge($params, [$offset + 1, $offset + $pageSize]);
$stmt = $this->db->prepare($pagedSql);
$stmt->execute($paramsWithRange);
return [
'data' => $stmt->fetchAll(),
'total' => $total,
];
}
// ============================================================
// 1. 退货对账 — finance_purchaseReturn (36791行)
// ============================================================
public function returnReconciliations(array $filters, int $page, int $pageSize): array
{
$where = [];
$params = [];
if (!empty($filters['supplier'])) {
$where[] = 'supplierName LIKE ?';
$params[] = '%' . $filters['supplier'] . '%';
}
if (!empty($filters['warehouse'])) {
$where[] = 'warehouseName LIKE ?';
$params[] = '%' . $filters['warehouse'] . '%';
}
if (!empty($filters['sku_id'])) {
$where[] = 'sku_id LIKE ?';
$params[] = '%' . $filters['sku_id'] . '%';
}
if (!empty($filters['express_no'])) {
$where[] = 'expressNo LIKE ?';
$params[] = '%' . $filters['express_no'] . '%';
}
if (!empty($filters['date_from'])) {
$where[] = 'date >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where[] = 'date <= ?';
$params[] = $filters['date_to'];
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT
CONVERT(varchar(10), date, 120) AS send_time,
supplierName AS supplier,
sku_id AS barcode,
name AS sku_name,
CAST(qty AS decimal(18,2)) AS qty,
CAST(price AS decimal(18,2)) AS unit_price,
CAST(price * qty AS decimal(18,2)) AS amount,
CAST(priceB AS decimal(18,2)) AS guess_unit_price,
CAST(priceB * qty AS decimal(18,2)) AS guess_amount,
remark,
io_id AS wms_pick_no,
warehouseName AS warehouse,
expressNo AS express_no,
expressName AS express_name,
CONVERT(varchar(10), io_date, 120) AS io_date,
labels,
creator_name AS creator,
'' AS title,
'' AS supplier_barcode,
'' AS original_barcode,
'' AS reconciler
FROM finance_purchaseReturn
$whereSql";
$result = $this->runQuery($sql, $params, $page, $pageSize);
return [
'data' => $result['data'],
'total' => $result['total'],
];
}
public function returnReconciliationSummary(array $filters): array
{
$where = [];
$params = [];
if (!empty($filters['supplier'])) {
$where[] = 'supplierName LIKE ?';
$params[] = '%' . $filters['supplier'] . '%';
}
if (!empty($filters['date_from'])) {
$where[] = 'date >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where[] = 'date <= ?';
$params[] = $filters['date_to'];
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT
ISNULL(SUM(price * qty), 0) AS total_amount,
ISNULL(SUM(priceB * qty), 0) AS total_guess_amount,
COUNT(*) AS total_count
FROM finance_purchaseReturn $whereSql";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
return [
'totalAmount' => (float) $row['total_amount'],
'totalGuessAmount' => (float) $row['total_guess_amount'],
'totalCount' => (int) $row['total_count'],
];
}
// ============================================================
// 2. 发票扣款 — finance_invoiceDeduction (195行)
// ============================================================
public function invoiceDeductions(array $filters, int $page, int $pageSize): array
{
$where = [];
$params = [];
if (!empty($filters['supplier'])) {
$where[] = 'supplier LIKE ?';
$params[] = '%' . $filters['supplier'] . '%';
}
if (!empty($filters['month_from'])) {
$where[] = 'month >= ?';
$params[] = $filters['month_from'];
}
if (!empty($filters['month_to'])) {
$where[] = 'month <= ?';
$params[] = $filters['month_to'];
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT
CONVERT(varchar(7), month, 120) AS month_str,
'' AS category,
supplier AS payee,
supplier AS receiver,
'' AS board_factory,
CAST(0 AS decimal(18,2)) AS subsidy_fee,
remark,
CAST(price AS decimal(18,2)) AS price,
remark AS detail,
'' AS note,
personCharge AS person_charge,
CONVERT(varchar(19), createTime, 120) AS create_time,
creator
FROM finance_invoiceDeduction
$whereSql";
return $this->runQuery($sql, $params, $page, $pageSize);
}
public function invoiceDeductionSummary(array $filters): array
{
$where = [];
$params = [];
if (!empty($filters['supplier'])) {
$where[] = 'supplier LIKE ?';
$params[] = '%' . $filters['supplier'] . '%';
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT ISNULL(SUM(price), 0) AS total_price, COUNT(*) AS total_count FROM finance_invoiceDeduction $whereSql";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
return [
'totalPrice' => (float) $row['total_price'],
'totalCount' => (int) $row['total_count'],
];
}
// ============================================================
// 3. 赔偿明细 — fys_paper_factory + fys_pressing_factory UNION
// ============================================================
public function compensationDetails(array $filters, int $page, int $pageSize): array
{
$where = [];
$params = [];
if (!empty($filters['supplier'])) {
$where[] = 'supplier LIKE ?';
$params[] = '%' . $filters['supplier'] . '%';
}
if (!empty($filters['month_from'])) {
$where[] = 'month >= ?';
$params[] = $filters['month_from'];
}
if (!empty($filters['month_to'])) {
$where[] = 'month <= ?';
$params[] = $filters['month_to'];
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT
month,
supplier,
CAST(compensation_amount AS decimal(18,2)) AS amount,
reason,
'' AS remark,
creator,
CONVERT(varchar(19), create_time, 120) AS create_time,
'纸板厂' AS factory_type
FROM (
SELECT
month,
factory_subject_name AS supplier,
compensation_amount,
reason,
creator,
create_time
FROM fys_paper_factory_compensation_detail
UNION ALL
SELECT
month,
factory_subject_name AS supplier,
compensation_amount,
reason,
creator,
create_time
FROM fys_pressing_factory_compensation_detail
) AS t
$whereSql
ORDER BY month DESC, create_time DESC";
return $this->runQuery($sql, $params, $page, $pageSize);
}
public function compensationDetailSummary(array $filters): array
{
$where = [];
$params = [];
if (!empty($filters['supplier'])) {
$where[] = 'supplier LIKE ?';
$params[] = '%' . $filters['supplier'] . '%';
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT
ISNULL(SUM(compensation_amount), 0) AS total_amount,
COUNT(*) AS total_count
FROM (
SELECT compensation_amount, factory_subject_name AS supplier FROM fys_paper_factory_compensation_detail
UNION ALL
SELECT compensation_amount, factory_subject_name AS supplier FROM fys_pressing_factory_compensation_detail
) AS t
$whereSql";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
return [
'totalAmount' => (float) $row['total_amount'],
'totalCount' => (int) $row['total_count'],
];
}
// ============================================================
// 4. 借支明细 — finance_advanceLoans (4行, 字段不足,部分列显示-)
// ============================================================
public function advanceDetails(array $filters, int $page, int $pageSize): array
{
$where = [];
$params = [];
if (!empty($filters['supplier'])) {
$where[] = 'supplier LIKE ?';
$params[] = '%' . $filters['supplier'] . '%';
}
if (!empty($filters['month_from'])) {
$where[] = 'month >= ?';
$params[] = $filters['month_from'];
}
if (!empty($filters['month_to'])) {
$where[] = 'month <= ?';
$params[] = $filters['month_to'];
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT
supplier,
'' AS contract_name,
CAST(price AS decimal(18,2)) AS advance_amount,
CAST(0 AS decimal(18,2)) AS actual_pay,
CAST(0 AS decimal(18,2)) AS month_deduct,
CAST(0 AS decimal(18,2)) AS should_deduct,
'' AS deduct_start_month,
'' AS deduct_end_month,
0 AS remain_periods,
CONVERT(varchar(7), month, 120) AS month_str,
'' AS loan_term,
remark,
'' AS is_paid,
CONVERT(varchar(7), month, 120) AS pay_month,
'' AS serial_no,
'' AS loan_agreement,
personCharge AS person_charge,
'' AS receipt_0,
'' AS receipt_1,
'' AS receipt_2,
CONVERT(varchar(19), createTime, 120) AS create_time,
creator
FROM finance_advanceLoans
$whereSql
ORDER BY month DESC, createTime DESC";
return $this->runQuery($sql, $params, $page, $pageSize);
}
public function advanceDetailSummary(array $filters): array
{
$where = [];
$params = [];
if (!empty($filters['supplier'])) {
$where[] = 'supplier LIKE ?';
$params[] = '%' . $filters['supplier'] . '%';
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT ISNULL(SUM(price), 0) AS total_amount, COUNT(*) AS total_count FROM finance_advanceLoans $whereSql";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
return [
'totalAmount' => (float) $row['total_amount'],
'totalCount' => (int) $row['total_count'],
];
}
// ============================================================
// 5. 样品扣款 — finance_sampleDeduction (12行)
// ============================================================
public function sampleDeductions(array $filters, int $page, int $pageSize): array
{
$where = [];
$params = [];
if (!empty($filters['supplier'])) {
$where[] = 'supplier LIKE ?';
$params[] = '%' . $filters['supplier'] . '%';
}
if (!empty($filters['department'])) {
$where[] = 'department LIKE ?';
$params[] = '%' . $filters['department'] . '%';
}
if (!empty($filters['creator'])) {
$where[] = 'creator LIKE ?';
$params[] = '%' . $filters['creator'] . '%';
}
if (!empty($filters['sku_id'])) {
$where[] = 'sku_id LIKE ?';
$params[] = '%' . $filters['sku_id'] . '%';
}
if (!empty($filters['date_from'])) {
$where[] = 'finishTime >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where[] = 'finishTime <= ?';
$params[] = $filters['date_to'];
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT
CONVERT(varchar(10), finishTime, 120) AS finish_time,
department,
creator,
sku_id,
skuName,
CAST(num AS decimal(18,2)) AS num,
supplier,
purchase,
productCode,
CAST(shippingFee AS decimal(18,2)) AS shipping_fee,
CAST(costPrice AS decimal(18,2)) AS cost_price,
CAST(totalAmount AS decimal(18,2)) AS total_amount,
CAST(estimatedPrice AS decimal(18,2)) AS estimated_price,
CAST(estimatedPriceAll AS decimal(18,2)) AS estimated_price_all,
'' AS remark,
businessId,
CONVERT(varchar(19), updateAt, 120) AS update_time
FROM finance_sampleDeduction
$whereSql
ORDER BY finishTime DESC";
return $this->runQuery($sql, $params, $page, $pageSize);
}
public function sampleDeductionSummary(array $filters): array
{
$where = [];
$params = [];
if (!empty($filters['supplier'])) {
$where[] = 'supplier LIKE ?';
$params[] = '%' . $filters['supplier'] . '%';
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT
ISNULL(SUM(totalAmount), 0) AS total_amount,
ISNULL(SUM(estimatedPriceAll), 0) AS total_estimate,
COUNT(*) AS total_count
FROM finance_sampleDeduction $whereSql";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
return [
'totalAmount' => (float) $row['total_amount'],
'totalEstimate' => (float) $row['total_estimate'],
'totalCount' => (int) $row['total_count'],
];
}
// ============================================================
// 筛选器选项
// ============================================================
public function filterOptionsForReturn(): array
{
$suppliers = $this->fetchDistinct('SELECT DISTINCT supplierName FROM finance_purchaseReturn WHERE supplierName IS NOT NULL ORDER BY supplierName');
$warehouses = $this->fetchDistinct('SELECT DISTINCT warehouseName FROM finance_purchaseReturn WHERE warehouseName IS NOT NULL ORDER BY warehouseName');
return compact('suppliers', 'warehouses');
}
public function filterOptionsForInvoice(): array
{
$suppliers = $this->fetchDistinct('SELECT DISTINCT supplier FROM finance_invoiceDeduction WHERE supplier IS NOT NULL ORDER BY supplier');
return ['suppliers' => $suppliers];
}
public function filterOptionsForCompensation(): array
{
$suppliers = $this->fetchDistinct("SELECT DISTINCT supplier FROM (
SELECT factory_subject_name AS supplier FROM fys_paper_factory_compensation_detail WHERE factory_subject_name IS NOT NULL
UNION
SELECT factory_subject_name AS supplier FROM fys_pressing_factory_compensation_detail WHERE factory_subject_name IS NOT NULL
) AS t ORDER BY supplier");
return ['suppliers' => $suppliers];
}
public function filterOptionsForAdvance(): array
{
$suppliers = $this->fetchDistinct('SELECT DISTINCT supplier FROM finance_advanceLoans WHERE supplier IS NOT NULL ORDER BY supplier');
return ['suppliers' => $suppliers];
}
public function filterOptionsForSample(): array
{
$suppliers = $this->fetchDistinct('SELECT DISTINCT supplier FROM finance_sampleDeduction WHERE supplier IS NOT NULL ORDER BY supplier');
$departments = $this->fetchDistinct('SELECT DISTINCT department FROM finance_sampleDeduction WHERE department IS NOT NULL ORDER BY department');
$creators = $this->fetchDistinct('SELECT DISTINCT creator FROM finance_sampleDeduction WHERE creator IS NOT NULL ORDER BY creator');
return compact('suppliers', 'departments', 'creators');
}
private function fetchDistinct(string $sql): array
{
$rows = $this->db->query($sql)->fetchAll(PDO::FETCH_COLUMN);
return array_values(array_filter($rows, fn ($v) => $v !== null && $v !== ''));
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace Modules\EcommerceFinance\Services;
use PDO;
use PDOException;
/**
* JianSun SQL Server 数据库连接管理
* 使用 PDO_ODBC 直连 SQL Server(无需 sqlsrv 扩展)
*/
class JianSunConnection
{
private static ?PDO $instance = null;
/**
* 获取 PDO 连接实例(单例模式)
*/
public static function get(): PDO
{
if (self::$instance === null) {
self::$instance = self::create();
}
return self::$instance;
}
/**
* 创建新的 PDO 连接
*/
public static function create(): PDO
{
$host = env('DB_JIANSUN_HOST', '218.93.191.21');
$port = env('DB_JIANSUN_PORT', '1433');
$db = env('DB_JIANSUN_DATABASE', 'JianSun');
$user = env('DB_JIANSUN_USERNAME', 'financeReader');
$pass = env('DB_JIANSUN_PASSWORD', '');
$dsn = sprintf(
'odbc:Driver={SQL Server};Server=%s,%s;Database=%s',
$host,
$port,
$db
);
try {
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
} catch (PDOException $e) {
throw new \RuntimeException(
'JianSun 数据库连接失败: ' . $e->getMessage(),
500,
$e
);
}
}
/**
* 重置连接(用于长时间运行或测试场景)
*/
public static function reset(): void
{
self::$instance = null;
}
}

1
public/public Symbolic link
View File

@ -0,0 +1 @@
.

View File

@ -0,0 +1,84 @@
require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import {
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message
} from 'element-ui';
Vue.use(DashboardPlugin);
Vue.component(Table.name, Table);
Vue.component(TableColumn.name, TableColumn);
Vue.component(Pagination.name, Pagination);
Vue.component(DatePicker.name, DatePicker);
Vue.component(Tag.name, Tag);
Vue.component(Input.name, Input);
Vue.component(Button.name, Button);
Vue.prototype.$message = Message;
const app = new Vue({
el: '#advance-detail-app',
mixins: [Global],
data() {
return {
baseUrl: url + '/api/ecommerce-finance/supplier-payables/advance-details',
loading: false,
tableData: [],
total: 0,
currentPage: 1,
pageSize: 25,
summary: { totalAmount: 0, totalCount: 0 },
filters: { supplier: '', month_range: null }
};
},
mounted() { this.fetchData(); },
methods: {
fetchData() {
this.loading = true;
const params = this.buildParams();
Promise.all([
axios.get(this.baseUrl, { params }),
axios.get(this.baseUrl + '/summary', { params })
]).then(([listRes, sumRes]) => {
this.tableData = listRes.data.data || [];
this.total = listRes.data.total || 0;
this.currentPage = listRes.data.page || 1;
this.pageSize = listRes.data.per_page || 25;
this.summary = sumRes.data;
}).catch(() => this.$message.error('获取数据失败'))
.finally(() => { this.loading = false; });
},
buildParams() {
const params = { page: this.currentPage, limit: this.pageSize };
if (this.filters.supplier) params.supplier = this.filters.supplier;
if (this.filters.month_range && this.filters.month_range.length === 2) {
params.month_from = this.filters.month_range[0];
params.month_to = this.filters.month_range[1];
}
return params;
},
onFilterChange() { this.currentPage = 1; this.fetchData(); },
onPageChange(p) { this.currentPage = p; this.fetchData(); },
onPageSizeChange(s) { this.pageSize = s; this.currentPage = 1; this.fetchData(); },
onReset() {
this.filters = { supplier: '', month_range: null };
this.currentPage = 1;
this.fetchData();
},
formatMoney(v) {
if (v == null || isNaN(v)) return '0.00';
return '¥' + Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
}
});
export default app;

View File

@ -0,0 +1,84 @@
require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import {
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message
} from 'element-ui';
Vue.use(DashboardPlugin);
Vue.component(Table.name, Table);
Vue.component(TableColumn.name, TableColumn);
Vue.component(Pagination.name, Pagination);
Vue.component(DatePicker.name, DatePicker);
Vue.component(Tag.name, Tag);
Vue.component(Input.name, Input);
Vue.component(Button.name, Button);
Vue.prototype.$message = Message;
const app = new Vue({
el: '#compensation-detail-app',
mixins: [Global],
data() {
return {
baseUrl: url + '/api/ecommerce-finance/supplier-payables/compensation-details',
loading: false,
tableData: [],
total: 0,
currentPage: 1,
pageSize: 25,
summary: { totalAmount: 0, totalCount: 0 },
filters: { supplier: '', month_range: null }
};
},
mounted() { this.fetchData(); },
methods: {
fetchData() {
this.loading = true;
const params = this.buildParams();
Promise.all([
axios.get(this.baseUrl, { params }),
axios.get(this.baseUrl + '/summary', { params })
]).then(([listRes, sumRes]) => {
this.tableData = listRes.data.data || [];
this.total = listRes.data.total || 0;
this.currentPage = listRes.data.page || 1;
this.pageSize = listRes.data.per_page || 25;
this.summary = sumRes.data;
}).catch(() => this.$message.error('获取数据失败'))
.finally(() => { this.loading = false; });
},
buildParams() {
const params = { page: this.currentPage, limit: this.pageSize };
if (this.filters.supplier) params.supplier = this.filters.supplier;
if (this.filters.month_range && this.filters.month_range.length === 2) {
params.month_from = this.filters.month_range[0];
params.month_to = this.filters.month_range[1];
}
return params;
},
onFilterChange() { this.currentPage = 1; this.fetchData(); },
onPageChange(p) { this.currentPage = p; this.fetchData(); },
onPageSizeChange(s) { this.pageSize = s; this.currentPage = 1; this.fetchData(); },
onReset() {
this.filters = { supplier: '', month_range: null };
this.currentPage = 1;
this.fetchData();
},
formatMoney(v) {
if (v == null || isNaN(v)) return '0.00';
return '¥' + Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
}
});
export default app;

View File

@ -0,0 +1,277 @@
require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import {
Table,
TableColumn,
Pagination,
DatePicker,
Tag,
Message
} from 'element-ui';
Vue.use(DashboardPlugin);
Vue.component(Table.name, Table);
Vue.component(TableColumn.name, TableColumn);
Vue.component(Pagination.name, Pagination);
Vue.component(DatePicker.name, DatePicker);
Vue.component(Tag.name, Tag);
Vue.prototype.$message = Message;
const app = new Vue({
el: '#goods-reconciliation-app',
mixins: [Global],
data() {
return {
baseUrl: url + '/api/ecommerce-finance/goods-reconciliations',
loading: false,
tableData: [],
total: 0,
currentPage: 1,
pageSize: 25,
activeTab: 'all',
summary: {
totalAmount: 0,
totalGuessAmount: 0,
totalCount: 0,
sentCount: 0,
unsentCount: 0
},
options: {
suppliers: [],
warehouses: []
},
filters: {
record_type: 'all',
supplier: '',
warehouse: '',
stock_no: '',
purchase_no: '',
product_code: '',
express_no: '',
send_time_range: null,
create_time_range: null,
sent_status: 'all',
has_diff: false
},
tabs: [
{ key: 'all', label: '全部记录' },
{ key: 'purchase', label: '入库对账' },
{ key: 'sale', label: '代发对账' },
{ key: 'unsent', label: '未发出' },
{ key: 'diff', label: '有差异' }
],
sortProp: 'createTime',
sortOrder: 'descending'
};
},
mounted() {
this.fetchOptions();
this.fetchData();
},
methods: {
/**
* Fetch filter options (suppliers, warehouses)
*/
fetchOptions() {
axios.get(this.baseUrl + '/options')
.then(response => {
this.options = response.data;
})
.catch(() => {
this.$message.error('获取筛选选项失败');
});
},
/**
* Fetch data list and summary
*/
fetchData() {
this.loading = true;
const params = this.buildParams();
const summaryParams = this.buildSummaryParams();
Promise.all([
axios.get(this.baseUrl, { params }),
axios.get(this.baseUrl + '/summary', { params: summaryParams })
])
.then(([listRes, summaryRes]) => {
this.tableData = listRes.data.data || [];
this.total = listRes.data.total || 0;
this.currentPage = listRes.data.page || 1;
this.pageSize = listRes.data.per_page || 25;
this.summary = summaryRes.data;
})
.catch(() => {
this.$message.error('获取数据失败,请稍后重试');
})
.finally(() => {
this.loading = false;
});
},
/**
* Build list query params
*/
buildParams() {
const params = {
page: this.currentPage,
limit: this.pageSize,
record_type: this.filters.record_type
};
// text filters
const textFields = ['supplier', 'warehouse', 'stock_no', 'purchase_no', 'product_code', 'express_no'];
textFields.forEach(f => {
if (this.filters[f]) {
params[f] = this.filters[f];
}
});
// date range filters
if (this.filters.send_time_range && this.filters.send_time_range.length === 2) {
params.send_time_from = this.filters.send_time_range[0];
params.send_time_to = this.filters.send_time_range[1];
}
if (this.filters.create_time_range && this.filters.create_time_range.length === 2) {
params.create_time_from = this.filters.create_time_range[0];
params.create_time_to = this.filters.create_time_range[1];
}
// sent status
if (this.filters.sent_status && this.filters.sent_status !== 'all') {
params.sent_status = this.filters.sent_status;
}
// has diff
if (this.filters.has_diff) {
params.has_diff = 1;
}
return params;
},
/**
* Summary params always use 'all' for overall counts
*/
buildSummaryParams() {
const params = {};
const textFields = ['supplier', 'warehouse', 'stock_no', 'purchase_no', 'product_code', 'express_no'];
textFields.forEach(f => {
if (this.filters[f]) {
params[f] = this.filters[f];
}
});
if (this.filters.send_time_range && this.filters.send_time_range.length === 2) {
params.send_time_from = this.filters.send_time_range[0];
params.send_time_to = this.filters.send_time_range[1];
}
if (this.filters.create_time_range && this.filters.create_time_range.length === 2) {
params.create_time_from = this.filters.create_time_range[0];
params.create_time_to = this.filters.create_time_range[1];
}
params.record_type = this.filters.record_type;
if (this.filters.sent_status && this.filters.sent_status !== 'all') {
params.sent_status = this.filters.sent_status;
}
return params;
},
/**
* Switch tab
*/
switchTab(key) {
this.activeTab = key;
this.currentPage = 1;
switch (key) {
case 'all':
this.filters.record_type = 'all';
this.filters.sent_status = 'all';
this.filters.has_diff = false;
break;
case 'purchase':
this.filters.record_type = 'purchase';
this.filters.sent_status = 'all';
this.filters.has_diff = false;
break;
case 'sale':
this.filters.record_type = 'sale';
this.filters.sent_status = 'all';
this.filters.has_diff = false;
break;
case 'unsent':
this.filters.sent_status = 'unsent';
this.filters.has_diff = false;
break;
case 'diff':
this.filters.has_diff = true;
this.filters.sent_status = 'all';
break;
}
this.fetchData();
},
/**
* On filter change, reset to page 1
*/
onFilterChange() {
this.currentPage = 1;
this.fetchData();
},
/**
* Pagination page change
*/
onPageChange(page) {
this.currentPage = page;
this.fetchData();
},
/**
* Pagination page size change
*/
onPageSizeChange(size) {
this.pageSize = size;
this.currentPage = 1;
this.fetchData();
},
/**
* Sort change
*/
onSortChange({ prop, order }) {
this.sortProp = prop;
this.sortOrder = order;
// Sorting is handled server-side for now; re-fetch
this.fetchData();
},
/**
* Format money (CNY)
*/
formatMoney(value) {
if (value == null || isNaN(value)) return '0.00';
return '¥' + Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
}
});
export default app;

View File

@ -0,0 +1,84 @@
require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import {
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message
} from 'element-ui';
Vue.use(DashboardPlugin);
Vue.component(Table.name, Table);
Vue.component(TableColumn.name, TableColumn);
Vue.component(Pagination.name, Pagination);
Vue.component(DatePicker.name, DatePicker);
Vue.component(Tag.name, Tag);
Vue.component(Input.name, Input);
Vue.component(Button.name, Button);
Vue.prototype.$message = Message;
const app = new Vue({
el: '#invoice-deduction-app',
mixins: [Global],
data() {
return {
baseUrl: url + '/api/ecommerce-finance/supplier-payables/invoice-deductions',
loading: false,
tableData: [],
total: 0,
currentPage: 1,
pageSize: 25,
summary: { totalPrice: 0, totalCount: 0 },
filters: { supplier: '', month_range: null }
};
},
mounted() { this.fetchData(); },
methods: {
fetchData() {
this.loading = true;
const params = this.buildParams();
Promise.all([
axios.get(this.baseUrl, { params }),
axios.get(this.baseUrl + '/summary', { params })
]).then(([listRes, sumRes]) => {
this.tableData = listRes.data.data || [];
this.total = listRes.data.total || 0;
this.currentPage = listRes.data.page || 1;
this.pageSize = listRes.data.per_page || 25;
this.summary = sumRes.data;
}).catch(() => this.$message.error('获取数据失败'))
.finally(() => { this.loading = false; });
},
buildParams() {
const params = { page: this.currentPage, limit: this.pageSize };
if (this.filters.supplier) params.supplier = this.filters.supplier;
if (this.filters.month_range && this.filters.month_range.length === 2) {
params.month_from = this.filters.month_range[0];
params.month_to = this.filters.month_range[1];
}
return params;
},
onFilterChange() { this.currentPage = 1; this.fetchData(); },
onPageChange(p) { this.currentPage = p; this.fetchData(); },
onPageSizeChange(s) { this.pageSize = s; this.currentPage = 1; this.fetchData(); },
onReset() {
this.filters = { supplier: '', month_range: null };
this.currentPage = 1;
this.fetchData();
},
formatMoney(v) {
if (v == null || isNaN(v)) return '0.00';
return '¥' + Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
}
});
export default app;

View File

@ -0,0 +1,113 @@
require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import {
Table,
TableColumn,
Pagination,
DatePicker,
Tag,
Button,
Input,
Select,
Option,
Message
} from 'element-ui';
Vue.use(DashboardPlugin);
Vue.component(Table.name, Table);
Vue.component(TableColumn.name, TableColumn);
Vue.component(Pagination.name, Pagination);
Vue.component(DatePicker.name, DatePicker);
Vue.component(Tag.name, Tag);
Vue.component(Input.name, Input);
Vue.component(Button.name, Button);
Vue.component(Select.name, Select);
Vue.component(Option.name, Option);
Vue.prototype.$message = Message;
const app = new Vue({
el: '#return-reconciliation-app',
mixins: [Global],
data() {
return {
baseUrl: url + '/api/ecommerce-finance/supplier-payables/return-reconciliations',
loading: false,
tableData: [],
total: 0,
currentPage: 1,
pageSize: 25,
summary: { totalAmount: 0, totalGuessAmount: 0, totalCount: 0 },
options: { suppliers: [], warehouses: [] },
filters: {
supplier: '',
warehouse: '',
sku_id: '',
express_no: '',
date_range: null
}
};
},
mounted() {
this.fetchOptions();
this.fetchData();
},
methods: {
fetchOptions() {
axios.get(this.baseUrl + '/options')
.then(r => { this.options = r.data; })
.catch(() => this.$message.error('获取筛选选项失败'));
},
fetchData() {
this.loading = true;
const params = this.buildParams();
Promise.all([
axios.get(this.baseUrl, { params }),
axios.get(this.baseUrl + '/summary', { params })
]).then(([listRes, sumRes]) => {
this.tableData = listRes.data.data || [];
this.total = listRes.data.total || 0;
this.currentPage = listRes.data.page || 1;
this.pageSize = listRes.data.per_page || 25;
this.summary = sumRes.data;
}).catch(() => this.$message.error('获取数据失败'))
.finally(() => { this.loading = false; });
},
buildParams() {
const params = { page: this.currentPage, limit: this.pageSize };
['supplier', 'warehouse', 'sku_id', 'express_no'].forEach(f => {
if (this.filters[f]) params[f] = this.filters[f];
});
if (this.filters.date_range && this.filters.date_range.length === 2) {
params.date_from = this.filters.date_range[0];
params.date_to = this.filters.date_range[1];
}
return params;
},
onFilterChange() { this.currentPage = 1; this.fetchData(); },
onPageChange(p) { this.currentPage = p; this.fetchData(); },
onPageSizeChange(s) { this.pageSize = s; this.currentPage = 1; this.fetchData(); },
onReset() {
this.filters = { supplier: '', warehouse: '', sku_id: '', express_no: '', date_range: null };
this.currentPage = 1;
this.fetchData();
},
formatMoney(v) {
if (v == null || isNaN(v)) return '0.00';
return '¥' + Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
}
});
export default app;

View File

@ -0,0 +1,92 @@
require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import {
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message
} from 'element-ui';
Vue.use(DashboardPlugin);
Vue.component(Table.name, Table);
Vue.component(TableColumn.name, TableColumn);
Vue.component(Pagination.name, Pagination);
Vue.component(DatePicker.name, DatePicker);
Vue.component(Tag.name, Tag);
Vue.component(Input.name, Input);
Vue.component(Button.name, Button);
Vue.prototype.$message = Message;
const app = new Vue({
el: '#sample-deduction-app',
mixins: [Global],
data() {
return {
baseUrl: url + '/api/ecommerce-finance/supplier-payables/sample-deductions',
loading: false,
tableData: [],
total: 0,
currentPage: 1,
pageSize: 25,
summary: { totalAmount: 0, totalEstimate: 0, totalCount: 0 },
filters: {
supplier: '',
department: '',
creator: '',
sku_id: '',
date_range: null
}
};
},
mounted() { this.fetchData(); },
methods: {
fetchData() {
this.loading = true;
const params = this.buildParams();
Promise.all([
axios.get(this.baseUrl, { params }),
axios.get(this.baseUrl + '/summary', { params })
]).then(([listRes, sumRes]) => {
this.tableData = listRes.data.data || [];
this.total = listRes.data.total || 0;
this.currentPage = listRes.data.page || 1;
this.pageSize = listRes.data.per_page || 25;
this.summary = sumRes.data;
}).catch(() => this.$message.error('获取数据失败'))
.finally(() => { this.loading = false; });
},
buildParams() {
const params = { page: this.currentPage, limit: this.pageSize };
['supplier', 'department', 'creator', 'sku_id'].forEach(f => {
if (this.filters[f]) params[f] = this.filters[f];
});
if (this.filters.date_range && this.filters.date_range.length === 2) {
params.date_from = this.filters.date_range[0];
params.date_to = this.filters.date_range[1];
}
return params;
},
onFilterChange() { this.currentPage = 1; this.fetchData(); },
onPageChange(p) { this.currentPage = p; this.fetchData(); },
onPageSizeChange(s) { this.pageSize = s; this.currentPage = 1; this.fetchData(); },
onReset() {
this.filters = { supplier: '', department: '', creator: '', sku_id: '', date_range: null };
this.currentPage = 1;
this.fetchData();
},
formatMoney(v) {
if (v == null || isNaN(v)) return '0.00';
return '¥' + Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
}
});
export default app;

View File

@ -0,0 +1,111 @@
<div id="advance-detail-app" class="advance-detail-page">
<div v-cloak>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-4 text-sm text-yellow-800">
<i class="el-icon-warning mr-1"></i>
借支明细数据源 finance_advanceLoans 当前仅 4 行数据;表头 21 列中部分字段(如实际打款金额、分期扣款、票据等)在数据库中无对应列,已显示为占位符。
</div>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">借支总金额</div>
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">总记录数</div>
<div class="text-2xl font-bold text-indigo-600">@{{ (summary.totalCount || 0).toLocaleString() }}</div>
</div>
</div>
{{-- 筛选栏 --}}
<div class="bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm">
<div class="flex flex-wrap items-center gap-3">
<el-input v-model="filters.supplier" placeholder="供应商" size="small" style="width: 200px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
<el-date-picker v-model="filters.month_range" type="monthrange"
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
</div>
</div>
{{-- 数据表格 --}}
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="supplier" label="供应商" width="140" fixed="left" show-overflow-tooltip></el-table-column>
<el-table-column label="协议签订名称" width="140">
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.contract_name || '-' }}</span></template>
</el-table-column>
<el-table-column prop="advance_amount" label="借支金额-借款协议" width="140" align="right">
<template slot-scope="scope">
<span :class="scope.row.advance_amount < 0 ? 'text-red-600' : 'text-gray-800'">@{{ formatMoney(scope.row.advance_amount) }}</span>
</template>
</el-table-column>
<el-table-column label="实际打款金额" width="120" align="right">
<template slot-scope="scope"><span class="text-gray-400">@{{ formatMoney(scope.row.actual_pay) }}</span></template>
</el-table-column>
<el-table-column label="本月扣款金额" width="120" align="right">
<template slot-scope="scope"><span class="text-gray-400">@{{ formatMoney(scope.row.month_deduct) }}</span></template>
</el-table-column>
<el-table-column label="应扣金额" width="120" align="right">
<template slot-scope="scope"><span class="text-gray-400">@{{ formatMoney(scope.row.should_deduct) }}</span></template>
</el-table-column>
<el-table-column label="分期起始扣款月份" width="130">
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.deduct_start_month || '-' }}</span></template>
</el-table-column>
<el-table-column label="分期最后次扣款月份" width="130">
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.deduct_end_month || '-' }}</span></template>
</el-table-column>
<el-table-column prop="remain_periods" label="分期剩余期数" width="110" align="center"></el-table-column>
<el-table-column prop="month_str" label="借支日期" width="110"></el-table-column>
<el-table-column label="借款期限" width="100">
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.loan_term || '-' }}</span></template>
</el-table-column>
<el-table-column prop="remark" label="备注" width="180" show-overflow-tooltip></el-table-column>
<el-table-column label="是否打款" width="100">
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.is_paid || '-' }}</span></template>
</el-table-column>
<el-table-column prop="pay_month" label="打款月份" width="110"></el-table-column>
<el-table-column label="流水号" width="120">
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.serial_no || '-' }}</span></template>
</el-table-column>
<el-table-column label="借支协议" width="120">
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.loan_agreement || '-' }}</span></template>
</el-table-column>
<el-table-column prop="person_charge" label="负责人" width="100"></el-table-column>
<el-table-column label="票据" width="80">
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.receipt_0 || '-' }}</span></template>
</el-table-column>
<el-table-column label="票据1" width="80">
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.receipt_1 || '-' }}</span></template>
</el-table-column>
<el-table-column label="票据2" width="80">
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.receipt_2 || '-' }}</span></template>
</el-table-column>
</el-table>
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-100" v-if="!loading">
<div class="text-sm text-gray-500">共 @{{ total }} 条记录</div>
<el-pagination
background
layout="prev, pager, next, sizes, jumper"
:total="total"
:page-size="pageSize"
:current-page.sync="currentPage"
:page-sizes="[25, 50, 100]"
@size-change="onPageSizeChange"
@current-change="onPageChange">
</el-pagination>
</div>
</div>
</div>
</div>
@push('body_end')
<script src="{{ asset('public/js/ecommerce-finance/advance-details.min.js') }}"></script>
@endpush

View File

@ -0,0 +1,73 @@
<div id="compensation-detail-app" class="compensation-detail-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">赔付总金额</div>
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalAmount) }}</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">总记录数</div>
<div class="text-2xl font-bold text-indigo-600">@{{ (summary.totalCount || 0).toLocaleString() }}</div>
</div>
</div>
{{-- 筛选栏 --}}
<div class="bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm">
<div class="flex flex-wrap items-center gap-3">
<el-input v-model="filters.supplier" placeholder="供应商" size="small" style="width: 220px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
<el-date-picker v-model="filters.month_range" type="monthrange"
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
</div>
</div>
{{-- 数据表格 --}}
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="month" label="日期" width="100" fixed="left"></el-table-column>
<el-table-column prop="supplier" label="供应商" width="260" show-overflow-tooltip></el-table-column>
<el-table-column prop="amount" label="赔付金额" width="140" align="right">
<template slot-scope="scope">
<span :class="scope.row.amount < 0 ? 'text-red-600' : 'text-gray-800'">@{{ formatMoney(scope.row.amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="reason" label="赔付原因" width="240" show-overflow-tooltip></el-table-column>
<el-table-column prop="remark" label="备注" width="180" show-overflow-tooltip>
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.remark || '-' }}</span></template>
</el-table-column>
<el-table-column prop="factory_type" label="类型" width="100">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.factory_type === '纸板厂' ? 'primary' : 'success'">@{{ scope.row.factory_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator" label="创建人" width="100"></el-table-column>
</el-table>
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-100" v-if="!loading">
<div class="text-sm text-gray-500">共 @{{ total }} 条记录</div>
<el-pagination
background
layout="prev, pager, next, sizes, jumper"
:total="total"
:page-size="pageSize"
:current-page.sync="currentPage"
:page-sizes="[25, 50, 100]"
@size-change="onPageSizeChange"
@current-change="onPageChange">
</el-pagination>
</div>
</div>
</div>
</div>
@push('body_end')
<script src="{{ asset('public/js/ecommerce-finance/compensation-details.min.js') }}"></script>
@endpush

View File

@ -0,0 +1,79 @@
<div id="invoice-deduction-app" class="invoice-deduction-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">扣款总金额</div>
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalPrice) }}</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">总记录数</div>
<div class="text-2xl font-bold text-indigo-600">@{{ (summary.totalCount || 0).toLocaleString() }}</div>
</div>
</div>
{{-- 筛选栏 --}}
<div class="bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm">
<div class="flex flex-wrap items-center gap-3">
<el-input v-model="filters.supplier" placeholder="供应商/打款抬头/收款方" size="small" style="width: 220px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
<el-date-picker v-model="filters.month_range" type="monthrange"
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
</div>
</div>
{{-- 数据表格 --}}
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="month_str" label="日期" width="100" fixed="left"></el-table-column>
<el-table-column label="类别" width="100">
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.category || '-' }}</span></template>
</el-table-column>
<el-table-column prop="payee" label="打款抬头" width="160" show-overflow-tooltip></el-table-column>
<el-table-column prop="receiver" label="收款方" width="160" show-overflow-tooltip></el-table-column>
<el-table-column label="供板材厂家/代扣厂家" width="180">
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.board_factory || '-' }}</span></template>
</el-table-column>
<el-table-column label="补贴费用" width="120" align="right">
<template slot-scope="scope"><span class="text-gray-400">@{{ formatMoney(scope.row.subsidy_fee) }}</span></template>
</el-table-column>
<el-table-column prop="remark" label="备注" width="200" show-overflow-tooltip></el-table-column>
<el-table-column prop="price" label="对私打款金额" width="140" align="right">
<template slot-scope="scope">
<span :class="scope.row.price < 0 ? 'text-red-600' : 'text-gray-800'">@{{ formatMoney(scope.row.price) }}</span>
</template>
</el-table-column>
<el-table-column prop="detail" label="明细" width="200" show-overflow-tooltip></el-table-column>
<el-table-column label="注明" width="100">
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.note || '-' }}</span></template>
</el-table-column>
<el-table-column prop="person_charge" label="负责人" width="100" show-overflow-tooltip></el-table-column>
</el-table>
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-100" v-if="!loading">
<div class="text-sm text-gray-500">共 @{{ total }} 条记录</div>
<el-pagination
background
layout="prev, pager, next, sizes, jumper"
:total="total"
:page-size="pageSize"
:current-page.sync="currentPage"
:page-sizes="[25, 50, 100]"
@size-change="onPageSizeChange"
@current-change="onPageChange">
</el-pagination>
</div>
</div>
</div>
</div>
@push('body_end')
<script src="{{ asset('public/js/ecommerce-finance/invoice-deductions.min.js') }}"></script>
@endpush

View File

@ -0,0 +1,139 @@
<div id="goods-reconciliation-app" class="goods-reconciliation-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">对账总金额</div>
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">预估总金额</div>
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalGuessAmount) }}</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">已发出</div>
<div class="text-2xl font-bold text-green-600">@{{ summary.sentCount.toLocaleString() }}</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">未发出</div>
<div class="text-2xl font-bold text-orange-500">@{{ summary.unsentCount.toLocaleString() }}</div>
</div>
</div>
{{-- 筛选栏 --}}
<div class="bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm">
<div class="flex flex-wrap items-center gap-3 mb-3">
<el-select v-model="filters.record_type" placeholder="记录类型" size="small" style="width: 140px" @change="onFilterChange">
<el-option label="全部记录" value="all"></el-option>
<el-option label="入库对账" value="purchase"></el-option>
<el-option label="代发对账" value="sale"></el-option>
</el-select>
<el-select v-model="filters.supplier" placeholder="供应商" size="small" style="width: 160px" filterable clearable @change="onFilterChange">
<el-option v-for="s in options.suppliers" :key="s" :label="s" :value="s"></el-option>
</el-select>
<el-select v-model="filters.warehouse" placeholder="仓库" size="small" style="width: 140px" filterable clearable @change="onFilterChange">
<el-option v-for="w in options.warehouses" :key="w" :label="w" :value="w"></el-option>
</el-select>
<el-input v-model="filters.stock_no" placeholder="入库单号" size="small" style="width: 140px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
<el-input v-model="filters.purchase_no" placeholder="采购单号" size="small" style="width: 140px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
</div>
<div class="flex flex-wrap items-center gap-3">
<el-input v-model="filters.product_code" placeholder="商品编码" size="small" style="width: 140px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
<el-input v-model="filters.express_no" placeholder="快递单号" size="small" style="width: 140px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
<el-date-picker v-model="filters.send_time_range" type="daterange"
start-placeholder="发货开始" end-placeholder="发货结束" size="small"
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
</el-date-picker>
<el-date-picker v-model="filters.create_time_range" type="daterange"
start-placeholder="创建开始" end-placeholder="创建结束" size="small"
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
</el-date-picker>
<el-select v-model="filters.sent_status" placeholder="发出状态" size="small" style="width: 120px" clearable @change="onFilterChange">
<el-option label="全部" value="all"></el-option>
<el-option label="已发出" value="sent"></el-option>
<el-option label="未发出" value="unsent"></el-option>
</el-select>
</div>
</div>
{{-- Tab 切换 --}}
<div class="flex gap-1 mb-4">
<button v-for="tab in tabs" :key="tab.key"
@click="switchTab(tab.key)"
:class="['px-4 py-2 rounded-lg text-sm font-medium transition-colors',
activeTab === tab.key ? 'bg-indigo-600 text-white shadow-sm' : 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-50']">
@{{ tab.label }}
</button>
</div>
{{-- 数据表格 --}}
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small"
:default-sort="{prop: 'createTime', order: 'descending'}"
@sort-change="onSortChange">
<el-table-column type="index" label="#" width="55" fixed="left"></el-table-column>
<el-table-column prop="recordType" label="类型" width="80">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.recordType === 'purchase' ? 'primary' : 'success'">
@{{ scope.row.recordType === 'purchase' ? '入库' : '代发' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="stockNo" label="入库单号" width="120" show-overflow-tooltip></el-table-column>
<el-table-column prop="purchaseNo" label="采购单号" width="110" show-overflow-tooltip></el-table-column>
<el-table-column prop="sendTime" label="发货时间" width="145" sortable="custom"></el-table-column>
<el-table-column prop="supplier" label="供应商" width="110" show-overflow-tooltip></el-table-column>
<el-table-column prop="skuCode" label="货号" width="130" show-overflow-tooltip></el-table-column>
<el-table-column prop="productCode" label="商品编码" width="120" show-overflow-tooltip></el-table-column>
<el-table-column prop="skuName" label="规格名称" width="180" show-overflow-tooltip></el-table-column>
<el-table-column prop="qty" label="数量" width="80" align="right"></el-table-column>
<el-table-column label="单价" width="100" align="right">
<template slot-scope="scope">@{{ formatMoney(scope.row.unitPrice) }}</template>
</el-table-column>
<el-table-column label="金额" width="120" align="right">
<template slot-scope="scope">@{{ formatMoney(scope.row.amount) }}</template>
</el-table-column>
<el-table-column label="预估单价" width="100" align="right">
<template slot-scope="scope">@{{ formatMoney(scope.row.guessUnitPrice) }}</template>
</el-table-column>
<el-table-column label="预估金额" width="120" align="right">
<template slot-scope="scope">@{{ formatMoney(scope.row.guessAmount) }}</template>
</el-table-column>
<el-table-column prop="supplierGoodsCode" label="供应商商品编码" width="140" show-overflow-tooltip></el-table-column>
<el-table-column prop="remark" label="备注" width="150" show-overflow-tooltip></el-table-column>
<el-table-column prop="warehouse" label="仓库名称" width="100" show-overflow-tooltip></el-table-column>
<el-table-column prop="createTime" label="创建时间" width="145" sortable="custom"></el-table-column>
<el-table-column prop="expressName" label="快递名称" width="100" show-overflow-tooltip></el-table-column>
<el-table-column prop="expressNo" label="快递单号" width="130" show-overflow-tooltip></el-table-column>
<el-table-column label="未发出" width="90" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.isUnsent" size="mini" type="warning">未发出</el-tag>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
</el-table>
{{-- 分页 --}}
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-100" v-if="!loading">
<div class="text-sm text-gray-500">共 @{{ total }} 条记录</div>
<el-pagination
background
layout="prev, pager, next, sizes, jumper"
:total="total"
:page-size="pageSize"
:current-page.sync="currentPage"
:page-sizes="[25, 50, 100]"
@size-change="onPageSizeChange"
@current-change="onPageChange">
</el-pagination>
</div>
</div>
</div>
</div>
@push('body_end')
<script src="{{ asset('public/js/ecommerce-finance/goods-reconciliations.min.js') }}"></script>
@endpush

View File

@ -0,0 +1,107 @@
<div id="return-reconciliation-app" class="return-reconciliation-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">对账总金额</div>
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">预估总金额</div>
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalGuessAmount) }}</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">总记录数</div>
<div class="text-2xl font-bold text-indigo-600">@{{ (summary.totalCount || 0).toLocaleString() }}</div>
</div>
</div>
{{-- 筛选栏 --}}
<div class="bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm">
<div class="flex flex-wrap items-center gap-3">
<el-input v-model="filters.supplier" placeholder="明细供应商" size="small" style="width: 160px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
<el-select v-model="filters.warehouse" placeholder="仓库" size="small" style="width: 140px" filterable clearable @change="onFilterChange">
<el-option v-for="w in options.warehouses" :key="w" :label="w" :value="w"></el-option>
</el-select>
<el-input v-model="filters.sku_id" placeholder="条形码" size="small" style="width: 160px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
<el-input v-model="filters.express_no" placeholder="快递单号" size="small" style="width: 160px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
<el-date-picker v-model="filters.date_range" type="daterange"
start-placeholder="发货开始" end-placeholder="发货结束" size="small"
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
</div>
</div>
{{-- 数据表格 --}}
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="send_time" label="发货时间" width="110" fixed="left" show-overflow-tooltip></el-table-column>
<el-table-column prop="supplier" label="明细供应商" width="110" show-overflow-tooltip></el-table-column>
<el-table-column prop="barcode" label="条形码" width="140" show-overflow-tooltip></el-table-column>
<el-table-column prop="sku_name" label="商品名称" width="220" show-overflow-tooltip></el-table-column>
<el-table-column prop="qty" label="发货量" width="80" align="right"></el-table-column>
<el-table-column label="单价" width="100" align="right">
<template slot-scope="scope">@{{ formatMoney(scope.row.unit_price) }}</template>
</el-table-column>
<el-table-column label="金额" width="110" align="right">
<template slot-scope="scope">@{{ formatMoney(scope.row.amount) }}</template>
</el-table-column>
<el-table-column label="预估单价" width="100" align="right">
<template slot-scope="scope">@{{ formatMoney(scope.row.guess_unit_price) }}</template>
</el-table-column>
<el-table-column label="预估金额" width="110" align="right">
<template slot-scope="scope">@{{ formatMoney(scope.row.guess_amount) }}</template>
</el-table-column>
<el-table-column prop="supplier_barcode" label="供应商条码" width="120" show-overflow-tooltip>
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.supplier_barcode || '-' }}</span></template>
</el-table-column>
<el-table-column prop="remark" label="备注" width="140" show-overflow-tooltip></el-table-column>
<el-table-column prop="title" label="标题" width="100" show-overflow-tooltip>
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.title || '-' }}</span></template>
</el-table-column>
<el-table-column prop="wms_pick_no" label="wms配货单号" width="120" show-overflow-tooltip></el-table-column>
<el-table-column prop="warehouse" label="仓库名称" width="120" show-overflow-tooltip></el-table-column>
<el-table-column prop="express_no" label="快递单号" width="130" show-overflow-tooltip></el-table-column>
<el-table-column prop="express_name" label="快递名称" width="100" show-overflow-tooltip></el-table-column>
<el-table-column prop="io_date" label="发货时间" width="110" show-overflow-tooltip></el-table-column>
<el-table-column prop="original_barcode" label="原条码" width="130" show-overflow-tooltip>
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.original_barcode || '-' }}</span></template>
</el-table-column>
<el-table-column prop="labels" label="标记多标签" width="120" show-overflow-tooltip>
<template slot-scope="scope">
<el-tag v-if="scope.row.labels" size="mini" type="warning">@{{ scope.row.labels }}</el-tag>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
<el-table-column prop="creator" label="创建人" width="100" show-overflow-tooltip></el-table-column>
<el-table-column prop="reconciler" label="对账人" width="100" show-overflow-tooltip>
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.reconciler || '-' }}</span></template>
</el-table-column>
</el-table>
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-100" v-if="!loading">
<div class="text-sm text-gray-500">共 @{{ total }} 条记录</div>
<el-pagination
background
layout="prev, pager, next, sizes, jumper"
:total="total"
:page-size="pageSize"
:current-page.sync="currentPage"
:page-sizes="[25, 50, 100]"
@size-change="onPageSizeChange"
@current-change="onPageChange">
</el-pagination>
</div>
</div>
</div>
</div>
@push('body_end')
<script src="{{ asset('public/js/ecommerce-finance/return-reconciliations.min.js') }}"></script>
@endpush

View File

@ -0,0 +1,93 @@
<div id="sample-deduction-app" class="sample-deduction-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">合计扣款金额</div>
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalAmount) }}</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">预估总金额</div>
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalEstimate) }}</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">总记录数</div>
<div class="text-2xl font-bold text-indigo-600">@{{ (summary.totalCount || 0).toLocaleString() }}</div>
</div>
</div>
{{-- 筛选栏 --}}
<div class="bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm">
<div class="flex flex-wrap items-center gap-3">
<el-input v-model="filters.supplier" placeholder="厂家" size="small" style="width: 160px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
<el-input v-model="filters.department" placeholder="部门" size="small" style="width: 160px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
<el-input v-model="filters.creator" placeholder="申请人" size="small" style="width: 120px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
<el-input v-model="filters.sku_id" placeholder="商品代码" size="small" style="width: 160px" clearable @clear="onFilterChange" @keyup.enter="onFilterChange"></el-input>
<el-date-picker v-model="filters.date_range" type="daterange"
start-placeholder="开始" end-placeholder="结束" size="small"
style="width: 240px" value-format="yyyy-MM-dd" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
</div>
</div>
{{-- 数据表格 --}}
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="finish_time" label="日期" width="110" fixed="left"></el-table-column>
<el-table-column prop="department" label="部门" width="200" show-overflow-tooltip></el-table-column>
<el-table-column prop="creator" label="申请人" width="100"></el-table-column>
<el-table-column prop="sku_id" label="商品代码" width="160" show-overflow-tooltip></el-table-column>
<el-table-column prop="skuName" label="商品名称" width="220" show-overflow-tooltip></el-table-column>
<el-table-column prop="num" label="数量" width="70" align="right"></el-table-column>
<el-table-column prop="supplier" label="厂家" width="120" show-overflow-tooltip></el-table-column>
<el-table-column prop="purchase" label="对应采购" width="200" show-overflow-tooltip></el-table-column>
<el-table-column prop="productCode" label="扣款厂家商品代码" width="160" show-overflow-tooltip></el-table-column>
<el-table-column prop="shipping_fee" label="运费" width="100" align="right">
<template slot-scope="scope">@{{ formatMoney(scope.row.shipping_fee) }}</template>
</el-table-column>
<el-table-column prop="cost_price" label="成本价" width="110" align="right">
<template slot-scope="scope">@{{ formatMoney(scope.row.cost_price) }}</template>
</el-table-column>
<el-table-column prop="total_amount" label="合计扣款金额" width="130" align="right">
<template slot-scope="scope">
<span :class="scope.row.total_amount < 0 ? 'text-red-600' : 'text-gray-800'">@{{ formatMoney(scope.row.total_amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="estimated_price" label="预估价" width="100" align="right">
<template slot-scope="scope">@{{ formatMoney(scope.row.estimated_price) }}</template>
</el-table-column>
<el-table-column prop="estimated_price_all" label="预估金额" width="110" align="right">
<template slot-scope="scope">@{{ formatMoney(scope.row.estimated_price_all) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" width="150" show-overflow-tooltip>
<template slot-scope="scope"><span class="text-gray-400">@{{ scope.row.remark || '-' }}</span></template>
</el-table-column>
<el-table-column prop="businessId" label="业务单号" width="200" show-overflow-tooltip></el-table-column>
</el-table>
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-100" v-if="!loading">
<div class="text-sm text-gray-500">共 @{{ total }} 条记录</div>
<el-pagination
background
layout="prev, pager, next, sizes, jumper"
:total="total"
:page-size="pageSize"
:current-page.sync="currentPage"
:page-sizes="[25, 50, 100]"
@size-change="onPageSizeChange"
@current-change="onPageChange">
</el-pagination>
</div>
</div>
</div>
</div>
@push('body_end')
<script src="{{ asset('public/js/ecommerce-finance/sample-deductions.min.js') }}"></script>
@endpush

142
scripts/build-frontend.ps1 Normal file
View File

@ -0,0 +1,142 @@
[CmdletBinding()]
param(
[string]$NodeVersion = '20.20.2',
[int]$MaxOldSpaceSizeMB = 8192
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$toolRoot = Join-Path $env:LOCALAPPDATA 'CaiWuXiTong\tools'
$nodeFolderName = "node-v$NodeVersion-win-x64"
$nodeRoot = Join-Path $toolRoot $nodeFolderName
$nodeExe = Join-Path $nodeRoot 'node.exe'
$npmCmd = Join-Path $nodeRoot 'npm.cmd'
$archiveName = "$nodeFolderName.zip"
$archivePath = Join-Path $toolRoot $archiveName
$checksumPath = Join-Path $toolRoot 'SHASUMS256.txt'
$trackedCssPath = Join-Path $repoRoot 'public/css/app.css'
$frontendOutputRoot = Join-Path $repoRoot '.caiwu-build/public'
$fingerprintPath = Join-Path $repoRoot '.caiwu-build/frontend.sha'
function Invoke-Native {
param(
[Parameter(Mandatory = $true)]
[scriptblock]$Command,
[Parameter(Mandatory = $true)]
[string]$FailureMessage
)
& $Command
if ($LASTEXITCODE -ne 0) {
throw $FailureMessage
}
}
$fingerprintInputs = @(
'HEAD:resources',
'HEAD:package.json',
'HEAD:package-lock.json',
'HEAD:webpack.mix.js',
'HEAD:tailwind.config.js'
)
$fingerprintHashes = @(& git -C $repoRoot rev-parse @fingerprintInputs)
if ($LASTEXITCODE -ne 0) {
throw '无法计算前端输入指纹。'
}
$frontendFingerprint = "$NodeVersion|$($fingerprintHashes -join '|')"
$cachedOutputs = @(
(Join-Path $frontendOutputRoot 'js/auth/common.min.js'),
(Join-Path $frontendOutputRoot 'css/app.css'),
(Join-Path $frontendOutputRoot 'mix-manifest.json'),
$fingerprintPath
)
$cacheComplete = -not ($cachedOutputs | Where-Object { -not (Test-Path -LiteralPath $_) })
if ($cacheComplete -and (Get-Content -Raw -LiteralPath $fingerprintPath).Trim() -eq $frontendFingerprint) {
Write-Host '前端输入未变化,复用已有构建产物。'
return
}
if (-not (Test-Path -LiteralPath $nodeExe)) {
New-Item -ItemType Directory -Force -Path $toolRoot | Out-Null
try {
$downloadRoot = "https://nodejs.org/dist/v$NodeVersion"
Invoke-WebRequest -Uri "$downloadRoot/$archiveName" -OutFile $archivePath
Invoke-WebRequest -Uri "$downloadRoot/SHASUMS256.txt" -OutFile $checksumPath
$checksumLine = Select-String -Path $checksumPath -Pattern ([regex]::Escape($archiveName) + '$') |
Select-Object -First 1
if (-not $checksumLine) {
throw 'Node 安装包缺少官方 SHA-256 校验值。'
}
$expectedHash = ($checksumLine.Line -split '\s+')[0].ToLowerInvariant()
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archivePath).Hash.ToLowerInvariant()
if ($actualHash -ne $expectedHash) {
throw 'Node 安装包 SHA-256 校验失败。'
}
Expand-Archive -LiteralPath $archivePath -DestinationPath $toolRoot -Force
}
finally {
if (Test-Path -LiteralPath $archivePath) {
Remove-Item -LiteralPath $archivePath -Force
}
if (Test-Path -LiteralPath $checksumPath) {
Remove-Item -LiteralPath $checksumPath -Force
}
}
}
if (-not (Test-Path -LiteralPath $nodeExe) -or -not (Test-Path -LiteralPath $npmCmd)) {
throw "项目专用 Node $NodeVersion 安装不完整。"
}
$originalPath = $env:Path
$originalNodeOptions = $env:NODE_OPTIONS
$trackedCssBackup = if (Test-Path -LiteralPath $trackedCssPath) {
[IO.File]::ReadAllBytes($trackedCssPath)
}
else {
$null
}
Push-Location $repoRoot
try {
$env:Path = "$nodeRoot;$originalPath"
$env:NODE_OPTIONS = "--max-old-space-size=$MaxOldSpaceSizeMB"
Invoke-Native { & $nodeExe --version } '项目专用 Node 无法运行。'
Invoke-Native {
& $npmCmd ci --prefer-offline --no-audit --no-fund
} '前端依赖安装失败。'
Invoke-Native { & $npmCmd run production } '前端资源构建失败。'
$requiredOutputs = @(
'public/js/auth/common.min.js',
'public/css/app.css',
'public/mix-manifest.json'
)
foreach ($output in $requiredOutputs) {
if (-not (Test-Path -LiteralPath (Join-Path $repoRoot $output))) {
throw "前端构建缺少产物:$output"
}
}
if (Test-Path -LiteralPath $frontendOutputRoot) {
Remove-Item -LiteralPath $frontendOutputRoot -Recurse -Force
}
New-Item -ItemType Directory -Force -Path (Join-Path $frontendOutputRoot 'css') | Out-Null
Copy-Item -LiteralPath (Join-Path $repoRoot 'public/js') -Destination $frontendOutputRoot -Recurse
Copy-Item -LiteralPath (Join-Path $repoRoot 'public/css/app.css') -Destination (Join-Path $frontendOutputRoot 'css/app.css')
Copy-Item -LiteralPath (Join-Path $repoRoot 'public/mix-manifest.json') -Destination $frontendOutputRoot
Set-Content -LiteralPath $fingerprintPath -Value $frontendFingerprint -NoNewline
}
finally {
Pop-Location
$env:Path = $originalPath
$env:NODE_OPTIONS = $originalNodeOptions
if ($null -ne $trackedCssBackup) {
[IO.File]::WriteAllBytes($trackedCssPath, $trackedCssBackup)
}
}

View File

@ -0,0 +1,404 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Server,
[Parameter(Mandatory = $true)]
[string]$KeyPath,
[string]$SshUser = 'root',
[string]$RemotePath = '/opt/caiwuxitong',
[string]$AppUrl,
[string]$BuilderName = 'caiwu-builder',
[string]$BuildkitImage = 'm.daocloud.io/docker.io/moby/buildkit:buildx-stable-1',
[string]$DatabaseImage = 'mariadb:11.4',
[string]$DatabaseMirrorImage = 'm.daocloud.io/docker.io/library/mariadb:11.4',
[int]$KeepImages = 2,
[switch]$BootstrapServer,
[switch]$ConfigureHttps,
[switch]$AllowNonMain,
[switch]$AllowMigrations
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$keyFullPath = (Resolve-Path $KeyPath).Path
$sshTarget = "${SshUser}@${Server}"
$imageName = 'caiwu_akaunting'
$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("caiwu-deploy-" + [Guid]::NewGuid().ToString('N'))
$localTar = Join-Path $tempRoot 'image.tar'
$localArchive = Join-Path $tempRoot 'image.tar.gz'
$remoteArchive = $null
$bootstrapRemote = $null
$sshTunnel = $null
$buildkitConfig = Join-Path $repoRoot 'deploy/buildkitd.toml'
$regctlVersion = 'v0.11.5'
$regctlSha256 = '9b83975d464e4ee5a9f899a3ead26072ed67e9a8ed0f582897bfad8141b45938'
function Invoke-Native {
param(
[Parameter(Mandatory = $true)]
[scriptblock]$Command,
[Parameter(Mandatory = $true)]
[string]$FailureMessage
)
& $Command
if ($LASTEXITCODE -ne 0) {
throw $FailureMessage
}
}
function Remove-OldProjectImages {
param([string]$CurrentImage)
$projectImages = @(
docker image ls $imageName --format '{{.Repository}}:{{.Tag}}' |
Where-Object { $_ -and $_ -ne '<none>:<none>' } |
Select-Object -Unique
)
$keep = @($CurrentImage)
foreach ($candidate in $projectImages) {
if ($keep.Count -ge $KeepImages) { break }
if ($candidate -notin $keep) { $keep += $candidate }
}
foreach ($candidate in $projectImages) {
if ($candidate -notin $keep) {
docker image rm $candidate 2>$null | Out-Null
}
}
}
function Compress-Gzip {
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[Parameter(Mandatory = $true)]
[string]$Destination
)
$inputStream = $null
$outputStream = $null
$gzipStream = $null
try {
$inputStream = [IO.File]::OpenRead($Source)
$outputStream = [IO.File]::Create($Destination)
$gzipStream = [IO.Compression.GZipStream]::new(
$outputStream,
[IO.Compression.CompressionLevel]::Fastest
)
$inputStream.CopyTo($gzipStream)
}
finally {
if ($gzipStream) { $gzipStream.Dispose() }
if ($outputStream) { $outputStream.Dispose() }
if ($inputStream) { $inputStream.Dispose() }
}
}
function Get-RegctlPath {
$toolRoot = Join-Path $env:LOCALAPPDATA 'CaiWuXiTong\tools'
$regctlPath = Join-Path $toolRoot "regctl-$regctlVersion.exe"
if (Test-Path -LiteralPath $regctlPath) {
$actualHash = (Get-FileHash -LiteralPath $regctlPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -eq $regctlSha256) {
return $regctlPath
}
Remove-Item -LiteralPath $regctlPath -Force
}
New-Item -ItemType Directory -Path $toolRoot -Force | Out-Null
$downloadPath = "$regctlPath.download"
try {
Invoke-WebRequest `
-Uri "https://github.com/regclient/regclient/releases/download/$regctlVersion/regctl-windows-amd64.exe" `
-OutFile $downloadPath
$actualHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $regctlSha256) {
throw "regctl 校验失败:预期 $regctlSha256,实际 $actualHash。"
}
Move-Item -LiteralPath $downloadPath -Destination $regctlPath -Force
}
finally {
if (Test-Path -LiteralPath $downloadPath) {
Remove-Item -LiteralPath $downloadPath -Force
}
}
return $regctlPath
}
function Get-AvailableLocalPort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
try {
$listener.Start()
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
}
finally {
$listener.Stop()
}
}
function Start-RegistryTunnel {
param([int]$LocalPort)
$sshPath = (Get-Command ssh -ErrorAction Stop).Source
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $sshPath
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
foreach ($argument in @(
'-N',
'-i', $keyFullPath,
'-o', 'BatchMode=yes',
'-o', 'ExitOnForwardFailure=yes',
'-o', 'ServerAliveInterval=15',
'-L', "127.0.0.1:${LocalPort}:127.0.0.1:5000",
$sshTarget
)) {
$startInfo.ArgumentList.Add($argument)
}
$process = [Diagnostics.Process]::Start($startInfo)
$attempt = 1
while ($attempt -le 20) {
if ($process.HasExited) {
throw 'SSH 增量传输隧道启动失败。'
}
try {
Invoke-WebRequest -Uri "http://127.0.0.1:$LocalPort/v2/" -TimeoutSec 1 | Out-Null
return $process
}
catch {
Start-Sleep -Milliseconds 500
$attempt++
}
}
$process.Kill()
$process.Dispose()
throw 'SSH 隧道已建立,但无法访问服务器本机 Registry。'
}
Push-Location $repoRoot
try {
$branch = (git branch --show-current).Trim()
if (-not $AllowNonMain -and $branch -ne 'main') {
throw "当前分支是 $branch。正式发布只允许 main;测试分支需显式添加 -AllowNonMain。"
}
$trackedChanges = git status --porcelain --untracked-files=no
if ($trackedChanges) {
throw '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。'
}
$deploySha = (git rev-parse HEAD).Trim()
$imageTag = $deploySha.Substring(0, 12)
$fullImage = "${imageName}:$imageTag"
$remoteArchive = "$RemotePath/image-$imageTag.tar.gz"
$buildDate = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
if (-not $AppUrl) {
$AppUrl = "https://${Server}"
}
Invoke-Native { docker version | Out-Null } 'Docker Desktop 未启动或不可用。'
& (Join-Path $PSScriptRoot 'build-frontend.ps1')
docker buildx inspect $BuilderName 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Invoke-Native {
docker buildx create `
--name $BuilderName `
--driver docker-container `
--driver-opt "image=$BuildkitImage" `
--buildkitd-config $buildkitConfig `
--use | Out-Null
} '创建项目专属 BuildKit 构建器失败。'
}
Invoke-Native { docker buildx inspect $BuilderName --bootstrap | Out-Null } '启动 BuildKit 构建器失败。'
Invoke-Native {
docker buildx build `
--builder $BuilderName `
--load `
--tag $fullImage `
--build-context 'frontend=.caiwu-build/public' `
--build-arg "VCS_REF=$deploySha" `
--build-arg "BUILD_DATE=$buildDate" `
--label 'com.caiwu.project=CaiWuXiTong' `
--file deploy/Dockerfile `
.
} '本机镜像构建失败。服务器未发生任何变更。'
$previousShaOutput = @(& ssh -i $keyFullPath -o BatchMode=yes -o StrictHostKeyChecking=accept-new $sshTarget "cat '$RemotePath/.deployed-sha' 2>/dev/null || true")
$previousSha = ($previousShaOutput -join "`n").Trim()
if ($LASTEXITCODE -ne 0) {
throw '无法连接服务器。'
}
$previousShaExists = $false
if ($previousSha) {
git cat-file -e "${previousSha}^{commit}" 2>$null
$previousShaExists = $LASTEXITCODE -eq 0
}
if ($previousSha -and -not $previousShaExists -and -not $AllowMigrations) {
throw "本地缺少服务器当前提交 $previousSha,无法安全比较数据库迁移。请先 git fetch,或在负责人授权后使用 -AllowMigrations。"
}
if ($previousSha -and $previousShaExists) {
$migrationChanges = git diff --name-only "$previousSha..$deploySha" -- database/migrations
if ($migrationChanges -and -not $AllowMigrations) {
throw "检测到数据库迁移:$($migrationChanges -join ', ')。需负责人确认后添加 -AllowMigrations。"
}
}
New-Item -ItemType Directory -Path $tempRoot | Out-Null
if ($BootstrapServer) {
$bootstrapRemote = "/tmp/caiwu-bootstrap-$imageTag.sh"
Invoke-Native {
scp -i $keyFullPath -o BatchMode=yes deploy/bootstrap-server.sh "${sshTarget}:$bootstrapRemote"
} '上传服务器初始化脚本失败。'
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$bootstrapRemote' && '$bootstrapRemote' '$AppUrl' '$RemotePath' && rm -f '$bootstrapRemote'"
} '服务器初始化失败。'
}
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'"
} '服务器 Docker 未就绪;首次发布请添加 -BootstrapServer。'
$migrationFlag = if ($AllowMigrations) { '1' } else { '0' }
Invoke-Native {
scp -i $keyFullPath -o BatchMode=yes `
compose.jiangling.yml `
deploy/activate-release.sh `
deploy/activate-incremental-release.sh `
deploy/update-runtime-url.sh `
deploy/setup-https.sh `
deploy/host-nginx-http.conf `
deploy/host-nginx.conf `
deploy/setup-incremental-registry.sh `
deploy/registry-config.yml `
"${sshTarget}:$RemotePath/"
} '上传运行配置失败。'
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/update-runtime-url.sh' && '$RemotePath/update-runtime-url.sh' '$AppUrl' '$RemotePath'"
} '更新服务器访问地址失败。'
if ($BootstrapServer) {
docker image inspect $DatabaseImage 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
docker image inspect $DatabaseMirrorImage 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Invoke-Native { docker pull $DatabaseMirrorImage } '下载数据库运行镜像失败。'
}
Invoke-Native { docker tag $DatabaseMirrorImage $DatabaseImage } '标记数据库运行镜像失败。'
}
Invoke-Native {
docker save --output $localTar $fullImage $DatabaseImage
} '导出应用与数据库运行镜像失败。'
Compress-Gzip -Source $localTar -Destination $localArchive
Remove-Item -LiteralPath $localTar -Force
$archiveSizeMB = [math]::Round((Get-Item -LiteralPath $localArchive).Length / 1MB, 1)
Write-Host "首次初始化全量包:$archiveSizeMB MiB"
Invoke-Native { scp -i $keyFullPath -o BatchMode=yes $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。'
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/'*.sh && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'"
} '服务器激活、健康检查或增量仓库初始化失败。'
}
else {
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/'*.sh && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml')"
} '服务器增量镜像仓库初始化失败。'
# docker save 导出未压缩层,每次导入重新压缩会改变摘要,导致 Registry 无法复用。
# 直接导出 BuildKit 原生 OCI 层,让未变化层的压缩摘要跨版本保持稳定。
Invoke-Native {
docker buildx build `
--builder $BuilderName `
--output "type=oci,dest=$localTar" `
--tag $fullImage `
--build-context 'frontend=.caiwu-build/public' `
--build-arg "VCS_REF=$deploySha" `
--build-arg "BUILD_DATE=$buildDate" `
--label 'com.caiwu.project=CaiWuXiTong' `
--file deploy/Dockerfile `
.
} '导出稳定分层的 OCI 应用镜像失败。'
$regctlPath = Get-RegctlPath
$localRegistryPort = Get-AvailableLocalPort
$sshTunnel = Start-RegistryTunnel -LocalPort $localRegistryPort
$registryImage = "127.0.0.1:${localRegistryPort}/caiwuxitong/app:$imageTag"
$serverRegistryImage = "127.0.0.1:5000/caiwuxitong/app:$imageTag"
$beforeBytesOutput = @(& ssh -i $keyFullPath -o BatchMode=yes $sshTarget "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1")
$beforeBytes = [long](($beforeBytesOutput -join '').Trim())
$uploadTimer = [Diagnostics.Stopwatch]::StartNew()
Invoke-Native {
& $regctlPath `
--host "reg=127.0.0.1:${localRegistryPort},tls=disabled" `
image import $registryImage $localTar
} '增量镜像层上传失败。'
$uploadTimer.Stop()
$afterBytesOutput = @(& ssh -i $keyFullPath -o BatchMode=yes $sshTarget "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1")
$afterBytes = [long](($afterBytesOutput -join '').Trim())
$incrementalMiB = [math]::Round([math]::Max(0, $afterBytes - $beforeBytes) / 1MB, 2)
Write-Host "增量新增镜像层:$incrementalMiB MiB;传输耗时:$([math]::Round($uploadTimer.Elapsed.TotalSeconds, 1)) 秒"
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "'$RemotePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'"
} '服务器增量激活或健康检查失败。'
}
if ($ConfigureHttps) {
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/setup-https.sh' && '$RemotePath/setup-https.sh' '$Server' '$RemotePath'"
} '服务器 HTTPS 配置或证书申请失败。'
}
Remove-OldProjectImages -CurrentImage $fullImage
Write-Host "发布完成:$fullImage -> $AppUrl"
}
finally {
Pop-Location
if (Test-Path -LiteralPath $tempRoot) {
Remove-Item -LiteralPath $tempRoot -Recurse -Force
}
if ($sshTunnel) {
if (-not $sshTunnel.HasExited) {
$sshTunnel.Kill()
}
$sshTunnel.Dispose()
}
if ($remoteArchive) {
ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$remoteArchive'" 2>$null | Out-Null
}
if ($bootstrapRemote) {
ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$bootstrapRemote'" 2>$null | Out-Null
}
$pruneHelp = docker buildx prune --help 2>$null | Out-String
docker buildx prune --builder $BuilderName --filter 'until=168h' --force 2>$null | Out-Null
if ($pruneHelp -match '--max-used-space') {
docker buildx prune --builder $BuilderName --max-used-space 8GB --force 2>$null | Out-Null
}
elseif ($pruneHelp -match '--keep-storage') {
docker buildx prune --builder $BuilderName --keep-storage 8GB --force 2>$null | Out-Null
}
docker system df
}