Compare commits
1 Commits
main
...
hotfix/yue
| Author | SHA1 | Date | |
|---|---|---|---|
| d9535cf9b8 |
@ -10,7 +10,6 @@ runtime
|
||||
*.md
|
||||
JetBrainsMono-字体
|
||||
node_modules
|
||||
**/__pycache__
|
||||
vendor
|
||||
bootstrap/cache/*
|
||||
.caiwu-build
|
||||
|
||||
8
.github/workflows/tests.yml
vendored
8
.github/workflows/tests.yml
vendored
@ -38,14 +38,8 @@ jobs:
|
||||
- name: Copy .env
|
||||
run: cp .env.testing .env
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install NPM
|
||||
run: npm ci --legacy-peer-deps --no-audit --no-fund
|
||||
run: npm install
|
||||
|
||||
- name: Compile assets
|
||||
run: npm run production
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -113,7 +113,6 @@ modules/*
|
||||
.workbuddy/
|
||||
runtime/.env
|
||||
/.private/kaifa-progress-sync/
|
||||
__pycache__/
|
||||
public/net-layer-demo.html
|
||||
public/perf-diff-plan.html
|
||||
public/plan-a-changes-202608.csv
|
||||
|
||||
@ -173,7 +173,7 @@
|
||||
"refersTo": "button-primary",
|
||||
"description": "浅色内容区的主要操作,使用可读深金而不是亮金。",
|
||||
"html": "<button type=\"button\" class=\"ds-btn-primary\">查询</button>",
|
||||
"css": ".ds-btn-primary { height: 34px; padding: 0 14px; border: 1px solid #856632; border-radius: 8px; background: #856632; color: #fff; font: 500 13px/1.5 'JetBrains Mono', 'CaiWu CJK', monospace; cursor: pointer; transition: background 150ms cubic-bezier(.22,1,.36,1), border-color 150ms cubic-bezier(.22,1,.36,1); } .ds-btn-primary:hover { background: #6A5129; border-color: #6A5129; } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-btn-primary:active { transform: translateY(1px); } .ds-btn-primary:disabled { opacity: .55; cursor: not-allowed; transform: none; }"
|
||||
"css": ".ds-btn-primary { height: 34px; padding: 0 14px; border: 1px solid #856632; border-radius: 8px; background: #856632; color: #fff; font: 500 13px/1.5 'JetBrains Mono', 'Microsoft YaHei', monospace; cursor: pointer; transition: background 150ms cubic-bezier(.22,1,.36,1), border-color 150ms cubic-bezier(.22,1,.36,1); } .ds-btn-primary:hover { background: #6A5129; border-color: #6A5129; } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-btn-primary:active { transform: translateY(1px); } .ds-btn-primary:disabled { opacity: .55; cursor: not-allowed; transform: none; }"
|
||||
},
|
||||
{
|
||||
"name": "Secondary Button",
|
||||
@ -181,7 +181,7 @@
|
||||
"refersTo": "button-secondary",
|
||||
"description": "次要操作保持白底、暖灰边界和正文墨色。",
|
||||
"html": "<button type=\"button\" class=\"ds-btn-secondary\">重置</button>",
|
||||
"css": ".ds-btn-secondary { height: 34px; padding: 0 14px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font: 500 13px/1.5 'JetBrains Mono', 'CaiWu CJK', monospace; cursor: pointer; transition: background 150ms cubic-bezier(.22,1,.36,1), border-color 150ms cubic-bezier(.22,1,.36,1); } .ds-btn-secondary:hover { border-color: #A17D3E; background: #FBF8F1; } .ds-btn-secondary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-btn-secondary:active { background: #F7F0DE; } .ds-btn-secondary:disabled { opacity: .55; cursor: not-allowed; }"
|
||||
"css": ".ds-btn-secondary { height: 34px; padding: 0 14px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font: 500 13px/1.5 'JetBrains Mono', 'Microsoft YaHei', monospace; cursor: pointer; transition: background 150ms cubic-bezier(.22,1,.36,1), border-color 150ms cubic-bezier(.22,1,.36,1); } .ds-btn-secondary:hover { border-color: #A17D3E; background: #FBF8F1; } .ds-btn-secondary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-btn-secondary:active { background: #F7F0DE; } .ds-btn-secondary:disabled { opacity: .55; cursor: not-allowed; }"
|
||||
},
|
||||
{
|
||||
"name": "Text Field",
|
||||
@ -189,7 +189,7 @@
|
||||
"refersTo": "input-default",
|
||||
"description": "紧凑、清晰、具有可见标签和深金焦点状态的业务输入框。",
|
||||
"html": "<label class=\"ds-field\"><span class=\"ds-field-label\">订单号</span><input class=\"ds-field-input\" type=\"text\" placeholder=\"请输入订单号\"></label>",
|
||||
"css": ".ds-field { display: inline-flex; min-width: 220px; flex-direction: column; gap: 5px; color: #171717; font-family: 'JetBrains Mono', 'CaiWu CJK', monospace; } .ds-field-label { color: #595959; font-size: 12px; font-weight: 500; line-height: 16px; } .ds-field-input { height: 34px; padding: 0 10px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font: 400 13px/1.5 inherit; outline: none; transition: border-color 150ms cubic-bezier(.22,1,.36,1), box-shadow 150ms cubic-bezier(.22,1,.36,1); } .ds-field-input::placeholder { color: #595959; } .ds-field-input:hover { border-color: #AFA99E; } .ds-field-input:focus { border-color: #856632; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-field-input:disabled { background: #EEE9DF; color: #595959; cursor: not-allowed; }"
|
||||
"css": ".ds-field { display: inline-flex; min-width: 220px; flex-direction: column; gap: 5px; color: #171717; font-family: 'JetBrains Mono', 'Microsoft YaHei', monospace; } .ds-field-label { color: #595959; font-size: 12px; font-weight: 500; line-height: 16px; } .ds-field-input { height: 34px; padding: 0 10px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font: 400 13px/1.5 inherit; outline: none; transition: border-color 150ms cubic-bezier(.22,1,.36,1), box-shadow 150ms cubic-bezier(.22,1,.36,1); } .ds-field-input::placeholder { color: #595959; } .ds-field-input:hover { border-color: #AFA99E; } .ds-field-input:focus { border-color: #856632; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-field-input:disabled { background: #EEE9DF; color: #595959; cursor: not-allowed; }"
|
||||
},
|
||||
{
|
||||
"name": "Sidebar Active Item",
|
||||
@ -197,7 +197,7 @@
|
||||
"refersTo": "sidebar-item-active",
|
||||
"description": "浅黑功能栏上的单一圆形 chevron 活动状态。",
|
||||
"html": "<div class=\"ds-sidebar\"><a class=\"ds-sidebar-item is-active\" href=\"#\"><svg class=\"ds-sidebar-icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M4 4h16v16H4zM8 9h8M8 13h8M8 17h5\"/></svg><span>店铺收款账户账单</span><span class=\"ds-active-indicator\" aria-hidden=\"true\">›</span></a></div>",
|
||||
"css": ".ds-sidebar { width: 224px; padding: 8px; background: #1D1D1F; } .ds-sidebar-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 4px; color: #F0E4BF; font: 500 12px/16px 'JetBrains Mono', 'CaiWu CJK', monospace; text-decoration: none; transition: background 150ms cubic-bezier(.22,1,.36,1); } .ds-sidebar-item:hover { background: rgba(240,228,191,.10); } .ds-sidebar-item.is-active { background: #2A2927; color: #F2E7C5; } .ds-sidebar-icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; } .ds-active-indicator { display: inline-flex; width: 16px; height: 16px; margin-left: auto; align-items: center; justify-content: center; border: 1px solid rgba(246,233,194,.72); border-radius: 999px; background: rgba(246,233,194,.14); color: #F6E9C2; font-size: 14px; } .ds-sidebar-item:focus-visible { outline: 2px solid #F2E7C5; outline-offset: 2px; }"
|
||||
"css": ".ds-sidebar { width: 224px; padding: 8px; background: #1D1D1F; } .ds-sidebar-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 4px; color: #F0E4BF; font: 500 12px/16px 'JetBrains Mono', 'Microsoft YaHei', monospace; text-decoration: none; transition: background 150ms cubic-bezier(.22,1,.36,1); } .ds-sidebar-item:hover { background: rgba(240,228,191,.10); } .ds-sidebar-item.is-active { background: #2A2927; color: #F2E7C5; } .ds-sidebar-icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; } .ds-active-indicator { display: inline-flex; width: 16px; height: 16px; margin-left: auto; align-items: center; justify-content: center; border: 1px solid rgba(246,233,194,.72); border-radius: 999px; background: rgba(246,233,194,.14); color: #F6E9C2; font-size: 14px; } .ds-sidebar-item:focus-visible { outline: 2px solid #F2E7C5; outline-offset: 2px; }"
|
||||
},
|
||||
{
|
||||
"name": "Workspace Tabs",
|
||||
@ -205,7 +205,7 @@
|
||||
"refersTo": "tab-active",
|
||||
"description": "白色工作区内使用深金活动文字和单一金色下划线的局部标签切换。",
|
||||
"html": "<nav class=\"ds-tabs\" aria-label=\"账户类型\"><a class=\"ds-tab is-active\" aria-current=\"page\" href=\"#\">淘系支付宝账户</a><a class=\"ds-tab\" href=\"#\">淘系微信账户</a></nav>",
|
||||
"css": ".ds-tabs { display: flex; align-items: center; gap: 36px; min-height: 34px; padding: 0 32px; overflow-x: auto; border: 1px solid #D8D2C7; border-radius: 16px; background: #fff; } .ds-tab { position: relative; display: inline-flex; min-height: 34px; align-items: center; color: #595959; font: 600 14px/1.25 'JetBrains Mono', 'CaiWu CJK', monospace; text-decoration: none; white-space: nowrap; transition: color 150ms cubic-bezier(.22,1,.36,1); } .ds-tab:hover, .ds-tab.is-active { color: #856632; } .ds-tab.is-active::after { position: absolute; right: 0; bottom: 0; left: 0; height: 3px; border-radius: 999px 999px 0 0; background: #B9954F; content: ''; } .ds-tab:focus-visible { outline: 2px solid #856632; outline-offset: 3px; }"
|
||||
"css": ".ds-tabs { display: flex; align-items: center; gap: 36px; min-height: 34px; padding: 0 32px; overflow-x: auto; border: 1px solid #D8D2C7; border-radius: 16px; background: #fff; } .ds-tab { position: relative; display: inline-flex; min-height: 34px; align-items: center; color: #595959; font: 600 14px/1.25 'JetBrains Mono', 'Microsoft YaHei', monospace; text-decoration: none; white-space: nowrap; transition: color 150ms cubic-bezier(.22,1,.36,1); } .ds-tab:hover, .ds-tab.is-active { color: #856632; } .ds-tab.is-active::after { position: absolute; right: 0; bottom: 0; left: 0; height: 3px; border-radius: 999px 999px 0 0; background: #B9954F; content: ''; } .ds-tab:focus-visible { outline: 2px solid #856632; outline-offset: 3px; }"
|
||||
},
|
||||
{
|
||||
"name": "Business Panel",
|
||||
@ -213,21 +213,21 @@
|
||||
"refersTo": "panel",
|
||||
"description": "使用白色表面、暖灰边界和有限圆角的单层业务容器。",
|
||||
"html": "<section class=\"ds-panel\"><h2 class=\"ds-panel-title\">账单明细</h2><p class=\"ds-panel-copy\">按账户和日期范围核对交易数据。</p></section>",
|
||||
"css": ".ds-panel { padding: 16px; border: 1px solid #D8D2C7; border-radius: 12px; background: #fff; color: #171717; font-family: 'JetBrains Mono', 'CaiWu CJK', monospace; } .ds-panel-title { margin: 0; color: #171717; font-size: 14px; font-weight: 600; line-height: 1.25; } .ds-panel-copy { max-width: 70ch; margin: 8px 0 0; color: #595959; font-size: 13px; line-height: 1.5; } .ds-panel:focus-within { border-color: #856632; }"
|
||||
"css": ".ds-panel { padding: 16px; border: 1px solid #D8D2C7; border-radius: 12px; background: #fff; color: #171717; font-family: 'JetBrains Mono', 'Microsoft YaHei', monospace; } .ds-panel-title { margin: 0; color: #171717; font-size: 14px; font-weight: 600; line-height: 1.25; } .ds-panel-copy { max-width: 70ch; margin: 8px 0 0; color: #595959; font-size: 13px; line-height: 1.5; } .ds-panel:focus-within { border-color: #856632; }"
|
||||
},
|
||||
{
|
||||
"name": "Status Tags",
|
||||
"kind": "chip",
|
||||
"description": "语义颜色只表达真实业务状态,并始终带文字。",
|
||||
"html": "<div class=\"ds-tags\"><span class=\"ds-tag ds-tag-success\">已核对</span><span class=\"ds-tag ds-tag-warning\">待处理</span><span class=\"ds-tag ds-tag-danger\">异常</span></div>",
|
||||
"css": ".ds-tags { display: flex; gap: 8px; font-family: 'JetBrains Mono', 'CaiWu CJK', monospace; } .ds-tag { display: inline-flex; min-height: 24px; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 500; } .ds-tag-success { background: #F1F6EE; color: #53793E; } .ds-tag-warning { background: #FEF5E7; color: #935F07; } .ds-tag-danger { background: #FAE6E6; color: #B80000; }"
|
||||
"css": ".ds-tags { display: flex; gap: 8px; font-family: 'JetBrains Mono', 'Microsoft YaHei', monospace; } .ds-tag { display: inline-flex; min-height: 24px; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 500; } .ds-tag-success { background: #F1F6EE; color: #53793E; } .ds-tag-warning { background: #FEF5E7; color: #935F07; } .ds-tag-danger { background: #FAE6E6; color: #B80000; }"
|
||||
},
|
||||
{
|
||||
"name": "Finance Data Table",
|
||||
"kind": "custom",
|
||||
"description": "紧凑、可扫描、数字右对齐的财务数据表。支持列显示/隐藏、拖动排序、恢复默认及按用户和具体表格保存;冻结多层表头,横向滚动条始终可达。导出明确筛选范围和可见列顺序,保留精度并排除密码;允许导入的业务提供模板、预览、校验及失败反馈,只读镜像不写回。",
|
||||
"html": "<table class=\"ds-table\"><thead><tr><th>订单号</th><th class=\"ds-num\">收入</th><th class=\"ds-num\">余额</th></tr></thead><tbody><tr><td>20260722001</td><td class=\"ds-num ds-income\">¥1,280.00</td><td class=\"ds-num\">¥8,460.00</td></tr></tbody></table>",
|
||||
"css": ".ds-table { width: 100%; border-collapse: collapse; overflow: hidden; border: 1px solid #D8D2C7; border-radius: 12px; background: #fff; color: #171717; font: 400 12.5px/1.5 'JetBrains Mono', 'CaiWu CJK', monospace; } .ds-table th { padding: 9px 10px; border-bottom: 1px solid #D8D2C7; background: #FBF8F1; color: #595959; font-size: 12px; font-weight: 600; text-align: left; } .ds-table td { padding: 9px 10px; border-bottom: 1px solid #EEE9DF; } .ds-table tbody tr:hover { background: #F7F0DE; } .ds-table .ds-num { text-align: right; font-variant-numeric: tabular-nums; } .ds-table .ds-income { color: #B80000; font-weight: 600; }"
|
||||
"css": ".ds-table { width: 100%; border-collapse: collapse; overflow: hidden; border: 1px solid #D8D2C7; border-radius: 12px; background: #fff; color: #171717; font: 400 12.5px/1.5 'JetBrains Mono', 'Microsoft YaHei', monospace; } .ds-table th { padding: 9px 10px; border-bottom: 1px solid #D8D2C7; background: #FBF8F1; color: #595959; font-size: 12px; font-weight: 600; text-align: left; } .ds-table td { padding: 9px 10px; border-bottom: 1px solid #EEE9DF; } .ds-table tbody tr:hover { background: #F7F0DE; } .ds-table .ds-num { text-align: right; font-variant-numeric: tabular-nums; } .ds-table .ds-income { color: #B80000; font-weight: 600; }"
|
||||
}
|
||||
],
|
||||
"narrative": {
|
||||
@ -236,7 +236,7 @@
|
||||
"keyCharacteristics": [
|
||||
"深黑工具轨道、浅黑功能栏、浅色内容区,三层边界清楚。",
|
||||
"浅金用于深色导航,深金用于浅色内容区的文字与主要操作。",
|
||||
"全站使用 JetBrains Mono 与仓库内置 CaiWu CJK 字体,标题和数据采用固定字号,不使用流体放大。",
|
||||
"全站使用 JetBrains Mono,标题和数据采用固定字号,不使用流体放大。",
|
||||
"页面标题位于内容区右侧,保持 14px;导航使用统一的紧凑字号。",
|
||||
"一、二级导航统一 12px;同一层级的图标、字号和交互状态必须一致。",
|
||||
"表格、筛选器和状态反馈优先,装饰性卡片、阴影和动画受到严格限制。",
|
||||
@ -250,7 +250,6 @@
|
||||
{ "name": "The Fixed-Scale Rule", "body": "产品界面使用固定 rem 字号,不使用 clamp() 放大标题;桌面与移动端通过布局重排适配,不通过夸张缩放字体适配。", "section": "typography" },
|
||||
{ "name": "The Stable-First-Paint Rule", "body": "关键字号、行高和容器尺寸必须由首屏 CSS 直接提供,不能等待 JavaScript 或异步样式后再缩小,禁止首次进入时出现巨大字体闪烁。", "section": "typography" },
|
||||
{ "name": "The One-Family Rule", "body": "按钮、标签、导航、表格和正文都使用同一字体家族;禁止在数据界面引入装饰性展示字体。", "section": "typography" },
|
||||
{ "name": "The Cross-Platform Control Rule", "body": "业务控件不得依赖 macOS、Windows 或不同浏览器的原生外观。字体使用仓库内置 Web 字体;下拉框、复选框、日期选择、联想输入使用项目自绘控件;桌面筛选区使用明确 CSS 网格。新页面必须在 Safari 与 Chromium 下验证布局、换行和控件尺寸一致。", "section": "typography" },
|
||||
{ "name": "The Flat-by-Default Rule", "body": "静态业务面板默认无阴影;若已经有 1px 边框,不得再叠加 16px 以上模糊半径的宽阴影。", "section": "elevation" },
|
||||
{ "name": "The No-Nested-Cards Rule", "body": "卡片只用于真实分组;卡片内部不能再套同样外观的卡片。优先使用标题、间距、分隔线和表格分组表达层级。", "section": "elevation" }
|
||||
],
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@ -38,15 +38,6 @@
|
||||
- 数据页面按本次影响验证代表性数据量下的首页、翻页和筛选耗时;明显长时间等待不能以“返回正确”判定通过。低风险改动不增加重复或仅复述实现的测试。
|
||||
- 明确区分:本地已验证、已提交/推送、已合并、已上线和仍需生产验收。
|
||||
|
||||
### 性能与资源验收(强制)
|
||||
|
||||
- 涉及查询、报表、导入导出、同步或后台任务时,开发前先确认实际数据规模、单次数据量、文件大小和运行频率,并据此选择实现与验收数据;不能只验证几十行样例。
|
||||
- Excel/CSV 必须流式读取;表扫描、关联和排序使用合适索引,页面数据使用分页。分批写库只控制写入批次,不能据此声称读取过程内存受控。
|
||||
- 重型任务复用项目现有独立批处理容器,完成命令登记,并明确内存上限、超时、互斥和失败恢复;不得占用网页服务资源执行全量处理。
|
||||
- 禁止通过取消 `memory_limit`、无限重试、放大数据库参数或其他绕过资源边界的办法掩盖性能问题。
|
||||
- 使用代表性数据测量耗时、进程峰值内存和数据库开销,并在交付说明中写明测量范围、数据量及未验证项。生产环境不运行压测,不随意重跑全量任务。
|
||||
- 性能优化不得改变金额口径、筛选范围或现有业务结果;相关金额和功能回归必须同时通过。
|
||||
|
||||
## 5. 前端与视觉
|
||||
|
||||
- 只有前端任务需要先阅读根目录 `DESIGN.md`;`.impeccable/design.json` 是同源机器规范。
|
||||
@ -80,7 +71,6 @@
|
||||
- 当前生产入口为 `https://caiwu.jiansuntuzhi.cn:8442`;公网仍只使用 `8442`,日常发布不得改成无端口地址。
|
||||
- 发布脚本检测到新增数据库迁移时,由 Codex 核对全部待执行迁移。仅涉及本任务共享镜像建表、字段、索引和同步登记的迁移,完成备份后自主使用 `-AllowMigrations`(macOS 为 `--allow-migrations`);其他迁移仍按第 6 节确认授权。不能因需要迁移开关就把镜像接入停给负责人。
|
||||
- 容器内 Laravel 命令使用 `www-data`。发布后只需确认部署 SHA、应用容器健康、受影响的真实页面/接口、迁移是否执行,以及当前和上一版镜像可回滚。
|
||||
- 生产重型 CLI 导入统一使用 `sh /opt/caiwuxitong/run-finance-batch.sh <命令> <原参数>`,不得在网页容器内直接执行或取消 PHP 内存限制。新增导入登记 `deploy/finance-batch-imports.json`;入口提供独立容器、互斥、资源与时间上限,主应用保留自己的内存边界。
|
||||
- 程序静态资源随应用镜像同源发布;七牛只存用户上传的业务图片、票据和附件。
|
||||
- 只有本地初始化、服务器维护、生产发布或数据库迁移任务需要读取 `docs/开发与部署SOP.md` 的对应章节;普通功能开发无需加载整份 SOP。
|
||||
|
||||
|
||||
15
DESIGN.md
15
DESIGN.md
@ -22,22 +22,22 @@ colors:
|
||||
info: "#006395"
|
||||
typography:
|
||||
title:
|
||||
fontFamily: "JetBrains Mono, CaiWu CJK, monospace"
|
||||
fontFamily: "JetBrains Mono, Microsoft YaHei, PingFang SC, monospace"
|
||||
fontSize: "0.875rem"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.25
|
||||
body:
|
||||
fontFamily: "JetBrains Mono, CaiWu CJK, monospace"
|
||||
fontFamily: "JetBrains Mono, Microsoft YaHei, PingFang SC, monospace"
|
||||
fontSize: "0.8125rem"
|
||||
fontWeight: 400
|
||||
lineHeight: 1.5
|
||||
label:
|
||||
fontFamily: "JetBrains Mono, CaiWu CJK, monospace"
|
||||
fontFamily: "JetBrains Mono, Microsoft YaHei, PingFang SC, monospace"
|
||||
fontSize: "0.75rem"
|
||||
fontWeight: 500
|
||||
lineHeight: 1.333
|
||||
data:
|
||||
fontFamily: "JetBrains Mono, CaiWu CJK, monospace"
|
||||
fontFamily: "JetBrains Mono, Microsoft YaHei, PingFang SC, monospace"
|
||||
fontSize: "0.78125rem"
|
||||
fontWeight: 400
|
||||
lineHeight: 1.5
|
||||
@ -180,9 +180,9 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
|
||||
|
||||
## 3. Typography
|
||||
|
||||
**Display Font:** JetBrains Mono (with bundled CaiWu CJK, monospace)
|
||||
**Display Font:** JetBrains Mono (with Microsoft YaHei, PingFang SC, monospace)
|
||||
|
||||
**Body Font:** JetBrains Mono (with bundled CaiWu CJK, monospace)
|
||||
**Body Font:** JetBrains Mono (with Microsoft YaHei, PingFang SC, monospace)
|
||||
|
||||
**Label/Mono Font:** JetBrains Mono
|
||||
|
||||
@ -206,8 +206,6 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
|
||||
|
||||
**The One-Family Rule.** 按钮、标签、导航、表格和正文都使用同一字体家族;禁止在数据界面引入装饰性展示字体。
|
||||
|
||||
**The Cross-Platform Control Rule.** 业务控件不得依赖 macOS、Windows 或不同浏览器的原生外观。字体使用仓库内置 Web 字体;下拉框、复选框、日期选择、联想输入使用项目自绘控件;桌面筛选区使用明确 CSS 网格。新页面必须在 Safari 与 Chromium 下验证布局、换行和控件尺寸一致。
|
||||
|
||||
## 4. Elevation
|
||||
|
||||
界面默认采用“色调分层 + 细边界”,不是依赖大阴影堆叠。工具轨道、功能栏和内容区通过背景明度形成第一层深度;内容面板通过白色表面与细边界形成第二层。阴影只用于浮层或短暂交互反馈。
|
||||
@ -265,7 +263,6 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
|
||||
- 标准高度 34px,8px 圆角,白底、1px 边界;标签位于字段上方,12px/500。
|
||||
- 聚焦使用深金边框和半透明焦点环;占位文字至少使用辅助墨色,不允许低对比浅灰。
|
||||
- 错误状态使用语义危险色、明确文字和错误图标;不能只把边框染红。
|
||||
- 不使用原生 `datalist`、系统日期框或未归一的系统下拉外观承载业务交互;关联选项使用项目自绘菜单或勾选面板,避免 macOS 与 Windows 显示不同。
|
||||
|
||||
### Cards / Containers
|
||||
|
||||
|
||||
@ -1,169 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
/**
|
||||
* 经营报表页面顶部的小格子指标。
|
||||
*
|
||||
* 汇总卡片只描述当前表本身,不把其他目录的数据拼成跨表大看板。
|
||||
*/
|
||||
class BusinessSummaryCards
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $summary
|
||||
* @return list<array{label:string,value:mixed,hint:string,format:string,query:array<string,mixed>,tone:string}>
|
||||
*/
|
||||
public function forPage(string $key, array $summary = []): array
|
||||
{
|
||||
return match ($key) {
|
||||
'store-management/store-report' => $summary === [] ? [] : $this->storeReportCards($summary),
|
||||
'company-management/subject-operating-report' => $summary === [] ? [] : $this->subjectOperatingReportCards($summary),
|
||||
'store-management/internal-flow-report' => $summary === [] ? [] : $this->internalFlowReportCards($summary),
|
||||
'company-management/company-cost-arrangement' => $this->companyCostArrangementCards(),
|
||||
'company-management/tax-forecast' => $this->taxForecastCards(),
|
||||
'company-management/personnel-inventory' => $this->personnelInventoryCards(),
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $summary */
|
||||
private function subjectOperatingReportCards(array $summary): array
|
||||
{
|
||||
$ready = (bool) ($summary['company_status_ready'] ?? false);
|
||||
$previousLabel = (string) ($summary['previous_month_label'] ?? '上月');
|
||||
$currentLabel = (string) ($summary['current_month_label'] ?? '当月');
|
||||
$previousStores = (int) ($summary['previous_income_entities'] ?? 0);
|
||||
$currentStores = (int) ($summary['current_income_entities'] ?? 0);
|
||||
|
||||
if (! $ready) {
|
||||
return [
|
||||
$this->card('正常存续主体', '—', '公司资料镜像未就绪,暂时无法剔除注销主体', [], 'text', 'warning'),
|
||||
$this->card('上月收入', '—', '公司资料镜像未就绪', [], 'text', 'warning'),
|
||||
$this->card('当月收入', '—', '公司资料镜像未就绪', [], 'text', 'warning'),
|
||||
$this->card('小规模纳税人', '—', '公司资料镜像未就绪', [], 'text'),
|
||||
$this->card('一般纳税人', '—', '公司资料镜像未就绪', [], 'text'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
$this->card('正常存续主体', $summary['normal'] ?? 0, '只统计公司台账标签为保留的主体', ['company_status' => '正常'], 'integer', 'accent'),
|
||||
$this->card($previousLabel . '收入', $summary['previous_income'] ?? 0, $previousStores . ' 个存续主体有收入', [
|
||||
'company_status' => '正常',
|
||||
'sort' => $summary['previous_month_key'] ?? null,
|
||||
'direction' => 'desc',
|
||||
], 'amount', 'success'),
|
||||
$this->card($currentLabel . '收入', $summary['current_month_income'] ?? 0, $currentStores . ' 个存续主体有收入', [
|
||||
'company_status' => '正常',
|
||||
'sort' => $summary['current_month_key'] ?? null,
|
||||
'direction' => 'desc',
|
||||
], 'amount', 'warning'),
|
||||
$this->card('小规模纳税人', $summary['small_taxpayer'] ?? 0, '正常存续主体中的小规模纳税人', [
|
||||
'company_status' => '正常',
|
||||
'tax_type' => '小规模纳税人',
|
||||
]),
|
||||
$this->card('一般纳税人', $summary['general_taxpayer'] ?? 0, '正常存续主体中的一般纳税人', [
|
||||
'company_status' => '正常',
|
||||
'tax_type' => '一般纳税人',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $summary */
|
||||
private function storeReportCards(array $summary): array
|
||||
{
|
||||
$previousLabel = (string) ($summary['previous_month_label'] ?? '上月');
|
||||
$currentLabel = (string) ($summary['current_month_label'] ?? '当月');
|
||||
$normalStores = (int) ($summary['normal_stores'] ?? 0);
|
||||
$previousStores = (int) ($summary['previous_month_stores'] ?? 0);
|
||||
$currentStores = (int) ($summary['current_month_stores'] ?? 0);
|
||||
|
||||
return [
|
||||
$this->card('正常存续店铺', $normalStores, '只统计开店中店铺,不含关店和已注销', ['status' => '0'], 'integer', 'accent'),
|
||||
$this->card($previousLabel . '销量', $summary['previous_month_sales'] ?? 0, $previousStores . ' 家存续店铺有销量', [
|
||||
'status' => '0',
|
||||
'sort' => $summary['previous_month_key'] ?? null,
|
||||
'direction' => 'desc',
|
||||
], 'amount', 'success'),
|
||||
$this->card($currentLabel . '销量', $summary['current_month_sales'] ?? 0, $currentStores . ' 家存续店铺有销量', [
|
||||
'status' => '0',
|
||||
'sort' => $summary['current_month_key'] ?? null,
|
||||
'direction' => 'desc',
|
||||
], 'amount', 'warning'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $summary */
|
||||
private function internalFlowReportCards(array $summary): array
|
||||
{
|
||||
return [
|
||||
$this->card('公司数', $summary['companies'] ?? 0, '当前筛选内内部流转公司', [], 'integer', 'accent'),
|
||||
$this->card('预计销量合计', $summary['projected_sales'] ?? 0, '当前筛选内预计销量合计', [
|
||||
'sort' => 'projected_sales',
|
||||
'direction' => 'desc',
|
||||
], 'amount'),
|
||||
$this->card('已收票合计', $summary['receipt_total'] ?? 0, '供应商、关联公司与内部流转收票合计', [
|
||||
'sort' => 'receipt_total',
|
||||
'direction' => 'desc',
|
||||
], 'amount', 'success'),
|
||||
$this->card('剩余缺口合计', $summary['remaining_gap'] ?? 0, '金额越大越应优先处理', [
|
||||
'sort' => 'remaining_gap',
|
||||
'direction' => 'desc',
|
||||
], 'amount', 'warning'),
|
||||
$this->card('平均开票比例', $summary['average_invoicing_ratio'] ?? 0, '按有比例的公司简单平均', [], 'percent'),
|
||||
];
|
||||
}
|
||||
|
||||
private function companyCostArrangementCards(): array
|
||||
{
|
||||
return [
|
||||
$this->card('数据状态', '待接入', '当前表尚无业务数据', [], 'text', 'warning'),
|
||||
$this->card('完整表头', '11 列', '包含序号,收入、成本、税金、人工等字段全部保留', [], 'text'),
|
||||
$this->card('核心结果', '预计结余 / 缺口', '当月收入预测减去成本与费用', [], 'text'),
|
||||
$this->card('缺口提示', '负值标红', '成本或费用未覆盖收入时提醒', [], 'text', 'danger'),
|
||||
$this->card('配平提示', '零或正结余标绿', '表示当月预算已覆盖', [], 'text', 'success'),
|
||||
];
|
||||
}
|
||||
|
||||
private function taxForecastCards(): array
|
||||
{
|
||||
return [
|
||||
$this->card('数据状态', '待取数', '规则已定义,业务数据尚未接入', [], 'text', 'warning'),
|
||||
$this->card('完整表头', '22 列', '包含序号、征管口径、收入、税额与备注', [], 'text'),
|
||||
$this->card('计算税种', '4 类', '增值税、附加税、印花税、所得税', [], 'text'),
|
||||
$this->card('计税口径', '3 项', '公司税务类型、工商类型和征收方式', [], 'text'),
|
||||
$this->card('当前记录', '0 条', '数据接入后按完整字段展示', [], 'text'),
|
||||
];
|
||||
}
|
||||
|
||||
private function personnelInventoryCards(): array
|
||||
{
|
||||
return [
|
||||
$this->card('数据状态', '待接入', '当前表尚无人员数据', [], 'text', 'warning'),
|
||||
$this->card('完整表头', '17 列', '包含序号和全部人员、容量字段', [], 'text'),
|
||||
$this->card('敏感字段', '4 项', '密码、身份证、实名手机和身份证地址只显示维护状态', [], 'text', 'danger'),
|
||||
$this->card('容量规则', '3 档', '可担任公司数量按风险档位提示', [], 'text'),
|
||||
$this->card('当前记录', '0 条', '接入后按正常在职人员汇总', [], 'text'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label:string,value:mixed,hint:string,format:string,query:array<string,mixed>,tone:string}
|
||||
*/
|
||||
private function card(
|
||||
string $label,
|
||||
mixed $value,
|
||||
string $hint,
|
||||
array $query = [],
|
||||
string $format = 'integer',
|
||||
string $tone = 'neutral',
|
||||
): array {
|
||||
return [
|
||||
'label' => $label,
|
||||
'value' => $value,
|
||||
'hint' => $hint,
|
||||
'format' => $format,
|
||||
'query' => ['page' => null, ...$query],
|
||||
'tone' => $tone,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,237 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use RuntimeException;
|
||||
|
||||
class InventoryInboundDetailImporter
|
||||
{
|
||||
private const TABLE = 'inventory_inbound_detail_rows';
|
||||
|
||||
/**
|
||||
* Replace the imported inbound detail snapshot only after the workbook has
|
||||
* been opened successfully. The delete and inserts share one transaction,
|
||||
* so a malformed file cannot leave the page with a partially imported table.
|
||||
*
|
||||
* @return array{imported:int,mapped:int,skipped:int,total:int}
|
||||
*/
|
||||
public function import(string $file, string $forcedMonth = ''): array
|
||||
{
|
||||
$path = realpath($file);
|
||||
if ($path === false || ! is_file($path) || ! is_readable($path)) {
|
||||
throw new RuntimeException('入库明细文件不存在或不可读取。');
|
||||
}
|
||||
|
||||
$forcedMonth = $this->normalizeForcedMonth($forcedMonth);
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$now = now();
|
||||
$maps = $this->shortNameMap();
|
||||
|
||||
$reader = IOFactory::createReaderForFile($path);
|
||||
$reader->setReadDataOnly(true);
|
||||
$spreadsheet = $reader->load($path);
|
||||
$sheet = $spreadsheet->getSheet(0);
|
||||
|
||||
try {
|
||||
return DB::connection($connection)->transaction(function () use (
|
||||
$connection,
|
||||
$sheet,
|
||||
$maps,
|
||||
$forcedMonth,
|
||||
$now,
|
||||
): array {
|
||||
DB::connection($connection)->table(self::TABLE)->delete();
|
||||
|
||||
$batch = [];
|
||||
$imported = 0;
|
||||
$mapped = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($sheet->getRowIterator(2) as $row) {
|
||||
$rowNumber = $row->getRowIndex();
|
||||
$cells = [];
|
||||
foreach ($row->getCellIterator('A', 'U') as $cell) {
|
||||
$cells[] = $cell->getFormattedValue();
|
||||
}
|
||||
|
||||
$warehouse = trim((string) ($cells[14] ?? ''));
|
||||
$createdRaw = trim((string) ($cells[16] ?? ''));
|
||||
if ($warehouse === '' && $createdRaw === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$month = $forcedMonth !== '' ? $forcedMonth : $this->monthOf($createdRaw);
|
||||
if ($month === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 代发仓按仓储方匹配;其它仓库按仓库名称做唯一匹配。
|
||||
$shortName = '';
|
||||
if ($warehouse !== '') {
|
||||
$shortName = str_contains($warehouse, '代发仓')
|
||||
? ($maps['provider'][$warehouse] ?? $maps['warehouse'][$warehouse] ?? '')
|
||||
: ($maps['warehouse'][$warehouse] ?? '');
|
||||
}
|
||||
if ($shortName !== '') {
|
||||
$mapped++;
|
||||
}
|
||||
|
||||
$batch[] = [
|
||||
'report_month' => $month,
|
||||
'row_key' => sha1(substr($month, 0, 7) . '|' . $rowNumber),
|
||||
'source_no' => $this->text($cells[0] ?? ''),
|
||||
'document_no' => $this->text($cells[1] ?? ''),
|
||||
'created_serial' => $this->text($cells[2] ?? ''),
|
||||
'supplier' => $this->text($cells[3] ?? ''),
|
||||
'item_no' => $this->text($cells[4] ?? ''),
|
||||
'barcode' => $this->text($cells[5] ?? ''),
|
||||
'spec_name' => $this->text($cells[6] ?? ''),
|
||||
'quantity' => $this->number($cells[7] ?? ''),
|
||||
'unit_price' => $this->number($cells[8] ?? ''),
|
||||
'amount' => $this->number($cells[9] ?? ''),
|
||||
'estimated_unit_price' => $this->number($cells[10] ?? ''),
|
||||
'estimated_amount' => $this->number($cells[11] ?? ''),
|
||||
'supplier_barcode' => $this->text($cells[12] ?? ''),
|
||||
'remark' => $this->text($cells[13] ?? ''),
|
||||
'warehouse' => $warehouse !== '' ? $warehouse : null,
|
||||
'warehouse_short_name' => $shortName !== '' ? $shortName : null,
|
||||
'created_at_raw' => $this->moment($createdRaw),
|
||||
'express_name' => $this->text($cells[17] ?? ''),
|
||||
'express_no' => $this->text($cells[18] ?? ''),
|
||||
'not_sent' => $this->text($cells[19] ?? ''),
|
||||
'owner_name' => $this->text($cells[20] ?? ''),
|
||||
'imported_at' => $now,
|
||||
];
|
||||
$imported++;
|
||||
|
||||
if (count($batch) >= 1000) {
|
||||
DB::connection($connection)->table(self::TABLE)->insertOrIgnore($batch);
|
||||
$batch = [];
|
||||
}
|
||||
}
|
||||
|
||||
if ($batch !== []) {
|
||||
DB::connection($connection)->table(self::TABLE)->insertOrIgnore($batch);
|
||||
}
|
||||
if ($imported === 0) {
|
||||
throw new RuntimeException('入库明细文件中没有可导入的数据。');
|
||||
}
|
||||
|
||||
return [
|
||||
'imported' => $imported,
|
||||
'mapped' => $mapped,
|
||||
'skipped' => $skipped,
|
||||
'total' => DB::connection($connection)->table(self::TABLE)->count(),
|
||||
];
|
||||
});
|
||||
} finally {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeForcedMonth(string $month): string
|
||||
{
|
||||
$month = trim($month);
|
||||
if ($month === '') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
|
||||
return $month . '-01';
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $month) === 1) {
|
||||
return $month;
|
||||
}
|
||||
|
||||
throw new RuntimeException('指定月份格式应为 YYYY-MM。');
|
||||
}
|
||||
|
||||
/** @return array{warehouse:array<string,string>,provider:array<string,string>} */
|
||||
private function shortNameMap(): array
|
||||
{
|
||||
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
|
||||
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$warehouse = trim((string) $row->warehouse);
|
||||
if ($warehouse === '') {
|
||||
continue;
|
||||
}
|
||||
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
|
||||
}
|
||||
|
||||
$byWarehouse = [];
|
||||
foreach ($grouped as $warehouse => $names) {
|
||||
$unique = array_values(array_unique($names));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byWarehouse[$warehouse] = $unique[0];
|
||||
}
|
||||
}
|
||||
|
||||
$byProvider = [];
|
||||
foreach ($rows as $row) {
|
||||
$provider = trim((string) $row->warehouse_provider);
|
||||
if ($provider !== '') {
|
||||
$byProvider[$provider][] = trim((string) $row->warehouse_short_name);
|
||||
}
|
||||
}
|
||||
foreach ($byProvider as $provider => $names) {
|
||||
$unique = array_values(array_unique($names));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byProvider[$provider] = $unique[0];
|
||||
}
|
||||
}
|
||||
|
||||
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
|
||||
}
|
||||
|
||||
private function monthOf(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
|
||||
$parsed = DateTimeImmutable::createFromFormat($format, $value);
|
||||
if ($parsed instanceof DateTimeImmutable) {
|
||||
return $parsed->format('Y-m') . '-01';
|
||||
}
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp === false ? '' : date('Y-m-01', $timestamp);
|
||||
}
|
||||
|
||||
private function moment(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
|
||||
private function text(mixed $value): ?string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function number(mixed $value): ?float
|
||||
{
|
||||
$text = str_replace(',', '', trim((string) $value));
|
||||
if ($text === '' || ! is_numeric($text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (float) $text;
|
||||
}
|
||||
}
|
||||
@ -15,16 +15,11 @@ use RuntimeException;
|
||||
* 两个数据源:
|
||||
* - 共享镜像 ops_jst_orders(常规,覆盖镜像有数据的区间);
|
||||
* - 聚水潭「导出订单」Excel(镜像没有的历史区间,例如 2026-03,只能靠财务导出补)。
|
||||
*
|
||||
* 卖家备注包含「SSDD」时同时生成 SSDD 标签。卖家备注不是聚水潭系统标签,
|
||||
* 但种菜判断需要把这类订单纳入标签明细。
|
||||
*/
|
||||
class JstOrderLabelImporter
|
||||
{
|
||||
public const NO_LABEL = '(无标签)';
|
||||
|
||||
public const SELLER_REMARK_LABEL = 'SSDD';
|
||||
|
||||
private const READ_CHUNK = 2000;
|
||||
|
||||
private const INSERT_CHUNK = 2000;
|
||||
@ -32,8 +27,6 @@ class JstOrderLabelImporter
|
||||
/** 聚水潭导出文件里用到的表头。 */
|
||||
private const EXPORT_COLUMNS = ['线上订单号', '店铺名称', '下单时间', '标签'];
|
||||
|
||||
private const EXPORT_OPTIONAL_COLUMNS = ['卖家备注'];
|
||||
|
||||
public function __construct(
|
||||
private readonly DatabaseManager $databases,
|
||||
private readonly ?XlsxSheetReader $exportReader = null,
|
||||
@ -105,10 +98,7 @@ class JstOrderLabelImporter
|
||||
continue;
|
||||
}
|
||||
|
||||
$labels = $this->labelsForRecord(
|
||||
(string) ($record->labels ?? ''),
|
||||
(string) ($record->remark ?? ''),
|
||||
);
|
||||
$labels = $this->splitLabels((string) ($record->labels ?? ''));
|
||||
if ($labels === []) {
|
||||
$labels = [self::NO_LABEL];
|
||||
$noLabelOrders++;
|
||||
@ -231,12 +221,9 @@ class JstOrderLabelImporter
|
||||
// 明细行带标签;不按订单判断就会给本来有标签的订单多写一行「(无标签)」。
|
||||
$labeledOrders = [];
|
||||
$anyRow = false;
|
||||
foreach ($reader->rows($path, self::EXPORT_COLUMNS, self::EXPORT_OPTIONAL_COLUMNS) as $line) {
|
||||
foreach ($reader->rows($path, self::EXPORT_COLUMNS) as $line) {
|
||||
$anyRow = true;
|
||||
if ($this->labelsForRecord(
|
||||
(string) ($line['标签'] ?? ''),
|
||||
(string) ($line['卖家备注'] ?? ''),
|
||||
) === []) {
|
||||
if ($this->splitLabels((string) ($line['标签'] ?? '')) === []) {
|
||||
continue;
|
||||
}
|
||||
$orderNo = trim((string) ($line['线上订单号'] ?? ''));
|
||||
@ -254,7 +241,7 @@ class JstOrderLabelImporter
|
||||
}
|
||||
|
||||
// 第二遍:按「线上订单号 + 标签」去重后写入。
|
||||
foreach ($reader->rows($path, self::EXPORT_COLUMNS, self::EXPORT_OPTIONAL_COLUMNS) as $line) {
|
||||
foreach ($reader->rows($path, self::EXPORT_COLUMNS) as $line) {
|
||||
$orderNo = trim((string) ($line['线上订单号'] ?? ''));
|
||||
$orderedAt = $this->normalizeOrderedAt((string) ($line['下单时间'] ?? ''));
|
||||
if ($orderNo === '' || $orderedAt === null) {
|
||||
@ -268,10 +255,7 @@ class JstOrderLabelImporter
|
||||
continue;
|
||||
}
|
||||
|
||||
$labels = $this->labelsForRecord(
|
||||
(string) ($line['标签'] ?? ''),
|
||||
(string) ($line['卖家备注'] ?? ''),
|
||||
);
|
||||
$labels = $this->splitLabels((string) ($line['标签'] ?? ''));
|
||||
$withoutLabel = $labels === [];
|
||||
if ($withoutLabel) {
|
||||
if (isset($labeledOrders[$orderNo])) {
|
||||
@ -366,18 +350,6 @@ class JstOrderLabelImporter
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function labelsForRecord(string $labels, string $sellerRemark): array
|
||||
{
|
||||
$labels = $this->splitLabels($labels);
|
||||
if (stripos($sellerRemark, self::SELLER_REMARK_LABEL) !== false
|
||||
&& ! in_array(self::SELLER_REMARK_LABEL, $labels, true)) {
|
||||
$labels[] = self::SELLER_REMARK_LABEL;
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
private function normalizeDate(string $value, string $label): string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
@ -1,141 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\LaborExpenseSnapshot;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LaborExpenseDataStore
|
||||
{
|
||||
public const MAX_ROWS = 2000;
|
||||
|
||||
/**
|
||||
* @return array{rows: array<int, array<string, mixed>>, row_count: int, source_name: ?string, saved_by: ?int, saved_at: ?string}
|
||||
*/
|
||||
public function load(): array
|
||||
{
|
||||
$snapshot = LaborExpenseSnapshot::query()
|
||||
->where('company_id', company_id())
|
||||
->first();
|
||||
|
||||
return [
|
||||
'rows' => is_array($snapshot?->rows) ? array_values($snapshot->rows) : [],
|
||||
'row_count' => (int) ($snapshot?->row_count ?? 0),
|
||||
'source_name' => $snapshot?->source_name,
|
||||
'saved_by' => $snapshot?->saved_by,
|
||||
'saved_at' => $snapshot?->saved_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $rows
|
||||
* @return array{rows: array<int, array<string, mixed>>, row_count: int, source_name: ?string, saved_by: ?int, saved_at: string}
|
||||
*/
|
||||
public function save(array $rows, ?string $sourceName, ?int $savedBy): array
|
||||
{
|
||||
if (count($rows) > self::MAX_ROWS) {
|
||||
throw ValidationException::withMessages([
|
||||
'rows' => '单次最多保存 ' . number_format(self::MAX_ROWS) . ' 行,请拆分后再保存。',
|
||||
]);
|
||||
}
|
||||
|
||||
$fields = collect(config('labor_expense_table.fields', []))
|
||||
->filter(static fn (array $field): bool => ($field['kind'] ?? null) !== 'sequence'
|
||||
&& blank($field['formula'] ?? null))
|
||||
->values();
|
||||
|
||||
if ($fields->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'rows' => '人工费用表没有可保存的录入列。',
|
||||
]);
|
||||
}
|
||||
|
||||
$companyId = (int) company_id();
|
||||
if ($companyId < 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'rows' => '当前没有可保存的公司。',
|
||||
]);
|
||||
}
|
||||
|
||||
$normalizedRows = [];
|
||||
foreach (array_values($rows) as $index => $row) {
|
||||
if (! is_array($row)) {
|
||||
throw ValidationException::withMessages([
|
||||
'rows' => '第 ' . ($index + 1) . ' 行数据格式无效。',
|
||||
]);
|
||||
}
|
||||
|
||||
$record = [];
|
||||
$hasValue = false;
|
||||
foreach ($fields as $field) {
|
||||
$value = $this->normalizeCell($row[$field['key']] ?? null, $field, $index + 1);
|
||||
$record[$field['key']] = $value;
|
||||
$hasValue = $hasValue || $value !== '';
|
||||
}
|
||||
|
||||
if ($hasValue) {
|
||||
$normalizedRows[] = $record;
|
||||
}
|
||||
}
|
||||
|
||||
$sourceName = trim((string) $sourceName);
|
||||
$sourceName = $sourceName === '' ? null : mb_substr($sourceName, 0, 191);
|
||||
$savedAt = now();
|
||||
|
||||
DB::transaction(function () use ($companyId, $sourceName, $savedBy, $savedAt, $normalizedRows): void {
|
||||
LaborExpenseSnapshot::query()->updateOrCreate(
|
||||
['company_id' => $companyId],
|
||||
[
|
||||
'source_name' => $sourceName,
|
||||
'rows' => $normalizedRows,
|
||||
'row_count' => count($normalizedRows),
|
||||
'saved_by' => $savedBy,
|
||||
'saved_at' => $savedAt,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
'rows' => $normalizedRows,
|
||||
'row_count' => count($normalizedRows),
|
||||
'source_name' => $sourceName,
|
||||
'saved_by' => $savedBy,
|
||||
'saved_at' => $savedAt->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeCell(mixed $value, array $field, int $line): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$text = trim((string) $value);
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (! in_array($field['kind'] ?? 'text', ['number', 'percent'], true)) {
|
||||
if (mb_strlen($text) > 120) {
|
||||
throw ValidationException::withMessages([
|
||||
'rows' => "第 {$line} 行“{$field['label']}”不能超过 120 个字符。",
|
||||
]);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
$isPercentText = ($field['kind'] ?? null) === 'percent' && str_ends_with($text, '%');
|
||||
$number = str_replace([',', ',', '¥', '¥', '%'], '', $text);
|
||||
if (! is_numeric($number)) {
|
||||
throw ValidationException::withMessages([
|
||||
'rows' => "第 {$line} 行“{$field['label']}”不是有效数字。",
|
||||
]);
|
||||
}
|
||||
|
||||
$normalized = (float) $number;
|
||||
|
||||
return $isPercentText ? $normalized / 100 : $normalized;
|
||||
}
|
||||
}
|
||||
@ -1,177 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Throwable;
|
||||
|
||||
class LaborExpenseWorkbookImporter
|
||||
{
|
||||
private const MAX_DATA_ROWS = 2000;
|
||||
|
||||
/**
|
||||
* @return array{rows: array<int, array<string, mixed>>, row_count: int, blank_count: int}
|
||||
*/
|
||||
public function import(UploadedFile $file): array
|
||||
{
|
||||
$fields = collect(config('labor_expense_table.fields', []))
|
||||
->filter(static fn (array $field): bool => ($field['kind'] ?? null) !== 'sequence'
|
||||
&& blank($field['formula'] ?? null));
|
||||
|
||||
if ($fields->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '人工费用表没有可导入的录入列。',
|
||||
]);
|
||||
}
|
||||
|
||||
$lookup = [];
|
||||
foreach ($fields as $field) {
|
||||
$lookup[$this->normalizeHeader($field['key'])] = $field['key'];
|
||||
$lookup[$this->normalizeHeader($field['label'])] = $field['key'];
|
||||
}
|
||||
|
||||
$spreadsheet = null;
|
||||
|
||||
try {
|
||||
$readerType = match (mb_strtolower($file->getClientOriginalExtension())) {
|
||||
'xlsx' => 'Xlsx',
|
||||
'xls' => 'Xls',
|
||||
'csv' => 'Csv',
|
||||
default => throw new \RuntimeException('Unsupported workbook format.'),
|
||||
};
|
||||
$reader = IOFactory::createReader($readerType);
|
||||
if (method_exists($reader, 'setReadDataOnly')) {
|
||||
$reader->setReadDataOnly(true);
|
||||
}
|
||||
$spreadsheet = $reader->load($file->getRealPath());
|
||||
[$sheetRows, $headerRow, $columns] = $this->bestSheet($spreadsheet, $lookup);
|
||||
} catch (Throwable) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
|
||||
]);
|
||||
} finally {
|
||||
if ($spreadsheet) {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
}
|
||||
|
||||
if ($sheetRows === null || $columns === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '未找到当前人工费用表的可录入列,请使用页面“导出全部字段”生成的文件作为模板。',
|
||||
]);
|
||||
}
|
||||
|
||||
$fieldMap = $fields->keyBy('key');
|
||||
$rows = [];
|
||||
$blankCount = 0;
|
||||
|
||||
foreach (array_slice($sheetRows, $headerRow + 1, null, true) as $rowIndex => $row) {
|
||||
$record = [];
|
||||
$hasValue = false;
|
||||
|
||||
foreach ($columns as $columnIndex => $fieldKey) {
|
||||
$field = $fieldMap[$fieldKey];
|
||||
$value = $this->normalizeCell(
|
||||
$row[$columnIndex] ?? null,
|
||||
$field['kind'] ?? 'text',
|
||||
(int) $rowIndex + 1,
|
||||
$field['label'] ?? $fieldKey,
|
||||
);
|
||||
$record[$fieldKey] = $value;
|
||||
$hasValue = $hasValue || $value !== '';
|
||||
}
|
||||
|
||||
if (! $hasValue) {
|
||||
$blankCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = $record;
|
||||
if (count($rows) > self::MAX_DATA_ROWS) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '单次最多导入 ' . number_format(self::MAX_DATA_ROWS) . ' 行,请拆分文件后重试。',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '表格中没有可导入的数据行。',
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'row_count' => count($rows),
|
||||
'blank_count' => $blankCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: array<int, array<int, mixed>>|null, 1: int|null, 2: array<int, string>}
|
||||
*/
|
||||
private function bestSheet(mixed $spreadsheet, array $lookup): array
|
||||
{
|
||||
$best = [null, null, []];
|
||||
$bestScore = 0;
|
||||
|
||||
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
|
||||
$rows = $worksheet->toArray(null, false, false, false);
|
||||
|
||||
foreach (array_slice($rows, 0, 30, true) as $rowIndex => $row) {
|
||||
$columns = [];
|
||||
|
||||
foreach ($row as $columnIndex => $heading) {
|
||||
$key = $this->normalizeHeader($heading);
|
||||
if ($key !== '' && isset($lookup[$key])) {
|
||||
$columns[$columnIndex] = $lookup[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$score = count(array_unique(array_values($columns)));
|
||||
if ($score > $bestScore) {
|
||||
$best = [$rows, (int) $rowIndex, $columns];
|
||||
$bestScore = $score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
private function normalizeCell(mixed $value, string $kind, int $line, string $label): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$text = trim((string) $value);
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (! in_array($kind, ['number', 'percent'], true)) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
$isPercentText = $kind === 'percent' && str_ends_with($text, '%');
|
||||
$number = str_replace([',', ',', '¥', '¥', '%'], '', $text);
|
||||
|
||||
if (! is_numeric($number)) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => "第 {$line} 行“{$label}”不是有效数字。",
|
||||
]);
|
||||
}
|
||||
|
||||
$normalized = (float) $number;
|
||||
|
||||
return $isPercentText ? $normalized / 100 : $normalized;
|
||||
}
|
||||
|
||||
private function normalizeHeader(mixed $value): string
|
||||
{
|
||||
return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()*::]+/u', '', trim((string) $value)));
|
||||
}
|
||||
}
|
||||
@ -1,261 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 汇总商品月加权平均单价结果,并将入库明细差异按原因分类。
|
||||
*/
|
||||
class ProductMonthlyWeightedAverageReporter
|
||||
{
|
||||
// 异常阈值未由业务另行指定:取至少 100 行,且不超过总行数的 0.1%。
|
||||
private const ANOMALY_THRESHOLD_MIN_ROWS = 100;
|
||||
|
||||
private const ANOMALY_THRESHOLD_RATE = 0.001;
|
||||
|
||||
private const ROW_TABLE = 'product_monthly_weighted_average_rows';
|
||||
|
||||
private const STATE_TABLE = 'product_monthly_weighted_average_states';
|
||||
|
||||
private const INBOUND_TABLE = 'inventory_inbound_detail_rows';
|
||||
|
||||
private const INVENTORY_MONTH_TABLE = 'inventory_report_authoritative_months';
|
||||
|
||||
private const INVENTORY_ROW_TABLE = 'inventory_report_authoritative_rows';
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function build(string $connection, CarbonImmutable $month): array
|
||||
{
|
||||
$month = $month->startOfMonth();
|
||||
$db = DB::connection($connection);
|
||||
$baseQuery = static fn () => $db->table(self::ROW_TABLE)
|
||||
->where('report_month', $month->toDateString());
|
||||
|
||||
$totals = $baseQuery()
|
||||
->selectRaw('count(*) as total_rows')
|
||||
->selectRaw('coalesce(sum(opening_quantity), 0) as opening_quantity')
|
||||
->selectRaw('coalesce(sum(opening_amount), 0) as opening_amount')
|
||||
->selectRaw('coalesce(sum(purchase_quantity), 0) as purchase_quantity')
|
||||
->selectRaw('coalesce(sum(purchase_amount), 0) as purchase_amount')
|
||||
->selectRaw('sum(case when weighted_average_unit_price is not null then 1 else 0 end) as weighted_price_rows')
|
||||
->selectRaw('sum(case when weighted_average_unit_price is null then 1 else 0 end) as missing_weighted_rows')
|
||||
->selectRaw('sum(case when pricing_status = ? then 1 else 0 end) as manual_review_rows', ['待人工核查'])
|
||||
->first();
|
||||
|
||||
if ($totals === null || (int) $totals->total_rows === 0) {
|
||||
return [
|
||||
'status' => 'missing',
|
||||
'report_month' => $month->toDateString(),
|
||||
'message' => '该月份尚未生成商品月加权平均单价。',
|
||||
'generated_at' => now()->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
$weightedKeys = [];
|
||||
foreach ($baseQuery()->select('product_code', 'warehouse_short_name')->cursor() as $row) {
|
||||
$weightedKeys[$this->key($row->product_code, $row->warehouse_short_name)] = true;
|
||||
}
|
||||
|
||||
$inbound = [
|
||||
'source_rows' => 0,
|
||||
'source_quantity' => 0.0,
|
||||
'source_amount' => 0.0,
|
||||
'matched_rows' => 0,
|
||||
'matched_quantity' => 0.0,
|
||||
'matched_amount' => 0.0,
|
||||
'unmatched_rows' => 0,
|
||||
'unmatched_quantity' => 0.0,
|
||||
'unmatched_amount' => 0.0,
|
||||
'unmatched_groups' => [],
|
||||
];
|
||||
foreach ($db->table(self::INBOUND_TABLE)
|
||||
->where('report_month', $month->toDateString())
|
||||
->selectRaw('upper(barcode) as code, warehouse_short_name, count(*) as rows_count')
|
||||
->selectRaw('coalesce(sum(quantity), 0) as quantity')
|
||||
->selectRaw('coalesce(sum(coalesce(amount, 0) + coalesce(estimated_amount, 0)), 0) as amount')
|
||||
->groupByRaw('upper(barcode), warehouse_short_name')
|
||||
->cursor() as $row) {
|
||||
$rows = (int) $row->rows_count;
|
||||
$quantity = (float) $row->quantity;
|
||||
$amount = (float) $row->amount;
|
||||
$inbound['source_rows'] += $rows;
|
||||
$inbound['source_quantity'] += $quantity;
|
||||
$inbound['source_amount'] += $amount;
|
||||
|
||||
if (isset($weightedKeys[$this->key($row->code, $row->warehouse_short_name)])) {
|
||||
$inbound['matched_rows'] += $rows;
|
||||
$inbound['matched_quantity'] += $quantity;
|
||||
$inbound['matched_amount'] += $amount;
|
||||
continue;
|
||||
}
|
||||
|
||||
$inbound['unmatched_rows'] += $rows;
|
||||
$inbound['unmatched_quantity'] += $quantity;
|
||||
$inbound['unmatched_amount'] += $amount;
|
||||
$inbound['unmatched_groups'][] = [
|
||||
'product_code' => trim((string) $row->code),
|
||||
'warehouse_short_name' => trim((string) $row->warehouse_short_name),
|
||||
'source_rows' => $rows,
|
||||
'quantity' => $this->number($quantity),
|
||||
'amount' => $this->number($amount),
|
||||
'reason' => '入库明细未匹配到商品编码 + 仓库简称行',
|
||||
];
|
||||
}
|
||||
usort(
|
||||
$inbound['unmatched_groups'],
|
||||
static fn (array $left, array $right): int =>
|
||||
abs((float) $right['amount']) <=> abs((float) $left['amount']),
|
||||
);
|
||||
$inbound = $this->roundNestedNumbers($inbound);
|
||||
|
||||
$monthState = $db->table(self::INVENTORY_MONTH_TABLE)
|
||||
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
|
||||
->where('source_month', $month->toDateString())
|
||||
->first();
|
||||
$generation = $monthState?->active_generation;
|
||||
$actualSourceRows = $generation === null
|
||||
? 0
|
||||
: $db->table(self::INVENTORY_ROW_TABLE)
|
||||
->where('generation', (string) $generation)
|
||||
->count();
|
||||
$expectedSourceRows = (int) ($monthState?->source_row_count ?? 0);
|
||||
|
||||
$weightedAnomalyRows = $baseQuery()
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('weighted_average_unit_price')
|
||||
->orWhere('pricing_status', '待人工核查');
|
||||
})
|
||||
->count();
|
||||
$reasons = [];
|
||||
if ((int) $totals->missing_weighted_rows > 0) {
|
||||
$reasons[] = [
|
||||
'code' => 'missing_weighted_price',
|
||||
'label' => '加权平均单价为空',
|
||||
'rows' => (int) $totals->missing_weighted_rows,
|
||||
];
|
||||
}
|
||||
if ((int) $totals->manual_review_rows > 0) {
|
||||
$reasons[] = [
|
||||
'code' => 'manual_review',
|
||||
'label' => '待人工核查',
|
||||
'rows' => (int) $totals->manual_review_rows,
|
||||
];
|
||||
}
|
||||
if ((int) $inbound['unmatched_rows'] > 0) {
|
||||
$reasons[] = [
|
||||
'code' => 'inbound_without_inventory_dimension',
|
||||
'label' => '入库明细未匹配到商品编码 + 仓库简称行',
|
||||
'rows' => (int) $inbound['unmatched_rows'],
|
||||
'groups' => count($inbound['unmatched_groups']),
|
||||
];
|
||||
}
|
||||
if ($actualSourceRows !== $expectedSourceRows) {
|
||||
$reasons[] = [
|
||||
'code' => 'upstream_source_row_mismatch',
|
||||
'label' => '上游进销存源表行数与已激活明细不一致',
|
||||
'rows' => abs($actualSourceRows - $expectedSourceRows),
|
||||
];
|
||||
}
|
||||
|
||||
$anomalyRows = $weightedAnomalyRows + (int) $inbound['unmatched_rows'];
|
||||
$threshold = max(
|
||||
self::ANOMALY_THRESHOLD_MIN_ROWS,
|
||||
(int) ceil((int) $totals->total_rows * self::ANOMALY_THRESHOLD_RATE),
|
||||
);
|
||||
|
||||
return [
|
||||
'status' => $reasons === [] ? 'computed' : 'review_required',
|
||||
'report_month' => $month->toDateString(),
|
||||
'totals' => [
|
||||
'rows' => (int) $totals->total_rows,
|
||||
'opening_quantity' => $this->number((float) $totals->opening_quantity),
|
||||
'opening_amount' => $this->number((float) $totals->opening_amount),
|
||||
'purchase_quantity' => $this->number((float) $totals->purchase_quantity),
|
||||
'purchase_amount' => $this->number((float) $totals->purchase_amount),
|
||||
'weighted_price_rows' => (int) $totals->weighted_price_rows,
|
||||
'missing_weighted_rows' => (int) $totals->missing_weighted_rows,
|
||||
'manual_review_rows' => (int) $totals->manual_review_rows,
|
||||
],
|
||||
'inbound' => $inbound,
|
||||
'source' => [
|
||||
'expected_rows' => $expectedSourceRows,
|
||||
'active_rows' => $actualSourceRows,
|
||||
'row_difference' => $actualSourceRows - $expectedSourceRows,
|
||||
'status' => (string) ($monthState?->status ?? 'missing'),
|
||||
'active_generation' => $generation,
|
||||
'source_filename' => $monthState?->source_filename,
|
||||
],
|
||||
'anomalies' => [
|
||||
'affected_rows' => $anomalyRows,
|
||||
'threshold_rows' => $threshold,
|
||||
'threshold_exceeded' => $anomalyRows > $threshold,
|
||||
'reasons' => $reasons,
|
||||
],
|
||||
'state' => $db->table(self::STATE_TABLE)
|
||||
->where('report_month', $month->toDateString())
|
||||
->first(),
|
||||
'generated_at' => now()->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $report */
|
||||
public function brief(array $report): string
|
||||
{
|
||||
if (($report['status'] ?? null) === 'missing') {
|
||||
return (string) ($report['message'] ?? '商品月加权平均单价尚未生成');
|
||||
}
|
||||
|
||||
$totals = $report['totals'] ?? [];
|
||||
$inbound = $report['inbound'] ?? [];
|
||||
$anomalies = $report['anomalies'] ?? [];
|
||||
$reasonLabels = array_map(
|
||||
static fn (array $reason): string =>
|
||||
(string) ($reason['label'] ?? $reason['code'] ?? '未知异常'),
|
||||
$anomalies['reasons'] ?? [],
|
||||
);
|
||||
|
||||
return sprintf(
|
||||
'%s:总行数 %s;期初 %s 件 / %s;入库 %s 件 / %s;加权单价 %s 行;异常 %s 行%s',
|
||||
substr((string) ($report['report_month'] ?? ''), 0, 7),
|
||||
number_format((int) ($totals['rows'] ?? 0)),
|
||||
number_format((float) ($totals['opening_quantity'] ?? 0), 2),
|
||||
number_format((float) ($totals['opening_amount'] ?? 0), 2),
|
||||
number_format((float) ($inbound['matched_quantity'] ?? 0), 2),
|
||||
number_format((float) ($inbound['matched_amount'] ?? 0), 2),
|
||||
number_format((int) ($totals['weighted_price_rows'] ?? 0)),
|
||||
number_format((int) ($anomalies['affected_rows'] ?? 0)),
|
||||
$reasonLabels === [] ? '' : '(' . implode('、', $reasonLabels) . ')',
|
||||
);
|
||||
}
|
||||
|
||||
private function key(mixed $productCode, mixed $warehouseShortName): string
|
||||
{
|
||||
return mb_strtoupper(trim((string) $productCode))
|
||||
. "\0"
|
||||
. trim((string) $warehouseShortName);
|
||||
}
|
||||
|
||||
private function number(float $value): float
|
||||
{
|
||||
return round($value, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $value
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function roundNestedNumbers(array $value): array
|
||||
{
|
||||
foreach ($value as $key => $item) {
|
||||
if (is_array($item)) {
|
||||
$value[$key] = $this->roundNestedNumbers($item);
|
||||
} elseif (is_float($item)) {
|
||||
$value[$key] = round($item, 6);
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
|
||||
use RuntimeException;
|
||||
|
||||
class StoreInformationDailyArchive
|
||||
{
|
||||
public const TABLE = 'store_information_daily_archives';
|
||||
|
||||
public function __construct(
|
||||
private readonly BusinessInformationMirrorRepository $mirror,
|
||||
private readonly UpstreamBusinessInformation $information,
|
||||
private readonly DatabaseManager $database,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 保存指定日期的完整店铺基础信息快照;同一天重跑时覆盖当天版本。
|
||||
*/
|
||||
public function capture(CarbonInterface $snapshotDate): int
|
||||
{
|
||||
if (! $this->mirror->ready('store-information.read')) {
|
||||
throw new RuntimeException('店铺基础信息共享镜像尚未完成首次同步。');
|
||||
}
|
||||
|
||||
$rows = $this->information->storesWithAccountOwnership(
|
||||
$this->mirror->allStores(),
|
||||
true,
|
||||
);
|
||||
if ($rows === []) {
|
||||
throw new RuntimeException('店铺基础信息共享镜像没有可存档的数据。');
|
||||
}
|
||||
|
||||
$snapshotDateValue = $snapshotDate->format('Y-m-d');
|
||||
$capturedAt = now('Asia/Shanghai')->format('Y-m-d H:i:s.u');
|
||||
$values = [];
|
||||
foreach ($rows as $row) {
|
||||
$recordId = trim((string) ($row['id'] ?? ''));
|
||||
if ($recordId === '') {
|
||||
continue;
|
||||
}
|
||||
$values[] = [
|
||||
'snapshot_date' => $snapshotDateValue,
|
||||
'store_record_id' => mb_substr($recordId, 0, 64),
|
||||
'erp_store_name' => mb_substr(trim((string) ($row['erp_store_name'] ?? '')), 0, 191),
|
||||
'store_name' => $this->nullable($row['store_name'] ?? null, 191),
|
||||
'company_name' => $this->nullable($row['company_name'] ?? null, 255),
|
||||
'store_manager' => $this->nullable($row['store_manager'] ?? null, 191),
|
||||
'department_manager' => $this->nullable($row['department_manager'] ?? null, 191),
|
||||
'profit_center' => $this->nullable($row['profit_center'] ?? null, 191),
|
||||
'store_status' => $this->nullable($row['store_status'] ?? null, 64),
|
||||
'payload' => json_encode(
|
||||
$row,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
|
||||
),
|
||||
'captured_at' => $capturedAt,
|
||||
'created_at' => $capturedAt,
|
||||
'updated_at' => $capturedAt,
|
||||
];
|
||||
}
|
||||
|
||||
if ($values === []) {
|
||||
throw new RuntimeException('店铺基础信息缺少可用的店铺记录 ID,未写入快照。');
|
||||
}
|
||||
|
||||
$connection = $this->database->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
);
|
||||
$connection->transaction(function () use ($connection, $values): void {
|
||||
foreach (array_chunk($values, 250) as $chunk) {
|
||||
$connection->table(self::TABLE)->upsert(
|
||||
$chunk,
|
||||
['snapshot_date', 'store_record_id'],
|
||||
[
|
||||
'erp_store_name', 'store_name', 'company_name',
|
||||
'store_manager', 'department_manager', 'profit_center',
|
||||
'store_status', 'payload', 'captured_at', 'updated_at',
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return count($values);
|
||||
}
|
||||
|
||||
private function nullable(mixed $value, int $limit): ?string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
|
||||
return $text === '' ? null : mb_substr($text, 0, $limit);
|
||||
}
|
||||
}
|
||||
@ -1,217 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Http\Controllers\Business\Pages;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 「业绩明细」差额规则的物化状态。
|
||||
*
|
||||
* 备注分类、有效差额、有/无差额原先都在查询时按同一套规则链逐行现算,整月
|
||||
* 30 多万行需要十几到几十秒。store-receipt:materialize-differences 把结果写进
|
||||
* store_receipt_order_match_rows 的 difference_reason_index / base_difference /
|
||||
* has_difference 三列,页面改读列。
|
||||
*
|
||||
* 规则表达式始终取自 Pages 的静态方法(唯一口径),这里只负责:
|
||||
* - 用规则表达式的指纹判断物化结果是否仍与当前代码一致;
|
||||
* - 记录/读取每个月是否已物化。
|
||||
*/
|
||||
class StoreReceiptDifferenceMaterialization
|
||||
{
|
||||
public const REASON_INDEX_COLUMN = 'difference_reason_index';
|
||||
|
||||
public const BASE_DIFFERENCE_COLUMN = 'base_difference';
|
||||
|
||||
public const HAS_DIFFERENCE_COLUMN = 'has_difference';
|
||||
|
||||
private const TABLE = 'store_receipt_order_match_rows';
|
||||
|
||||
private const STATE_TABLE = 'store_receipt_order_match_materializations';
|
||||
|
||||
/** @var array<string,bool> */
|
||||
private static array $memo = [];
|
||||
|
||||
public static function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public static function table(): string
|
||||
{
|
||||
return self::TABLE;
|
||||
}
|
||||
|
||||
public static function stateTable(): string
|
||||
{
|
||||
return self::STATE_TABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前规则表达式的指纹。规则改动后指纹变化,旧的物化结果会自动视为过期。
|
||||
*/
|
||||
public static function ruleVersion(): string
|
||||
{
|
||||
return md5(implode('|', [
|
||||
Pages::receiptDefaultDifferenceSql(),
|
||||
Pages::receiptDifferenceRemarkIndexSql(),
|
||||
Pages::receiptEffectiveDifferenceSql(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 物化列与状态表是否已就绪(迁移是否执行过)。
|
||||
*
|
||||
* 结果缓存 10 分钟:schema 探测在共享镜像库上每次要 0.3~0.5 秒,
|
||||
* 不能放在每个页面请求的热路径上。迁移执行后由 markMaterialized() 主动失效。
|
||||
*/
|
||||
public static function available(bool $fresh = false): bool
|
||||
{
|
||||
static $memo = null;
|
||||
|
||||
if (! $fresh) {
|
||||
if ($memo !== null) {
|
||||
return $memo;
|
||||
}
|
||||
|
||||
return $memo = (bool) Cache::remember(
|
||||
self::availabilityCacheKey(),
|
||||
now()->addMinutes(10),
|
||||
static fn (): bool => self::detectAvailability(),
|
||||
);
|
||||
}
|
||||
|
||||
$available = self::detectAvailability();
|
||||
if ($available) {
|
||||
Cache::put(self::availabilityCacheKey(), true, now()->addMinutes(10));
|
||||
}
|
||||
|
||||
return $memo = $available;
|
||||
}
|
||||
|
||||
private static function detectAvailability(): bool
|
||||
{
|
||||
try {
|
||||
$schema = Schema::connection(self::connection());
|
||||
|
||||
return $schema->hasTable(self::TABLE)
|
||||
&& $schema->hasTable(self::STATE_TABLE)
|
||||
&& $schema->hasColumn(self::TABLE, self::REASON_INDEX_COLUMN)
|
||||
&& $schema->hasColumn(self::TABLE, self::BASE_DIFFERENCE_COLUMN)
|
||||
&& $schema->hasColumn(self::TABLE, self::HAS_DIFFERENCE_COLUMN);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function availabilityCacheKey(): string
|
||||
{
|
||||
return 'store-receipt-materialization-available:v1';
|
||||
}
|
||||
|
||||
/**
|
||||
* 该账期是否已经按当前规则物化完成。未物化时页面回退到实时规则表达式。
|
||||
*/
|
||||
public static function materialized(string $month): bool
|
||||
{
|
||||
$month = substr(trim($month), 0, 10);
|
||||
if ($month === '' || ! self::available()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (array_key_exists($month, self::$memo)) {
|
||||
return self::$memo[$month];
|
||||
}
|
||||
|
||||
$cacheKey = 'store-receipt-materialized:' . $month . ':' . self::ruleVersion();
|
||||
|
||||
return self::$memo[$month] = (bool) Cache::remember($cacheKey, now()->addMinutes(10), function () use ($month): bool {
|
||||
$row = DB::connection(self::connection())
|
||||
->table(self::STATE_TABLE)
|
||||
->where('report_month', $month)
|
||||
->first();
|
||||
|
||||
if ($row === null || (string) ($row->rule_version ?? '') !== self::ruleVersion()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 过渡期保护:尚未部署新代码的同步任务重刷账期时不会写物化列,
|
||||
// 一旦出现 NULL 就回退实时口径,避免页面把未物化的行当成 0 差额。
|
||||
return ! DB::connection(self::connection())
|
||||
->table(self::TABLE)
|
||||
->where('report_month', $month)
|
||||
->whereNull(self::HAS_DIFFERENCE_COLUMN)
|
||||
->limit(1)
|
||||
->exists();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 该账期的数据版本:物化后自增,页面聚合/联动统计的缓存键带上它即可精确失效。
|
||||
*/
|
||||
public static function dataVersion(string $month): string
|
||||
{
|
||||
return (string) Cache::remember(
|
||||
self::dataVersionKey($month),
|
||||
now()->addDays(30),
|
||||
static fn (): int => 1,
|
||||
);
|
||||
}
|
||||
|
||||
public static function bumpDataVersion(string $month): void
|
||||
{
|
||||
$key = self::dataVersionKey($month);
|
||||
$current = (int) Cache::get($key, 1);
|
||||
Cache::put($key, $current + 1, now()->addDays(30));
|
||||
}
|
||||
|
||||
private static function dataVersionKey(string $month): string
|
||||
{
|
||||
return 'store-receipt-data-version:' . substr(trim($month), 0, 10);
|
||||
}
|
||||
|
||||
public static function markMaterialized(string $month, int $rowCount): void
|
||||
{
|
||||
$month = substr(trim($month), 0, 10);
|
||||
$connection = self::connection();
|
||||
|
||||
DB::connection($connection)->table(self::STATE_TABLE)->updateOrInsert(
|
||||
['report_month' => $month],
|
||||
[
|
||||
'rule_version' => self::ruleVersion(),
|
||||
'row_count' => $rowCount,
|
||||
'materialized_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
Cache::forget('store-receipt-materialized:' . $month . ':' . self::ruleVersion());
|
||||
Cache::forget(self::availabilityCacheKey());
|
||||
self::bumpDataVersion($month);
|
||||
unset(self::$memo[$month]);
|
||||
}
|
||||
|
||||
public static function forget(string $month): void
|
||||
{
|
||||
$month = substr(trim($month), 0, 10);
|
||||
Cache::forget('store-receipt-materialized:' . $month . ':' . self::ruleVersion());
|
||||
unset(self::$memo[$month]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 账期相邻月份(物化跨月备注规则时需要一起重算)。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function adjacentMonths(string $month): array
|
||||
{
|
||||
$date = \Illuminate\Support\Carbon::parse(substr($month, 0, 10) . ' 00:00:00');
|
||||
|
||||
return [
|
||||
$date->copy()->subMonthNoOverflow()->format('Y-m-01'),
|
||||
$date->copy()->format('Y-m-01'),
|
||||
$date->copy()->addMonthNoOverflow()->format('Y-m-01'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
/**
|
||||
* 平台数字 SKU → 历史已拆分内部商品编码。
|
||||
*
|
||||
* 平台 shop_sku_id 已包含平台规格维度;仅当同一 shop_sku_id + shop_i_id
|
||||
* 的历史内部编码唯一时才允许替换,多候选保持待核验。
|
||||
*/
|
||||
class StoreReceiptPlatformSkuMapper
|
||||
{
|
||||
/** @var array<string,array<string,int>> */
|
||||
private array $internalSkus = [];
|
||||
|
||||
/** @var array<string,array<string,int>> */
|
||||
private array $internalProperties = [];
|
||||
|
||||
/** @var array<string,int> */
|
||||
private array $historyRows = [];
|
||||
|
||||
/** @param iterable<object|array<string,mixed>> $historyRows */
|
||||
public function __construct(iterable $historyRows)
|
||||
{
|
||||
foreach ($historyRows as $row) {
|
||||
$shopSkuId = trim((string) $this->value($row, 'shop_sku_id'));
|
||||
$shopItemId = trim((string) $this->value($row, 'shop_i_id'));
|
||||
$skuId = trim((string) $this->value($row, 'sku_id'));
|
||||
$itemId = trim((string) $this->value($row, 'i_id'));
|
||||
if ($shopSkuId === '' || $shopItemId === '' || $skuId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 平台原始行的 i_id 等于 shop_i_id;内部拆分行的 i_id 是内部款式编码。
|
||||
if ($itemId === '' || $itemId === $shopItemId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $this->key($shopSkuId, $shopItemId);
|
||||
$this->internalSkus[$key][$skuId] = ($this->internalSkus[$key][$skuId] ?? 0) + 1;
|
||||
$property = trim((string) $this->value($row, 'properties_value'));
|
||||
if ($property !== '') {
|
||||
$this->internalProperties[$key][$property] = ($this->internalProperties[$key][$property] ?? 0) + 1;
|
||||
}
|
||||
$this->historyRows[$key] = ($this->historyRows[$key] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* status:string,
|
||||
* internal_sku:?string,
|
||||
* candidates:list<string>,
|
||||
* history_rows:int,
|
||||
* internal_properties:list<string>
|
||||
* }
|
||||
*/
|
||||
public function resolve(string $shopSkuId, string $shopItemId): array
|
||||
{
|
||||
$key = $this->key(trim($shopSkuId), trim($shopItemId));
|
||||
$skus = array_keys($this->internalSkus[$key] ?? []);
|
||||
sort($skus);
|
||||
$properties = array_keys($this->internalProperties[$key] ?? []);
|
||||
sort($properties);
|
||||
|
||||
return [
|
||||
'status' => count($skus) === 1 ? 'unique' : ($skus === [] ? 'missing' : 'ambiguous'),
|
||||
'internal_sku' => count($skus) === 1 ? $skus[0] : null,
|
||||
'candidates' => $skus,
|
||||
'history_rows' => (int) ($this->historyRows[$key] ?? 0),
|
||||
'internal_properties' => $properties,
|
||||
];
|
||||
}
|
||||
|
||||
private function key(string $shopSkuId, string $shopItemId): string
|
||||
{
|
||||
return $shopSkuId . "\0" . $shopItemId;
|
||||
}
|
||||
|
||||
private function value(object|array $row, string $key): mixed
|
||||
{
|
||||
return is_array($row) ? ($row[$key] ?? null) : ($row->{$key} ?? null);
|
||||
}
|
||||
}
|
||||
@ -4,13 +4,11 @@ namespace App\Business;
|
||||
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\StoreReportPeriodBuilder;
|
||||
|
||||
class StoreReceivingAccountSynchronizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BusinessInformationMirrorRepository $mirror,
|
||||
private readonly StoreReportPeriodBuilder $storeReportPeriods,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -37,15 +35,6 @@ class StoreReceivingAccountSynchronizer
|
||||
$byWallet = [];
|
||||
$byPlatformAndName = [];
|
||||
$byName = [];
|
||||
$sourceStores = $this->mirror->receivingAccountStores();
|
||||
// 店铺主体跟随店铺维度报表的“存续中”期间,不再直接取店铺档案当前主体。
|
||||
$currentCompanies = $this->storeReportPeriods->currentCompanies(array_map(
|
||||
static fn (array $row): array => [
|
||||
'erp_store_name' => trim((string) ($row['erp_store_name'] ?? '')),
|
||||
'system_company_name' => trim((string) ($row['company_name'] ?? '')),
|
||||
],
|
||||
$sourceStores,
|
||||
));
|
||||
|
||||
foreach ($existing as $account) {
|
||||
$platform = (string) $account->store_platform;
|
||||
@ -60,7 +49,7 @@ class StoreReceivingAccountSynchronizer
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($sourceStores as $row) {
|
||||
foreach ($this->mirror->receivingAccountStores() as $row) {
|
||||
$erpStoreName = trim((string) ($row['erp_store_name'] ?? ''));
|
||||
$platform = $this->classify($erpStoreName, (string) ($row['source_platform'] ?? ''));
|
||||
|
||||
@ -86,8 +75,7 @@ class StoreReceivingAccountSynchronizer
|
||||
'company_id' => $companyId,
|
||||
'store_platform' => $platform,
|
||||
'store_name' => $erpStoreName,
|
||||
'store_entity_name' => $currentCompanies[$erpStoreName]
|
||||
?? trim((string) ($row['company_name'] ?? '')),
|
||||
'store_entity_name' => trim((string) ($row['company_name'] ?? '')),
|
||||
'shop_name' => trim((string) ($row['shop_name'] ?? '')) ?: $erpStoreName,
|
||||
'store_status' => $this->status($row['store_status'] ?? null),
|
||||
'wallet_number' => $storeId,
|
||||
|
||||
@ -23,7 +23,7 @@ class StoreViolationAlertCalculator
|
||||
/** @var list<array<string,mixed>>|null */
|
||||
private ?array $rules = null;
|
||||
|
||||
/** @var array<string,array{operation_owner:string,operation_department_head:string,operation_director:string,profit_center:string}>|null */
|
||||
/** @var array<string,array{operation_owner:string,operation_director:string,profit_center:string}>|null */
|
||||
private ?array $ownerMap = null;
|
||||
|
||||
/** @var array<string,?string> */
|
||||
@ -236,8 +236,7 @@ class StoreViolationAlertCalculator
|
||||
if ($this->ownerMap === null) {
|
||||
$this->ownerMap = $this->storeAccounts->ownershipByStoreName();
|
||||
}
|
||||
$key = StoreAccountMirrorRepository::storeNameKey($name);
|
||||
$owner = trim((string) ($this->ownerMap[$key]['operation_owner'] ?? ''));
|
||||
$owner = trim((string) ($this->ownerMap[$name]['operation_owner'] ?? ''));
|
||||
if ($owner !== '') {
|
||||
return mb_substr($owner, 0, 64);
|
||||
}
|
||||
|
||||
@ -1,939 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
use DateTimeImmutable;
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use XMLReader;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* 供应商汇总表月度增量导入。
|
||||
*
|
||||
* 仅追加目标月份的新行,不删除其他月份。内容相同的源明细按出现次序分配
|
||||
* 稳定标识后全部保留;重复执行时只跳过已经写入的同一源行。
|
||||
* 单行字段异常会进入失败清单,不阻断其他有效行。
|
||||
*/
|
||||
class SupplierSummaryInboundImporter
|
||||
{
|
||||
private const TABLE = 'inventory_inbound_detail_rows';
|
||||
|
||||
private const DETAIL_SHEET = '明细';
|
||||
|
||||
/** @var array<string,list<string>> */
|
||||
private const REQUIRED_HEADERS = [
|
||||
'source_no' => ['来源单号'],
|
||||
'document_no' => ['单据编号(采购单号)', '单据编号(采购单号)', '单据编号'],
|
||||
'supplier' => ['供应商'],
|
||||
'item_no' => ['货号'],
|
||||
'barcode' => ['条码'],
|
||||
'spec_name' => ['规格名'],
|
||||
'quantity' => ['库存变动量'],
|
||||
'unit_price' => ['单价'],
|
||||
'amount' => ['金额'],
|
||||
'estimated_unit_price' => ['预估单价'],
|
||||
'estimated_amount' => ['预估金额'],
|
||||
'supplier_barcode' => ['供应商条码'],
|
||||
'remark' => ['备注'],
|
||||
'warehouse' => ['仓库'],
|
||||
];
|
||||
|
||||
/** @var array<string,list<string>> */
|
||||
private const OPTIONAL_HEADERS = [
|
||||
'express_name' => ['快递名称'],
|
||||
'express_no' => ['快递单号'],
|
||||
'not_sent' => ['未发出'],
|
||||
'owner_name' => ['负责人'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* status:string,
|
||||
* report_month:string,
|
||||
* source_filename:string,
|
||||
* success:int,
|
||||
* duplicates:int,
|
||||
* failed:int,
|
||||
* mapped:int,
|
||||
* total_in_table:int,
|
||||
* failure_reason_count:int,
|
||||
* failure_reasons:list<array{row:int,reason:string}>
|
||||
* }
|
||||
*/
|
||||
public function import(string $file, string $reportMonth): array
|
||||
{
|
||||
$path = realpath($file);
|
||||
if ($path === false || ! is_file($path) || ! is_readable($path)) {
|
||||
throw new RuntimeException('供应商汇总文件不存在或不可读取。');
|
||||
}
|
||||
|
||||
$month = $this->normalizeMonth($reportMonth);
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$maps = $this->shortNameMap();
|
||||
|
||||
[$reader, $sharedStrings] = $this->streamDetailSheet($path);
|
||||
|
||||
try {
|
||||
$success = 0;
|
||||
$duplicates = 0;
|
||||
$mapped = 0;
|
||||
$failed = 0;
|
||||
$reasons = [];
|
||||
$occurrences = [];
|
||||
$batch = [];
|
||||
|
||||
DB::connection($connection)->transaction(function () use (
|
||||
$connection,
|
||||
$reader,
|
||||
$sharedStrings,
|
||||
$maps,
|
||||
$month,
|
||||
&$success,
|
||||
&$duplicates,
|
||||
&$mapped,
|
||||
&$failed,
|
||||
&$reasons,
|
||||
&$occurrences,
|
||||
&$batch,
|
||||
): void {
|
||||
$flush = function () use (
|
||||
$connection,
|
||||
$month,
|
||||
&$success,
|
||||
&$duplicates,
|
||||
&$batch,
|
||||
): void {
|
||||
if ($batch === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$keys = array_column($batch, 'row_key');
|
||||
$existing = DB::connection($connection)
|
||||
->table(self::TABLE)
|
||||
->where('report_month', $month)
|
||||
->whereIn('row_key', $keys)
|
||||
->pluck('row_key')
|
||||
->map(static fn (mixed $key): string => (string) $key)
|
||||
->all();
|
||||
$existing = array_fill_keys($existing, true);
|
||||
|
||||
$insertable = [];
|
||||
foreach ($batch as $record) {
|
||||
if (isset($existing[$record['row_key']])) {
|
||||
$duplicates++;
|
||||
continue;
|
||||
}
|
||||
$insertable[] = $record;
|
||||
}
|
||||
|
||||
if ($insertable !== []) {
|
||||
$inserted = DB::connection($connection)
|
||||
->table(self::TABLE)
|
||||
->insertOrIgnore($insertable);
|
||||
$success += (int) $inserted;
|
||||
$duplicates += count($insertable) - (int) $inserted;
|
||||
}
|
||||
$batch = [];
|
||||
};
|
||||
|
||||
$headerRow = null;
|
||||
$columns = [];
|
||||
$headerCandidates = [];
|
||||
|
||||
while ($reader->read()) {
|
||||
if (
|
||||
$reader->nodeType !== XMLReader::ELEMENT
|
||||
|| $reader->name !== 'row'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowNumber = (int) $reader->getAttribute('r');
|
||||
$values = $this->readRow($reader, $sharedStrings);
|
||||
|
||||
if ($headerRow === null) {
|
||||
$headerCandidates[$rowNumber] = $values;
|
||||
$header = $this->headerColumns($headerCandidates);
|
||||
if ($header !== null) {
|
||||
[$headerRow, $columns] = $header;
|
||||
$headerCandidates = [];
|
||||
continue;
|
||||
}
|
||||
if ($rowNumber >= 20) {
|
||||
throw new RuntimeException(
|
||||
'「明细」子表前 20 行未找到供应商汇总标准表头。',
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rowNumber <= $headerRow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->emptyRow($values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$record = $this->record(
|
||||
$values,
|
||||
$columns,
|
||||
$maps,
|
||||
$month,
|
||||
$rowNumber,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$failed++;
|
||||
$reasons[] = [
|
||||
'row' => $rowNumber,
|
||||
'reason' => $exception->getMessage(),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$baseKey = (string) $record['row_key'];
|
||||
$occurrence = ($occurrences[$baseKey] ?? 0) + 1;
|
||||
$occurrences[$baseKey] = $occurrence;
|
||||
if ($occurrence > 1) {
|
||||
$record['row_key'] = hash(
|
||||
'sha256',
|
||||
$baseKey . '|occurrence:' . $occurrence,
|
||||
);
|
||||
}
|
||||
$mapped++;
|
||||
$batch[] = $record;
|
||||
|
||||
if (count($batch) >= 500) {
|
||||
$flush();
|
||||
}
|
||||
}
|
||||
|
||||
if ($headerRow === null) {
|
||||
throw new RuntimeException(
|
||||
'「明细」子表前 20 行未找到供应商汇总标准表头。',
|
||||
);
|
||||
}
|
||||
|
||||
$flush();
|
||||
});
|
||||
|
||||
$status = $failed === 0
|
||||
? 'success'
|
||||
: ($success > 0 ? 'partial' : 'failed');
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'report_month' => $month,
|
||||
'source_filename' => basename($path),
|
||||
'success' => $success,
|
||||
'duplicates' => $duplicates,
|
||||
'failed' => $failed,
|
||||
'mapped' => $mapped,
|
||||
'total_in_table' => DB::connection($connection)
|
||||
->table(self::TABLE)
|
||||
->where('report_month', $month)
|
||||
->count(),
|
||||
'failure_reason_count' => count($reasons),
|
||||
'failure_reasons' => $reasons,
|
||||
];
|
||||
} finally {
|
||||
$reader->close();
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeMonth(string $month): string
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
|
||||
throw new RuntimeException('月份格式应为 YYYY-MM。');
|
||||
}
|
||||
|
||||
return $month . '-01';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<int,mixed>> $rows
|
||||
* @return array{0:int,1:array<string,int>}|null
|
||||
*/
|
||||
private function headerColumns(array $rows): ?array
|
||||
{
|
||||
ksort($rows);
|
||||
foreach ($rows as $row => $values) {
|
||||
$positions = [];
|
||||
foreach ($values as $index => $value) {
|
||||
$heading = $this->normalizeHeader(
|
||||
(string) $this->plainValue($value),
|
||||
);
|
||||
if ($heading !== '') {
|
||||
$positions[$heading][] = $index;
|
||||
}
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
foreach (self::REQUIRED_HEADERS as $field => $aliases) {
|
||||
$position = $this->firstHeaderPosition($positions, $aliases);
|
||||
if ($position !== null) {
|
||||
$columns[$field] = $position;
|
||||
}
|
||||
}
|
||||
|
||||
$createdPositions = $positions[$this->normalizeHeader('创建时间')] ?? [];
|
||||
if (count($createdPositions) >= 2) {
|
||||
$columns['created_serial'] = $createdPositions[0];
|
||||
$columns['created_at_raw'] = $createdPositions[1];
|
||||
}
|
||||
|
||||
if (count($columns) < count(self::REQUIRED_HEADERS) + 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (self::OPTIONAL_HEADERS as $field => $aliases) {
|
||||
$position = $this->firstHeaderPosition($positions, $aliases);
|
||||
if ($position !== null) {
|
||||
$columns[$field] = $position;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['created_serial', 'created_at_raw'] as $field) {
|
||||
if (! isset($columns[$field])) {
|
||||
throw new RuntimeException('「明细」表头缺少第二个「创建时间」列。');
|
||||
}
|
||||
}
|
||||
|
||||
return [$row, $columns];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<string,list<int>> $positions @param list<string> $aliases */
|
||||
private function firstHeaderPosition(array $positions, array $aliases): ?int
|
||||
{
|
||||
foreach ($aliases as $alias) {
|
||||
$heading = $this->normalizeHeader($alias);
|
||||
if (isset($positions[$heading][0])) {
|
||||
return $positions[$heading][0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeHeader(string $heading): string
|
||||
{
|
||||
return preg_replace('/[\s\x{3000}]+/u', '', trim($heading)) ?? trim($heading);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $values
|
||||
* @param array<string,int> $columns
|
||||
* @param array{warehouse:array<string,string>,provider:array<string,string>} $maps
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function record(
|
||||
array $values,
|
||||
array $columns,
|
||||
array $maps,
|
||||
string $month,
|
||||
int $rowNumber,
|
||||
): array {
|
||||
$text = fn (string $field): ?string => $this->text(
|
||||
$values[$columns[$field] ?? -1] ?? null,
|
||||
);
|
||||
$number = function (string $label, string $field) use (
|
||||
$values,
|
||||
$columns,
|
||||
$rowNumber,
|
||||
): ?float {
|
||||
$value = $values[$columns[$field] ?? -1] ?? null;
|
||||
if (is_array($value)) {
|
||||
$formula = trim((string) ($value['__formula'] ?? ''));
|
||||
if ($formula !== '') {
|
||||
return $this->formulaValue(
|
||||
$label,
|
||||
$field,
|
||||
$formula,
|
||||
$rowNumber,
|
||||
$values,
|
||||
$columns,
|
||||
);
|
||||
}
|
||||
$value = $value['__cached'] ?? null;
|
||||
}
|
||||
|
||||
$text = str_replace(
|
||||
[',', ','],
|
||||
'',
|
||||
trim((string) $this->plainValue($value)),
|
||||
);
|
||||
if ($text === '') {
|
||||
return null;
|
||||
}
|
||||
if (! is_numeric($text)) {
|
||||
throw new RuntimeException("字段「{$label}」不是有效数字:" . $text);
|
||||
}
|
||||
|
||||
return (float) $text;
|
||||
};
|
||||
|
||||
$barcode = $text('barcode');
|
||||
$warehouse = $text('warehouse');
|
||||
if ($barcode === null) {
|
||||
throw new RuntimeException('商品编码(条码)为空');
|
||||
}
|
||||
if ($warehouse === null) {
|
||||
throw new RuntimeException('仓库为空');
|
||||
}
|
||||
|
||||
$shortName = $maps['warehouse'][$warehouse]
|
||||
?? $maps['provider'][$warehouse]
|
||||
?? null;
|
||||
if ($shortName === null || trim($shortName) === '') {
|
||||
throw new RuntimeException('仓库未匹配到唯一仓库简称:' . $warehouse);
|
||||
}
|
||||
|
||||
$createdRaw = $this->moment($values[$columns['created_at_raw']] ?? null);
|
||||
if ($createdRaw === null) {
|
||||
throw new RuntimeException('第二个「创建时间」为空或格式无效');
|
||||
}
|
||||
|
||||
$record = [
|
||||
'report_month' => $month,
|
||||
'source_no' => $text('source_no'),
|
||||
'document_no' => $text('document_no'),
|
||||
'created_serial' => $text('created_serial'),
|
||||
'supplier' => $text('supplier'),
|
||||
'item_no' => $text('item_no'),
|
||||
'barcode' => mb_strtoupper($barcode),
|
||||
'spec_name' => $text('spec_name'),
|
||||
'quantity' => $number('库存变动量', 'quantity'),
|
||||
'unit_price' => $number('单价', 'unit_price'),
|
||||
'amount' => $number('金额', 'amount'),
|
||||
'estimated_unit_price' => $number('预估单价', 'estimated_unit_price'),
|
||||
'estimated_amount' => $number('预估金额', 'estimated_amount'),
|
||||
'supplier_barcode' => $text('supplier_barcode'),
|
||||
'remark' => $text('remark'),
|
||||
'warehouse' => $warehouse,
|
||||
'warehouse_short_name' => $shortName,
|
||||
'created_at_raw' => $createdRaw,
|
||||
'express_name' => $text('express_name'),
|
||||
'express_no' => $text('express_no'),
|
||||
'not_sent' => $text('not_sent'),
|
||||
'owner_name' => $text('owner_name'),
|
||||
];
|
||||
|
||||
unset($record['warehouse_short_name']);
|
||||
$record['row_key'] = hash(
|
||||
'sha256',
|
||||
json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
);
|
||||
$record['warehouse_short_name'] = $shortName;
|
||||
$record['imported_at'] = now();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅接受金额类字段当前行内的乘积公式,避免执行来源文件中的任意公式。
|
||||
*
|
||||
* @param array<int,mixed> $values
|
||||
* @param array<string,int> $columns
|
||||
*/
|
||||
private function formulaValue(
|
||||
string $label,
|
||||
string $field,
|
||||
string $formula,
|
||||
int $rowNumber,
|
||||
array $values,
|
||||
array $columns,
|
||||
): float {
|
||||
$expectedFields = match ($field) {
|
||||
'amount' => ['quantity', 'unit_price'],
|
||||
'estimated_amount' => ['estimated_unit_price', 'quantity'],
|
||||
default => [],
|
||||
};
|
||||
if ($expectedFields === []) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」不允许使用公式:" . $formula,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
preg_match(
|
||||
'/^=?\s*\$?([A-Z]{1,3})\$?(\d+)\s*\*\s*\$?([A-Z]{1,3})\$?(\d+)\s*$/i',
|
||||
$formula,
|
||||
$matches,
|
||||
) !== 1
|
||||
) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」公式无法安全识别:" . $formula,
|
||||
);
|
||||
}
|
||||
|
||||
if ((int) $matches[2] !== $rowNumber || (int) $matches[4] !== $rowNumber) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」公式引用了其他行:" . $formula,
|
||||
);
|
||||
}
|
||||
|
||||
$actualColumns = [
|
||||
$this->columnIndex($matches[1]),
|
||||
$this->columnIndex($matches[3]),
|
||||
];
|
||||
$expectedColumns = [
|
||||
$columns[$expectedFields[0]] + 1,
|
||||
$columns[$expectedFields[1]] + 1,
|
||||
];
|
||||
sort($actualColumns);
|
||||
sort($expectedColumns);
|
||||
if ($actualColumns !== $expectedColumns) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」公式与取数口径不一致:" . $formula,
|
||||
);
|
||||
}
|
||||
|
||||
$left = $this->numericReference(
|
||||
$values[$actualColumns[0] - 1] ?? null,
|
||||
$label,
|
||||
);
|
||||
$right = $this->numericReference(
|
||||
$values[$actualColumns[1] - 1] ?? null,
|
||||
$label,
|
||||
);
|
||||
|
||||
return $left * $right;
|
||||
}
|
||||
|
||||
private function numericReference(mixed $value, string $label): float
|
||||
{
|
||||
if (is_array($value)) {
|
||||
if (($value['__formula'] ?? '') !== '') {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」的公式引用了另一个公式单元格",
|
||||
);
|
||||
}
|
||||
$value = $value['__cached'] ?? null;
|
||||
}
|
||||
|
||||
$text = str_replace(
|
||||
[',', ','],
|
||||
'',
|
||||
trim((string) $this->plainValue($value)),
|
||||
);
|
||||
if (! is_numeric($text)) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」的公式引用值不是有效数字:" . $text,
|
||||
);
|
||||
}
|
||||
|
||||
return (float) $text;
|
||||
}
|
||||
|
||||
/** @param array<int,mixed> $values */
|
||||
private function emptyRow(array $values): bool
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
if (trim((string) $this->plainValue($value)) !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function text(mixed $value): ?string
|
||||
{
|
||||
$text = trim((string) $this->plainValue($value));
|
||||
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function plainValue(mixed $value): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value['__cached'] ?? null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function moment(mixed $value): ?string
|
||||
{
|
||||
$value = $this->plainValue($value);
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return null;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
try {
|
||||
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d H:i:s');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
|
||||
$date = DateTimeImmutable::createFromFormat($format, $value);
|
||||
if ($date instanceof DateTimeImmutable) {
|
||||
return $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
|
||||
/** @return array{warehouse:array<string,string>,provider:array<string,string>} */
|
||||
private function shortNameMap(): array
|
||||
{
|
||||
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
|
||||
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$warehouse = trim((string) $row->warehouse);
|
||||
if ($warehouse !== '') {
|
||||
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
|
||||
}
|
||||
}
|
||||
|
||||
$byWarehouse = [];
|
||||
foreach ($grouped as $warehouse => $names) {
|
||||
$unique = array_values(array_unique($names));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byWarehouse[$warehouse] = $unique[0];
|
||||
}
|
||||
}
|
||||
|
||||
$providerGrouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$provider = trim((string) $row->warehouse_provider);
|
||||
if ($provider !== '') {
|
||||
$providerGrouped[$provider][] = [
|
||||
'warehouse' => trim((string) $row->warehouse),
|
||||
'short_name' => trim((string) $row->warehouse_short_name),
|
||||
];
|
||||
}
|
||||
}
|
||||
$byProvider = [];
|
||||
foreach ($providerGrouped as $provider => $providerRows) {
|
||||
$unique = array_values(array_unique(array_column(
|
||||
$providerRows,
|
||||
'short_name',
|
||||
)));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byProvider[$provider] = $unique[0];
|
||||
continue;
|
||||
}
|
||||
|
||||
$mainShortNames = [];
|
||||
foreach ($providerRows as $providerRow) {
|
||||
if (str_contains($providerRow['warehouse'], '主仓')) {
|
||||
$mainShortNames[] = $providerRow['short_name'];
|
||||
}
|
||||
}
|
||||
$mainShortNames = array_values(array_unique($mainShortNames));
|
||||
if (count($mainShortNames) === 1 && $mainShortNames[0] !== '') {
|
||||
$byProvider[$provider] = $mainShortNames[0];
|
||||
}
|
||||
}
|
||||
|
||||
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
|
||||
}
|
||||
|
||||
/** @return array{0:XMLReader,1:list<string>} */
|
||||
private function streamDetailSheet(string $path): array
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($path) !== true) {
|
||||
throw new RuntimeException('供应商汇总文件不是有效的 XLSX 工作簿。');
|
||||
}
|
||||
|
||||
try {
|
||||
$sheetPath = $this->detailSheetPath($zip);
|
||||
$sharedStrings = $zip->locateName('xl/sharedStrings.xml') !== false
|
||||
? $this->readSharedStrings($path)
|
||||
: [];
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
return [
|
||||
$this->openZipEntry($path, $sheetPath),
|
||||
$sharedStrings,
|
||||
];
|
||||
}
|
||||
|
||||
private function detailSheetPath(ZipArchive $zip): string
|
||||
{
|
||||
$workbookXml = $zip->getFromName('xl/workbook.xml');
|
||||
$relationshipsXml = $zip->getFromName('xl/_rels/workbook.xml.rels');
|
||||
if ($workbookXml === false || $relationshipsXml === false) {
|
||||
throw new RuntimeException('供应商汇总工作簿缺少工作簿结构文件。');
|
||||
}
|
||||
|
||||
$workbook = new DOMDocument();
|
||||
$relationships = new DOMDocument();
|
||||
if (
|
||||
! $workbook->loadXML($workbookXml, LIBXML_NONET)
|
||||
|| ! $relationships->loadXML($relationshipsXml, LIBXML_NONET)
|
||||
) {
|
||||
throw new RuntimeException('供应商汇总工作簿结构无法读取。');
|
||||
}
|
||||
|
||||
$relationshipId = null;
|
||||
$xpath = new DOMXPath($workbook);
|
||||
$xpath->registerNamespace(
|
||||
'main',
|
||||
'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
|
||||
);
|
||||
$xpath->registerNamespace(
|
||||
'office',
|
||||
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||
);
|
||||
foreach ($xpath->query('/main:workbook/main:sheets/main:sheet') ?: [] as $sheet) {
|
||||
if ($sheet instanceof \DOMElement
|
||||
&& $sheet->getAttribute('name') === self::DETAIL_SHEET
|
||||
) {
|
||||
$relationshipId = $sheet->getAttributeNS(
|
||||
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||
'id',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($relationshipId === null || $relationshipId === '') {
|
||||
throw new RuntimeException('供应商汇总表缺少「明细」子表。');
|
||||
}
|
||||
|
||||
$target = null;
|
||||
foreach ($relationships->getElementsByTagName('Relationship') as $relationship) {
|
||||
if ($relationship->getAttribute('Id') === $relationshipId) {
|
||||
$target = $relationship->getAttribute('Target');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($target === null || $target === '') {
|
||||
throw new RuntimeException('「明细」子表的工作簿关系不存在。');
|
||||
}
|
||||
|
||||
return $this->normalizeZipPath(
|
||||
str_starts_with($target, '/') ? ltrim($target, '/') : 'xl/' . $target,
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeZipPath(string $path): string
|
||||
{
|
||||
$segments = [];
|
||||
foreach (explode('/', str_replace('\\', '/', $path)) as $segment) {
|
||||
if ($segment === '' || $segment === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($segment === '..') {
|
||||
array_pop($segments);
|
||||
continue;
|
||||
}
|
||||
$segments[] = $segment;
|
||||
}
|
||||
|
||||
return implode('/', $segments);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function readSharedStrings(string $path): array
|
||||
{
|
||||
$reader = $this->openZipEntry($path, 'xl/sharedStrings.xml');
|
||||
$strings = [];
|
||||
$current = null;
|
||||
$stringDepth = null;
|
||||
$phoneticDepth = null;
|
||||
|
||||
try {
|
||||
while ($reader->read()) {
|
||||
if (
|
||||
$reader->nodeType === XMLReader::ELEMENT
|
||||
&& $reader->name === 'si'
|
||||
) {
|
||||
$current = '';
|
||||
$stringDepth = $reader->depth;
|
||||
$phoneticDepth = null;
|
||||
continue;
|
||||
}
|
||||
if ($current === null) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::ELEMENT
|
||||
&& $reader->name === 'rPh'
|
||||
) {
|
||||
$phoneticDepth = $reader->depth;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'rPh'
|
||||
&& $reader->depth === $phoneticDepth
|
||||
) {
|
||||
$phoneticDepth = null;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::ELEMENT
|
||||
&& $reader->name === 't'
|
||||
&& $phoneticDepth === null
|
||||
) {
|
||||
$current .= $reader->readString();
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'si'
|
||||
&& $reader->depth === $stringDepth
|
||||
) {
|
||||
$strings[] = $current;
|
||||
$current = null;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$reader->close();
|
||||
}
|
||||
|
||||
return $strings;
|
||||
}
|
||||
|
||||
private function openZipEntry(string $path, string $entry): XMLReader
|
||||
{
|
||||
$reader = new XMLReader();
|
||||
$flags = LIBXML_NONET | LIBXML_COMPACT;
|
||||
if (! $reader->open("zip://{$path}#{$entry}", null, $flags)) {
|
||||
throw new RuntimeException("无法读取工作簿内容:{$entry}");
|
||||
}
|
||||
|
||||
return $reader;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $sharedStrings
|
||||
* @return array<int,mixed>
|
||||
*/
|
||||
private function readRow(XMLReader $reader, array $sharedStrings): array
|
||||
{
|
||||
$values = [];
|
||||
$rowDepth = $reader->depth;
|
||||
while ($reader->read()) {
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'row'
|
||||
&& $reader->depth === $rowDepth
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType !== XMLReader::ELEMENT
|
||||
|| $reader->name !== 'c'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reference = $reader->getAttribute('r') ?? '';
|
||||
if (
|
||||
preg_match('/^([A-Z]{1,3})\d+$/i', $reference, $matches) !== 1
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
$values[$this->columnIndex($matches[1]) - 1] = $this->readCell(
|
||||
$reader,
|
||||
$sharedStrings,
|
||||
);
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/** @param list<string> $sharedStrings */
|
||||
private function readCell(XMLReader $reader, array $sharedStrings): mixed
|
||||
{
|
||||
if ($reader->isEmptyElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = $reader->getAttribute('t') ?? '';
|
||||
$cellDepth = $reader->depth;
|
||||
$cached = null;
|
||||
$formula = null;
|
||||
$hasFormula = false;
|
||||
$inlineParts = [];
|
||||
$phoneticDepth = null;
|
||||
|
||||
while ($reader->read()) {
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'c'
|
||||
&& $reader->depth === $cellDepth
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if ($reader->nodeType === XMLReader::ELEMENT) {
|
||||
if ($reader->name === 'f') {
|
||||
$hasFormula = true;
|
||||
$formula = $reader->readString();
|
||||
continue;
|
||||
}
|
||||
if ($reader->name === 'v') {
|
||||
$cached = $reader->readString();
|
||||
continue;
|
||||
}
|
||||
if ($reader->name === 'rPh') {
|
||||
$phoneticDepth = $reader->depth;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->name === 't'
|
||||
&& $phoneticDepth === null
|
||||
) {
|
||||
$inlineParts[] = $reader->readString();
|
||||
}
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'rPh'
|
||||
&& $reader->depth === $phoneticDepth
|
||||
) {
|
||||
$phoneticDepth = null;
|
||||
}
|
||||
}
|
||||
|
||||
$value = match ($type) {
|
||||
's' => is_numeric($cached)
|
||||
? ($sharedStrings[(int) $cached] ?? null)
|
||||
: null,
|
||||
'inlineStr' => implode('', $inlineParts),
|
||||
'b' => $cached === null ? null : ($cached === '1' ? '1' : '0'),
|
||||
default => $cached ?? implode('', $inlineParts),
|
||||
};
|
||||
|
||||
if ($hasFormula) {
|
||||
return [
|
||||
'__formula' => (string) $formula,
|
||||
'__cached' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function columnIndex(string $column): int
|
||||
{
|
||||
$index = 0;
|
||||
foreach (str_split(strtoupper($column)) as $letter) {
|
||||
$index = ($index * 26) + (ord($letter) - 64);
|
||||
}
|
||||
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
@ -1,303 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Auth\User;
|
||||
use App\Models\Business\SystemizationTodo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class SystemizationTodoAuditLogger
|
||||
{
|
||||
public const TABLE = 'business_systemization_todo_audit_logs';
|
||||
|
||||
public const HISTORY_LIMIT = 500;
|
||||
|
||||
public const ACTION_LABELS = [
|
||||
'create' => '新增',
|
||||
'update' => '修改',
|
||||
'delete' => '删除',
|
||||
'import' => '导入',
|
||||
];
|
||||
|
||||
public const FIELD_LABELS = [
|
||||
'category' => '类型',
|
||||
'section' => '内容',
|
||||
'title' => '待办事项',
|
||||
'owner' => '总负责人',
|
||||
'assignee' => '责任人',
|
||||
'start_date' => '开始时间',
|
||||
'start_note' => '开始时间备注',
|
||||
'due_date' => '完成时间',
|
||||
'due_note' => '完成时间备注',
|
||||
'status' => '状态',
|
||||
'completed_at' => '完成日期',
|
||||
'progress' => '完成情况',
|
||||
'notes' => '备注',
|
||||
'attachment_count' => '完成截图',
|
||||
'sort_order' => '排序位置',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function snapshot(SystemizationTodo $todo, bool $withAttachments = false): array
|
||||
{
|
||||
$snapshot = [
|
||||
'id' => (int) $todo->id,
|
||||
'category' => (string) ($todo->category ?? ''),
|
||||
'section' => (string) ($todo->section ?? ''),
|
||||
'title' => (string) ($todo->title ?? ''),
|
||||
'owner' => (string) ($todo->owner ?? ''),
|
||||
'assignee' => (string) ($todo->assignee ?? ''),
|
||||
'start_date' => $todo->start_date?->format('Y-m-d') ?? '',
|
||||
'start_note' => (string) ($todo->start_note ?? ''),
|
||||
'due_date' => $todo->due_date?->format('Y-m-d') ?? '',
|
||||
'due_note' => (string) ($todo->due_note ?? ''),
|
||||
'status' => $todo->statusLabel(),
|
||||
'completed_at' => $todo->completed_at?->format('Y-m-d') ?? '',
|
||||
'progress' => (string) ($todo->progress ?? ''),
|
||||
'notes' => (string) ($todo->notes ?? ''),
|
||||
'sort_order' => (int) $todo->sort_order,
|
||||
'source_key' => (string) ($todo->source_key ?? ''),
|
||||
];
|
||||
|
||||
if ($withAttachments) {
|
||||
$snapshot['attachment_count'] = (int) $todo->attachments()->count();
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
public function record(
|
||||
string $action,
|
||||
int $companyId,
|
||||
?int $todoId,
|
||||
?array $before,
|
||||
?array $after,
|
||||
?int $userId = null,
|
||||
?string $actorName = null,
|
||||
array $meta = [],
|
||||
): void {
|
||||
$this->recordMany([[
|
||||
'company_id' => $companyId,
|
||||
'todo_id' => $todoId,
|
||||
'action' => $action,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'user_id' => $userId,
|
||||
'actor_name' => $actorName,
|
||||
'meta' => $meta,
|
||||
]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{
|
||||
* company_id:int,
|
||||
* todo_id:?int,
|
||||
* action:string,
|
||||
* before:?array,
|
||||
* after:?array,
|
||||
* user_id:?int,
|
||||
* actor_name?:?string,
|
||||
* meta?:array
|
||||
* }> $events
|
||||
*/
|
||||
public function recordMany(array $events): void
|
||||
{
|
||||
if ($events === []) {
|
||||
return;
|
||||
}
|
||||
if (! $this->tableReady()) {
|
||||
throw new RuntimeException('系统化待办操作记录表尚未就绪,请先完成数据库迁移。');
|
||||
}
|
||||
|
||||
$now = $this->platformNow();
|
||||
$actorNames = [];
|
||||
$rows = [];
|
||||
foreach ($events as $event) {
|
||||
$action = (string) ($event['action'] ?? '');
|
||||
if (! array_key_exists($action, self::ACTION_LABELS)) {
|
||||
throw new RuntimeException('不支持的待办操作记录类型。');
|
||||
}
|
||||
|
||||
$userId = isset($event['user_id']) ? (int) $event['user_id'] : null;
|
||||
$actorName = trim((string) ($event['actor_name'] ?? ''));
|
||||
if ($actorName === '' && $userId !== null) {
|
||||
if (! array_key_exists($userId, $actorNames)) {
|
||||
$actorNames[$userId] = (string) (User::query()->whereKey($userId)->value('name') ?? '');
|
||||
}
|
||||
$actorName = $actorNames[$userId];
|
||||
}
|
||||
|
||||
$meta = is_array($event['meta'] ?? null) ? $event['meta'] : [];
|
||||
$rows[] = [
|
||||
'company_id' => max(1, (int) ($event['company_id'] ?? 1)),
|
||||
'todo_id' => isset($event['todo_id']) ? (int) $event['todo_id'] : null,
|
||||
'action' => $action,
|
||||
'before_json' => $this->json($event['before'] ?? null),
|
||||
'after_json' => $this->json($event['after'] ?? null),
|
||||
'actor_user_id' => $userId,
|
||||
'actor_name' => mb_substr($actorName, 0, 191),
|
||||
'extra_json' => $meta === [] ? null : $this->json($meta),
|
||||
'at' => $now->toDateTimeString(),
|
||||
'created_at' => $now->toDateTimeString(),
|
||||
'updated_at' => $now->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
DB::table(self::TABLE)->insert($rows);
|
||||
$companyIds = array_values(array_unique(array_column($rows, 'company_id')));
|
||||
foreach ($companyIds as $companyId) {
|
||||
$maxId = (int) DB::table(self::TABLE)
|
||||
->where('company_id', $companyId)
|
||||
->max('id');
|
||||
if ($maxId > self::HISTORY_LIMIT) {
|
||||
DB::table(self::TABLE)
|
||||
->where('company_id', $companyId)
|
||||
->where('id', '<=', $maxId - self::HISTORY_LIMIT)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function history(
|
||||
int $companyId,
|
||||
int $limit = self::HISTORY_LIMIT,
|
||||
?string $from = null,
|
||||
): array
|
||||
{
|
||||
if (! $this->tableReady()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table(self::TABLE)
|
||||
->where('company_id', $companyId)
|
||||
->when($from !== null && $from !== '', fn ($query) => $query->where('at', '>=', $from))
|
||||
->orderByDesc('id')
|
||||
->limit(max(1, min($limit, self::HISTORY_LIMIT)))
|
||||
->get()
|
||||
->map(fn (object $row): array => $this->serializeEvent($row))
|
||||
->all();
|
||||
}
|
||||
|
||||
public function platformNow(): Carbon
|
||||
{
|
||||
$timezone = trim((string) config('app.platform_timezone', ''));
|
||||
if ($timezone === '') {
|
||||
$timezone = (string) config('app.timezone', 'UTC');
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::now($timezone);
|
||||
} catch (Throwable) {
|
||||
return Carbon::now((string) config('app.timezone', 'UTC'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeEvent(object $row): array
|
||||
{
|
||||
$before = $row->before_json ? json_decode((string) $row->before_json, true) : null;
|
||||
$after = $row->after_json ? json_decode((string) $row->after_json, true) : null;
|
||||
$before = is_array($before) ? $before : null;
|
||||
$after = is_array($after) ? $after : null;
|
||||
$meta = $row->extra_json ? json_decode((string) $row->extra_json, true) : [];
|
||||
$meta = is_array($meta) ? $meta : [];
|
||||
$source = $after ?? $before ?? [];
|
||||
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'at' => (string) $row->at,
|
||||
'action' => (string) $row->action,
|
||||
'action_label' => self::ACTION_LABELS[(string) $row->action] ?? (string) $row->action,
|
||||
'todo_id' => $row->todo_id === null ? null : (int) $row->todo_id,
|
||||
'category' => (string) ($source['category'] ?? ''),
|
||||
'section' => (string) ($source['section'] ?? ''),
|
||||
'title' => (string) ($source['title'] ?? ''),
|
||||
'actor' => (string) ($row->actor_name ?? ''),
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'meta' => $meta,
|
||||
'changes' => $this->changes($before, $after),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function changes(?array $before, ?array $after): array
|
||||
{
|
||||
if ($after === null && $before !== null) {
|
||||
return ['删除前:' . $this->itemLabel($before)];
|
||||
}
|
||||
if ($before === null && $after !== null) {
|
||||
return ['新增:' . $this->itemLabel($after)];
|
||||
}
|
||||
if ($before === null && $after === null) {
|
||||
return ['—'];
|
||||
}
|
||||
|
||||
$changes = [];
|
||||
foreach (self::FIELD_LABELS as $field => $label) {
|
||||
$old = $this->displayValue($field, $before[$field] ?? '');
|
||||
$new = $this->displayValue($field, $after[$field] ?? '');
|
||||
if ($old !== $new) {
|
||||
$changes[] = $label . ':' . ($old !== '' ? $old : '空')
|
||||
. ' → ' . ($new !== '' ? $new : '空');
|
||||
}
|
||||
}
|
||||
|
||||
return $changes === [] ? ['内容无变化'] : $changes;
|
||||
}
|
||||
|
||||
private function itemLabel(array $row): string
|
||||
{
|
||||
$parts = array_values(array_filter([
|
||||
trim((string) ($row['category'] ?? '')),
|
||||
trim((string) ($row['section'] ?? '')),
|
||||
trim((string) ($row['title'] ?? '')),
|
||||
], static fn (string $value): bool => $value !== ''));
|
||||
|
||||
return $parts === [] ? '未命名待办' : implode(' · ', $parts);
|
||||
}
|
||||
|
||||
private function displayValue(string $field, mixed $value): string
|
||||
{
|
||||
if ($field === 'attachment_count') {
|
||||
return max(0, (int) $value) . ' 张';
|
||||
}
|
||||
|
||||
return trim(str_replace(["\r\n", "\r"], "\n", (string) ($value ?? '')));
|
||||
}
|
||||
|
||||
private function json(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('待办操作记录序列化失败。');
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
private function tableReady(): bool
|
||||
{
|
||||
try {
|
||||
return DB::getSchemaBuilder()->hasTable(self::TABLE);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,847 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\SystemizationTodo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Services\PayablesWorkbookStreamReader;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use RuntimeException;
|
||||
|
||||
class SystemizationTodoWorkbookImporter
|
||||
{
|
||||
private const REQUIRED_HEADERS = [
|
||||
'category' => '类型',
|
||||
'section' => '内容',
|
||||
'title' => '待办事项',
|
||||
'owner' => '总负责人',
|
||||
'assignee' => '责任人',
|
||||
'start' => '开始时间',
|
||||
'finish' => '完成时间',
|
||||
'progress' => '完成情况',
|
||||
'notes' => '备注',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly SystemizationTodoAuditLogger $audit,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入财务系统化待办。只新增系统中不存在的来源行,不覆盖日常维护后的状态与截图。
|
||||
*
|
||||
* @return array{created:int,skipped:int,sheet:string}
|
||||
*/
|
||||
public function import(
|
||||
string $path,
|
||||
int $companyId,
|
||||
?int $userId = null,
|
||||
?string $originalName = null,
|
||||
): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException('找不到待办 Excel 文件。');
|
||||
}
|
||||
|
||||
$extension = $this->extension($path, $originalName);
|
||||
if ($extension !== 'xlsx') {
|
||||
throw new RuntimeException('系统化待办仅支持 .xlsx 文件。');
|
||||
}
|
||||
|
||||
$reader = PayablesWorkbookStreamReader::forFile($path, $extension);
|
||||
foreach ($reader->sheetNames() as $sheetIndex => $sheetName) {
|
||||
$headerInfo = $this->headerMap($reader, $sheetIndex);
|
||||
if ($headerInfo === null) {
|
||||
continue;
|
||||
}
|
||||
$header = $headerInfo['columns'];
|
||||
$headerRow = $headerInfo['row'];
|
||||
|
||||
$rows = $this->workbookRows(
|
||||
$reader,
|
||||
$sheetIndex,
|
||||
$sheetName,
|
||||
$header,
|
||||
$headerRow,
|
||||
$companyId,
|
||||
);
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
DB::transaction(function () use (
|
||||
$rows,
|
||||
$sheetName,
|
||||
$companyId,
|
||||
$userId,
|
||||
&$created,
|
||||
&$skipped,
|
||||
): void {
|
||||
$auditEvents = [];
|
||||
foreach ($rows as $row) {
|
||||
$exists = SystemizationTodo::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->where('source_key', $row['data']['source_key'])
|
||||
->exists();
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$todo = SystemizationTodo::withoutGlobalScopes()->create([
|
||||
...$row['data'],
|
||||
'company_id' => $companyId,
|
||||
'imported_at' => now(),
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$auditEvents[] = [
|
||||
'company_id' => $companyId,
|
||||
'todo_id' => (int) $todo->id,
|
||||
'action' => 'import',
|
||||
'before' => null,
|
||||
'after' => $this->audit->snapshot($todo),
|
||||
'user_id' => $userId,
|
||||
'meta' => [
|
||||
'source' => 'excel',
|
||||
'mode' => 'import',
|
||||
'sheet' => $sheetName,
|
||||
],
|
||||
];
|
||||
$created++;
|
||||
}
|
||||
$this->audit->recordMany($auditEvents);
|
||||
});
|
||||
|
||||
return ['created' => $created, 'skipped' => $skipped, 'sheet' => $sheetName];
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
'未找到待办表头。请确认工作表包含:类型、内容、待办事项、总负责人、责任人、开始时间、完成时间、完成情况、备注。'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将新版工作簿同步到已有来源记录。保留截图和来源中已不在工作簿的事项,只新增和更新。
|
||||
*
|
||||
* @return array{created:int,updated:int,retained:int,sheet:string}
|
||||
*/
|
||||
public function sync(
|
||||
string $path,
|
||||
int $companyId,
|
||||
?int $userId = null,
|
||||
bool $dryRun = false,
|
||||
?string $originalName = null,
|
||||
): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException('找不到待办 Excel 文件。');
|
||||
}
|
||||
|
||||
$extension = $this->extension($path, $originalName);
|
||||
if ($extension !== 'xlsx') {
|
||||
throw new RuntimeException('系统化待办仅支持 .xlsx 文件。');
|
||||
}
|
||||
|
||||
$reader = PayablesWorkbookStreamReader::forFile($path, $extension);
|
||||
foreach ($reader->sheetNames() as $sheetIndex => $sheetName) {
|
||||
$headerInfo = $this->headerMap($reader, $sheetIndex);
|
||||
if ($headerInfo === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = $this->workbookRows(
|
||||
$reader,
|
||||
$sheetIndex,
|
||||
$sheetName,
|
||||
$headerInfo['columns'],
|
||||
$headerInfo['row'],
|
||||
$companyId,
|
||||
);
|
||||
$activeTodos = SystemizationTodo::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
$rows = $this->alignIncomingCategories($rows, $activeTodos);
|
||||
$existing = $activeTodos
|
||||
->filter(static fn (SystemizationTodo $todo): bool => $todo->imported_at !== null)
|
||||
->values();
|
||||
[$matches, $newIndexes] = $this->matchRows($rows, $existing);
|
||||
$matchedIds = array_values($matches);
|
||||
$retained = $existing
|
||||
->filter(static fn (SystemizationTodo $todo): bool => ! in_array($todo->id, $matchedIds, true))
|
||||
->sortBy(static fn (SystemizationTodo $todo): string =>
|
||||
sprintf('%010d-%010d', (int) $todo->sort_order, (int) $todo->id)
|
||||
)
|
||||
->values();
|
||||
$retainedSortOrder = max(
|
||||
(int) $existing->max('sort_order'),
|
||||
...array_map(
|
||||
static fn (array $row): int => (int) $row['data']['sort_order'],
|
||||
$rows,
|
||||
),
|
||||
) + 1;
|
||||
$oldSourceKeys = $existing->pluck('source_key', 'id')->all();
|
||||
$updated = 0;
|
||||
foreach ($matches as $rowIndex => $todoId) {
|
||||
$todo = $existing->firstWhere('id', $todoId);
|
||||
if ($todo && $this->contentDiffers($todo, $rows[$rowIndex]['data'])) {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
return [
|
||||
'created' => count($newIndexes),
|
||||
'updated' => $updated,
|
||||
'retained' => $retained->count(),
|
||||
'sheet' => $sheetName,
|
||||
];
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
DB::transaction(function () use (
|
||||
$rows,
|
||||
$matches,
|
||||
$newIndexes,
|
||||
$existing,
|
||||
$retained,
|
||||
$oldSourceKeys,
|
||||
$retainedSortOrder,
|
||||
$companyId,
|
||||
$userId,
|
||||
$sheetName,
|
||||
&$created,
|
||||
&$updated,
|
||||
): void {
|
||||
$auditEvents = [];
|
||||
SystemizationTodo::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('imported_at')
|
||||
->update(['source_key' => null]);
|
||||
|
||||
foreach ($matches as $rowIndex => $todoId) {
|
||||
$todo = $existing->firstWhere('id', $todoId);
|
||||
if (! $todo) {
|
||||
continue;
|
||||
}
|
||||
$before = $this->audit->snapshot($todo);
|
||||
$todo->source_key = null;
|
||||
$todo->syncOriginalAttribute('source_key');
|
||||
$todo->fill($rows[$rowIndex]['data']);
|
||||
$contentChanged = $todo->isDirty([
|
||||
'category',
|
||||
'section',
|
||||
'title',
|
||||
'owner',
|
||||
'assignee',
|
||||
'start_date',
|
||||
'start_note',
|
||||
'due_date',
|
||||
'due_note',
|
||||
'status',
|
||||
'completed_at',
|
||||
'progress',
|
||||
'notes',
|
||||
]);
|
||||
$todo->updated_by = $userId;
|
||||
$todo->save();
|
||||
if ($contentChanged) {
|
||||
$auditEvents[] = [
|
||||
'company_id' => $companyId,
|
||||
'todo_id' => (int) $todo->id,
|
||||
'action' => 'import',
|
||||
'before' => $before,
|
||||
'after' => $this->audit->snapshot($todo),
|
||||
'user_id' => $userId,
|
||||
'meta' => [
|
||||
'source' => 'excel',
|
||||
'mode' => 'sync',
|
||||
'sheet' => $sheetName,
|
||||
],
|
||||
];
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($newIndexes as $rowIndex) {
|
||||
$todo = SystemizationTodo::withoutGlobalScopes()->create([
|
||||
...$rows[$rowIndex]['data'],
|
||||
'company_id' => $companyId,
|
||||
'imported_at' => now(),
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$auditEvents[] = [
|
||||
'company_id' => $companyId,
|
||||
'todo_id' => (int) $todo->id,
|
||||
'action' => 'import',
|
||||
'before' => null,
|
||||
'after' => $this->audit->snapshot($todo),
|
||||
'user_id' => $userId,
|
||||
'meta' => [
|
||||
'source' => 'excel',
|
||||
'mode' => 'sync',
|
||||
'sheet' => $sheetName,
|
||||
],
|
||||
];
|
||||
$created++;
|
||||
}
|
||||
|
||||
foreach ($retained as $todo) {
|
||||
SystemizationTodo::withoutGlobalScopes()
|
||||
->whereKey($todo->id)
|
||||
->update([
|
||||
'sort_order' => $retainedSortOrder++,
|
||||
'source_key' => $oldSourceKeys[$todo->id] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->audit->recordMany($auditEvents);
|
||||
});
|
||||
|
||||
return [
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'retained' => $retained->count(),
|
||||
'sheet' => $sheetName,
|
||||
];
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
'未找到待办表头。请确认工作表包含:类型、内容、待办事项、总负责人、责任人、开始时间、完成时间、完成情况、备注。'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,int> $columns
|
||||
* @return array<int,array{
|
||||
* row:int,
|
||||
* title_index:int,
|
||||
* title_count:int,
|
||||
* source_title:string,
|
||||
* category:string,
|
||||
* section:string,
|
||||
* title:string,
|
||||
* data:array<string,mixed>
|
||||
* }>
|
||||
*/
|
||||
private function workbookRows(
|
||||
PayablesWorkbookStreamReader $reader,
|
||||
int $sheetIndex,
|
||||
string $sheetName,
|
||||
array $columns,
|
||||
int $headerRow,
|
||||
int $companyId,
|
||||
): array {
|
||||
$rows = [];
|
||||
$category = '';
|
||||
$section = '';
|
||||
foreach ($reader->rows($sheetIndex) as $rowNumber => $cells) {
|
||||
if ($rowNumber <= $headerRow) {
|
||||
continue;
|
||||
}
|
||||
$categoryValue = $this->cleanImportedLabel(
|
||||
$this->cell($cells, $columns['category'] ?? null)
|
||||
);
|
||||
if ($categoryValue !== '') {
|
||||
$category = $categoryValue;
|
||||
}
|
||||
$sectionValue = $this->cleanImportedLabel(
|
||||
$this->cell($cells, $columns['section'] ?? null)
|
||||
);
|
||||
if ($sectionValue !== '') {
|
||||
$section = $sectionValue;
|
||||
}
|
||||
|
||||
$sourceTitle = $this->cell($cells, $columns['title'] ?? null);
|
||||
if ($sourceTitle === '') {
|
||||
continue;
|
||||
}
|
||||
$titles = $this->titleSegments($sourceTitle);
|
||||
$titleCount = count($titles);
|
||||
$owner = $this->cell($cells, $columns['owner'] ?? null);
|
||||
$assignee = $this->cell($cells, $columns['assignee'] ?? null);
|
||||
$startText = $this->cell($cells, $columns['start'] ?? null);
|
||||
$finishText = $this->cell($cells, $columns['finish'] ?? null);
|
||||
[$startDate, $startNote] = $this->dateParts($startText);
|
||||
[$finishDate, $finishNote] = $this->dateParts($finishText);
|
||||
$progress = $this->cell($cells, $columns['progress'] ?? null);
|
||||
$notes = $this->cell($cells, $columns['notes'] ?? null);
|
||||
$progress = (string) (
|
||||
SystemizationTodo::mergeNotesIntoProgress($progress, $notes) ?? ''
|
||||
);
|
||||
$status = $this->status($progress);
|
||||
|
||||
foreach ($titles as $offset => $title) {
|
||||
$titleIndex = $offset + 1;
|
||||
$sortOrder = ($rowNumber * 100) + $titleIndex;
|
||||
$rows[] = [
|
||||
'row' => $rowNumber,
|
||||
'title_index' => $titleIndex,
|
||||
'title_count' => $titleCount,
|
||||
'source_title' => $sourceTitle,
|
||||
'category' => $category,
|
||||
'section' => $section,
|
||||
'title' => $title,
|
||||
'data' => [
|
||||
'category' => $category !== '' ? $category : null,
|
||||
'section' => $section !== '' ? $section : null,
|
||||
'title' => $title,
|
||||
'owner' => $owner !== '' ? $owner : null,
|
||||
'assignee' => $assignee !== '' ? $assignee : null,
|
||||
'start_date' => $startDate,
|
||||
'start_note' => $startNote !== '' ? $startNote : null,
|
||||
'due_date' => $finishDate,
|
||||
'due_note' => $finishNote !== '' ? $finishNote : null,
|
||||
'status' => $status,
|
||||
'completed_at' => $status === SystemizationTodo::STATUS_COMPLETED
|
||||
? ($finishDate ?? now()->toDateString())
|
||||
: null,
|
||||
'progress' => $progress !== '' ? $progress : null,
|
||||
'notes' => null,
|
||||
'sort_order' => $sortOrder,
|
||||
'source_key' => hash(
|
||||
'sha256',
|
||||
implode('|', [
|
||||
$companyId,
|
||||
$sheetName,
|
||||
$rowNumber,
|
||||
$titleIndex,
|
||||
$category,
|
||||
$section,
|
||||
$title,
|
||||
$owner,
|
||||
$assignee,
|
||||
])
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a cell into separate tasks only when it contains multiple numbered lines.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function titleSegments(string $value): array
|
||||
{
|
||||
$value = trim(str_replace(["\r\n", "\r"], "\n", $value));
|
||||
$marker = '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u';
|
||||
$lines = preg_split('/\n/u', $value) ?: [];
|
||||
$numberedLines = array_filter(
|
||||
$lines,
|
||||
static fn (string $line): bool => preg_match($marker, trim($line)) === 1,
|
||||
);
|
||||
if (count($numberedLines) < 2) {
|
||||
return [$this->cleanImportedLabel($value)];
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
$current = '';
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match($marker, $line) === 1) {
|
||||
if ($current !== '') {
|
||||
$segments[] = trim($current);
|
||||
}
|
||||
$current = trim((string) preg_replace($marker, '', $line));
|
||||
continue;
|
||||
}
|
||||
$current = $current === '' ? $line : $current . "\n" . $line;
|
||||
}
|
||||
if ($current !== '') {
|
||||
$segments[] = trim($current);
|
||||
}
|
||||
|
||||
return $segments !== []
|
||||
? $segments
|
||||
: [$this->cleanImportedLabel($value)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{
|
||||
* row:int,
|
||||
* title_index:int,
|
||||
* title_count:int,
|
||||
* source_title:string,
|
||||
* category:string,
|
||||
* section:string,
|
||||
* title:string,
|
||||
* data:array<string,mixed>
|
||||
* }> $rows
|
||||
* @return array{0:array<int,int>,1:array<int,int>}
|
||||
*/
|
||||
private function matchRows(array $rows, \Illuminate\Support\Collection $existing): array
|
||||
{
|
||||
$matches = [];
|
||||
$usedIds = [];
|
||||
$candidates = [];
|
||||
foreach ($existing as $todo) {
|
||||
$candidates[$this->matchTitleKey($todo->title)][] = $todo;
|
||||
}
|
||||
|
||||
foreach ($rows as $rowIndex => $row) {
|
||||
if ($row['title_count'] < 2 || $row['title_index'] !== 1) {
|
||||
continue;
|
||||
}
|
||||
$sourceKey = $this->matchTitleKey($row['source_title']);
|
||||
$available = array_values(array_filter(
|
||||
$candidates[$sourceKey] ?? [],
|
||||
fn (SystemizationTodo $todo): bool =>
|
||||
! in_array($todo->id, $usedIds, true)
|
||||
&& $this->matchGroupKey($todo->category) === $this->matchGroupKey($row['category'])
|
||||
&& $this->matchGroupKey($todo->section) === $this->matchGroupKey($row['section']),
|
||||
));
|
||||
if ($available === []) {
|
||||
continue;
|
||||
}
|
||||
usort($available, static fn (SystemizationTodo $a, SystemizationTodo $b): int =>
|
||||
abs($a->sort_order - $row['data']['sort_order'])
|
||||
<=> abs($b->sort_order - $row['data']['sort_order'])
|
||||
);
|
||||
$matches[$rowIndex] = $available[0]->id;
|
||||
$usedIds[] = $available[0]->id;
|
||||
}
|
||||
|
||||
// 同名待办可能分布在多个内容组,优先匹配同一「类型 + 内容」。
|
||||
foreach ($rows as $rowIndex => $row) {
|
||||
if (isset($matches[$rowIndex])) {
|
||||
continue;
|
||||
}
|
||||
$key = $this->matchTitleKey($row['title']);
|
||||
$available = array_values(array_filter(
|
||||
$candidates[$key] ?? [],
|
||||
fn (SystemizationTodo $todo): bool =>
|
||||
! in_array($todo->id, $usedIds, true)
|
||||
&& $this->matchGroupKey($todo->category) === $this->matchGroupKey($row['category'])
|
||||
&& $this->matchGroupKey($todo->section) === $this->matchGroupKey($row['section']),
|
||||
));
|
||||
if ($available === []) {
|
||||
continue;
|
||||
}
|
||||
usort($available, static fn (SystemizationTodo $a, SystemizationTodo $b): int =>
|
||||
abs($a->sort_order - $row['data']['sort_order'])
|
||||
<=> abs($b->sort_order - $row['data']['sort_order'])
|
||||
);
|
||||
$matches[$rowIndex] = $available[0]->id;
|
||||
$usedIds[] = $available[0]->id;
|
||||
}
|
||||
|
||||
foreach ($rows as $rowIndex => $row) {
|
||||
if (isset($matches[$rowIndex])) {
|
||||
continue;
|
||||
}
|
||||
$key = $this->matchTitleKey($row['title']);
|
||||
$available = array_values(array_filter(
|
||||
$candidates[$key] ?? [],
|
||||
static fn (SystemizationTodo $todo): bool => ! in_array($todo->id, $usedIds, true),
|
||||
));
|
||||
if ($available === []) {
|
||||
continue;
|
||||
}
|
||||
usort($available, static fn (SystemizationTodo $a, SystemizationTodo $b): int =>
|
||||
abs($a->sort_order - $row['data']['sort_order'])
|
||||
<=> abs($b->sort_order - $row['data']['sort_order'])
|
||||
);
|
||||
$todo = $available[0];
|
||||
$matches[$rowIndex] = $todo->id;
|
||||
$usedIds[] = $todo->id;
|
||||
}
|
||||
|
||||
$remainingIds = $existing
|
||||
->pluck('id')
|
||||
->reject(static fn (int $id): bool => in_array($id, $usedIds, true))
|
||||
->values();
|
||||
$todoById = $existing->keyBy('id');
|
||||
$pairs = [];
|
||||
foreach ($rows as $rowIndex => $row) {
|
||||
if (isset($matches[$rowIndex])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($remainingIds as $todoId) {
|
||||
$todo = $todoById->get($todoId);
|
||||
if (! $todo
|
||||
|| $this->matchGroupKey($todo->category) !== $this->matchGroupKey($row['category'])
|
||||
|| $this->matchGroupKey($todo->section) !== $this->matchGroupKey($row['section'])) {
|
||||
continue;
|
||||
}
|
||||
$similarity = $this->titleSimilarity((string) $todo->title, $row['title']);
|
||||
if ($similarity >= 0.55) {
|
||||
$pairs[] = [
|
||||
'rowIndex' => $rowIndex,
|
||||
'todoId' => $todoId,
|
||||
'score' => $similarity - abs($todo->sort_order - $row['data']['sort_order']) / 1000,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
usort($pairs, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
|
||||
foreach ($pairs as $pair) {
|
||||
if (isset($matches[$pair['rowIndex']]) || in_array($pair['todoId'], $usedIds, true)) {
|
||||
continue;
|
||||
}
|
||||
$matches[$pair['rowIndex']] = $pair['todoId'];
|
||||
$usedIds[] = $pair['todoId'];
|
||||
}
|
||||
|
||||
return [
|
||||
$matches,
|
||||
array_values(array_filter(
|
||||
array_keys($rows),
|
||||
static fn (int $rowIndex): bool => ! isset($matches[$rowIndex]),
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容组已唯一归属某个类型时,忽略 Excel 中误填的类型占位值。
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @param \Illuminate\Support\Collection<int,SystemizationTodo> $activeTodos
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function alignIncomingCategories(
|
||||
array $rows,
|
||||
\Illuminate\Support\Collection $activeTodos,
|
||||
): array {
|
||||
$categoriesBySection = [];
|
||||
foreach ($activeTodos as $todo) {
|
||||
$sectionKey = $this->matchGroupKey($todo->section);
|
||||
$category = trim((string) $todo->category);
|
||||
if ($sectionKey === '' || $category === '') {
|
||||
continue;
|
||||
}
|
||||
$categoriesBySection[$sectionKey][$this->matchText($category)] = $category;
|
||||
}
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$categories = $categoriesBySection[$this->matchGroupKey($row['section'])] ?? [];
|
||||
if (count($categories) === 1) {
|
||||
$category = reset($categories);
|
||||
$row['data']['category'] = $category;
|
||||
$row['category'] = $category;
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function matchTitleKey(?string $value): string
|
||||
{
|
||||
$value = preg_replace(
|
||||
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u',
|
||||
'',
|
||||
(string) $value,
|
||||
) ?? '';
|
||||
|
||||
return $this->matchText($value);
|
||||
}
|
||||
|
||||
private function matchGroupKey(?string $value): string
|
||||
{
|
||||
return $this->matchTitleKey($this->cleanImportedLabel($value));
|
||||
}
|
||||
|
||||
private function cleanImportedLabel(?string $value): string
|
||||
{
|
||||
return trim((string) preg_replace(
|
||||
'/(^|\R)\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;))]\s*/u',
|
||||
'$1',
|
||||
(string) $value,
|
||||
));
|
||||
}
|
||||
|
||||
private function matchText(?string $value): string
|
||||
{
|
||||
return mb_strtolower(
|
||||
preg_replace('/[\s “”"\',。;、,.!?::;()()【】\[\]—-]+/u', '', (string) $value) ?? '',
|
||||
'UTF-8',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
private function contentDiffers(SystemizationTodo $todo, array $data): bool
|
||||
{
|
||||
foreach ([
|
||||
'category',
|
||||
'section',
|
||||
'title',
|
||||
'owner',
|
||||
'assignee',
|
||||
'start_date',
|
||||
'start_note',
|
||||
'due_date',
|
||||
'due_note',
|
||||
'status',
|
||||
'completed_at',
|
||||
'progress',
|
||||
'notes',
|
||||
] as $field) {
|
||||
$current = $todo->{$field};
|
||||
$incoming = $data[$field] ?? null;
|
||||
if ($current instanceof \DateTimeInterface && $incoming !== null) {
|
||||
$current = $current->format('Y-m-d');
|
||||
}
|
||||
if ((string) ($current ?? '') !== (string) ($incoming ?? '')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function titleSimilarity(string $left, string $right): float
|
||||
{
|
||||
$left = $this->matchTitleKey($left);
|
||||
$right = $this->matchTitleKey($right);
|
||||
if ($left === '' || $right === '') {
|
||||
return 0.0;
|
||||
}
|
||||
if ($left === $right) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
$leftChars = mb_str_split($left);
|
||||
$rightChars = mb_str_split($right);
|
||||
$leftPairs = [];
|
||||
$rightPairs = [];
|
||||
for ($index = 0; $index < count($leftChars) - 1; $index++) {
|
||||
$leftPairs[$leftChars[$index] . $leftChars[$index + 1]] = true;
|
||||
}
|
||||
for ($index = 0; $index < count($rightChars) - 1; $index++) {
|
||||
$rightPairs[$rightChars[$index] . $rightChars[$index + 1]] = true;
|
||||
}
|
||||
$leftPairs = array_keys($leftPairs);
|
||||
$rightPairs = array_keys($rightPairs);
|
||||
$containment = str_contains($left, $right) || str_contains($right, $left) ? 0.95 : 0.0;
|
||||
|
||||
$dice = ($leftPairs === [] || $rightPairs === [])
|
||||
? 0.0
|
||||
: 2 * count(array_intersect($leftPairs, $rightPairs)) / (count($leftPairs) + count($rightPairs));
|
||||
|
||||
return max($containment, $dice);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{row:int,columns:array<string,int>}|null
|
||||
*/
|
||||
private function headerMap(PayablesWorkbookStreamReader $reader, int $sheetIndex): ?array
|
||||
{
|
||||
foreach ($reader->rows($sheetIndex, null, 12) as $rowNumber => $cells) {
|
||||
$normalized = [];
|
||||
foreach ($cells as $column => $value) {
|
||||
$value = $this->normalizeHeader($this->text($value));
|
||||
if ($value !== '') {
|
||||
$normalized[$value] = $column;
|
||||
}
|
||||
}
|
||||
|
||||
$mapped = [];
|
||||
foreach (self::REQUIRED_HEADERS as $field => $label) {
|
||||
$needle = $this->normalizeHeader($label);
|
||||
foreach ($normalized as $header => $column) {
|
||||
if ($header === $needle || str_starts_with($header, $needle)) {
|
||||
$mapped[$field] = $column;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($mapped['title'])
|
||||
&& isset($mapped['owner'])
|
||||
&& isset($mapped['assignee'])) {
|
||||
return ['row' => $rowNumber, 'columns' => $mapped];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeHeader(string $value): string
|
||||
{
|
||||
return preg_replace('/[\s ()()]+/u', '', $value) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $cells
|
||||
*/
|
||||
private function cell(array $cells, ?int $column): string
|
||||
{
|
||||
return $column === null ? '' : $this->text($cells[$column] ?? null);
|
||||
}
|
||||
|
||||
private function text(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
return trim(str_replace("\u{00A0}", ' ', (string) $value));
|
||||
}
|
||||
|
||||
private function extension(string $path, ?string $originalName): string
|
||||
{
|
||||
$name = trim((string) $originalName);
|
||||
$extension = $name !== ''
|
||||
? pathinfo($name, PATHINFO_EXTENSION)
|
||||
: pathinfo($path, PATHINFO_EXTENSION);
|
||||
|
||||
return strtolower((string) $extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string|null,1:string}
|
||||
*/
|
||||
private function dateParts(string $value): array
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '' || $value === '已完成') {
|
||||
return [null, ''];
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
$serial = (float) $value;
|
||||
if ($serial >= 20000 && $serial <= 80000) {
|
||||
return [ExcelDate::excelToDateTimeObject($serial)->format('Y-m-d'), ''];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['Y-m-d', 'Y/m/d', 'Y.m.d', 'Y年n月j日'] as $format) {
|
||||
try {
|
||||
$date = Carbon::createFromFormat($format, $value);
|
||||
if ($date !== false) {
|
||||
return [$date->format('Y-m-d'), ''];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// 不是标准日期,保留原值作为时间备注。
|
||||
}
|
||||
}
|
||||
|
||||
return [null, mb_substr($value, 0, 100)];
|
||||
}
|
||||
|
||||
private function status(string $progress): string
|
||||
{
|
||||
if (str_contains($progress, '已完成')) {
|
||||
return SystemizationTodo::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
return SystemizationTodo::STATUS_INCOMPLETE;
|
||||
}
|
||||
}
|
||||
@ -20,10 +20,10 @@ class UpstreamBusinessInformation
|
||||
public function __construct(
|
||||
private readonly UpstreamQueryCacheFactory $caches,
|
||||
private readonly BusinessInformationMirrorRepository $mirror,
|
||||
private readonly InstallationDetailMirrorRepository $installationMirror,
|
||||
// 兼容仍按旧构造方式调用的代码;容器解析时会正常注入仓储。
|
||||
private readonly ?StoreReportRepository $storeReports = null,
|
||||
private readonly ?StoreAccountMirrorRepository $storeAccounts = null,
|
||||
private readonly ?InstallationDetailMirrorRepository $installationMirror = null,
|
||||
) {}
|
||||
|
||||
public function paginate(string $section, string $page, int $perPage = 50): LengthAwarePaginator
|
||||
@ -43,10 +43,6 @@ class UpstreamBusinessInformation
|
||||
'financial-risk-control/process-statistics' => $this->shopInfoFlowPage($currentPage, $perPage),
|
||||
default => ['data' => [], 'total' => 0],
|
||||
};
|
||||
request()->attributes->set(
|
||||
'business-page-summary-cards',
|
||||
app(BusinessSummaryCards::class)->forPage($key, $payload['summary'] ?? []),
|
||||
);
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
array_map(static fn (array $row) => (object) $row, $payload['data']),
|
||||
@ -82,8 +78,7 @@ class UpstreamBusinessInformation
|
||||
|
||||
private function installationPage(int $page, int $perPage): array
|
||||
{
|
||||
$mirror = $this->installationMirror ?? app(InstallationDetailMirrorRepository::class);
|
||||
$payload = $mirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters());
|
||||
$payload = $this->installationMirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters());
|
||||
if (! $payload['ready']) {
|
||||
throw new ServiceUnavailableHttpException(null, '安装明细镜像尚未完成首次同步');
|
||||
}
|
||||
@ -162,156 +157,16 @@ SQL);
|
||||
private function storePage(int $page, int $perPage): array
|
||||
{
|
||||
$filters = $this->pageFilters('store-management', 'basic-information');
|
||||
if ($this->mirror->ready('store-information.read')) {
|
||||
$storeFilters = array_diff_key(
|
||||
$filters,
|
||||
array_flip(['store_manager', 'department_manager', '_sort', '_direction']),
|
||||
);
|
||||
return $this->pageStoreInformation(
|
||||
$page,
|
||||
$perPage,
|
||||
$filters,
|
||||
$this->storesWithAccountOwnership(array_map(
|
||||
fn (array $row): array => $this->mapStore($row),
|
||||
$this->mirror->allStores($storeFilters),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
$payload = $this->page(
|
||||
return $this->page(
|
||||
'store-information.read',
|
||||
$page,
|
||||
$perPage,
|
||||
array_diff_key($filters, array_flip(['store_manager', 'department_manager'])),
|
||||
$filters,
|
||||
fn () => $this->snapshot('store-information.read', [
|
||||
'page' => $page,
|
||||
'perPage' => $perPage,
|
||||
]),
|
||||
false,
|
||||
);
|
||||
$rows = $this->storesWithAccountOwnership($payload['data']);
|
||||
$rows = array_values(array_filter(
|
||||
$rows,
|
||||
fn (array $row): bool => $this->storeOwnershipMatchesFilters($row, $filters),
|
||||
));
|
||||
|
||||
return ['data' => $rows, 'total' => count($rows)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 店铺基础信息页每次读取时把负责人字段覆盖为店铺账号镜像中的值。
|
||||
* 账号店铺名与 ERP 店铺名只差下划线/空格,因此统一归一后关联。
|
||||
*
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function storesWithAccountOwnership(array $rows, bool $requireAccountOwnership = false): array
|
||||
{
|
||||
$ownership = $this->storeOwnership($requireAccountOwnership);
|
||||
foreach ($rows as &$row) {
|
||||
$key = StoreAccountMirrorRepository::storeNameKey($row['erp_store_name'] ?? '');
|
||||
$match = $ownership[$key] ?? null;
|
||||
if ($match === null) {
|
||||
continue;
|
||||
}
|
||||
foreach ([
|
||||
'store_manager' => $match['operation_owner'],
|
||||
'department_manager' => $match['operation_department_head'],
|
||||
] as $field => $value) {
|
||||
if ($value !== '') {
|
||||
$row[$field] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param array<string,string|list<string>> $filters */
|
||||
private function pageStoreInformation(int $page, int $perPage, array $filters, array $rows): array
|
||||
{
|
||||
$rows = array_values(array_filter(
|
||||
$rows,
|
||||
fn (array $row): bool => $this->storeOwnershipMatchesFilters($row, $filters),
|
||||
));
|
||||
$summary = $this->storeInformationSummary($rows);
|
||||
$this->sortStoreInformation($rows, $filters);
|
||||
$total = count($rows);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return [
|
||||
'data' => array_slice($rows, $offset, $perPage),
|
||||
'total' => $total,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows */
|
||||
private function storeInformationSummary(array $rows): array
|
||||
{
|
||||
$stores = [];
|
||||
$platforms = [];
|
||||
foreach ($rows as $row) {
|
||||
$key = trim((string) ($row['id'] ?? $row['erp_store_name'] ?? $row['store_name'] ?? ''));
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$stores[$key] = match ((string) ($row['store_status'] ?? '')) {
|
||||
'0', '正常' => '正常',
|
||||
'3', '关闭中' => '关闭中',
|
||||
'6', '已关闭', '关店' => '已关闭',
|
||||
default => (string) ($row['store_status'] ?? ''),
|
||||
};
|
||||
$platform = trim((string) ($row['platform'] ?? ''));
|
||||
if ($platform !== '') {
|
||||
$platforms[$platform] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$statusCounts = array_count_values($stores);
|
||||
|
||||
return [
|
||||
'total' => count($stores),
|
||||
'normal' => (int) ($statusCounts['正常'] ?? 0),
|
||||
'closing' => (int) ($statusCounts['关闭中'] ?? 0),
|
||||
'closed' => (int) ($statusCounts['已关闭'] ?? 0),
|
||||
'platforms' => count($platforms),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,string|list<string>> $filters */
|
||||
private function storeOwnershipMatchesFilters(array $row, array $filters): bool
|
||||
{
|
||||
foreach (['store_manager', 'department_manager'] as $key) {
|
||||
$expected = trim((string) ($filters[$key] ?? ''));
|
||||
if ($expected !== ''
|
||||
&& mb_stripos((string) ($row[$key] ?? ''), $expected) === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows @param array<string,string|list<string>> $filters */
|
||||
private function sortStoreInformation(array &$rows, array $filters): void
|
||||
{
|
||||
$key = (string) ($filters['_sort'] ?? '');
|
||||
if ($key === '') {
|
||||
return;
|
||||
}
|
||||
$direction = ($filters['_direction'] ?? 'asc') === 'desc' ? -1 : 1;
|
||||
usort($rows, static function (array $left, array $right) use ($key, $direction): int {
|
||||
$comparison = strnatcasecmp(
|
||||
(string) ($left[$key] ?? ''),
|
||||
(string) ($right[$key] ?? ''),
|
||||
) * $direction;
|
||||
|
||||
return $comparison !== 0
|
||||
? $comparison
|
||||
: ((int) ($right['id'] ?? 0) <=> (int) ($left['id'] ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
private function storeReportPage(int $page, int $perPage): array
|
||||
@ -319,7 +174,7 @@ SQL);
|
||||
$filters = StoreReportRepository::requestFilters();
|
||||
$rows = $this->storeReportRows($filters);
|
||||
|
||||
// 整列为 0 的月份视为没有销量,默认隐藏;仍可从「筛选字段」手动打开。
|
||||
// 所有店铺该月都没有业绩的月份列默认隐藏;「筛选字段」中仍保留勾选项,可手动选出来。
|
||||
request()->attributes->set(
|
||||
'store-report.default_hidden_columns',
|
||||
$this->columnsWithoutData($rows, 'sales_', zeroCountsAsData: false),
|
||||
@ -327,65 +182,7 @@ SQL);
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return [
|
||||
'data' => array_slice($rows, $offset, $perPage),
|
||||
'total' => count($rows),
|
||||
'summary' => $this->storeReportSummary($rows),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows */
|
||||
private function storeReportSummary(array $rows): array
|
||||
{
|
||||
$normalStores = [];
|
||||
$previousMonth = now()->startOfMonth()->subMonthNoOverflow();
|
||||
$currentMonth = now()->startOfMonth();
|
||||
$previousMonthKey = 'sales_' . $previousMonth->format('m');
|
||||
$currentMonthKey = 'sales_' . $currentMonth->format('m');
|
||||
$previousSales = 0.0;
|
||||
$currentSales = 0.0;
|
||||
$previousStores = [];
|
||||
$currentStores = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (trim((string) ($row['status'] ?? '')) !== '开店中') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = trim((string) ($row['erp_store_name'] ?? ''));
|
||||
if ($key === '') {
|
||||
$key = trim((string) ($row['store_id'] ?? $row['id'] ?? ''));
|
||||
}
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalStores[$key] = true;
|
||||
$previousValue = (float) ($row[$previousMonthKey] ?? 0);
|
||||
$currentValue = (float) ($row[$currentMonthKey] ?? 0);
|
||||
$previousSales += $previousValue;
|
||||
$currentSales += $currentValue;
|
||||
if ($previousValue > 0) {
|
||||
$previousStores[$key] = true;
|
||||
}
|
||||
if ($currentValue > 0) {
|
||||
$currentStores[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'normal_stores' => count($normalStores),
|
||||
'previous_month_sales' => round($previousSales, 2),
|
||||
'current_month_sales' => round($currentSales, 2),
|
||||
'previous_month_stores' => count($previousStores),
|
||||
'current_month_stores' => count($currentStores),
|
||||
'previous_month_key' => $previousMonthKey,
|
||||
'current_month_key' => $currentMonthKey,
|
||||
'previous_month_label' => $previousMonth->isSameYear($currentMonth)
|
||||
? '上月(' . $previousMonth->format('n月') . ')'
|
||||
: '上月(' . $previousMonth->format('Y年n月') . ')',
|
||||
'current_month_label' => '当月(' . $currentMonth->format('n月') . ')',
|
||||
];
|
||||
return ['data' => array_slice($rows, $offset, $perPage), 'total' => count($rows)];
|
||||
}
|
||||
|
||||
/** @return array{data:array<int,array<string,mixed>>,total:int} */
|
||||
@ -488,11 +285,6 @@ SQL);
|
||||
private function subjectOperatingReportPage(int $page, int $perPage): array
|
||||
{
|
||||
$filters = $this->pageFilters('company-management', 'subject-operating-report');
|
||||
$companyStatusReady = $this->mirror->ready('company-information.read');
|
||||
$companyStatus = trim((string) request('company_status', ''));
|
||||
if (in_array($companyStatus, ['正常', '已注销'], true)) {
|
||||
$filters['company_status'] = $companyStatus;
|
||||
}
|
||||
$rows = $this->storeReportRows(array_intersect_key($filters, array_flip(['company_type'])));
|
||||
$entities = [];
|
||||
|
||||
@ -526,139 +318,34 @@ SQL);
|
||||
|
||||
$rows = array_values($entities);
|
||||
$details = $this->companyDetails(array_column($rows, 'entity'));
|
||||
try {
|
||||
$classifications = $this->mirror->companyClassificationsByNames(array_column($rows, 'entity'));
|
||||
} catch (Throwable) {
|
||||
$classifications = [];
|
||||
}
|
||||
foreach ($rows as &$row) {
|
||||
$row['company_details'] = $details[$row['entity']] ?? [];
|
||||
$row['city'] = $this->cityFromDetails($row['company_details']);
|
||||
$row['company_status'] = $row['company_details'][0]['company_status'] ?? null;
|
||||
$classification = $classifications[$row['entity']] ?? [];
|
||||
$taxType = $classification['tax_type']
|
||||
?? $row['company_details'][0]['taxpayer_category']
|
||||
?? null;
|
||||
$businessType = $classification['business_registration_type']
|
||||
?? $row['company_details'][0]['business_registration_type']
|
||||
?? null;
|
||||
$classificationStatus = trim((string) ($classification['status'] ?? ''));
|
||||
$retained = $classificationStatus !== ''
|
||||
? $classificationStatus === '保留'
|
||||
: $row['company_status'] === '正常';
|
||||
$row['company_status'] = $retained ? '正常' : '已注销';
|
||||
// Excel 台账明确有税务类型时始终展示;没有时再回退到上游公司资料。
|
||||
$row['tax_type'] = $this->normalizeTaxType($taxType) ?? '/';
|
||||
$businessType = trim((string) $businessType);
|
||||
$row['business_registration_type'] = $retained
|
||||
&& in_array($businessType, ['核定', '个人独资企业'], true)
|
||||
? $businessType
|
||||
: '/';
|
||||
}
|
||||
unset($row);
|
||||
|
||||
// 整列为空的月份默认隐藏;金额 0 仍视为有账单,不隐藏。
|
||||
// 所有主体该月收入均为空时,该月份列在页面上默认隐藏;「筛选字段」中仍保留勾选项,可手动选出来。
|
||||
request()->attributes->set(
|
||||
'subject-operating-report.default_hidden_columns',
|
||||
$this->columnsWithoutData($rows, 'income_', zeroCountsAsData: true),
|
||||
);
|
||||
|
||||
$rows = array_values(array_filter($rows, fn (array $row): bool => $this->subjectMatchesFilters($row, $filters)));
|
||||
$summary = $this->subjectSummary($rows, $companyStatusReady);
|
||||
$this->sortSubjectRows($rows, $filters);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$pageRows = array_slice($rows, $offset, $perPage);
|
||||
|
||||
return ['data' => $pageRows, 'total' => count($rows), 'summary' => $summary];
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows */
|
||||
private function subjectSummary(array $rows, bool $companyStatusReady): array
|
||||
{
|
||||
$normal = 0;
|
||||
$deregistered = 0;
|
||||
$smallTaxpayers = 0;
|
||||
$generalTaxpayers = 0;
|
||||
$previousMonth = now()->startOfMonth()->subMonthNoOverflow();
|
||||
$currentMonth = now()->startOfMonth();
|
||||
$previousMonthKey = 'income_' . $previousMonth->format('m');
|
||||
$currentMonthKey = 'income_' . $currentMonth->format('m');
|
||||
$previousIncome = 0.0;
|
||||
$currentMonthIncome = 0.0;
|
||||
$previousIncomeEntities = 0;
|
||||
$currentIncomeEntities = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$status = trim((string) ($row['company_status'] ?? ''));
|
||||
if ($status === '正常') {
|
||||
$normal++;
|
||||
} elseif ($status === '已注销') {
|
||||
$deregistered++;
|
||||
}
|
||||
|
||||
if ($status !== '正常') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$taxType = (string) ($row['tax_type'] ?? '');
|
||||
if (str_contains($taxType, '小规模')) {
|
||||
$smallTaxpayers++;
|
||||
}
|
||||
if (str_contains($taxType, '一般纳税人')) {
|
||||
$generalTaxpayers++;
|
||||
}
|
||||
|
||||
$previousValue = (float) ($row[$previousMonthKey] ?? 0);
|
||||
$currentValue = (float) ($row[$currentMonthKey] ?? 0);
|
||||
$previousIncome += $previousValue;
|
||||
$currentMonthIncome += $currentValue;
|
||||
if ($previousValue > 0) {
|
||||
$previousIncomeEntities++;
|
||||
}
|
||||
if ($currentValue > 0) {
|
||||
$currentIncomeEntities++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => count($rows),
|
||||
'normal' => $normal,
|
||||
'deregistered' => $deregistered,
|
||||
'small_taxpayer' => $smallTaxpayers,
|
||||
'general_taxpayer' => $generalTaxpayers,
|
||||
'previous_income' => round($previousIncome, 2),
|
||||
'current_month_income' => round($currentMonthIncome, 2),
|
||||
'previous_income_entities' => $previousIncomeEntities,
|
||||
'current_income_entities' => $currentIncomeEntities,
|
||||
'previous_month_key' => $previousMonthKey,
|
||||
'current_month_key' => $currentMonthKey,
|
||||
'previous_month_label' => $previousMonth->isSameYear($currentMonth)
|
||||
? '上月(' . $previousMonth->format('n月') . ')'
|
||||
: '上月(' . $previousMonth->format('Y年n月') . ')',
|
||||
'current_month_label' => '当月(' . $currentMonth->format('n月') . ')',
|
||||
'company_status_ready' => $companyStatusReady,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeTaxType(mixed $value): ?string
|
||||
{
|
||||
$taxType = trim((string) $value);
|
||||
if ($taxType === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($taxType) {
|
||||
'小规模' => '小规模纳税人',
|
||||
'一般纳税人', '小规模纳税人', '个体工商户', '无税务登记' => $taxType,
|
||||
default => $taxType,
|
||||
};
|
||||
return ['data' => $pageRows, 'total' => count($rows)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回整列都没有数据的月份列;这类列只默认隐藏,字段和数据不会被删除。
|
||||
* 没有数据的月份列:所有行该月都没有数值时返回该列名,页面据此默认隐藏。
|
||||
*
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return list<string>
|
||||
* 收入(income_)以 null/空字符串表示无账单,金额 0 也算有账单;
|
||||
* 销量(sales_)没有空值标记,数值 0 视为当月没有业绩。
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function columnsWithoutData(array $rows, string $prefix, bool $zeroCountsAsData): array
|
||||
{
|
||||
@ -687,16 +374,11 @@ SQL);
|
||||
/** @param array<string,mixed> $row @param array<string,string> $filters */
|
||||
private function subjectMatchesFilters(array $row, array $filters): bool
|
||||
{
|
||||
$companyStatus = trim((string) ($filters['company_status'] ?? ''));
|
||||
if ($companyStatus !== '' && (string) ($row['company_status'] ?? '') !== $companyStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($filters['company_type'] ?? '') !== '' && (string) ($row['company_type'] ?? '') !== (string) $filters['company_type']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (['city', 'entity', 'tax_type', 'business_registration_type', 'plan', 'new_entity'] as $key) {
|
||||
foreach (['city', 'entity', 'plan', 'new_entity'] as $key) {
|
||||
$needle = trim((string) ($filters[$key] ?? ''));
|
||||
if ($needle !== '' && mb_stripos((string) ($row[$key] ?? ''), $needle) === false) {
|
||||
return false;
|
||||
@ -720,7 +402,7 @@ SQL);
|
||||
{
|
||||
$key = (string) ($filters['_sort'] ?? 'entity');
|
||||
$allowed = [
|
||||
'city', 'entity', 'company_type', 'tax_type', 'business_registration_type', 'rolling_12m_income',
|
||||
'city', 'entity', 'company_type', 'rolling_12m_income',
|
||||
...array_map(static fn (int $month): string => sprintf('income_%02d', $month), range(1, 12)),
|
||||
'can_change_entity', 'plan', 'new_entity',
|
||||
];
|
||||
@ -821,8 +503,7 @@ SQL);
|
||||
'company_status' => $row['companyStatus'] ?? $row['company_status'] ?? null,
|
||||
'financial_officer' => $row['financialManager'] ?? $row['financial_officer'] ?? null,
|
||||
'supervisor' => $row['supervisor'] ?? null,
|
||||
'taxpayer_category' => $row['finance_tax_type'] ?? $row['taxCategoryAndType'] ?? $row['taxpayer_category'] ?? null,
|
||||
'business_registration_type' => $row['business_registration_type'] ?? null,
|
||||
'taxpayer_category' => $row['taxCategoryAndType'] ?? $row['taxpayer_category'] ?? null,
|
||||
'shareholders' => $row['shareholders'] ?? null,
|
||||
]);
|
||||
}
|
||||
@ -836,15 +517,14 @@ SQL);
|
||||
$storeFilters = array_diff_key($filters, array_flip(['store_manager', 'department_manager', 'profit_center']));
|
||||
$stores = $this->allStores($storeFilters);
|
||||
foreach ($stores['data'] as &$store) {
|
||||
$key = StoreAccountMirrorRepository::storeNameKey($store['erp_store_name'] ?? '');
|
||||
$match = $ownership[$key] ?? null;
|
||||
$match = $ownership[trim((string) ($store['erp_store_name'] ?? ''))] ?? null;
|
||||
if ($match === null) {
|
||||
continue;
|
||||
}
|
||||
// 未匹配到账号或字段为空时保留经营分析副本库原值。
|
||||
foreach ([
|
||||
'store_manager' => $match['operation_owner'],
|
||||
'department_manager' => $match['operation_department_head'],
|
||||
'department_manager' => $match['operation_director'],
|
||||
'profit_center' => $match['profit_center'],
|
||||
] as $field => $value) {
|
||||
if ($value !== '') {
|
||||
@ -862,15 +542,12 @@ SQL);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,array{operation_owner:string,operation_department_head:string,operation_director:string,profit_center:string}> */
|
||||
private function storeOwnership(bool $requireAccountOwnership = false): array
|
||||
/** @return array<string,array{operation_owner:string,operation_director:string,profit_center:string}> */
|
||||
private function storeOwnership(): array
|
||||
{
|
||||
try {
|
||||
return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->ownershipByStoreName();
|
||||
} catch (\RuntimeException | \InvalidArgumentException $exception) {
|
||||
if ($requireAccountOwnership) {
|
||||
throw new \RuntimeException('店铺账号密码共享镜像尚未就绪,无法关联负责人。', 0, $exception);
|
||||
}
|
||||
// 账号镜像未登记或未就绪时保留经营分析副本库原值,不阻断店铺报表。
|
||||
return [];
|
||||
}
|
||||
@ -968,16 +645,9 @@ SQL);
|
||||
];
|
||||
}
|
||||
|
||||
private function page(
|
||||
string $scope,
|
||||
int $page,
|
||||
int $perPage,
|
||||
array $filters,
|
||||
callable $resolver,
|
||||
?bool $mirrorReady = null,
|
||||
): array
|
||||
private function page(string $scope, int $page, int $perPage, array $filters, callable $resolver): array
|
||||
{
|
||||
if ($mirrorReady ?? $this->mirror->ready($scope)) {
|
||||
if ($this->mirror->ready($scope)) {
|
||||
return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage, $filters));
|
||||
}
|
||||
|
||||
@ -1051,7 +721,6 @@ SQL);
|
||||
return [
|
||||
'data' => array_map($mapper, $payload['data']),
|
||||
'total' => (int) $payload['total'],
|
||||
'summary' => (array) ($payload['summary'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
@ -1066,10 +735,6 @@ SQL);
|
||||
'1' => '已注销',
|
||||
default => $row['company_status'] ?? null,
|
||||
};
|
||||
$businessType = trim((string) ($row['business_registration_type'] ?? ''));
|
||||
$row['business_registration_type'] = in_array($businessType, ['核定', '个人独资企业'], true)
|
||||
? $businessType
|
||||
: '/';
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
@ -1,243 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
use App\Models\Business\Todo;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
|
||||
|
||||
class WithdrawalBalanceAlertSynchronizer
|
||||
{
|
||||
public const CATEGORY = '提现余额预警';
|
||||
|
||||
public const TODO_STATUS = '待处理';
|
||||
|
||||
public function __construct(
|
||||
private readonly JushuitanShopBalanceRepository $shopBalances,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the latest balance date for a company and persist one open todo
|
||||
* per store that has exceeded its reserve standard without any change for
|
||||
* three consecutive calendar days.
|
||||
*
|
||||
* @return array{balance_date:string,dates:list<string>,alerts:array<int,array<string,mixed>>,created:int,updated:int}
|
||||
*/
|
||||
public function syncLatest(int $companyId): array
|
||||
{
|
||||
$latest = $this->shopBalances->latestBalanceDate(now());
|
||||
if ($latest === null) {
|
||||
return [
|
||||
'balance_date' => '',
|
||||
'dates' => [],
|
||||
'alerts' => [],
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->syncForDate($companyId, $latest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{balance_date:string,dates:list<string>,alerts:array<int,array<string,mixed>>,created:int,updated:int}
|
||||
*/
|
||||
public function syncForDate(int $companyId, string $balanceDate, ?string $sourceScope = null): array
|
||||
{
|
||||
$dates = self::datesEndingAt($balanceDate);
|
||||
$stores = StoreReceivingAccount::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($stores->isEmpty()) {
|
||||
return [
|
||||
'balance_date' => $balanceDate,
|
||||
'dates' => $dates,
|
||||
'alerts' => [],
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$balancesByDate = $this->shopBalances->matchesForStoresOnDates(
|
||||
$stores,
|
||||
$dates,
|
||||
null,
|
||||
$sourceScope,
|
||||
);
|
||||
$reserveStandards = $stores->mapWithKeys(
|
||||
static fn (StoreReceivingAccount $store): array => [
|
||||
(int) $store->id => WithdrawalReserveStandard::amountFor(
|
||||
$store->store_platform,
|
||||
$store->erp_store_name,
|
||||
),
|
||||
],
|
||||
)->all();
|
||||
$alerts = self::detect($stores, $balancesByDate, $reserveStandards, $dates);
|
||||
[$created, $updated] = DB::transaction(function () use ($companyId, $stores, &$alerts): array {
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
|
||||
foreach ($stores as $store) {
|
||||
$alert = $alerts[(int) $store->id] ?? null;
|
||||
if ($alert === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = $this->todoTitle($store);
|
||||
$description = $this->description($store, $alert);
|
||||
$todo = Todo::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->where('category', self::CATEGORY)
|
||||
->where('title', $title)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotIn('status', ['已完成', '已关闭'])
|
||||
->orderByDesc('id')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($todo === null) {
|
||||
$todo = Todo::withoutGlobalScopes()->create([
|
||||
'company_id' => $companyId,
|
||||
'title' => $title,
|
||||
'category' => self::CATEGORY,
|
||||
'priority' => '高',
|
||||
'status' => self::TODO_STATUS,
|
||||
'description' => $description,
|
||||
]);
|
||||
$created++;
|
||||
} else {
|
||||
$attributes = [
|
||||
'priority' => '高',
|
||||
'description' => $description,
|
||||
];
|
||||
if ($todo->status !== self::TODO_STATUS) {
|
||||
$attributes['status'] = self::TODO_STATUS;
|
||||
}
|
||||
if ($todo->fill($attributes)->isDirty()) {
|
||||
$todo->save();
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
$alerts[(int) $store->id]['todo_id'] = (int) $todo->id;
|
||||
}
|
||||
|
||||
return [$created, $updated];
|
||||
});
|
||||
|
||||
return [
|
||||
'balance_date' => $balanceDate,
|
||||
'dates' => $dates,
|
||||
'alerts' => $alerts,
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<StoreReceivingAccount> $stores
|
||||
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>> $balancesByDate
|
||||
* @param array<int,int|null> $reserveStandards
|
||||
* @param list<string> $dates
|
||||
* @return array<int,array{balance:float,start_date:string,end_date:string,message:string}>
|
||||
*/
|
||||
public static function detect(
|
||||
iterable $stores,
|
||||
array $balancesByDate,
|
||||
array $reserveStandards,
|
||||
array $dates,
|
||||
): array {
|
||||
if (count($dates) !== 3) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$alerts = [];
|
||||
foreach ($stores as $store) {
|
||||
$reserve = $reserveStandards[(int) $store->id] ?? null;
|
||||
if ($reserve === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$balanceCents = null;
|
||||
$unchanged = true;
|
||||
foreach ($dates as $date) {
|
||||
$match = $balancesByDate[(int) $store->id][$date]
|
||||
?? ['status' => 'not_ready', 'balance' => null];
|
||||
if (($match['status'] ?? '') !== 'matched' || ($match['balance'] ?? null) === null) {
|
||||
$unchanged = false;
|
||||
break;
|
||||
}
|
||||
|
||||
$cents = (int) round((float) $match['balance'] * 100);
|
||||
if ($balanceCents === null) {
|
||||
$balanceCents = $cents;
|
||||
} elseif ($balanceCents !== $cents) {
|
||||
$unchanged = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $unchanged || $balanceCents === null || $balanceCents <= $reserve * 100) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$balance = $balanceCents / 100;
|
||||
$alerts[(int) $store->id] = [
|
||||
'balance' => $balance,
|
||||
'start_date' => $dates[0],
|
||||
'end_date' => $dates[2],
|
||||
'message' => sprintf(
|
||||
'余额连续 3 天保持 %s 元,高于预留标准 %s 元。',
|
||||
number_format($balance, 2),
|
||||
number_format($reserve, 2),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return $alerts;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function datesEndingAt(string $balanceDate): array
|
||||
{
|
||||
$end = CarbonImmutable::createFromFormat('!Y-m-d', $balanceDate);
|
||||
if (! $end instanceof CarbonImmutable || $end->format('Y-m-d') !== $balanceDate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
$end->subDays(2)->toDateString(),
|
||||
$end->subDay()->toDateString(),
|
||||
$end->toDateString(),
|
||||
];
|
||||
}
|
||||
|
||||
private function todoTitle(StoreReceivingAccount $store): string
|
||||
{
|
||||
return mb_substr(
|
||||
'提现余额连续3天未变动|' . (string) $store->store_platform . '|' . (string) $store->erp_store_name,
|
||||
0,
|
||||
255,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array{balance:float,start_date:string,end_date:string,message:string} $alert */
|
||||
private function description(StoreReceivingAccount $store, array $alert): string
|
||||
{
|
||||
return implode("\n", array_filter([
|
||||
$alert['message'],
|
||||
'统计期间:' . $alert['start_date'] . ' 至 ' . $alert['end_date'],
|
||||
'店铺平台:' . ($store->store_platform ?: '未配置'),
|
||||
'ERP店铺名称:' . ($store->erp_store_name ?: '未配置'),
|
||||
'店铺主体:' . ($store->store_entity_name ?: '未配置'),
|
||||
'平台店铺ID:' . ($store->store_id ?: '未配置'),
|
||||
'处理建议:核实店铺余额是否真实停滞,并检查是否应执行提现。',
|
||||
], static fn (string $line): bool => $line !== ''));
|
||||
}
|
||||
}
|
||||
@ -18,10 +18,7 @@ class WithdrawalManagementReport
|
||||
'withdrawal_path',
|
||||
'withdrawal_account',
|
||||
'bank_card',
|
||||
'balance_two_days_ago',
|
||||
'balance_yesterday',
|
||||
'balance',
|
||||
'balance_alert',
|
||||
'reserve_standard',
|
||||
'withdrawal_amount',
|
||||
'compliance',
|
||||
@ -36,13 +33,10 @@ class WithdrawalManagementReport
|
||||
* @return array{
|
||||
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string},
|
||||
* platforms:list<string>,paths:list<string>,storeEntities:list<string>,rows:Collection<int,StoreReceivingAccount>,
|
||||
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>,
|
||||
* balancesByDate:array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>>,
|
||||
* balanceDays:list<array{key:string,date:string,label:string}>,
|
||||
* alerts:array<int,array{balance:float,start_date:string,end_date:string,message:string}>,
|
||||
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
|
||||
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
|
||||
* summary:array{source:int,matched:int,finance:int,ambiguous:int,unmatched:int,not_ready:int},
|
||||
* balanceColumnLabel:string,balanceDate:string,latestBalanceDate:?string,balanceDates:list<string>,sort:array{field:string,direction:string}
|
||||
* summary:array{source:int,matched:int,ambiguous:int,unmatched:int,not_ready:int},
|
||||
* balanceColumnLabel:string,balanceDate:string,balanceDates:list<string>,sort:array{field:string,direction:string}
|
||||
* }
|
||||
*/
|
||||
public function results(Request $request): array
|
||||
@ -77,24 +71,10 @@ class WithdrawalManagementReport
|
||||
? CarbonImmutable::parse($balanceDate)
|
||||
: $today;
|
||||
$rows = $query->orderByDesc('id')->get();
|
||||
$historyDates = WithdrawalBalanceAlertSynchronizer::datesEndingAt($balanceDate);
|
||||
$balancesByDate = $this->shopBalances->matchesForStoresOnDates($rows, $historyDates);
|
||||
$balances = array_map(
|
||||
static fn (array $dates): array => $dates[$balanceDate] ?? [
|
||||
'status' => 'not_ready',
|
||||
'balance' => null,
|
||||
'source_store_name' => null,
|
||||
'source_platform' => null,
|
||||
],
|
||||
$balancesByDate,
|
||||
);
|
||||
$balances = $this->shopBalances->matchesForStores($rows, $balanceDate);
|
||||
$balanceCounts = collect($balances)->countBy(
|
||||
static fn (array $match): string => $match['status'],
|
||||
);
|
||||
$financeCount = collect($balances)->filter(
|
||||
static fn (array $match): bool => ($match['data_source'] ?? null)
|
||||
=== JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE,
|
||||
)->count();
|
||||
|
||||
if ($filters['balanceStatus'] !== '') {
|
||||
$rows = $rows->filter(
|
||||
@ -110,19 +90,6 @@ class WithdrawalManagementReport
|
||||
),
|
||||
],
|
||||
)->all();
|
||||
$balanceDays = collect($historyDates)->map(
|
||||
static fn (string $date, int $index): array => [
|
||||
'key' => $index === 0 ? 'balance_two_days_ago' : ($index === 1 ? 'balance_yesterday' : 'balance'),
|
||||
'date' => $date,
|
||||
'label' => CarbonImmutable::parse($date)->format('n月j日') . '店铺余额',
|
||||
],
|
||||
)->values()->all();
|
||||
$alerts = WithdrawalBalanceAlertSynchronizer::detect(
|
||||
$rows,
|
||||
$balancesByDate,
|
||||
$reserveStandards,
|
||||
$historyDates,
|
||||
);
|
||||
$withdrawalAmounts = $rows->mapWithKeys(
|
||||
fn (StoreReceivingAccount $row): array => [
|
||||
(int) $row->id => $this->withdrawalAmountFor($row, $balanceDate),
|
||||
@ -130,7 +97,7 @@ class WithdrawalManagementReport
|
||||
)->all();
|
||||
$compliance = $rows->mapWithKeys(
|
||||
fn (StoreReceivingAccount $row): array => [
|
||||
(int) $row->id => self::complianceFor(
|
||||
(int) $row->id => $this->complianceFor(
|
||||
$balances[(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null],
|
||||
$reserveStandards[(int) $row->id] ?? null,
|
||||
$withdrawalAmounts[(int) $row->id] ?? null,
|
||||
@ -139,16 +106,7 @@ class WithdrawalManagementReport
|
||||
)->all();
|
||||
$rows = $sort['field'] === ''
|
||||
? $this->sortByBalanceStatus($rows, $balances)
|
||||
: $this->sortRows(
|
||||
$rows,
|
||||
$sort,
|
||||
$balancesByDate,
|
||||
$balanceDays,
|
||||
$alerts,
|
||||
$reserveStandards,
|
||||
$withdrawalAmounts,
|
||||
$compliance,
|
||||
);
|
||||
: $this->sortRows($rows, $sort, $balances, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
|
||||
return [
|
||||
'filters' => $filters,
|
||||
@ -157,23 +115,18 @@ class WithdrawalManagementReport
|
||||
'storeEntities' => $storeEntities,
|
||||
'rows' => $rows,
|
||||
'balances' => $balances,
|
||||
'balancesByDate' => $balancesByDate,
|
||||
'balanceDays' => $balanceDays,
|
||||
'alerts' => $alerts,
|
||||
'reserveStandards' => $reserveStandards,
|
||||
'withdrawalAmounts' => $withdrawalAmounts,
|
||||
'compliance' => $compliance,
|
||||
'summary' => [
|
||||
'source' => $this->shopBalances->countForDate($balanceDate),
|
||||
'matched' => (int) $balanceCounts->get('matched', 0),
|
||||
'finance' => $financeCount,
|
||||
'ambiguous' => (int) $balanceCounts->get('ambiguous', 0),
|
||||
'unmatched' => (int) $balanceCounts->get('unmatched', 0),
|
||||
'not_ready' => (int) $balanceCounts->get('not_ready', 0),
|
||||
],
|
||||
'balanceColumnLabel' => $labelDate->format('n月j日') . '店铺余额',
|
||||
'balanceDate' => $balanceDate,
|
||||
'latestBalanceDate' => $latestBalanceDate,
|
||||
'balanceDates' => $this->shopBalances->availableDates($today),
|
||||
'sort' => $sort,
|
||||
];
|
||||
@ -233,7 +186,7 @@ class WithdrawalManagementReport
|
||||
|
||||
/**
|
||||
* @param Collection<int,StoreReceivingAccount> $rows
|
||||
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}> $balances
|
||||
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
|
||||
* @return Collection<int,StoreReceivingAccount>
|
||||
*/
|
||||
private function sortByBalanceStatus(Collection $rows, array $balances): Collection
|
||||
@ -256,35 +209,17 @@ class WithdrawalManagementReport
|
||||
/**
|
||||
* @param Collection<int,StoreReceivingAccount> $rows
|
||||
* @param array{field:string,direction:string} $sort
|
||||
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>> $balancesByDate
|
||||
* @param list<array{key:string,date:string,label:string}> $balanceDays
|
||||
* @param array<int,array{balance:float,start_date:string,end_date:string,message:string}> $alerts
|
||||
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
|
||||
* @param array<int,int|null> $reserveStandards
|
||||
* @param array<int,float|null> $withdrawalAmounts
|
||||
* @param array<int,string|null> $compliance
|
||||
* @return Collection<int,StoreReceivingAccount>
|
||||
*/
|
||||
private function sortRows(
|
||||
Collection $rows,
|
||||
array $sort,
|
||||
array $balancesByDate,
|
||||
array $balanceDays,
|
||||
array $alerts,
|
||||
array $reserveStandards,
|
||||
array $withdrawalAmounts,
|
||||
array $compliance,
|
||||
): Collection {
|
||||
return $rows->sort(function (StoreReceivingAccount $left, StoreReceivingAccount $right) use (
|
||||
$sort,
|
||||
$balancesByDate,
|
||||
$balanceDays,
|
||||
$alerts,
|
||||
$reserveStandards,
|
||||
$withdrawalAmounts,
|
||||
$compliance,
|
||||
): int {
|
||||
$leftValue = $this->sortValue($left, $sort['field'], $balancesByDate, $balanceDays, $alerts, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
$rightValue = $this->sortValue($right, $sort['field'], $balancesByDate, $balanceDays, $alerts, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
private function sortRows(Collection $rows, array $sort, array $balances, array $reserveStandards, array $withdrawalAmounts, array $compliance): Collection
|
||||
{
|
||||
return $rows->sort(function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($sort, $balances, $reserveStandards, $withdrawalAmounts, $compliance): int {
|
||||
$leftValue = $this->sortValue($left, $sort['field'], $balances, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
$rightValue = $this->sortValue($right, $sort['field'], $balances, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
$comparison = $this->compareSortValues($leftValue, $rightValue, $sort['direction']);
|
||||
|
||||
return $comparison !== 0
|
||||
@ -294,38 +229,13 @@ class WithdrawalManagementReport
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>> $balancesByDate
|
||||
* @param list<array{key:string,date:string,label:string}> $balanceDays
|
||||
* @param array<int,array{balance:float,start_date:string,end_date:string,message:string}> $alerts
|
||||
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
|
||||
* @param array<int,int|null> $reserveStandards
|
||||
* @param array<int,float|null> $withdrawalAmounts
|
||||
* @param array<int,string|null> $compliance
|
||||
*/
|
||||
private function sortValue(
|
||||
StoreReceivingAccount $row,
|
||||
string $field,
|
||||
array $balancesByDate,
|
||||
array $balanceDays,
|
||||
array $alerts,
|
||||
array $reserveStandards,
|
||||
array $withdrawalAmounts,
|
||||
array $compliance,
|
||||
): mixed {
|
||||
$balanceFor = static function (string $dayKey) use ($row, $balancesByDate, $balanceDays): ?float {
|
||||
$date = null;
|
||||
foreach ($balanceDays as $day) {
|
||||
if ($day['key'] === $dayKey) {
|
||||
$date = $day['date'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$match = $date === null
|
||||
? null
|
||||
: ($balancesByDate[(int) $row->id][$date] ?? null);
|
||||
|
||||
return ($match['status'] ?? null) === 'matched' ? (float) $match['balance'] : null;
|
||||
};
|
||||
|
||||
private function sortValue(StoreReceivingAccount $row, string $field, array $balances, array $reserveStandards, array $withdrawalAmounts, array $compliance): mixed
|
||||
{
|
||||
return match ($field) {
|
||||
'platform' => $row->store_platform,
|
||||
'erp_store_name' => $row->erp_store_name,
|
||||
@ -334,10 +244,9 @@ class WithdrawalManagementReport
|
||||
'withdrawal_path' => $row->withdrawal_path,
|
||||
'withdrawal_account' => $row->withdrawal_account_name,
|
||||
'bank_card' => $row->bank_card_last_four,
|
||||
'balance_two_days_ago' => $balanceFor('balance_two_days_ago'),
|
||||
'balance_yesterday' => $balanceFor('balance_yesterday'),
|
||||
'balance' => $balanceFor('balance'),
|
||||
'balance_alert' => isset($alerts[(int) $row->id]) ? 1 : 0,
|
||||
'balance' => ($balances[(int) $row->id]['status'] ?? null) === 'matched'
|
||||
? $balances[(int) $row->id]['balance']
|
||||
: null,
|
||||
'reserve_standard' => $reserveStandards[(int) $row->id] ?? null,
|
||||
'withdrawal_amount' => $withdrawalAmounts[(int) $row->id] ?? null,
|
||||
'compliance' => $compliance[(int) $row->id] ?? null,
|
||||
@ -355,7 +264,7 @@ class WithdrawalManagementReport
|
||||
}
|
||||
|
||||
/** @param array{status:string,balance:?float} $balance */
|
||||
public static function complianceFor(array $balance, ?int $reserve, ?float $withdrawalAmount): ?string
|
||||
private function complianceFor(array $balance, ?int $reserve, ?float $withdrawalAmount): ?string
|
||||
{
|
||||
if (($balance['status'] ?? '') !== 'matched' || $balance['balance'] === null || $reserve === null || $withdrawalAmount === null) {
|
||||
return null;
|
||||
|
||||
@ -12,7 +12,6 @@ use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
@ -31,17 +30,6 @@ class WithdrawalManagementSpreadsheet
|
||||
];
|
||||
|
||||
private const CLEAR_VALUE = '[清空]';
|
||||
private const WITHDRAWAL_TEMPLATE_HEADERS = [
|
||||
'店铺平台',
|
||||
'ERP店铺名称',
|
||||
'平台店铺ID',
|
||||
'余额日期',
|
||||
'店铺余额',
|
||||
'预留标准',
|
||||
'提现金额',
|
||||
'提现后余额',
|
||||
'是否达标',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly JushuitanShopBalanceRepository $shopBalances,
|
||||
@ -51,10 +39,7 @@ class WithdrawalManagementSpreadsheet
|
||||
/**
|
||||
* @param array{
|
||||
* rows:\Illuminate\Support\Collection<int,StoreReceivingAccount>,
|
||||
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>,
|
||||
* balancesByDate:array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>>,
|
||||
* balanceDays:list<array{key:string,date:string,label:string}>,
|
||||
* alerts:array<int,array{balance:float,start_date:string,end_date:string,message:string}>,
|
||||
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
|
||||
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
|
||||
* balanceColumnLabel:string,balanceDate:string
|
||||
* } $report
|
||||
@ -97,113 +82,10 @@ class WithdrawalManagementSpreadsheet
|
||||
return $this->download($spreadsheet, 'withdrawal_management_import_template.xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前筛选预填店铺和余额参考值,同事只需填写提现金额。
|
||||
*
|
||||
* @param array<string,mixed> $report
|
||||
*/
|
||||
public function withdrawalTemplate(array $report): StreamedResponse
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('提现金额');
|
||||
$this->writeHeader($sheet, self::WITHDRAWAL_TEMPLATE_HEADERS);
|
||||
$this->sizeColumns($sheet, [14, 30, 20, 14, 18, 18, 18, 18, 14]);
|
||||
|
||||
$rowNumber = 2;
|
||||
foreach ($report['rows'] as $row) {
|
||||
$storeId = (int) $row->id;
|
||||
$balanceMatch = $report['balances'][$storeId] ?? [
|
||||
'status' => 'not_ready',
|
||||
'balance' => null,
|
||||
];
|
||||
$hasBalance = ($balanceMatch['status'] ?? '') === 'matched'
|
||||
&& ($balanceMatch['balance'] ?? null) !== null;
|
||||
$reserve = $report['reserveStandards'][$storeId] ?? null;
|
||||
$amount = $report['withdrawalAmounts'][$storeId] ?? null;
|
||||
|
||||
foreach ([
|
||||
'A' => (string) ($row->store_platform ?? ''),
|
||||
'B' => (string) ($row->erp_store_name ?? ''),
|
||||
'C' => (string) ($row->store_id ?? ''),
|
||||
'D' => (string) $report['balanceDate'],
|
||||
] as $column => $value) {
|
||||
$sheet->setCellValueExplicit("{$column}{$rowNumber}", $value, DataType::TYPE_STRING);
|
||||
}
|
||||
|
||||
if ($hasBalance) {
|
||||
$sheet->setCellValue("E{$rowNumber}", (float) $balanceMatch['balance']);
|
||||
} else {
|
||||
$sheet->setCellValueExplicit("E{$rowNumber}", $this->balanceStatus((string) ($balanceMatch['status'] ?? 'not_ready')), DataType::TYPE_STRING);
|
||||
}
|
||||
if ($reserve !== null) {
|
||||
$sheet->setCellValue("F{$rowNumber}", $reserve);
|
||||
} else {
|
||||
$sheet->setCellValueExplicit("F{$rowNumber}", '待配置', DataType::TYPE_STRING);
|
||||
}
|
||||
if ($amount !== null) {
|
||||
$sheet->setCellValue("G{$rowNumber}", $amount);
|
||||
}
|
||||
|
||||
$sheet->setCellValue(
|
||||
"H{$rowNumber}",
|
||||
"=IF(OR(NOT(ISNUMBER(E{$rowNumber})),G{$rowNumber}=\"\"),\"\",E{$rowNumber}-G{$rowNumber})",
|
||||
);
|
||||
$sheet->setCellValue(
|
||||
"I{$rowNumber}",
|
||||
"=IF(OR(H{$rowNumber}=\"\",NOT(ISNUMBER(F{$rowNumber}))),\"\",IF(H{$rowNumber}<=F{$rowNumber},\"是\",\"否\"))",
|
||||
);
|
||||
$rowNumber++;
|
||||
}
|
||||
|
||||
$lastDataRow = max(2, $rowNumber - 1);
|
||||
$sheet->getStyle("E2:I{$lastDataRow}")->getNumberFormat()->setFormatCode('#,##0.00');
|
||||
$sheet->getStyle("E2:I{$lastDataRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
$sheet->getStyle("G2:G{$lastDataRow}")->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()->setARGB('FFF7F0DE');
|
||||
$sheet->getStyle('G1')->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()->setARGB('FFB9954F');
|
||||
|
||||
$validation = new DataValidation();
|
||||
$validation->setType(DataValidation::TYPE_DECIMAL)
|
||||
->setOperator(DataValidation::OPERATOR_GREATERTHANOREQUAL)
|
||||
->setFormula1('0')
|
||||
->setAllowBlank(true)
|
||||
->setShowErrorMessage(true)
|
||||
->setErrorTitle('提现金额格式错误')
|
||||
->setError('请填写非负金额,最多两位小数。');
|
||||
$sheet->setDataValidation("G2:G{$lastDataRow}", $validation);
|
||||
|
||||
$instructions = $spreadsheet->createSheet();
|
||||
$instructions->setTitle('填写说明');
|
||||
$instructions->fromArray([
|
||||
['提现金额导入说明'],
|
||||
['1. 本模板按下载时的页面筛选生成,已预填店铺标识、余额日期、店铺余额和预留标准。'],
|
||||
['2. 只需填写黄色“提现金额”列;未填写金额的店铺不会提交。填写“' . self::CLEAR_VALUE . '”可清空该店铺提现金额。'],
|
||||
['3. 店铺余额、预留标准、提现后余额和是否达标仅用于下载时预览,系统会在上传后按最新余额日期重新计算。'],
|
||||
['4. 判定规则:店铺余额 - 提现金额 ≤ 预留标准时,“是否达标”为“是”,即该笔提现按大笔提现处理。'],
|
||||
['5. 模板余额日期为 ' . $report['balanceDate'] . ';日期滚动后请重新下载模板,避免上传过期金额。'],
|
||||
], null, 'A1');
|
||||
$instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14);
|
||||
$instructions->getColumnDimension('A')->setWidth(96);
|
||||
$instructions->getStyle('A1:A6')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
|
||||
foreach (range(1, 6) as $row) {
|
||||
$instructions->getRowDimension($row)->setRowHeight($row === 1 ? 26 : 36);
|
||||
}
|
||||
$spreadsheet->setActiveSheetIndex(0);
|
||||
|
||||
return $this->download(
|
||||
$spreadsheet,
|
||||
'withdrawal_amount_import_' . str_replace('-', '', (string) $report['balanceDate']) . '.xlsx',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* total:int,changed:int,unchanged:int,
|
||||
* preview:list<array{platform:string,erp_store_name:string,store_id:string,changes:string,withdrawal_amount:?float,post_withdrawal_balance:?float,reserve_standard:?int,compliance:?string}>,
|
||||
* preview:list<array{platform:string,erp_store_name:string,store_id:string,changes:string}>,
|
||||
* plan:list<array{id:int,updated_at:string,changes:array<string,string|null>}>
|
||||
* }
|
||||
*/
|
||||
@ -219,7 +101,7 @@ class WithdrawalManagementSpreadsheet
|
||||
'import' => 'Excel 文件无法读取,请确认文件未损坏并使用最新模板。',
|
||||
]);
|
||||
}
|
||||
[$headerIndex, $headers, $headerMode] = $this->headers($matrix);
|
||||
[$headerIndex, $headers] = $this->headers($matrix);
|
||||
$indexes = array_flip($headers);
|
||||
$balanceDate = $this->currentBalanceDate();
|
||||
$accounts = StoreReceivingAccount::withoutGlobalScopes()
|
||||
@ -227,8 +109,6 @@ class WithdrawalManagementSpreadsheet
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
[$byId, $byName] = $this->indexes($accounts);
|
||||
$balances = $this->shopBalances->matchesForStores($accounts, $balanceDate);
|
||||
$withdrawalOnlyMode = $headerMode === 'withdrawal';
|
||||
$errors = [];
|
||||
$plan = [];
|
||||
$preview = [];
|
||||
@ -238,15 +118,12 @@ class WithdrawalManagementSpreadsheet
|
||||
|
||||
foreach (array_slice($matrix, $headerIndex + 1, null, true) as $matrixIndex => $row) {
|
||||
$values = [];
|
||||
foreach ([...self::CONFIG_HEADERS, '提现金额', '余额日期'] as $header) {
|
||||
$values[$header] = isset($indexes[$header])
|
||||
? trim((string) ($row[$indexes[$header]] ?? ''))
|
||||
: '';
|
||||
}
|
||||
// 专用模板会预填所有店铺,只把填写了提现金额的行视为提交数据。
|
||||
if ($withdrawalOnlyMode && $values['提现金额'] === '') {
|
||||
continue;
|
||||
foreach (self::CONFIG_HEADERS as $header) {
|
||||
$values[$header] = trim((string) ($row[$indexes[$header]] ?? ''));
|
||||
}
|
||||
$values['提现金额'] = isset($indexes['提现金额'])
|
||||
? trim((string) ($row[$indexes['提现金额']] ?? ''))
|
||||
: '';
|
||||
if (count(array_filter($values, static fn (string $value): bool => $value !== '')) === 0) {
|
||||
continue;
|
||||
}
|
||||
@ -260,13 +137,8 @@ class WithdrawalManagementSpreadsheet
|
||||
$erpStoreName = mb_substr($values['ERP店铺名称'], 0, 191);
|
||||
$storeId = mb_substr(ltrim($values['平台店铺ID'], "'"), 0, 191);
|
||||
|
||||
if ($platform === '' || ($storeId === '' && $erpStoreName === '')) {
|
||||
$errors[] = "第 {$line} 行缺少店铺平台或店铺标识";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($withdrawalOnlyMode && $values['余额日期'] !== '' && $values['余额日期'] !== $balanceDate) {
|
||||
$errors[] = "第 {$line} 行余额日期 {$values['余额日期']} 已过期,请按最新余额日期 {$balanceDate} 重新下载模板";
|
||||
if ($platform === '' || $erpStoreName === '') {
|
||||
$errors[] = "第 {$line} 行缺少店铺平台或 ERP 店铺名称";
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -321,40 +193,11 @@ class WithdrawalManagementSpreadsheet
|
||||
'changes' => $changes,
|
||||
];
|
||||
if (count($preview) < 20) {
|
||||
$amount = array_key_exists('withdrawal_amount', $changes)
|
||||
? ($changes['withdrawal_amount'] === null ? null : (float) $changes['withdrawal_amount'])
|
||||
: (
|
||||
$account->withdrawal_balance_date === $balanceDate
|
||||
&& $account->withdrawal_amount !== null
|
||||
&& is_numeric($account->withdrawal_amount)
|
||||
? (float) $account->withdrawal_amount
|
||||
: null
|
||||
);
|
||||
$balance = ($balances[(int) $account->id]['status'] ?? '') === 'matched'
|
||||
? (float) $balances[(int) $account->id]['balance']
|
||||
: null;
|
||||
$reserve = WithdrawalReserveStandard::amountFor(
|
||||
$account->store_platform,
|
||||
$account->erp_store_name,
|
||||
);
|
||||
$preview[] = [
|
||||
'platform' => $platform,
|
||||
'erp_store_name' => $erpStoreName,
|
||||
'store_id' => $storeId,
|
||||
'changes' => implode('、', $changedLabels),
|
||||
'withdrawal_amount' => $amount,
|
||||
'post_withdrawal_balance' => $balance !== null && $amount !== null
|
||||
? round($balance - $amount, 2)
|
||||
: null,
|
||||
'reserve_standard' => $reserve,
|
||||
'compliance' => WithdrawalManagementReport::complianceFor(
|
||||
[
|
||||
'status' => $balance === null ? 'not_ready' : 'matched',
|
||||
'balance' => $balance,
|
||||
],
|
||||
$reserve,
|
||||
$amount,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -428,36 +271,19 @@ class WithdrawalManagementSpreadsheet
|
||||
|
||||
private function workbook(array $report): Spreadsheet
|
||||
{
|
||||
$balanceHeaders = array_map(
|
||||
static fn (array $day): string => (string) $day['label'],
|
||||
$report['balanceDays'],
|
||||
);
|
||||
$headers = [
|
||||
...self::CONFIG_HEADERS,
|
||||
...$balanceHeaders,
|
||||
'余额来源',
|
||||
'余额预警',
|
||||
'预留标准',
|
||||
'提现金额',
|
||||
'是否达标',
|
||||
];
|
||||
$headers = [...self::CONFIG_HEADERS, $report['balanceColumnLabel'], '预留标准', '提现金额', '是否达标'];
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('提现管理');
|
||||
$this->writeHeader($sheet, $headers);
|
||||
$this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 20, 20, 32, 18, 18, 18, 14]);
|
||||
$this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 18, 18, 14]);
|
||||
|
||||
$rowNumber = 2;
|
||||
foreach ($report['rows'] as $row) {
|
||||
$match = $report['balances'][(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null];
|
||||
$reserve = $report['reserveStandards'][(int) $row->id] ?? null;
|
||||
$withdrawalAmount = $report['withdrawalAmounts'][(int) $row->id] ?? null;
|
||||
$compliance = $report['compliance'][(int) $row->id] ?? null;
|
||||
$alert = $report['alerts'][(int) $row->id] ?? null;
|
||||
$hasCompleteAlertSeries = collect($report['balanceDays'])->every(
|
||||
fn (array $day): bool => (
|
||||
$report['balancesByDate'][(int) $row->id][(string) $day['date']]['status'] ?? ''
|
||||
) === 'matched',
|
||||
);
|
||||
$textValues = [
|
||||
1 => (string) ($row->store_platform ?? ''),
|
||||
2 => (string) ($row->erp_store_name ?? ''),
|
||||
@ -471,46 +297,22 @@ class WithdrawalManagementSpreadsheet
|
||||
$sheet->setCellValueExplicit([$column, $rowNumber], $value, DataType::TYPE_STRING);
|
||||
}
|
||||
|
||||
foreach ($report['balanceDays'] as $dayIndex => $day) {
|
||||
$column = 8 + $dayIndex;
|
||||
$match = $report['balancesByDate'][(int) $row->id][$day['date']]
|
||||
?? ['status' => 'not_ready', 'balance' => null];
|
||||
if ($match['status'] === 'matched') {
|
||||
$sheet->setCellValue([$column, $rowNumber], (float) $match['balance']);
|
||||
} else {
|
||||
$sheet->setCellValueExplicit([$column, $rowNumber], $this->balanceStatus($match['status']), DataType::TYPE_STRING);
|
||||
}
|
||||
}
|
||||
|
||||
$sourceParts = [];
|
||||
foreach ($report['balanceDays'] as $day) {
|
||||
$match = $report['balancesByDate'][(int) $row->id][$day['date']]
|
||||
?? ['status' => 'not_ready'];
|
||||
$sourceParts[] = str_replace('店铺余额', '', (string) $day['label']) . ':' . match ($match['status']) {
|
||||
'matched' => ($match['data_source'] ?? null) === 'finance' ? '财务系统' : '聚水潭',
|
||||
'ambiguous' => '多条匹配',
|
||||
'unmatched' => '未匹配',
|
||||
default => '待同步',
|
||||
};
|
||||
}
|
||||
$sheet->setCellValueExplicit([11, $rowNumber], implode(';', $sourceParts), DataType::TYPE_STRING);
|
||||
|
||||
$sheet->setCellValueExplicit(
|
||||
[12, $rowNumber],
|
||||
$alert !== null ? '连续3天未变动' : ($hasCompleteAlertSeries ? '未触发' : '数据不足'),
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
if ($reserve === null) {
|
||||
$sheet->setCellValueExplicit([13, $rowNumber], '待配置', DataType::TYPE_STRING);
|
||||
if ($match['status'] === 'matched') {
|
||||
$sheet->setCellValue([8, $rowNumber], (float) $match['balance']);
|
||||
} else {
|
||||
$sheet->setCellValue([13, $rowNumber], $reserve);
|
||||
$sheet->setCellValueExplicit([8, $rowNumber], $this->balanceStatus($match['status']), DataType::TYPE_STRING);
|
||||
}
|
||||
if ($reserve === null) {
|
||||
$sheet->setCellValueExplicit([9, $rowNumber], '待配置', DataType::TYPE_STRING);
|
||||
} else {
|
||||
$sheet->setCellValue([9, $rowNumber], $reserve);
|
||||
}
|
||||
if ($withdrawalAmount === null) {
|
||||
$sheet->setCellValueExplicit([14, $rowNumber], '待接入', DataType::TYPE_STRING);
|
||||
$sheet->setCellValueExplicit([10, $rowNumber], '待接入', DataType::TYPE_STRING);
|
||||
} else {
|
||||
$sheet->setCellValue([14, $rowNumber], $withdrawalAmount);
|
||||
$sheet->setCellValue([10, $rowNumber], $withdrawalAmount);
|
||||
}
|
||||
$sheet->setCellValueExplicit([15, $rowNumber], match ($compliance) {
|
||||
$sheet->setCellValueExplicit([11, $rowNumber], match ($compliance) {
|
||||
'yes' => '是',
|
||||
'no' => '否',
|
||||
default => '待核验',
|
||||
@ -520,9 +322,7 @@ class WithdrawalManagementSpreadsheet
|
||||
|
||||
if ($rowNumber > 2) {
|
||||
$sheet->getStyle('H2:J' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00');
|
||||
$sheet->getStyle('M2:N' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00');
|
||||
$sheet->getStyle('H2:J' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
$sheet->getStyle('M2:N' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
}
|
||||
$sheet->getStyle('C2:C' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@');
|
||||
$sheet->getStyle('G2:G' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@');
|
||||
@ -531,47 +331,25 @@ class WithdrawalManagementSpreadsheet
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
/** @return array{0:int,1:list<string>,2:'config'|'withdrawal'} */
|
||||
/** @return array{0:int,1:list<string>} */
|
||||
private function headers(array $matrix): array
|
||||
{
|
||||
foreach (array_slice($matrix, 0, 10, true) as $index => $row) {
|
||||
$headers = array_map(static fn (mixed $value): string => trim((string) $value), $row);
|
||||
if (! in_array('店铺平台', $headers, true) || ! in_array('提现金额', $headers, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->requireUniqueHeader($headers, '店铺平台');
|
||||
$this->requireUniqueHeader($headers, '提现金额');
|
||||
$fullConfigurationTemplate = collect(self::CONFIG_HEADERS)->every(
|
||||
static fn (string $header): bool => in_array($header, $headers, true),
|
||||
);
|
||||
|
||||
if ($fullConfigurationTemplate) {
|
||||
if (in_array('店铺平台', $headers, true) && in_array('ERP店铺名称', $headers, true)) {
|
||||
foreach (self::CONFIG_HEADERS as $required) {
|
||||
$this->requireUniqueHeader($headers, $required);
|
||||
if (count(array_keys($headers, $required, true)) !== 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => "导入表头必须且只能包含一个“{$required}”列,请下载最新模板。",
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (count(array_keys($headers, '提现金额', true)) > 1) {
|
||||
throw ValidationException::withMessages(['import' => '导入表头只能包含一个“提现金额”列。']);
|
||||
}
|
||||
|
||||
return [(int) $index, $headers, 'config'];
|
||||
return [(int) $index, $headers];
|
||||
}
|
||||
|
||||
if (! in_array('ERP店铺名称', $headers, true) && ! in_array('平台店铺ID', $headers, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '提现金额模板必须包含“ERP店铺名称”或“平台店铺ID”用于定位店铺,请下载最新模板。',
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'ERP店铺名称', '平台店铺ID', '余额日期', '店铺余额', '预留标准',
|
||||
'提现后余额', '是否达标',
|
||||
] as $optional) {
|
||||
if (count(array_keys($headers, $optional, true)) > 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => "导入表头只能包含一个“{$optional}”列。",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [(int) $index, $headers, 'withdrawal'];
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
@ -579,16 +357,6 @@ class WithdrawalManagementSpreadsheet
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param list<string> $headers */
|
||||
private function requireUniqueHeader(array $headers, string $header): void
|
||||
{
|
||||
if (count(array_keys($headers, $header, true)) !== 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => "导入表头必须且只能包含一个“{$header}”列,请下载最新模板。",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{0:array<string,list<StoreReceivingAccount>>,1:array<string,list<StoreReceivingAccount>>} */
|
||||
private function indexes($accounts): array
|
||||
{
|
||||
|
||||
@ -82,8 +82,6 @@ class XlsxSheetReader
|
||||
xml_set_element_handler(
|
||||
$parser,
|
||||
static function ($parser, string $name, array $attributes) use (&$state): void {
|
||||
$name = self::localName($name);
|
||||
|
||||
switch ($name) {
|
||||
case 'row':
|
||||
$state['row'] = [];
|
||||
@ -103,8 +101,6 @@ class XlsxSheetReader
|
||||
}
|
||||
},
|
||||
static function ($parser, string $name) use (&$state, $shared, $columns, $optionalColumns): void {
|
||||
$name = self::localName($name);
|
||||
|
||||
switch ($name) {
|
||||
case 'v':
|
||||
case 't':
|
||||
@ -296,8 +292,6 @@ class XlsxSheetReader
|
||||
xml_set_element_handler(
|
||||
$parser,
|
||||
static function ($parser, string $name) use (&$inside, &$buffer, &$index): void {
|
||||
$name = self::localName($name);
|
||||
|
||||
if ($name === 'si') {
|
||||
$index++;
|
||||
$buffer = '';
|
||||
@ -306,8 +300,6 @@ class XlsxSheetReader
|
||||
}
|
||||
},
|
||||
static function ($parser, string $name) use (&$inside, &$buffer, &$out): void {
|
||||
$name = self::localName($name);
|
||||
|
||||
if ($name === 't') {
|
||||
$inside = false;
|
||||
} elseif ($name === 'si') {
|
||||
@ -329,11 +321,4 @@ class XlsxSheetReader
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function localName(string $name): string
|
||||
{
|
||||
$separator = strrpos($name, ':');
|
||||
|
||||
return $separator === false ? $name : substr($name, $separator + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\StoreInformationDailyArchive;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ArchiveStoreInformationDaily extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:archive-store-information
|
||||
{--date= : 快照日期,格式 YYYY-MM-DD;默认今天}';
|
||||
|
||||
protected $description = '每日保存一份店铺基础信息完整快照,便于后续复查';
|
||||
|
||||
public function handle(StoreInformationDailyArchive $archive): int
|
||||
{
|
||||
$date = trim((string) ($this->option('date') ?: now('Asia/Shanghai')->format('Y-m-d')));
|
||||
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date)) {
|
||||
$this->error('快照日期格式应为 YYYY-MM-DD。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$snapshotDate = CarbonImmutable::createFromFormat('!Y-m-d', $date, 'Asia/Shanghai');
|
||||
} catch (Throwable) {
|
||||
$snapshotDate = null;
|
||||
}
|
||||
if ($snapshotDate === null || $snapshotDate->format('Y-m-d') !== $date) {
|
||||
$this->error('快照日期不是有效日期。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$count = $archive->capture($snapshotDate);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('店铺基础信息存档失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf('已保存 %s 店铺基础信息快照,共 %d 条。', $date, $count));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -1,246 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\ProductMonthlyWeightedAverageReporter;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 检查上游完整性后,自动生成尚未计算的最新月份商品月加权平均单价。
|
||||
*/
|
||||
class AutoComputeProductMonthlyWeightedAverage extends Command
|
||||
{
|
||||
protected $signature = 'product:monthly-weighted-average:auto
|
||||
{--month= : 指定月份;默认取最新已成功的进销存月份}
|
||||
{--result-json= : 将执行结果写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '上游完整后自动计算商品月加权平均单价,并保护已计算历史月份';
|
||||
|
||||
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
|
||||
{
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$month = $this->targetMonth($connection);
|
||||
if ($month === null) {
|
||||
return $this->finish('blocked', null, [
|
||||
'message' => '未找到已成功导入的进销存月份。',
|
||||
]);
|
||||
}
|
||||
|
||||
$lock = Cache::lock('product-monthly-weighted-average-auto:' . $month->format('Y-m'), 7200);
|
||||
if (! $lock->get()) {
|
||||
return $this->finish('blocked', $month, [
|
||||
'message' => '同月自动计算任务正在执行。',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$state = DB::connection($connection)->table('product_monthly_weighted_average_states')
|
||||
->where('report_month', $month->toDateString())
|
||||
->first();
|
||||
if (($state?->status ?? null) === 'computed') {
|
||||
$report = $reporter->build($connection, $month);
|
||||
$report['calculation_status'] = $report['status'];
|
||||
$report['status'] = 'already_computed';
|
||||
|
||||
return $this->finish('already_computed', $month, $report, $reporter);
|
||||
}
|
||||
|
||||
$checks = $this->checks($connection, $month);
|
||||
if (in_array(false, array_column($checks, 'passed'), true)) {
|
||||
DB::connection($connection)->table('product_monthly_weighted_average_states')->updateOrInsert(
|
||||
['report_month' => $month->toDateString()],
|
||||
[
|
||||
'status' => 'blocked',
|
||||
'last_error' => '上游数据未通过完整性检查',
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
return $this->finish('blocked', $month, [
|
||||
'message' => '上游数据未更新或不完整,未执行计算。',
|
||||
'checks' => $checks,
|
||||
]);
|
||||
}
|
||||
|
||||
$baseExit = $this->call('product:monthly-weighted-average:base', [
|
||||
'month' => $month->format('Y-m'),
|
||||
'--from-previous' => true,
|
||||
// 自动任务只会在尚未 computed 的月份走到这里,可安全清理失败重试留下
|
||||
// 的同月基表;已 computed 的历史月份在前面的门禁直接跳过。
|
||||
'--force' => true,
|
||||
]);
|
||||
if ($baseExit !== self::SUCCESS) {
|
||||
return $this->finish('failed', $month, [
|
||||
'message' => '生成期初基数失败。',
|
||||
]);
|
||||
}
|
||||
|
||||
$computeExit = $this->call('product:monthly-weighted-average:compute', [
|
||||
'month' => $month->format('Y-m'),
|
||||
]);
|
||||
if ($computeExit !== self::SUCCESS) {
|
||||
return $this->finish('failed', $month, [
|
||||
'message' => '加权平均单价计算失败。',
|
||||
]);
|
||||
}
|
||||
|
||||
$report = $reporter->build($connection, $month);
|
||||
$report['checks'] = $checks;
|
||||
|
||||
return $this->finish(
|
||||
($report['status'] ?? null) === 'review_required' ? 'review_required' : 'success',
|
||||
$month,
|
||||
$report,
|
||||
$reporter,
|
||||
);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function targetMonth(string $connection): ?CarbonImmutable
|
||||
{
|
||||
$requested = trim((string) $this->option('month'));
|
||||
if ($requested !== '') {
|
||||
return CarbonImmutable::parse($requested)->startOfMonth();
|
||||
}
|
||||
|
||||
$latest = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||||
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
|
||||
->where('status', 'succeeded')
|
||||
->whereNotNull('active_generation')
|
||||
->orderByDesc('source_month')
|
||||
->value('source_month');
|
||||
|
||||
return $latest === null ? null : CarbonImmutable::parse((string) $latest)->startOfMonth();
|
||||
}
|
||||
|
||||
/** @return list<array{name:string,passed:bool,actual:mixed,expected:mixed}> */
|
||||
private function checks(string $connection, CarbonImmutable $month): array
|
||||
{
|
||||
$db = DB::connection($connection);
|
||||
$monthState = $db->table('inventory_report_authoritative_months')
|
||||
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
|
||||
->where('source_month', $month->toDateString())
|
||||
->first();
|
||||
$generation = $monthState?->active_generation;
|
||||
$activeRows = $generation === null
|
||||
? 0
|
||||
: $db->table('inventory_report_authoritative_rows')
|
||||
->where('generation', (string) $generation)
|
||||
->count();
|
||||
$expectedRows = (int) ($monthState?->source_row_count ?? 0);
|
||||
|
||||
$previous = $month->subMonthNoOverflow();
|
||||
$previousState = $db->table('product_monthly_weighted_average_states')
|
||||
->where('report_month', $previous->toDateString())
|
||||
->value('status');
|
||||
$previousRows = $db->table('product_monthly_weighted_average_rows')
|
||||
->where('report_month', $previous->toDateString())
|
||||
->count();
|
||||
|
||||
$inboundRows = $db->table('inventory_inbound_detail_rows')
|
||||
->where('report_month', $month->toDateString())
|
||||
->count();
|
||||
$inboundMissingDimensions = $db->table('inventory_inbound_detail_rows')
|
||||
->where('report_month', $month->toDateString())
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('barcode')->orWhere('barcode', '')
|
||||
->orWhereNull('warehouse_short_name')->orWhere('warehouse_short_name', '');
|
||||
})
|
||||
->count();
|
||||
$inboundMissingAmounts = $db->table('inventory_inbound_detail_rows')
|
||||
->where('report_month', $month->toDateString())
|
||||
->whereNull('amount')
|
||||
->whereNull('estimated_amount')
|
||||
->count();
|
||||
$productCatalogRows = $db->table('ops_jst_sku')->whereNotNull('sku_id')->count();
|
||||
|
||||
return [
|
||||
[
|
||||
'name' => '进销存月份状态成功',
|
||||
'passed' => ($monthState?->status ?? null) === 'succeeded',
|
||||
'actual' => $monthState?->status,
|
||||
'expected' => 'succeeded',
|
||||
],
|
||||
[
|
||||
'name' => '进销存激活明细行数完整',
|
||||
'passed' => $expectedRows > 0 && $activeRows === $expectedRows,
|
||||
'actual' => $activeRows,
|
||||
'expected' => $expectedRows,
|
||||
],
|
||||
[
|
||||
'name' => '上月加权平均结果已完成',
|
||||
'passed' => $previousState === 'computed' && $previousRows > 0,
|
||||
'actual' => ['status' => $previousState, 'rows' => $previousRows],
|
||||
'expected' => ['status' => 'computed', 'rows' => '> 0'],
|
||||
],
|
||||
[
|
||||
'name' => '入库明细已更新',
|
||||
'passed' => $inboundRows > 0,
|
||||
'actual' => $inboundRows,
|
||||
'expected' => '> 0',
|
||||
],
|
||||
[
|
||||
'name' => '入库商品编码与仓库简称无缺失',
|
||||
'passed' => $inboundMissingDimensions === 0,
|
||||
'actual' => $inboundMissingDimensions,
|
||||
'expected' => 0,
|
||||
],
|
||||
[
|
||||
'name' => '入库金额至少取金额或预估金额',
|
||||
'passed' => $inboundMissingAmounts === 0,
|
||||
'actual' => $inboundMissingAmounts,
|
||||
'expected' => 0,
|
||||
],
|
||||
[
|
||||
'name' => '普通商品资料已更新',
|
||||
'passed' => $productCatalogRows > 0,
|
||||
'actual' => $productCatalogRows,
|
||||
'expected' => '> 0',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function finish(
|
||||
string $status,
|
||||
?CarbonImmutable $month,
|
||||
array $payload,
|
||||
?ProductMonthlyWeightedAverageReporter $reporter = null,
|
||||
): int {
|
||||
$result = ['status' => $status] + $payload;
|
||||
if (! isset($result['report_month']) && $month !== null) {
|
||||
$result['report_month'] = $month->toDateString();
|
||||
}
|
||||
|
||||
$path = trim((string) $this->option('result-json'));
|
||||
if ($path !== '') {
|
||||
File::ensureDirectoryExists(dirname($path));
|
||||
File::put(
|
||||
$path,
|
||||
json_encode(
|
||||
$result,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
|
||||
) . PHP_EOL,
|
||||
);
|
||||
}
|
||||
|
||||
if ($reporter !== null && isset($result['totals'])) {
|
||||
$this->line($reporter->brief($result));
|
||||
} elseif (isset($result['message'])) {
|
||||
$this->error((string) $result['message']);
|
||||
}
|
||||
|
||||
return in_array($status, ['blocked', 'failed'], true)
|
||||
? self::FAILURE
|
||||
: self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -1,133 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 固定月度顺序:先生成商品月加权平均单价,再同步商品成本表。
|
||||
*/
|
||||
class AutoMonthlyWeightedAverageAndProductCost extends Command
|
||||
{
|
||||
protected $signature = 'finance:monthly-weighted-product-cost:auto
|
||||
{--month= : 指定月份;默认取最新已成功的进销存月份}
|
||||
{--result-json= : 将两阶段结果写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '先计算商品月加权平均单价,再自动同步商品成本表';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$month = $this->targetMonth($connection);
|
||||
if ($month === null) {
|
||||
return $this->writeResult([
|
||||
'status' => 'blocked',
|
||||
'message' => '未找到已成功导入的进销存月份。',
|
||||
]);
|
||||
}
|
||||
|
||||
$weightedPath = tempnam(sys_get_temp_dir(), 'weighted-result-') . '.json';
|
||||
$costPath = tempnam(sys_get_temp_dir(), 'product-cost-result-') . '.json';
|
||||
try {
|
||||
$weightedExit = $this->call('product:monthly-weighted-average:auto', [
|
||||
'--month' => $month->format('Y-m'),
|
||||
'--result-json' => $weightedPath,
|
||||
]);
|
||||
$weighted = $this->readJson($weightedPath);
|
||||
if (
|
||||
$weightedExit !== self::SUCCESS
|
||||
|| in_array($weighted['status'] ?? 'failed', ['blocked', 'failed'], true)
|
||||
) {
|
||||
return $this->writeResult([
|
||||
'status' => 'failed',
|
||||
'report_month' => $month->toDateString(),
|
||||
'message' => '商品月加权平均单价未完成,商品成本未执行。',
|
||||
'weighted_average' => $weighted,
|
||||
'product_cost' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$costExit = $this->call('store-receipt:product-cost:sync', [
|
||||
'month' => $month->format('Y-m'),
|
||||
'--result-json' => $costPath,
|
||||
]);
|
||||
$cost = $this->readJson($costPath);
|
||||
$status = $costExit !== self::SUCCESS
|
||||
? 'failed'
|
||||
: ((int) ($cost['pending_cost_rows'] ?? 0) > 0 ? 'review_required' : 'success');
|
||||
|
||||
return $this->writeResult([
|
||||
'status' => $status,
|
||||
'report_month' => $month->toDateString(),
|
||||
'message' => match ($status) {
|
||||
'success' => '月加权平均单价与商品成本已按顺序完成。',
|
||||
'review_required' => '月加权平均单价与商品成本已完成,存在待核验成本。',
|
||||
default => '商品成本同步失败。',
|
||||
},
|
||||
'weighted_average' => $weighted,
|
||||
'product_cost' => $cost,
|
||||
]);
|
||||
} finally {
|
||||
@unlink($weightedPath);
|
||||
@unlink($costPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function targetMonth(string $connection): ?CarbonImmutable
|
||||
{
|
||||
$requested = trim((string) $this->option('month'));
|
||||
if ($requested !== '') {
|
||||
return CarbonImmutable::parse($requested)->startOfMonth();
|
||||
}
|
||||
|
||||
$latest = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||||
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
|
||||
->where('status', 'succeeded')
|
||||
->whereNotNull('active_generation')
|
||||
->orderByDesc('source_month')
|
||||
->value('source_month');
|
||||
|
||||
return $latest === null ? null : CarbonImmutable::parse((string) $latest)->startOfMonth();
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function readJson(string $path): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
return ['status' => 'failed', 'message' => '子任务未生成结果文件。'];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) file_get_contents($path), true);
|
||||
|
||||
return is_array($decoded)
|
||||
? $decoded
|
||||
: ['status' => 'failed', 'message' => '子任务结果文件无法解析。'];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $result */
|
||||
private function writeResult(array $result): int
|
||||
{
|
||||
$path = trim((string) $this->option('result-json'));
|
||||
if ($path !== '') {
|
||||
File::ensureDirectoryExists(dirname($path));
|
||||
File::put(
|
||||
$path,
|
||||
json_encode(
|
||||
$result,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
|
||||
) . PHP_EOL,
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($result['message'])) {
|
||||
$this->line((string) $result['message']);
|
||||
}
|
||||
|
||||
return in_array($result['status'] ?? 'failed', ['success', 'review_required'], true)
|
||||
? self::SUCCESS
|
||||
: self::FAILURE;
|
||||
}
|
||||
}
|
||||
@ -2,45 +2,26 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\ProductMonthlyWeightedAverageReporter;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 按取数逻辑计算某个月的更改价格、定价状态、采购单价、期初金额、入库金额和加权平均单价。
|
||||
*/
|
||||
class ComputeProductMonthlyWeightedAverage extends Command
|
||||
{
|
||||
protected $signature = 'product:monthly-weighted-average:compute
|
||||
{month : 报表月份,例如 2026-07}
|
||||
{--force : 覆盖已经计算完成的同月结果}
|
||||
{--result-json= : 将结果简报写入指定 JSON 文件}';
|
||||
protected $signature = 'product:monthly-weighted-average:compute {month : 报表月份,例如 2026-07}';
|
||||
|
||||
protected $description = '按取数逻辑计算商品月加权平均单价';
|
||||
|
||||
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
|
||||
public function handle(): int
|
||||
{
|
||||
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$table = 'product_monthly_weighted_average_rows';
|
||||
$deadline = $month->addMonthNoOverflow()->day(2)->startOfDay();
|
||||
|
||||
$stateStatus = DB::connection($connection)
|
||||
->table('product_monthly_weighted_average_states')
|
||||
->where('report_month', $month->toDateString())
|
||||
->value('status');
|
||||
$computedRows = DB::connection($connection)->table($table)
|
||||
->where('report_month', $month->toDateString())
|
||||
->whereNotNull('computed_at')
|
||||
->count();
|
||||
if (! $this->option('force') && ($stateStatus === 'computed' || $computedRows > 0)) {
|
||||
$this->error($month->format('Y-m') . ' 已经计算完成,历史月份不自动覆盖;如需人工纠正请显式使用 --force。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$query = DB::connection($connection)->table($table)->where('report_month', $month->toDateString());
|
||||
$codes = (clone $query)->distinct()->pluck('product_code')->map(fn ($code) => trim((string) $code))->unique()->values()->all();
|
||||
$this->info('待计算 SKU:' . count($codes));
|
||||
@ -106,21 +87,7 @@ class ComputeProductMonthlyWeightedAverage extends Command
|
||||
['status' => 'computed', 'computed_rows' => $computed, 'last_computed_at' => $now, 'updated_at' => $now, 'created_at' => $now],
|
||||
);
|
||||
|
||||
$report = $reporter->build($connection, $month);
|
||||
$json = json_encode($report, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
if (is_string($json)) {
|
||||
$resultPath = trim((string) $this->option('result-json'));
|
||||
if ($resultPath !== '') {
|
||||
File::ensureDirectoryExists(dirname($resultPath));
|
||||
File::put($resultPath, $json . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info($month->format('Y-m') . " 计算完成:{$computed} 行");
|
||||
$this->line($reporter->brief($report));
|
||||
if (($report['status'] ?? null) === 'review_required') {
|
||||
$this->warn('存在待复核差异;结果已生成,但不会视为完全无异常。');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@ -1,205 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\XlsxSheetReader;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Repositories\AfterSalesCostRowRepository;
|
||||
|
||||
/**
|
||||
* 导入聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」Excel。
|
||||
*
|
||||
* 该报表已按商品编码(组合装拆成组件)给出每行和成本,导入后订单售后费用明细页面
|
||||
* 在覆盖的店铺/月份范围内直接按这些明细行展示。
|
||||
*/
|
||||
class ImportAfterSalesCostRows extends Command
|
||||
{
|
||||
protected $signature = 'order-data:import-after-sales-cost
|
||||
{file : Excel 文件路径}
|
||||
{--period= : 数据月份,格式 YYYY-MM}
|
||||
{--chunk=500 : 每批写入行数}
|
||||
{--dry-run : 只解析不入库}';
|
||||
|
||||
protected $description = '导入聚水潭「订单售后费用数据」Excel 到订单售后费用明细';
|
||||
|
||||
public function handle(XlsxSheetReader $reader): int
|
||||
{
|
||||
$path = (string) $this->argument('file');
|
||||
$period = trim((string) $this->option('period'));
|
||||
|
||||
if (! is_file($path)) {
|
||||
$this->error('文件不存在:' . $path);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($period !== '' && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) !== 1) {
|
||||
$this->error('数据月份格式必须是 YYYY-MM:' . $period);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$headers = (array) config('order_after_sales_cost.headers', []);
|
||||
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
|
||||
|
||||
if ($headers === []) {
|
||||
$this->error('缺少表头映射:config/order_after_sales_cost.php');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('流式读取文件:' . $path);
|
||||
|
||||
$chunkSize = max((int) $this->option('chunk'), 100);
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$buffer = [];
|
||||
$total = 0;
|
||||
$written = 0;
|
||||
$skipped = 0;
|
||||
$shops = [];
|
||||
$mappedRows = 0;
|
||||
$minDate = null;
|
||||
$maxDate = null;
|
||||
$importedAt = now()->toDateTimeString();
|
||||
|
||||
try {
|
||||
foreach ($reader->rows($path, [], array_keys($headers)) as $row) {
|
||||
$total++;
|
||||
$payload = [];
|
||||
|
||||
foreach ($headers as $header => $key) {
|
||||
if (! array_key_exists($header, $row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = trim((string) $row[$header]);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($numberKeys[$key])) {
|
||||
$value = is_numeric($value) ? (float) $value : 0.0;
|
||||
} elseif (self::isDateKey($key)) {
|
||||
$value = self::dateValue($value);
|
||||
}
|
||||
|
||||
$payload[$key] = $value;
|
||||
}
|
||||
|
||||
if ($payload !== []) {
|
||||
$mappedRows++;
|
||||
}
|
||||
|
||||
$shop = trim((string) ($payload['shop_name'] ?? ''));
|
||||
$productCode = trim((string) ($payload['product_code'] ?? ''));
|
||||
if ($shop === '' || $productCode === '') {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$buffer[] = AfterSalesCostRowRepository::toRecord(
|
||||
$payload,
|
||||
$importedAt,
|
||||
$period !== '' ? $period : null,
|
||||
);
|
||||
$shops[$shop] = true;
|
||||
$orderTime = $payload['order_time'] ?? null;
|
||||
|
||||
if (is_string($orderTime) && $orderTime !== '') {
|
||||
$minDate = $minDate === null || $orderTime < $minDate ? $orderTime : $minDate;
|
||||
$maxDate = $maxDate === null || $orderTime > $maxDate ? $orderTime : $maxDate;
|
||||
}
|
||||
|
||||
if (! $dryRun && count($buffer) >= $chunkSize) {
|
||||
$written += $this->flush($buffer);
|
||||
$buffer = [];
|
||||
$this->output->write("\r写入 " . $written . ' 行');
|
||||
}
|
||||
|
||||
if ($dryRun && $buffer !== []) {
|
||||
$buffer = [];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
if (! $dryRun) {
|
||||
Cache::forget('order-data.after-sales.imported-shop-options');
|
||||
}
|
||||
$this->error('读取失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($mappedRows === 0) {
|
||||
$this->error('表头和配置的映射对不上,请检查文件或 config/order_after_sales_cost.php');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $dryRun && $buffer !== []) {
|
||||
$written += $this->flush($buffer);
|
||||
}
|
||||
if (! $dryRun) {
|
||||
Cache::forget('order-data.after-sales.imported-shop-options');
|
||||
}
|
||||
|
||||
$this->newLine(2);
|
||||
$this->info(sprintf(
|
||||
'完成:读取 %d 行,写入 %d 行,跳过 %d 行;店铺 %d 个;数据月份 %s;订单时间 %s ~ %s;峰值内存 %.1f MB',
|
||||
$total,
|
||||
$written,
|
||||
$skipped,
|
||||
count($shops),
|
||||
$period !== '' ? $period : '-',
|
||||
$minDate ?? '-',
|
||||
$maxDate ?? '-',
|
||||
memory_get_peak_usage(true) / 1048576,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows */
|
||||
private function flush(array $rows): int
|
||||
{
|
||||
DB::table('ops_after_sales_cost_rows')->upsert(
|
||||
$rows,
|
||||
['row_key'],
|
||||
[
|
||||
'shop_name', 'shop_code', 'online_order_no', 'internal_order_no', 'product_code',
|
||||
'style_code', 'shop_product_code', 'shop_style_code',
|
||||
'order_time', 'source_period', 'product_quantity', 'product_amount', 'product_cost', 'cost_source',
|
||||
'combine_type', 'payload', 'imported_at', 'updated_at',
|
||||
],
|
||||
);
|
||||
|
||||
return count($rows);
|
||||
}
|
||||
|
||||
private static function isDateKey(string $key): bool
|
||||
{
|
||||
return in_array($key, [
|
||||
'pay_time', 'order_complete_time', 'send_time', 'order_time', 'registration_time',
|
||||
'confirmation_time', 'warehouse_entry_time',
|
||||
], true);
|
||||
}
|
||||
|
||||
private static function dateValue(mixed $value): ?string
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
if (is_numeric($value) && (float) $value > 20000 && (float) $value < 80000) {
|
||||
$timestamp = ((float) $value - 25569) * 86400;
|
||||
|
||||
return gmdate('Y-m-d H:i:s', (int) $timestamp);
|
||||
}
|
||||
|
||||
$timestamp = strtotime((string) $value);
|
||||
|
||||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\CompanyAlipayBillImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportCompanyAlipayBills extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:import-company-alipay
|
||||
{path : 支付宝交易记录 CSV/XLSX/XLS 文件}
|
||||
{--date= : 账单日期 YYYY-MM-DD,默认前一天}
|
||||
{--account= : 支付宝登录账号,用于匹配账户总表}
|
||||
{--account-short= : 账户总表简称;账号重复时必填}
|
||||
{--dry-run : 只校验,不写入财务台账}';
|
||||
|
||||
protected $description = '导入公司非店铺支付宝前一天交易到财务台账动账明细';
|
||||
|
||||
public function handle(CompanyAlipayBillImporter $importer): int
|
||||
{
|
||||
$date = trim((string) ($this->option('date') ?: now()->subDay()->toDateString()));
|
||||
$account = trim((string) $this->option('account'));
|
||||
if ($account === '') {
|
||||
$this->error('必须使用 --account 指定支付宝登录账号。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('path'),
|
||||
$account,
|
||||
$date,
|
||||
$this->option('account-short') ?: null,
|
||||
(bool) $this->option('dry-run'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('支付宝账单未导入,保留原有动账明细。' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['项目', '值'], [
|
||||
['校验模式', $result['dry_run'] ? '仅校验' : '正式导入'],
|
||||
['账单日期', $result['balance_date']],
|
||||
['账户简称', $result['account_short']],
|
||||
['有效笔数', $result['rows']],
|
||||
['收入合计', number_format((float) $result['income'], 2)],
|
||||
['支出合计', number_format((float) $result['expense'], 2)],
|
||||
['替换原记录', $result['replaced']],
|
||||
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
|
||||
]);
|
||||
$this->info($result['dry_run'] ? '支付宝账单校验完成。' : '支付宝账单已接入财务台账动账明细。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -1,280 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use App\Models\Common\Company;
|
||||
use App\Support\CourierQuoteStructure;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 把整理好的结构化计费规则(标准计费规则_导入用.json)写入现有报价版本,供运费核算使用。
|
||||
* 只写 billing_structure / billing_rows 两列,原始报价单元格、执行日期、状态都不动。
|
||||
*/
|
||||
class ImportCourierQuoteBillingRules extends Command
|
||||
{
|
||||
protected $signature = 'courier-quotes:import-billing-rules {file : 标准计费规则 JSON}
|
||||
{--company= : 报价所属公司ID}
|
||||
{--dry-run : 只校验并输出结果,不写入}
|
||||
{--force : 覆盖已有且内容不同的计费规则}';
|
||||
|
||||
protected $description = '把存量报价的结构化计费规则批量写入报价版本(按版本号匹配,整批事务)';
|
||||
|
||||
private const MODES = ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then', CourierQuoteStructure::SEGMENTS];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$companyId = (int) $this->option('company');
|
||||
if ($companyId <= 0 || ! Company::query()->where('id', $companyId)->exists()) {
|
||||
$this->error('请指定有效的 --company。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) @file_get_contents((string) $this->argument('file')), true);
|
||||
$items = is_array($payload) ? ($payload['versions'] ?? null) : null;
|
||||
if (! is_array($items) || $items === []) {
|
||||
$this->error('文件不是有效的计费规则 JSON(缺少 versions)。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$plan = [];
|
||||
$report = [];
|
||||
$seen = [];
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item)) {
|
||||
$report[] = ['', '', '错误', '版本条目必须是对象'];
|
||||
continue;
|
||||
}
|
||||
$code = (string) ($item['version_id'] ?? '');
|
||||
if (isset($seen[$code])) {
|
||||
$report[] = [$code, '', '错误', '同一文件中的版本号不能重复'];
|
||||
continue;
|
||||
}
|
||||
$seen[$code] = true;
|
||||
[$status, $note, $data] = $this->prepare($companyId, $item);
|
||||
$report[] = [$code, (string) ($item['company_name'] ?? ''), $status, $note];
|
||||
if ($data !== null) {
|
||||
$plan[] = $data;
|
||||
}
|
||||
}
|
||||
|
||||
$this->table(['版本', '快递公司', '结果', '说明'], $report);
|
||||
$counts = array_count_values(array_column($report, 2));
|
||||
$summary = collect($counts)->map(fn ($n, $k) => "{$k} {$n}")->implode(',');
|
||||
|
||||
if (isset($counts['错误'])) {
|
||||
$this->error('存在错误,整批未写入:' . $summary);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info('校验通过,未写入(dry-run):' . $summary);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($plan): void {
|
||||
foreach ($plan as [$version, $structure, $rows]) {
|
||||
$current = DB::table($version->getTable())->where('id', $version->id)->lockForUpdate()->first();
|
||||
if (! $current || $current->company_name !== $version->company_name
|
||||
|| json_decode($current->billing_structure ?? 'null', true) != $version->billing_structure
|
||||
|| json_decode($current->billing_rows ?? 'null', true) != $version->billing_rows) {
|
||||
throw new RuntimeException('报价在校验后被其他操作修改,整批未写入,请重新校验。');
|
||||
}
|
||||
$version->forceFill(['billing_structure' => $structure, 'billing_rows' => $rows])->save();
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
$this->info('导入完成:' . $summary);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $item
|
||||
* @return array{0:string,1:string,2:?array}
|
||||
*/
|
||||
private function prepare(int $companyId, array $item): array
|
||||
{
|
||||
$code = (string) ($item['version_id'] ?? '');
|
||||
$version = CourierQuoteVersion::query()->allCompanies()->where('company_id', $companyId)->where('version_code', $code)->first();
|
||||
if ($version === null) {
|
||||
return ['错误', '系统中找不到该版本号', null];
|
||||
}
|
||||
if (trim((string) $version->company_name) !== trim((string) ($item['company_name'] ?? ''))) {
|
||||
return ['错误', '快递名称不一致:系统为「' . $version->company_name . '」', null];
|
||||
}
|
||||
if (($item['conversion'] ?? '') === 'manual') {
|
||||
return ['跳过', '特殊计费(' . ($item['manual_reason'] ?? '按体积/条码') . '),需人工核算', null];
|
||||
}
|
||||
if (($error = $this->inputError($item)) !== null) {
|
||||
return ['错误', $error, null];
|
||||
}
|
||||
|
||||
$source = (array) ($item['structure'] ?? []);
|
||||
$segments = ($item['conversion'] ?? '') === 'needs_segment_mode';
|
||||
$mode = $segments ? CourierQuoteStructure::SEGMENTS : (string) ($source['mode'] ?? '');
|
||||
if (! in_array($mode, self::MODES, true)) {
|
||||
return ['错误', '不支持的计费方式:' . $mode, null];
|
||||
}
|
||||
|
||||
$params = (array) ($item['params'] ?? []);
|
||||
$perTicket = 0.0;
|
||||
foreach ((array) ($params['surcharges'] ?? []) as $surcharge) {
|
||||
if (($surcharge['condition'] ?? '') === '每票' && is_numeric($surcharge['amount'] ?? null)) {
|
||||
$perTicket += (float) $surcharge['amount'];
|
||||
}
|
||||
}
|
||||
$structure = [
|
||||
'mode' => $mode,
|
||||
'flat_bands' => $segments ? [] : $this->numbers($source['flat_bands'] ?? []),
|
||||
'then_mode' => $segments ? '' : (string) ($source['then_mode'] ?? ''),
|
||||
'first_weight' => ! $segments && is_numeric($source['first_weight'] ?? null) ? (float) $source['first_weight'] : null,
|
||||
'rate_tiers' => $segments ? [] : $this->numbers($source['rate_tiers'] ?? []),
|
||||
'rounding' => in_array($source['rounding'] ?? 'none', array_keys(CourierQuoteStructure::ROUNDING), true) ? (string) ($source['rounding'] ?? 'none') : 'none',
|
||||
'volumetric_divisor' => is_numeric($params['volumetric_divisor'] ?? null) ? (int) $params['volumetric_divisor'] : null,
|
||||
'weight_limit' => is_numeric($params['weight_limit_kg'] ?? null) ? (float) $params['weight_limit_kg'] : null,
|
||||
'min_weight' => is_numeric($source['min_weight'] ?? $params['min_weight_kg'] ?? null) ? (float) ($source['min_weight'] ?? $params['min_weight_kg']) : null,
|
||||
'per_ticket_surcharge' => $perTicket > 0 ? $perTicket : null,
|
||||
'notes' => array_values(array_map('strval', (array) ($params['notes'] ?? []))),
|
||||
'imported_from' => 'stock-quote-billing-import',
|
||||
];
|
||||
|
||||
$rows = [];
|
||||
foreach ((array) ($segments ? ($item['segments_rows'] ?? []) : ($item['rows'] ?? [])) as $row) {
|
||||
$region = trim((string) ($row['region'] ?? ''));
|
||||
if ($region === '') {
|
||||
return ['错误', '存在没有地区名的行', null];
|
||||
}
|
||||
if ($segments) {
|
||||
$list = [];
|
||||
foreach ((array) ($row['segments'] ?? []) as $s) {
|
||||
$list[] = array_filter([
|
||||
'lo' => (float) ($s['lo'] ?? 0),
|
||||
'hi' => is_numeric($s['hi'] ?? null) ? (float) $s['hi'] : null,
|
||||
'type' => (string) ($s['type'] ?? ''),
|
||||
'price' => $this->number($s['price'] ?? null),
|
||||
'base' => $this->number($s['base'] ?? null),
|
||||
'rate' => $this->number($s['rate'] ?? null),
|
||||
'fw' => $this->number($s['fw'] ?? null),
|
||||
], static fn ($v, $k) => $v !== null || $k === 'hi', ARRAY_FILTER_USE_BOTH);
|
||||
}
|
||||
$rows[] = ['region' => $region, 'segments' => $list];
|
||||
} else {
|
||||
$rows[] = [
|
||||
'region' => $region,
|
||||
'flat' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['flat'] ?? []))),
|
||||
'base' => $this->number($row['base'] ?? null),
|
||||
'rates' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['rates'] ?? []))),
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($rows === []) {
|
||||
return ['错误', '没有任何地区价格', null];
|
||||
}
|
||||
|
||||
$existing = $version->billing_structure;
|
||||
$note = CourierQuoteStructure::describe($structure) . ',' . count($rows) . ' 个地区';
|
||||
if (is_array($existing) && $existing !== []) {
|
||||
if ($existing == $structure && $version->billing_rows == $rows) {
|
||||
return ['跳过', '已导入且内容相同', null];
|
||||
}
|
||||
if (! $this->option('force')) {
|
||||
return ['错误', '已有不同的计费规则,如需覆盖请加 --force', null];
|
||||
}
|
||||
|
||||
return ['覆盖', $note, [$version, $structure, $rows]];
|
||||
}
|
||||
|
||||
return ['写入', $note, [$version, $structure, $rows]];
|
||||
}
|
||||
|
||||
private function number(mixed $value): ?float
|
||||
{
|
||||
return is_numeric($value) ? round((float) $value, 4) : null;
|
||||
}
|
||||
|
||||
/** 金额导入拒绝负数、歧义分段和不支持的附加条件,不静默改写计费含义。 */
|
||||
private function inputError(array $item): ?string
|
||||
{
|
||||
$structure = $item['structure'] ?? null;
|
||||
$params = $item['params'] ?? [];
|
||||
if (! is_array($structure) || ! is_array($params)) {
|
||||
return 'structure 和 params 必须是对象';
|
||||
}
|
||||
if (! array_key_exists($structure['rounding'] ?? 'none', CourierQuoteStructure::ROUNDING)) {
|
||||
return '不支持的重量取整方式';
|
||||
}
|
||||
$valid = static fn ($v): bool => is_numeric($v) && is_finite((float) $v) && (float) $v >= 0;
|
||||
foreach ([$params['volumetric_divisor'] ?? null, $params['weight_limit_kg'] ?? null,
|
||||
$structure['min_weight'] ?? $params['min_weight_kg'] ?? null, $structure['first_weight'] ?? null] as $v) {
|
||||
if ($v !== null && ! $valid($v)) return '重量、体积系数必须是非负有限数字';
|
||||
}
|
||||
if (isset($params['volumetric_divisor']) && (float) $params['volumetric_divisor'] <= 0) return '体积系数必须大于0';
|
||||
$minimum = (float) ($structure['min_weight'] ?? $params['min_weight_kg'] ?? 0);
|
||||
if (($params['weight_limit_kg'] ?? 0) > 0 && $minimum > $params['weight_limit_kg']) return '起算重量不能大于限重';
|
||||
foreach (['flat_bands', 'rate_tiers'] as $field) {
|
||||
$previous = 0;
|
||||
if (! is_array($structure[$field] ?? [])) return '重量分段必须是数组';
|
||||
foreach ($structure[$field] ?? [] as $edge) {
|
||||
if (! $valid($edge) || (float) $edge <= $previous) return '重量节点必须为严格递增的正数';
|
||||
$previous = (float) $edge;
|
||||
}
|
||||
}
|
||||
$mode = ($item['conversion'] ?? '') === 'needs_segment_mode' ? CourierQuoteStructure::SEGMENTS : ($structure['mode'] ?? '');
|
||||
if (in_array($mode, ['flat_bands', 'flat_then'], true) && empty($structure['flat_bands'])) return '缺少一口价重量节点';
|
||||
if ($mode === 'flat_then' && ! in_array($structure['then_mode'] ?? '', ['waybill_rate', 'first_additional'], true)) return '不支持的超重计费方式';
|
||||
if (CourierQuoteStructure::usesFirstWeight($mode, $structure['then_mode'] ?? '') && ! (($structure['first_weight'] ?? 0) > 0)) return '首重必须大于0';
|
||||
if ($mode === 'flat_then' && ! empty($structure['rate_tiers']) && min($structure['rate_tiers']) <= max($structure['flat_bands'])) return '超重费率节点必须大于一口价最后节点';
|
||||
if (! is_array($params['surcharges'] ?? [])) return '附加费必须是数组';
|
||||
foreach ($params['surcharges'] ?? [] as $fee) {
|
||||
if (! is_array($fee) || ($fee['condition'] ?? '') !== '每票' || ! $valid($fee['amount'] ?? null)) return '附加费条件不支持自动核算,需人工核对';
|
||||
}
|
||||
$rows = $item[$mode === CourierQuoteStructure::SEGMENTS ? 'segments_rows' : 'rows'] ?? null;
|
||||
if (! is_array($rows) || $rows === []) return '没有任何地区价格';
|
||||
$regions = [];
|
||||
foreach ($rows as $row) {
|
||||
if (! is_array($row) || ! is_string($row['region'] ?? null) || trim($row['region']) === '') return '地区行格式不正确';
|
||||
$region = mb_strtolower(preg_replace('/\s+/u', '', $row['region']));
|
||||
if (isset($regions[$region])) return '存在重复地区行';
|
||||
$regions[$region] = true;
|
||||
if ($mode === CourierQuoteStructure::SEGMENTS) {
|
||||
if (! is_array($row['segments'] ?? null) || $row['segments'] === []) return '缺少地区计费分段';
|
||||
$previous = 0.0;
|
||||
foreach ($row['segments'] as $segment) {
|
||||
if (! is_array($segment) || ! $valid($segment['lo'] ?? null)
|
||||
|| (float) $segment['lo'] < $previous || ! in_array($segment['type'] ?? '', ['flat', 'waybill', 'first_add'], true)) return '分段类型不支持或存在重叠';
|
||||
$hi = $segment['hi'] ?? null;
|
||||
if ($hi !== null && (! $valid($hi) || (float) $hi <= (float) $segment['lo'])) return '分段上限必须大于下限';
|
||||
$previous = $hi === null ? INF : (float) $hi;
|
||||
foreach (['price', 'base', 'rate', 'fw'] as $key) {
|
||||
if (isset($segment[$key]) && ! $valid($segment[$key])) return '分段金额和重量必须是非负有限数字';
|
||||
}
|
||||
if ($segment['type'] === 'first_add' && ! $valid($segment['fw'] ?? null)) return '分段首重缺失';
|
||||
}
|
||||
} else {
|
||||
if (! is_array($row['flat'] ?? []) || ! is_array($row['rates'] ?? [])) return '价格必须是数组';
|
||||
if (count($row['flat'] ?? []) !== count($structure['flat_bands'] ?? [])
|
||||
|| (CourierQuoteStructure::usesRates($mode) && count($row['rates'] ?? []) !== count($structure['rate_tiers'] ?? []) + 1)) return '价格列数与重量节点不匹配';
|
||||
foreach (array_merge($row['flat'] ?? [], $row['rates'] ?? [], [$row['base'] ?? null]) as $v) {
|
||||
if ($v !== null && ! $valid($v)) return '价格必须是非负有限数字或null(不发)';
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<float> */
|
||||
private function numbers(mixed $values): array
|
||||
{
|
||||
return array_values(array_map('floatval', array_filter((array) $values, 'is_numeric')));
|
||||
}
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportDingProcessDetails extends Command
|
||||
{
|
||||
protected $signature = 'ding:import-process-details
|
||||
{file : 15 列流程明细 CSV 文件路径}
|
||||
{--scope=personal_permission : 数据权限范围标识}
|
||||
{--replace : 导入前清空同一范围的数据}';
|
||||
|
||||
protected $description = '兼容导入旧版铺平 CSV;生产请使用 business-mirror:sync-ding-process-details';
|
||||
|
||||
public function handle(DingProcessDetailMirrorImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('file'),
|
||||
(string) $this->option('scope'),
|
||||
(bool) $this->option('replace'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('导入失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'导入完成:%d 行,跳过占位 %d 行,跳过无效 %d 行,重复 %d 行,范围 %s。',
|
||||
$result['rows'],
|
||||
$result['placeholders'],
|
||||
$result['invalid'],
|
||||
$result['duplicates'],
|
||||
$result['scope'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\InventoryInboundDetailImporter;
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* 进销存报表 → 入库明细数据:按原始 Excel 原样导入,仓库简称用仓库名称映射表按「仓库」唯一匹配。
|
||||
@ -15,28 +17,194 @@ class ImportInventoryInboundDetail extends Command
|
||||
|
||||
protected $description = '导入进销存报表的入库明细数据';
|
||||
|
||||
public function handle(InventoryInboundDetailImporter $importer): int
|
||||
public function handle(): int
|
||||
{
|
||||
$file = (string) $this->argument('file');
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$table = 'inventory_inbound_detail_rows';
|
||||
$forcedMonth = trim((string) ($this->option('month') ?? ''));
|
||||
$now = now();
|
||||
|
||||
try {
|
||||
$result = $importer->import($file, $forcedMonth);
|
||||
$this->info(sprintf(
|
||||
'导入完成:%d 行,仓库简称匹配上 %d 行,跳过 %d 行;库中该表共 %d 行',
|
||||
$result['imported'],
|
||||
$result['mapped'],
|
||||
$result['skipped'],
|
||||
$result['total'],
|
||||
));
|
||||
$maps = $this->shortNameMap();
|
||||
$this->info('仓库名称映射:仓库名 ' . count($maps['warehouse']) . ' 个、仓储方 ' . count($maps['provider']) . ' 个可唯一匹配');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('入库明细导入失败:' . $exception->getMessage());
|
||||
$this->warn('已保留上一版有效数据。');
|
||||
// 原样导入:以本次文件为准整体重建,不做去重、不做加工。
|
||||
DB::connection($connection)->table($table)->delete();
|
||||
|
||||
return self::FAILURE;
|
||||
$reader = IOFactory::createReaderForFile($file);
|
||||
$reader->setReadDataOnly(true);
|
||||
$sheet = $reader->load($file)->getSheet(0);
|
||||
|
||||
$batch = [];
|
||||
$imported = 0;
|
||||
$mapped = 0;
|
||||
$skipped = 0;
|
||||
foreach ($sheet->getRowIterator(2) as $row) {
|
||||
$rowNumber = $row->getRowIndex();
|
||||
$cells = [];
|
||||
foreach ($row->getCellIterator('A', 'U') as $cell) {
|
||||
$cells[] = $cell->getFormattedValue();
|
||||
}
|
||||
$warehouse = trim((string) ($cells[14] ?? ''));
|
||||
$createdRaw = trim((string) ($cells[16] ?? ''));
|
||||
if ($warehouse === '' && $createdRaw === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$month = $forcedMonth !== '' ? $forcedMonth : $this->monthOf($createdRaw);
|
||||
if ($month === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 一般用"仓库"匹配仓库简称;含"代发仓"字样的按"仓储方"匹配。
|
||||
$shortName = '';
|
||||
if ($warehouse !== '') {
|
||||
$shortName = str_contains($warehouse, '代发仓')
|
||||
? ($maps['provider'][$warehouse] ?? $maps['warehouse'][$warehouse] ?? '')
|
||||
: ($maps['warehouse'][$warehouse] ?? '');
|
||||
}
|
||||
if ($shortName !== '') {
|
||||
$mapped++;
|
||||
}
|
||||
|
||||
$batch[] = [
|
||||
'report_month' => $month . '-01',
|
||||
// 唯一键按 Excel 行号生成,保留原表每一行(含重复行)。
|
||||
'row_key' => sha1($month . '|' . $rowNumber),
|
||||
'source_no' => $this->text($cells[0] ?? ''),
|
||||
'document_no' => $this->text($cells[1] ?? ''),
|
||||
'created_serial' => $this->text($cells[2] ?? ''),
|
||||
'supplier' => $this->text($cells[3] ?? ''),
|
||||
'item_no' => $this->text($cells[4] ?? ''),
|
||||
'barcode' => $this->text($cells[5] ?? ''),
|
||||
'spec_name' => $this->text($cells[6] ?? ''),
|
||||
'quantity' => $this->number($cells[7] ?? ''),
|
||||
'unit_price' => $this->number($cells[8] ?? ''),
|
||||
'amount' => $this->number($cells[9] ?? ''),
|
||||
'estimated_unit_price' => $this->number($cells[10] ?? ''),
|
||||
'estimated_amount' => $this->number($cells[11] ?? ''),
|
||||
'supplier_barcode' => $this->text($cells[12] ?? ''),
|
||||
'remark' => $this->text($cells[13] ?? ''),
|
||||
'warehouse' => $warehouse !== '' ? $warehouse : null,
|
||||
'warehouse_short_name' => $shortName !== '' ? $shortName : null,
|
||||
'created_at_raw' => $this->moment($createdRaw),
|
||||
'express_name' => $this->text($cells[17] ?? ''),
|
||||
'express_no' => $this->text($cells[18] ?? ''),
|
||||
'not_sent' => $this->text($cells[19] ?? ''),
|
||||
'owner_name' => $this->text($cells[20] ?? ''),
|
||||
'imported_at' => $now,
|
||||
];
|
||||
$imported++;
|
||||
|
||||
if (count($batch) >= 1000) {
|
||||
DB::connection($connection)->table($table)->insertOrIgnore($batch);
|
||||
$this->info('已写入 ' . $imported . ' 行');
|
||||
$batch = [];
|
||||
}
|
||||
}
|
||||
|
||||
if ($batch !== []) {
|
||||
DB::connection($connection)->table($table)->insertOrIgnore($batch);
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'导入完成:%d 行,仓库简称匹配上 %d 行,跳过 %d 行;库中该表共 %d 行',
|
||||
$imported,
|
||||
$mapped,
|
||||
$skipped,
|
||||
DB::connection($connection)->table($table)->count(),
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{warehouse:array<string,string>,provider:array<string,string>}
|
||||
* 仅保留能唯一匹配的键
|
||||
*/
|
||||
private function shortNameMap(): array
|
||||
{
|
||||
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
|
||||
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$warehouse = trim((string) $row->warehouse);
|
||||
if ($warehouse === '') {
|
||||
continue;
|
||||
}
|
||||
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
|
||||
}
|
||||
|
||||
$byWarehouse = [];
|
||||
foreach ($grouped as $warehouse => $names) {
|
||||
$unique = array_values(array_unique($names));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byWarehouse[$warehouse] = $unique[0];
|
||||
}
|
||||
}
|
||||
|
||||
$byProvider = [];
|
||||
foreach ($rows as $row) {
|
||||
$provider = trim((string) $row->warehouse_provider);
|
||||
if ($provider === '') {
|
||||
continue;
|
||||
}
|
||||
$byProvider[$provider][] = trim((string) $row->warehouse_short_name);
|
||||
}
|
||||
foreach ($byProvider as $provider => $names) {
|
||||
$unique = array_values(array_unique($names));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byProvider[$provider] = $unique[0];
|
||||
}
|
||||
}
|
||||
|
||||
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
|
||||
}
|
||||
|
||||
private function monthOf(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
|
||||
$parsed = \DateTimeImmutable::createFromFormat($format, $value);
|
||||
if ($parsed instanceof \DateTimeImmutable) {
|
||||
return $parsed->format('Y-m');
|
||||
}
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp === false ? '' : date('Y-m', $timestamp);
|
||||
}
|
||||
|
||||
private function moment(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
|
||||
private function text(mixed $value): ?string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function number(mixed $value): ?float
|
||||
{
|
||||
$text = str_replace(',', '', trim((string) $value));
|
||||
if ($text === '' || ! is_numeric($text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (float) $text;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,10 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\WithdrawalBalanceAlertSynchronizer;
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceBillShopBalanceFallback;
|
||||
use Modules\EcommerceFinance\Services\JushuitanShopBalanceImporter;
|
||||
use Throwable;
|
||||
|
||||
@ -17,13 +14,9 @@ class ImportJushuitanShopBalances extends Command
|
||||
{--scope=all : 来源范围,同一日期同一范围会被原子替换}
|
||||
{--dry-run : 只校验和统计,不写入共享业务库}';
|
||||
|
||||
protected $description = '导入聚水潭店铺余额,并用财务账单补录缺失店铺';
|
||||
protected $description = '校验并导入聚水潭平台原始账单中的店铺余额快照';
|
||||
|
||||
public function handle(
|
||||
JushuitanShopBalanceImporter $importer,
|
||||
WithdrawalBalanceAlertSynchronizer $alertSynchronizer,
|
||||
FinanceBillShopBalanceFallback $financeFallback,
|
||||
): int
|
||||
public function handle(JushuitanShopBalanceImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
@ -33,7 +26,6 @@ class ImportJushuitanShopBalances extends Command
|
||||
(bool) $this->option('dry-run'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
@ -47,60 +39,6 @@ class ImportJushuitanShopBalances extends Command
|
||||
$result['replaced'],
|
||||
));
|
||||
|
||||
$financeFallbackFailed = false;
|
||||
try {
|
||||
$fallback = $financeFallback->fillMissing(
|
||||
$result['balance_date'],
|
||||
(string) $this->option('scope'),
|
||||
(bool) $result['dry_run'],
|
||||
);
|
||||
if (! $fallback['supported']) {
|
||||
$this->warn('账单余额回填:当前来源的店铺收款账户账单没有累计账户余额字段,跳过回填。');
|
||||
} elseif ($fallback['missing'] === 0) {
|
||||
$this->info('账单余额回填:聚水潭余额没有缺失店铺。');
|
||||
} else {
|
||||
$this->info(sprintf(
|
||||
'账单余额回填:缺失 %d 家,匹配 %d 家,写入 %d 条,仍有 %d 家待补。',
|
||||
$fallback['missing'],
|
||||
$fallback['resolved'],
|
||||
$fallback['filled'],
|
||||
$fallback['unresolved'],
|
||||
));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('财务系统店铺收款账户账单余额回填失败,聚水潭余额已保留,财务回填未覆盖。');
|
||||
$financeFallbackFailed = true;
|
||||
}
|
||||
|
||||
if (! $result['dry_run']) {
|
||||
$companyIds = StoreReceivingAccount::withoutGlobalScopes()
|
||||
->whereNull('deleted_at')
|
||||
->distinct()
|
||||
->pluck('company_id')
|
||||
->filter()
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
foreach ($companyIds as $companyId) {
|
||||
try {
|
||||
$alerts = $alertSynchronizer->syncLatest($companyId);
|
||||
if ($alerts['created'] > 0 || $alerts['updated'] > 0) {
|
||||
$this->info(sprintf(
|
||||
'余额预警待办:公司 %d 新增 %d 条、更新 %d 条。',
|
||||
$companyId,
|
||||
$alerts['created'],
|
||||
$alerts['updated'],
|
||||
));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->warn("余额预警待办生成失败(公司 {$companyId}),余额快照已保留。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $financeFallbackFailed ? self::FAILURE : self::SUCCESS;
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,8 +21,7 @@ class ImportProductMonthlyWeightedAverageBase extends Command
|
||||
protected $signature = 'product:monthly-weighted-average:base
|
||||
{month : 报表月份,例如 2026-07}
|
||||
{--file= : 期初基数 Excel(表1),首月使用}
|
||||
{--from-previous : 用上月的加权平均单价作为本月原始价格}
|
||||
{--force : 覆盖已经存在的同月基数}';
|
||||
{--from-previous : 用上月的加权平均单价作为本月原始价格}';
|
||||
|
||||
protected $description = '导入/生成商品月加权平均单价的期初基数';
|
||||
|
||||
@ -33,18 +32,6 @@ class ImportProductMonthlyWeightedAverageBase extends Command
|
||||
$rowsTable = 'product_monthly_weighted_average_rows';
|
||||
$now = now();
|
||||
|
||||
$existingRows = DB::connection($connection)->table($rowsTable)
|
||||
->where('report_month', $month->toDateString())
|
||||
->count();
|
||||
$existingState = DB::connection($connection)->table('product_monthly_weighted_average_states')
|
||||
->where('report_month', $month->toDateString())
|
||||
->value('status');
|
||||
if (! $this->option('force') && ($existingRows > 0 || $existingState !== null)) {
|
||||
$this->error($month->format('Y-m') . ' 已存在基数或计算状态,不自动覆盖;如需人工纠正请显式使用 --force。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$inventory = $this->inventoryAggregate($connection, $month);
|
||||
$jiangxiPurchase = $this->jiangxiDeliveryPurchase($connection, $month);
|
||||
$nanchangOpening = $this->nanchangOpeningQuantity($connection, $month);
|
||||
@ -211,13 +198,9 @@ class ImportProductMonthlyWeightedAverageBase extends Command
|
||||
throw new \RuntimeException($month->format('Y-m') . ' 的进销存总表尚未导入。');
|
||||
}
|
||||
|
||||
$mappingModel = new InventoryWarehouseNameMapping();
|
||||
$mappingConnection = DB::connection($mappingModel->getConnectionName());
|
||||
$mappingTable = $mappingConnection->getDriverName() === 'sqlite'
|
||||
? $mappingModel->getTable()
|
||||
: $mappingModel->getConnection()->getDatabaseName()
|
||||
. '.' . $mappingConnection->getTablePrefix()
|
||||
. $mappingModel->getTable();
|
||||
$mappingTable = (new InventoryWarehouseNameMapping)->getConnection()->getDatabaseName()
|
||||
. '.' . DB::connection((new InventoryWarehouseNameMapping)->getConnectionName())->getTablePrefix()
|
||||
. (new InventoryWarehouseNameMapping)->getTable();
|
||||
|
||||
$sql = "select m.warehouse_short_name as short_name, r.product_code as product_code,
|
||||
sum(case when r.warehouse like '%主仓%' then r.opening_quantity else 0 end) as opening_quantity,
|
||||
|
||||
@ -1,140 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\XlsxSheetReader;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Repositories\PromotionCostRowRepository;
|
||||
|
||||
class ImportPromotionCostRows extends Command
|
||||
{
|
||||
protected $signature = 'order-data:import-promotion-cost
|
||||
{file : Excel 文件路径}
|
||||
{--period= : 数据月份,格式 YYYY-MM}
|
||||
{--chunk=500 : 每批写入行数}
|
||||
{--dry-run : 只解析不入库}';
|
||||
|
||||
protected $description = '导入聚水潭商品推广费用明细 Excel';
|
||||
|
||||
public function handle(XlsxSheetReader $reader): int
|
||||
{
|
||||
$path = (string) $this->argument('file');
|
||||
$period = trim((string) $this->option('period'));
|
||||
|
||||
if (! is_file($path)) {
|
||||
$this->error('文件不存在:' . $path);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) !== 1) {
|
||||
$this->error('数据月份格式必须是 YYYY-MM:' . ($period !== '' ? $period : '(空)'));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$headers = (array) config('order_promotion_cost.headers', []);
|
||||
if ($headers === []) {
|
||||
$this->error('缺少表头映射:config/order_promotion_cost.php');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('流式读取文件:' . $path);
|
||||
|
||||
$chunkSize = max((int) $this->option('chunk'), 100);
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$importedAt = now()->toDateTimeString();
|
||||
$buffer = [];
|
||||
$total = 0;
|
||||
$written = 0;
|
||||
$skipped = 0;
|
||||
$promotionFee = 0.0;
|
||||
$shops = [];
|
||||
|
||||
try {
|
||||
foreach ($reader->rows($path, array_keys($headers)) as $row) {
|
||||
$total++;
|
||||
$payload = [];
|
||||
|
||||
foreach ($headers as $header => $key) {
|
||||
$value = trim((string) ($row[$header] ?? ''));
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$payload[$key] = $key === 'promotion_fee'
|
||||
? (is_numeric($value) ? (float) $value : 0.0)
|
||||
: $value;
|
||||
}
|
||||
|
||||
$shop = trim((string) ($payload['shop_name'] ?? ''));
|
||||
$product = trim((string) ($payload['product_code'] ?? ''));
|
||||
if ($shop === '' || $product === '') {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$shops[$shop] = true;
|
||||
$promotionFee += (float) ($payload['promotion_fee'] ?? 0);
|
||||
$buffer[] = PromotionCostRowRepository::toRecord($payload, $period, $importedAt);
|
||||
|
||||
if (! $dryRun && count($buffer) >= $chunkSize) {
|
||||
$written += $this->flush($buffer);
|
||||
$buffer = [];
|
||||
$this->output->write("\r写入 " . $written . ' 行');
|
||||
}
|
||||
|
||||
if ($dryRun && $buffer !== []) {
|
||||
$buffer = [];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
if (! $dryRun) {
|
||||
Cache::forget('order-data.promotion.imported-shop-options:' . $period);
|
||||
}
|
||||
$this->error('读取失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $dryRun && $buffer !== []) {
|
||||
$written += $this->flush($buffer);
|
||||
}
|
||||
if (! $dryRun) {
|
||||
Cache::forget('order-data.promotion.imported-shop-options:' . $period);
|
||||
}
|
||||
|
||||
$this->newLine(2);
|
||||
$this->info(sprintf(
|
||||
'完成:读取 %d 行,写入 %d 行,跳过 %d 行;店铺 %d 个;数据月份 %s;推广费合计 %.2f;峰值内存 %.1f MB',
|
||||
$total,
|
||||
$written,
|
||||
$skipped,
|
||||
count($shops),
|
||||
$period,
|
||||
$promotionFee,
|
||||
memory_get_peak_usage(true) / 1048576,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows */
|
||||
private function flush(array $rows): int
|
||||
{
|
||||
DB::table('ops_promotion_cost_rows')->upsert(
|
||||
$rows,
|
||||
['row_key'],
|
||||
[
|
||||
'source_period', 'shop_name', 'product_code', 'style_code',
|
||||
'shop_product_code', 'shop_style_code', 'online_color_spec',
|
||||
'promotion_fee', 'payload', 'imported_at', 'updated_at',
|
||||
],
|
||||
);
|
||||
|
||||
return count($rows);
|
||||
}
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\SupplierSummaryInboundImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 供应商汇总表月度增量导入:读取已解密工作簿的「明细」子表,仅追加目标月份数据。
|
||||
*/
|
||||
class ImportSupplierSummaryInbound extends Command
|
||||
{
|
||||
protected $signature = 'inventory:supplier-summary:import
|
||||
{file : 已解密的供应商汇总 XLSX 文件}
|
||||
{--month= : 目标月份 YYYY-MM,默认上一个月}
|
||||
{--result-json= : 将执行结果写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '将供应商汇总表「明细」增量导入入库明细数据';
|
||||
|
||||
public function handle(SupplierSummaryInboundImporter $importer): int
|
||||
{
|
||||
$month = trim((string) $this->option('month'));
|
||||
if ($month === '') {
|
||||
$month = now('Asia/Shanghai')->subMonthNoOverflow()->format('Y-m');
|
||||
}
|
||||
|
||||
$lock = Cache::lock('inventory-supplier-summary-inbound-import', 21600);
|
||||
if (! $lock->get()) {
|
||||
return $this->finish([
|
||||
'status' => 'failed',
|
||||
'report_month' => $month,
|
||||
'source_filename' => basename((string) $this->argument('file')),
|
||||
'success' => 0,
|
||||
'duplicates' => 0,
|
||||
'failed' => 1,
|
||||
'mapped' => 0,
|
||||
'total_in_table' => 0,
|
||||
'failure_reason_count' => 1,
|
||||
'failure_reasons' => [[
|
||||
'row' => 0,
|
||||
'reason' => '已有供应商汇总导入任务正在执行',
|
||||
]],
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $importer->import((string) $this->argument('file'), $month);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$result = [
|
||||
'status' => 'failed',
|
||||
'report_month' => $month,
|
||||
'source_filename' => basename((string) $this->argument('file')),
|
||||
'success' => 0,
|
||||
'duplicates' => 0,
|
||||
'failed' => 1,
|
||||
'mapped' => 0,
|
||||
'total_in_table' => 0,
|
||||
'failure_reason_count' => 1,
|
||||
'failure_reasons' => [[
|
||||
'row' => 0,
|
||||
'reason' => $exception->getMessage(),
|
||||
]],
|
||||
];
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
return $this->finish($result);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $result */
|
||||
private function finish(array $result): int
|
||||
{
|
||||
$reasons = is_array($result['failure_reasons'] ?? null)
|
||||
? $result['failure_reasons']
|
||||
: [];
|
||||
$result['failure_reason_count'] = count($reasons);
|
||||
|
||||
$json = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (! is_string($json)) {
|
||||
$json = '{"status":"failed","failure_reasons":[{"row":0,"reason":"结果 JSON 编码失败"}]}';
|
||||
}
|
||||
|
||||
$resultPath = trim((string) $this->option('result-json'));
|
||||
if ($resultPath !== '') {
|
||||
File::ensureDirectoryExists(dirname($resultPath));
|
||||
File::put($resultPath, $json . PHP_EOL);
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'%s 导入状态=%s,成功=%s,重复=%s,失败=%s,目标月份总行数=%s',
|
||||
(string) ($result['report_month'] ?? ''),
|
||||
(string) ($result['status'] ?? 'failed'),
|
||||
number_format((int) ($result['success'] ?? 0)),
|
||||
number_format((int) ($result['duplicates'] ?? 0)),
|
||||
number_format((int) ($result['failed'] ?? 0)),
|
||||
number_format((int) ($result['total_in_table'] ?? 0)),
|
||||
));
|
||||
foreach (array_slice($reasons, 0, 10) as $reason) {
|
||||
$this->warn(sprintf(
|
||||
'第 %s 行:%s',
|
||||
(string) ($reason['row'] ?? ''),
|
||||
(string) ($reason['reason'] ?? '未知错误'),
|
||||
));
|
||||
}
|
||||
if (count($reasons) > 10) {
|
||||
$this->line(sprintf(
|
||||
'另有 %s 条失败原因,完整清单见结果 JSON。',
|
||||
number_format(count($reasons) - 10),
|
||||
));
|
||||
}
|
||||
|
||||
return ($result['status'] ?? 'failed') === 'success'
|
||||
? self::SUCCESS
|
||||
: self::FAILURE;
|
||||
}
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\SystemizationTodoWorkbookImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ImportSystemizationTodos extends Command
|
||||
{
|
||||
protected $signature = 'systemization-todos:import
|
||||
{file : 待办 Excel 文件路径}
|
||||
{--company=1 : 写入的 company_id}
|
||||
{--user= : 操作人用户 ID}
|
||||
{--sync : 新增并更新现有来源事项,保留截图}
|
||||
{--dry-run : 只预览同步结果,不写入数据库}';
|
||||
|
||||
protected $description = '导入或同步财务系统化待办清单';
|
||||
|
||||
public function handle(SystemizationTodoWorkbookImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$file = (string) $this->argument('file');
|
||||
$companyId = max((int) $this->option('company'), 1);
|
||||
$userId = $this->option('user') !== null ? (int) $this->option('user') : null;
|
||||
if ($this->option('sync')) {
|
||||
$result = $importer->sync($file, $companyId, $userId, (bool) $this->option('dry-run'));
|
||||
} else {
|
||||
$result = $importer->import($file, $companyId, $userId);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('sync')) {
|
||||
$this->info(sprintf(
|
||||
'%s工作表 %s:新增 %d 条,更新 %d 条,保留工作簿外事项 %d 条。',
|
||||
$this->option('dry-run') ? '同步预检:' : '同步完成:',
|
||||
$result['sheet'],
|
||||
$result['created'],
|
||||
$result['updated'],
|
||||
$result['retained'],
|
||||
));
|
||||
} else {
|
||||
$this->info(sprintf(
|
||||
'工作表 %s:新增 %d 条,跳过已存在 %d 条。',
|
||||
$result['sheet'],
|
||||
$result['created'],
|
||||
$result['skipped'],
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -1,211 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\StoreReceiptDifferenceMaterialization as Materialization;
|
||||
use App\Http\Controllers\Business\Pages;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MaterializeStoreReceiptDifferences extends Command
|
||||
{
|
||||
protected $signature = 'store-receipt:materialize-differences
|
||||
{month? : 账期 YYYY-MM;留空则物化镜像里已有数据的全部月份}
|
||||
{--chunk=5000 : 每批更新的行数}
|
||||
{--dry-run : 只统计与比对,不写库}';
|
||||
|
||||
protected $description = '把业绩明细的差额备注分类与有效差额物化到镜像表,页面改为读列';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! Materialization::available(true)) {
|
||||
$this->components->error('物化列或状态表不存在,请先执行数据库迁移。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$months = $this->targetMonths();
|
||||
if ($months === []) {
|
||||
$this->components->warn('镜像里没有可物化的账期。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$chunk = max(200, (int) $this->option('chunk'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
foreach ($months as $month) {
|
||||
$this->line('');
|
||||
$this->components->info(($dryRun ? '[dry-run] ' : '') . '账期 ' . substr($month, 0, 7));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->reportDryRun($month);
|
||||
continue;
|
||||
}
|
||||
|
||||
$started = microtime(true);
|
||||
$updated = $this->materializeMonth($month, $chunk);
|
||||
if ($updated === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Materialization::markMaterialized($month, $updated);
|
||||
$this->line(sprintf(' %s 行,用时 %.1f 秒', number_format($updated), microtime(true) - $started));
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line('');
|
||||
$this->components->info('dry-run 完成,未写入任何数据。');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function targetMonths(): array
|
||||
{
|
||||
$month = trim((string) $this->argument('month'));
|
||||
if ($month !== '') {
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
|
||||
$month .= '-01';
|
||||
}
|
||||
|
||||
// 跨月备注规则依赖相邻月份,指定月份时一起重算前后两个月。
|
||||
return array_values(array_unique(Materialization::adjacentMonths($month)));
|
||||
}
|
||||
|
||||
return DB::connection(Materialization::connection())
|
||||
->table(Materialization::table())
|
||||
->select('report_month')
|
||||
->distinct()
|
||||
->orderBy('report_month')
|
||||
->pluck('report_month')
|
||||
->map(static fn ($value): string => substr((string) $value, 0, 10))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分批把规则结果写进物化列;返回更新行数。
|
||||
*/
|
||||
private function materializeMonth(string $month, int $chunk): ?int
|
||||
{
|
||||
$connection = Materialization::connection();
|
||||
$table = Materialization::table();
|
||||
|
||||
$exists = DB::connection($connection)->table($table)->where('report_month', $month)->exists();
|
||||
if (! $exists) {
|
||||
$this->components->warn(' 镜像里没有该账期的数据,跳过。');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$baseExpression = Pages::receiptDefaultDifferenceSql();
|
||||
$indexExpression = Pages::receiptDifferenceRemarkIndexSql();
|
||||
|
||||
$updated = 0;
|
||||
$lastKey = '';
|
||||
$batch = 0;
|
||||
|
||||
while (true) {
|
||||
$keys = DB::connection($connection)->table($table)
|
||||
->where('report_month', $month)
|
||||
->where('row_key', '>', $lastKey)
|
||||
->orderBy('row_key')
|
||||
->limit($chunk)
|
||||
->pluck('row_key')
|
||||
->all();
|
||||
|
||||
if ($keys === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
$lastKey = (string) end($keys);
|
||||
$batch++;
|
||||
|
||||
// 第一遍:规则表达式只算一次,结果落列。
|
||||
DB::connection($connection)->table($table)
|
||||
->where('report_month', $month)
|
||||
->whereIn('row_key', $keys)
|
||||
->update([
|
||||
Materialization::BASE_DIFFERENCE_COLUMN => DB::raw($baseExpression),
|
||||
Materialization::REASON_INDEX_COLUMN => DB::raw($indexExpression),
|
||||
]);
|
||||
|
||||
// 第二遍:直接用刚写入的列推导「有/无差额」,避免重复执行规则链,
|
||||
// 也避免依赖 SET 子句从左到右求值的库间差异。
|
||||
DB::connection($connection)->table($table)
|
||||
->where('report_month', $month)
|
||||
->whereIn('row_key', $keys)
|
||||
->update([
|
||||
Materialization::HAS_DIFFERENCE_COLUMN => DB::raw($this->hasDifferenceExpression()),
|
||||
]);
|
||||
|
||||
$updated += count($keys);
|
||||
$this->output->write('.');
|
||||
}
|
||||
|
||||
if ($updated === 0) {
|
||||
$this->line('');
|
||||
} else {
|
||||
$this->line(' (' . $batch . ' 批)');
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于物化列的「有/无差额」推导:口径兜底行按 0 计。
|
||||
*/
|
||||
private function hasDifferenceExpression(): string
|
||||
{
|
||||
$basisMarks = implode(', ', array_map(
|
||||
static fn (string $remark): string => "'" . str_replace("'", "''", $remark) . "'",
|
||||
Pages::RECEIPT_BASIS_FALLBACK_REMARKS,
|
||||
));
|
||||
|
||||
return 'CASE WHEN ABS(CASE WHEN remark IN (' . $basisMarks . ')'
|
||||
. ' AND COALESCE(' . Materialization::REASON_INDEX_COLUMN . ', 0) = 0 THEN 0'
|
||||
. ' ELSE COALESCE(' . Materialization::BASE_DIFFERENCE_COLUMN . ', 0) END) > 0.005 THEN 1 ELSE 0 END';
|
||||
}
|
||||
|
||||
/**
|
||||
* dry-run:对比「库里的物化值」与「按当前规则重算」的差异,不写库。
|
||||
*/
|
||||
private function reportDryRun(string $month): void
|
||||
{
|
||||
$connection = Materialization::connection();
|
||||
$table = Materialization::table();
|
||||
|
||||
$total = (int) DB::connection($connection)->table($table)->where('report_month', $month)->count();
|
||||
if ($total === 0) {
|
||||
$this->components->warn(' 镜像里没有该账期的数据,跳过。');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$baseExpression = Pages::receiptDefaultDifferenceSql();
|
||||
$indexExpression = Pages::receiptDifferenceRemarkIndexSql();
|
||||
|
||||
$row = DB::connection($connection)->table($table)
|
||||
->where('report_month', $month)
|
||||
->selectRaw('COUNT(*) AS total')
|
||||
->selectRaw('SUM(CASE WHEN ' . Materialization::REASON_INDEX_COLUMN
|
||||
. ' IS NULL OR ' . Materialization::REASON_INDEX_COLUMN . ' <> (' . $indexExpression . ') THEN 1 ELSE 0 END) AS reason_mismatch')
|
||||
->selectRaw('SUM(CASE WHEN ' . Materialization::BASE_DIFFERENCE_COLUMN
|
||||
. ' IS NULL OR ABS(' . Materialization::BASE_DIFFERENCE_COLUMN . ' - (' . $baseExpression . ')) > 0.005 THEN 1 ELSE 0 END) AS base_mismatch')
|
||||
->selectRaw('SUM(CASE WHEN ' . Materialization::HAS_DIFFERENCE_COLUMN . ' IS NULL THEN 1 ELSE 0 END) AS unreasoned')
|
||||
->selectRaw('SUM(CASE WHEN ' . Materialization::HAS_DIFFERENCE_COLUMN . ' IS NOT NULL AND '
|
||||
. Materialization::HAS_DIFFERENCE_COLUMN . ' <> (' . $this->hasDifferenceExpression() . ') THEN 1 ELSE 0 END) AS state_mismatch')
|
||||
->first();
|
||||
|
||||
$this->line(sprintf(' 行数 %s|备注序号不一致 %s|差额不一致 %s|有/无不一致 %s|未物化 %s',
|
||||
number_format((int) ($row->total ?? 0)),
|
||||
number_format((int) ($row->reason_mismatch ?? 0)),
|
||||
number_format((int) ($row->base_mismatch ?? 0)),
|
||||
number_format((int) ($row->state_mismatch ?? 0)),
|
||||
number_format((int) ($row->unreasoned ?? 0)),
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\StoreReceiptDefaultCompensationSnapshotBuilder;
|
||||
use Throwable;
|
||||
|
||||
class PreviewStoreReceiptDefaultCompensation extends Command
|
||||
{
|
||||
protected $signature = 'store-receipt:default-compensation:preview
|
||||
{month : 账期 YYYY-MM}
|
||||
{mapping : 最新科目映射表 xlsx 路径}
|
||||
{--output= : 输出 JSON 路径;默认写入 storage/app/local/performance-details/default-compensation}';
|
||||
|
||||
protected $description = '人工核对:按指定科目映射表生成「违约赔付(冲减收入)」金额快照,不写数据库';
|
||||
|
||||
public function handle(StoreReceiptDefaultCompensationSnapshotBuilder $builder): int
|
||||
{
|
||||
$startedAt = hrtime(true);
|
||||
$month = trim((string) $this->argument('month'));
|
||||
$mapping = (string) $this->argument('mapping');
|
||||
|
||||
try {
|
||||
$snapshot = $builder->build($month, $mapping);
|
||||
$path = (string) ($this->option('output') ?: storage_path(
|
||||
'app/local/performance-details/default-compensation/' . $month . '.json'
|
||||
));
|
||||
$directory = dirname($path);
|
||||
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
|
||||
throw new \RuntimeException('无法创建快照目录:' . $directory);
|
||||
}
|
||||
$temporary = $path . '.tmp';
|
||||
$json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
if ($json === false || file_put_contents($temporary, $json, LOCK_EX) === false) {
|
||||
throw new \RuntimeException('无法写入本地快照。');
|
||||
}
|
||||
rename($temporary, $path);
|
||||
|
||||
$this->info('本地预览快照已生成:' . $path);
|
||||
$this->line('映射条数:' . $snapshot['mapping_rows']);
|
||||
$this->line('关联行数:' . count($snapshot['amounts']));
|
||||
$this->line('金额合计:' . number_format((float) $snapshot['total'], 2, '.', ''));
|
||||
$this->line('耗时:' . number_format((hrtime(true) - $startedAt) / 1e6, 0) . ' ms');
|
||||
$this->line('PHP 峰值内存:' . number_format(memory_get_peak_usage(true) / 1024 / 1024, 1) . ' MB');
|
||||
foreach ($snapshot['source_stats'] as $platform => $stats) {
|
||||
$this->line(sprintf(
|
||||
' %s:账单行 %d,金额 %s',
|
||||
$platform,
|
||||
$stats['rows'],
|
||||
number_format((float) $stats['amount'], 2, '.', ''),
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Common\Company;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\EcommerceFinance\Services\SupplierNegativeAlertDingTalkWorkflow;
|
||||
use Modules\EcommerceFinance\View\SupplierNegativeAlertPageData;
|
||||
use Throwable;
|
||||
|
||||
class PushSupplierNegativeAlertDingTalkTasks extends Command
|
||||
{
|
||||
protected $signature = 'supplier-negative-alert:push-dingtalk
|
||||
{--date= : 推送日期,格式 Y-m-d,默认今天}
|
||||
{--force : 忽略当天已推送标记}';
|
||||
|
||||
protected $description = '每天推送最新账期内尚未处理的供应商负数预警任务';
|
||||
|
||||
public function handle(
|
||||
SupplierNegativeAlertPageData $pageData,
|
||||
SupplierNegativeAlertDingTalkWorkflow $workflow,
|
||||
): int {
|
||||
$date = trim((string) $this->option('date')) ?: now()->toDateString();
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
|
||||
$this->error('推送日期格式无效。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$failed = false;
|
||||
foreach (Company::enabled()->orderBy('id')->get() as $company) {
|
||||
$company->makeCurrent();
|
||||
$rows = $pageData->rowsForFilters([]);
|
||||
$periods = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['summary_month'] ?? '')),
|
||||
$rows,
|
||||
))));
|
||||
if ($periods === []) {
|
||||
$this->line("#{$company->id} {$company->name}: 没有负数预警数据");
|
||||
continue;
|
||||
}
|
||||
rsort($periods);
|
||||
$period = $periods[0];
|
||||
$latestRows = array_values(array_filter(
|
||||
$rows,
|
||||
static fn (array $row): bool => trim((string) ($row['summary_month'] ?? '')) === $period,
|
||||
));
|
||||
$handled = array_fill_keys(
|
||||
$workflow->handledSuppliersForPeriod((int) $company->id, $period),
|
||||
true,
|
||||
);
|
||||
$unhandledRows = array_values(array_filter(
|
||||
$latestRows,
|
||||
static fn (array $row): bool => ! isset($handled[trim((string) ($row['supplier'] ?? ''))]),
|
||||
));
|
||||
if ($unhandledRows === []) {
|
||||
$this->line("#{$company->id} {$company->name} {$period}: 供应商均已处理");
|
||||
continue;
|
||||
}
|
||||
|
||||
$lockKey = sprintf(
|
||||
'supplier-negative-alert:auto-push:%d:%s:%s',
|
||||
$company->id,
|
||||
$period,
|
||||
$date,
|
||||
);
|
||||
if (! $this->option('force') && ! Cache::add($lockKey, true, now()->addDays(2))) {
|
||||
$this->line("#{$company->id} {$company->name} {$period}: 今天已经自动推送");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $workflow->sendGrouped(
|
||||
$unhandledRows,
|
||||
['month' => $period, 'automatic' => true],
|
||||
(int) $company->id,
|
||||
null,
|
||||
);
|
||||
$this->info(sprintf(
|
||||
'#%d %s %s: 已推送 %d 位采购、%d 家供应商、%d 条未处理预警',
|
||||
$company->id,
|
||||
$company->name,
|
||||
$period,
|
||||
$result['purchaser_count'],
|
||||
$result['supplier_count'],
|
||||
$result['row_count'],
|
||||
));
|
||||
} catch (Throwable $exception) {
|
||||
Cache::forget($lockKey);
|
||||
report($exception);
|
||||
$this->error("#{$company->id} {$company->name} {$period}: 自动推送失败:" . $exception->getMessage());
|
||||
$failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $failed ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\ProductMonthlyWeightedAverageReporter;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class ReportProductMonthlyWeightedAverage extends Command
|
||||
{
|
||||
protected $signature = 'product:monthly-weighted-average:report
|
||||
{month : 报表月份,例如 2026-09}
|
||||
{--result-json= : 将结果写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '输出商品月加权平均单价结果简报与差异分类';
|
||||
|
||||
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
|
||||
{
|
||||
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
|
||||
$report = $reporter->build(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
$month,
|
||||
);
|
||||
|
||||
$path = trim((string) $this->option('result-json'));
|
||||
if ($path !== '') {
|
||||
File::ensureDirectoryExists(dirname($path));
|
||||
File::put(
|
||||
$path,
|
||||
json_encode(
|
||||
$report,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
|
||||
) . PHP_EOL,
|
||||
);
|
||||
}
|
||||
|
||||
$this->line($reporter->brief($report));
|
||||
if (($report['status'] ?? null) === 'review_required') {
|
||||
$this->warn('存在待人工复核差异,完整清单已写入结果 JSON。');
|
||||
}
|
||||
|
||||
return ($report['status'] ?? null) === 'missing'
|
||||
? self::FAILURE
|
||||
: self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,6 @@ use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
|
||||
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorSynchronizer;
|
||||
use Modules\EcommerceFinance\Services\DingPayableWorkflowMirrorSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
@ -27,7 +26,6 @@ class SyncBusinessMirrors extends Command
|
||||
BusinessMirrorSynchronizer $synchronizer,
|
||||
GenericBusinessMirrorRepository $mirror,
|
||||
?DingPayableWorkflowMirrorSynchronizer $dingPayableWorkflows = null,
|
||||
?DingProcessDetailMirrorSynchronizer $dingProcessDetails = null,
|
||||
): int
|
||||
{
|
||||
if (! (bool) config('upstream_mirrors.refresh_enabled', false) && ! $this->option('full')) {
|
||||
@ -130,24 +128,6 @@ class SyncBusinessMirrors extends Command
|
||||
Log::error('钉钉线上代发价格流程镜像同步失败。', ['exception' => $exception::class]);
|
||||
$this->error('operations_replica.rpt.ding_online_daifa_price:同步失败,已保留上一版共享数据。');
|
||||
}
|
||||
|
||||
// 财务流程明细按结构化明细/付款事实同步。首次全量通过专用命令执行,
|
||||
// 日常只处理最近更新过的审批,避免每分钟重扫整批钉钉表单。
|
||||
if ($dingProcessDetails !== null) {
|
||||
try {
|
||||
$detailResult = $dingProcessDetails->sync(false);
|
||||
if ($detailResult['status'] === 'succeeded') {
|
||||
$this->info(sprintf(
|
||||
'operations_replica.rpt.ding_process_details:同步完成,本轮 %d 个审批。',
|
||||
$detailResult['workflows'],
|
||||
));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$failed = true;
|
||||
Log::error('钉钉流程明细结构镜像同步失败。', ['exception' => $exception::class]);
|
||||
$this->error('operations_replica.rpt.ding_process_details:同步失败,已保留上一版共享数据。');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $failed ? self::FAILURE : self::SUCCESS;
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
class SyncDingProcessDetailMirror extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-ding-process-details
|
||||
{--full : 从配置起始日期完整重建明细和付款事实}
|
||||
{--business-id= : 只同步指定审批编号,多个用逗号分隔(最多 50 个)}
|
||||
{--dry-run : 只读取上游并校验,不写入镜像}';
|
||||
|
||||
protected $description = '从结构化审批镜像同步流程明细,并用钉钉官方接口补取缺失付款';
|
||||
|
||||
public function handle(DingProcessDetailMirrorSynchronizer $synchronizer): int
|
||||
{
|
||||
$businessIds = array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) $this->option('business-id')),
|
||||
)));
|
||||
|
||||
try {
|
||||
if ($businessIds !== []) {
|
||||
if ((bool) $this->option('full')) {
|
||||
throw new \InvalidArgumentException('--full 不能和 --business-id 同时使用。');
|
||||
}
|
||||
$result = $synchronizer->syncBusinessIds(
|
||||
$businessIds,
|
||||
(bool) $this->option('dry-run'),
|
||||
);
|
||||
} else {
|
||||
$result = $synchronizer->sync(
|
||||
(bool) $this->option('full'),
|
||||
(bool) $this->option('dry-run'),
|
||||
);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('同步失败,已保留上一版共享数据:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($result['status'] === 'requires_full_sync') {
|
||||
$this->warn('流程明细尚未完成结构化全量同步,请使用 --full。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s:%d 个审批,%d 条明细,%d 笔付款;金额合计 %.2f,银行付款合计 %.2f。',
|
||||
$result['status'] === 'validated' ? '校验通过' : '同步完成',
|
||||
$result['workflows'],
|
||||
$result['details'],
|
||||
$result['payments'],
|
||||
$result['amount_total'],
|
||||
$result['bank_amount_total'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -11,28 +11,17 @@ class SyncFinanceLedgerRpt extends Command
|
||||
protected $signature = 'finance-ledger:sync-rpt
|
||||
{--from= : 业务日期起,默认读取配置}
|
||||
{--to= : 业务日期止,默认读取配置或当天}
|
||||
{--days= : 只拉取最近 N 天并合并到现有明细}
|
||||
{--merge : 合并到现有明细,不删除历史}
|
||||
{--no-recalculate : 只同步明细,不重算流水}';
|
||||
|
||||
protected $description = '从 AI 库 rpt 同步财务台账动账明细,并重算每日流水';
|
||||
|
||||
public function handle(FinanceLedgerRptSynchronizer $synchronizer): int
|
||||
{
|
||||
$days = (int) ($this->option('days') ?: 0);
|
||||
$from = $this->option('from') ?: null;
|
||||
$merge = (bool) $this->option('merge');
|
||||
if ($days > 0) {
|
||||
$from = now()->subDays(max(1, $days) - 1)->toDateString();
|
||||
$merge = true;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $synchronizer->sync(
|
||||
! $this->option('no-recalculate'),
|
||||
$from,
|
||||
$this->option('from') ?: null,
|
||||
$this->option('to') ?: null,
|
||||
$merge,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerShopDataSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
class SyncFinanceLedgerShopData extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:sync-shop-data
|
||||
{--from= : 提现业务日期起,默认从台账期初日}
|
||||
{--to= : 提现业务日期止,最晚只同步到前一晚}
|
||||
{--days= : 只同步最近 N 天}
|
||||
{--rebuild : 先用最新余额快照清空并重建店铺台账}
|
||||
{--balance-only : 只生成店铺余额快照并更新台账,不处理平台提现}
|
||||
{--no-recalculate : 只同步数据,不重算流水}';
|
||||
|
||||
protected $description = '生成上一晚店铺余额快照(店铺收款账户账单优先),并把平台提现对应到财务账户收入';
|
||||
|
||||
public function handle(FinanceLedgerShopDataSynchronizer $synchronizer): int
|
||||
{
|
||||
$days = (int) ($this->option('days') ?: 0);
|
||||
$from = $this->option('from') ?: null;
|
||||
if ($days > 0) {
|
||||
$from = now()->subDays(max(1, $days) - 1)->toDateString();
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->option('balance-only')) {
|
||||
$balance = $synchronizer->syncShopBalances(
|
||||
$this->option('to') ?: null,
|
||||
(bool) $this->option('rebuild'),
|
||||
);
|
||||
$result = [
|
||||
'balance' => $balance,
|
||||
'withdrawals' => [
|
||||
'processed' => 0,
|
||||
'written' => 0,
|
||||
'upstream_duplicates' => 0,
|
||||
'unmatched' => 0,
|
||||
'account_missing' => 0,
|
||||
'income' => 0.0,
|
||||
'expense' => 0.0,
|
||||
],
|
||||
'recalculated' => null,
|
||||
];
|
||||
} else {
|
||||
$result = $synchronizer->sync(
|
||||
$from,
|
||||
$this->option('to') ?: null,
|
||||
! $this->option('no-recalculate'),
|
||||
(bool) $this->option('rebuild'),
|
||||
);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['项目', '值'], [
|
||||
['店铺余额日期', $result['balance']['date'] ?: '-'],
|
||||
['余额源记录', $result['balance']['source']],
|
||||
['店铺账单余额来源', $result['balance']['receiving_bill']],
|
||||
['聚水潭补录来源', $result['balance']['jushuitan']],
|
||||
['补充确认店铺', $result['balance']['added']],
|
||||
['重建店铺台账', $result['balance']['rebuilt']],
|
||||
['已更新店铺余额', $result['balance']['matched']],
|
||||
['店铺余额合计', number_format((float) $result['balance']['amount'], 2)],
|
||||
['提现处理笔数', $result['withdrawals']['processed']],
|
||||
['提现新增或更新', $result['withdrawals']['written']],
|
||||
['上游已存在提现', $result['withdrawals']['upstream_duplicates']],
|
||||
['店铺未匹配', $result['withdrawals']['unmatched']],
|
||||
['账户缺失', $result['withdrawals']['account_missing']],
|
||||
['提现收入合计', number_format((float) $result['withdrawals']['income'], 2)],
|
||||
['提现冲回支出', number_format((float) $result['withdrawals']['expense'], 2)],
|
||||
['余额预警待办新增', $result['balance']['alerts']['created']],
|
||||
['余额预警待办更新', $result['balance']['alerts']['updated']],
|
||||
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
|
||||
['PHP 峰值内存', number_format(memory_get_peak_usage(true) / 1048576, 1) . ' MiB'],
|
||||
]);
|
||||
$this->info('财务台账店铺余额与提现同步完成。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -6,14 +6,12 @@ use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\JushuitanCostPriceHistorySynchronizer;
|
||||
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer;
|
||||
use Throwable;
|
||||
|
||||
class SyncJushuitanCostPriceHistory extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-jushuitan-cost-price-history
|
||||
{--full : 强制启动或继续首次全量同步}
|
||||
{--source=open_api : open_api 为正式操作历史;snapshot 为独立的镜像价格观测}
|
||||
{--page-size= : 聚水潭接口每页读取条数,最大 50}
|
||||
{--max-pages= : 本轮最多读取页数,0 表示直到完成}';
|
||||
|
||||
@ -27,25 +25,6 @@ class SyncJushuitanCostPriceHistory extends Command
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->option('source') === 'snapshot') {
|
||||
try {
|
||||
$result = app(JushuitanCostPriceSnapshotDiffer::class)->sync(
|
||||
$this->option('page-size') === null ? 500 : (int) $this->option('page-size'),
|
||||
$this->option('max-pages') === null ? 20 : (int) $this->option('max-pages'),
|
||||
);
|
||||
$this->info(sprintf('镜像价格观测:%s,本轮读取 %d 条,发现 %d 次价格变化;不代表完整操作历史。', $result['status'], $result['rows'], $result['changes']));
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('镜像价格观测失败。', ['exception' => $exception::class]);
|
||||
$this->error('镜像价格观测失败,已保留基线、记录和续传游标。');
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
if ($this->option('source') !== 'open_api') {
|
||||
$this->error('source 仅支持 open_api 或 snapshot。');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$state = $mirror->state();
|
||||
$full = $this->shouldRunFull($state);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,172 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class VerifyErpDailyBillCoverage extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:verify-erp-daily-coverage
|
||||
{--from=2026-07-01 : 起始日期 YYYY-MM-DD}
|
||||
{--to= : 截止日期 YYYY-MM-DD,默认昨日}
|
||||
{--json : 输出 JSON}';
|
||||
|
||||
protected $description = '核对京东日账单、京东钱包、头条放心购的月份与截止日覆盖是否完整';
|
||||
|
||||
private const TARGETS = [
|
||||
'jd-daily' => [
|
||||
'label' => '京东日账单',
|
||||
'periods' => 'jd_daily_bill_periods',
|
||||
'rows' => 'jd_daily_bill_rows',
|
||||
],
|
||||
'jd-wallet' => [
|
||||
'label' => '京东钱包',
|
||||
'periods' => 'jd_wallet_bill_periods',
|
||||
'rows' => 'jd_wallet_bill_rows',
|
||||
],
|
||||
'douyin' => [
|
||||
'label' => '头条放心购',
|
||||
'periods' => 'douyin_authoritative_periods',
|
||||
'rows' => 'douyin_authoritative_bills',
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$fromText = (string) $this->option('from');
|
||||
$toText = (string) ($this->option('to') ?: now('Asia/Shanghai')->subDay()->toDateString());
|
||||
$from = $this->date($fromText);
|
||||
$to = $this->date($toText);
|
||||
|
||||
if ($from === null || $to === null || $from->gt($to)) {
|
||||
$this->error('日期范围无效,应为 from <= to,格式 YYYY-MM-DD。');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$db = DB::connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$results = [];
|
||||
$failed = false;
|
||||
|
||||
foreach (self::TARGETS as $key => $target) {
|
||||
if (! $schema->hasTable($target['periods']) || ! $schema->hasTable($target['rows'])) {
|
||||
$results[] = [
|
||||
'dataset' => $key,
|
||||
'label' => $target['label'],
|
||||
'month' => null,
|
||||
'required_start' => $from->toDateString(),
|
||||
'required_through' => $to->toDateString(),
|
||||
'imported_start' => null,
|
||||
'imported_end' => null,
|
||||
'row_count' => 0,
|
||||
'registered_rows' => null,
|
||||
'income' => null,
|
||||
'expense' => null,
|
||||
'status' => 'missing_table',
|
||||
'issue' => '镜像表不存在',
|
||||
];
|
||||
$failed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
$month = $from->copy()->startOfMonth();
|
||||
while ($month->lte($to)) {
|
||||
$monthEnd = $month->copy()->endOfMonth();
|
||||
$requiredStart = ($month->lt($from) ? $from->copy() : $month->copy())->startOfDay();
|
||||
$requiredThrough = ($monthEnd->gt($to) ? $to->copy() : $monthEnd->copy())->startOfDay();
|
||||
$requiredEndExclusive = $requiredThrough->copy()->addDay();
|
||||
$period = $db->table($target['periods'])
|
||||
->where('period_start', $month->toDateString())
|
||||
->first();
|
||||
|
||||
$status = 'ok';
|
||||
$rowCount = 0;
|
||||
$issue = null;
|
||||
$summary = null;
|
||||
|
||||
if ($period === null) {
|
||||
$status = 'missing_month';
|
||||
$issue = '缺少该月份导入记录';
|
||||
} elseif (Carbon::parse((string) $period->period_end)->lt($requiredEndExclusive)) {
|
||||
$status = 'partial';
|
||||
$issue = '导入截止日早于要求日期';
|
||||
} else {
|
||||
$summary = $db->table($target['rows'])
|
||||
->where('generation', $period->active_generation)
|
||||
->selectRaw('COUNT(*) AS rows_count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')
|
||||
->first();
|
||||
$rowCount = (int) ($summary->rows_count ?? 0);
|
||||
if ($rowCount === 0) {
|
||||
$status = 'empty';
|
||||
$issue = '有效版本没有明细';
|
||||
} elseif ($rowCount !== (int) $period->row_count) {
|
||||
$status = 'row_count_mismatch';
|
||||
$issue = '有效版本实际行数与期间登记不一致';
|
||||
} elseif (abs(round((float) $summary->income, 2) - (float) $period->income) > 0.005
|
||||
|| abs(round((float) $summary->expense, 2) - (float) $period->expense) > 0.005) {
|
||||
$status = 'amount_mismatch';
|
||||
$issue = '有效版本收支合计与期间登记不一致';
|
||||
}
|
||||
}
|
||||
|
||||
if ($status !== 'ok') {
|
||||
$failed = true;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'dataset' => $key,
|
||||
'label' => $target['label'],
|
||||
'month' => $month->format('Y-m'),
|
||||
'required_start' => $requiredStart->toDateString(),
|
||||
'required_through' => $requiredThrough->toDateString(),
|
||||
'imported_start' => $period?->period_start,
|
||||
'imported_end' => $period?->period_end,
|
||||
'row_count' => $rowCount,
|
||||
'registered_rows' => $period === null ? null : (int) $period->row_count,
|
||||
'income' => isset($summary) ? round((float) $summary->income, 2) : null,
|
||||
'expense' => isset($summary) ? round((float) $summary->expense, 2) : null,
|
||||
'status' => $status,
|
||||
'issue' => $issue,
|
||||
];
|
||||
|
||||
unset($summary);
|
||||
$month->addMonthNoOverflow()->startOfMonth();
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line(json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
} else {
|
||||
$this->table(
|
||||
['数据集', '月份', '要求覆盖', '导入覆盖', '实际行数', '登记行数', '收入', '支出', '状态', '说明'],
|
||||
array_map(static fn (array $row): array => [
|
||||
$row['label'],
|
||||
$row['month'] ?? '—',
|
||||
($row['required_start'] ?? '—') . ' ~ ' . ($row['required_through'] ?? '—'),
|
||||
($row['imported_start'] ?? '—') . ' ~ ' . ($row['imported_end'] ?? '—'),
|
||||
number_format((int) $row['row_count']),
|
||||
$row['registered_rows'] === null ? '—' : number_format((int) $row['registered_rows']),
|
||||
$row['income'] === null ? '—' : number_format((float) $row['income'], 2),
|
||||
$row['expense'] === null ? '—' : number_format((float) $row['expense'], 2),
|
||||
$row['status'],
|
||||
$row['issue'] ?? '',
|
||||
], $results),
|
||||
);
|
||||
}
|
||||
|
||||
return $failed ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function date(string $value): ?Carbon
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = Carbon::createFromFormat('!Y-m-d', $value, 'Asia/Shanghai');
|
||||
return $date !== false && $date->format('Y-m-d') === $value ? $date : null;
|
||||
}
|
||||
}
|
||||
@ -53,13 +53,6 @@ class Kernel extends ConsoleKernel
|
||||
->withoutOverlapping(60)
|
||||
->runInBackground();
|
||||
|
||||
// 上游店铺档案每日刷新后留存一份完整基础信息,方便按日期复查负责人等字段。
|
||||
$schedule->command('business-mirror:archive-store-information')
|
||||
->dailyAt('07:00')
|
||||
->timezone('Asia/Shanghai')
|
||||
->withoutOverlapping(120)
|
||||
->runInBackground();
|
||||
|
||||
$schedule->command('business-mirror:sync-supplier-management')
|
||||
->dailyAt('08:15')
|
||||
->timezone('Asia/Shanghai')
|
||||
@ -83,17 +76,9 @@ class Kernel extends ConsoleKernel
|
||||
->withoutOverlapping(30)
|
||||
->runInBackground();
|
||||
|
||||
// 供应商负数预警:每天 10:00 仅推送最新账期内尚未处理的供应商。
|
||||
$schedule->command('supplier-negative-alert:push-dingtalk')
|
||||
->dailyAt('10:00')
|
||||
->timezone('Asia/Shanghai')
|
||||
->withoutOverlapping(60)
|
||||
->appendOutputTo(storage_path('logs/supplier-negative-alert-dingtalk.log'))
|
||||
->runInBackground();
|
||||
|
||||
// 财务台账:从 AI 库 rpt 同步动账明细,按日重算账户流水。
|
||||
// 生产宿主机 cron 仍需配置对应的 finance-ledger:sync-rpt 入口。
|
||||
$schedule->command('finance-ledger:sync-rpt --days=7 --merge')
|
||||
$schedule->command('finance-ledger:sync-rpt')
|
||||
->dailyAt('02:30')
|
||||
->timezone('Asia/Shanghai')
|
||||
->withoutOverlapping(120)
|
||||
@ -118,15 +103,12 @@ class Kernel extends ConsoleKernel
|
||||
$schedule->command('jushuitan:sync-shipping --days=40')
|
||||
->dailyAt('01:28')
|
||||
->withoutOverlapping(300)
|
||||
->appendOutputTo(storage_path('logs/jst-sync-shipping.log'))
|
||||
->runInBackground();
|
||||
// 凌晨副本库最近一两天的商品明细常未到齐(出库单常在发货次日下午才确认,视图随后才有商品);
|
||||
// 白天副本库负载高、查询常超时断开,补拉放到晚上:每天 21:28 再拉最近 3 天,
|
||||
// 补齐凌晨只写入单号、缺商品明细的包裹。
|
||||
// 凌晨副本库最近一两天的订单/商品明细常未到齐(视图、订单商品表滞后),
|
||||
// 早上再拉一次最近 3 天,补齐凌晨只写入单号、缺商品明细的包裹。
|
||||
$schedule->command('jushuitan:sync-shipping --days=3')
|
||||
->dailyAt('21:28')
|
||||
->dailyAt('07:28')
|
||||
->withoutOverlapping(120)
|
||||
->appendOutputTo(storage_path('logs/jst-sync-shipping.log'))
|
||||
->runInBackground();
|
||||
}
|
||||
|
||||
|
||||
@ -26,46 +26,19 @@ class CourierQuoteChanges extends Controller
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
public function index(): View
|
||||
{
|
||||
$filters = $this->historyFilters($request);
|
||||
$changes = CourierQuoteChange::query()
|
||||
->with(['items', 'version'])
|
||||
->when($filters['carrier'] !== '', fn ($q) => $q->where('carrier_name', 'like', '%' . addcslashes($filters['carrier'], '%_\\') . '%'))
|
||||
->when($filters['status'] !== '', fn ($q) => $q->where('status', $filters['status']))
|
||||
->when($filters['change_type'] !== '', fn ($q) => $q->where('change_type', $filters['change_type']))
|
||||
->latest('updated_at')
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
->paginate(20);
|
||||
|
||||
return view('business.pages.courier-payables.quote-change-history', [
|
||||
'changes' => $changes,
|
||||
'canReview' => $this->canReview(),
|
||||
'filters' => $filters,
|
||||
'carrierOptions' => CourierQuoteChange::query()->whereNotNull('carrier_name')->distinct()->orderBy('carrier_name')->pluck('carrier_name')->all(),
|
||||
'changeTypeOptions' => CourierQuoteChange::query()->whereNotNull('change_type')->distinct()->orderBy('change_type')->pluck('change_type')->all(),
|
||||
'statusOptions' => [
|
||||
CourierQuoteChange::STATUS_DRAFT => '草稿',
|
||||
CourierQuoteChange::STATUS_PENDING_REVIEW => '待审核',
|
||||
CourierQuoteChange::STATUS_RETURNED => '已退回',
|
||||
CourierQuoteChange::STATUS_APPROVED => '已审核',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array{carrier:string,status:string,change_type:string} */
|
||||
private function historyFilters(Request $request): array
|
||||
{
|
||||
$status = (string) $request->input('status', '');
|
||||
$allowed = [CourierQuoteChange::STATUS_DRAFT, CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_RETURNED, CourierQuoteChange::STATUS_APPROVED];
|
||||
|
||||
return [
|
||||
'carrier' => mb_substr(trim((string) $request->input('carrier', '')), 0, 100),
|
||||
'status' => in_array($status, $allowed, true) ? $status : '',
|
||||
'change_type' => mb_substr(trim((string) $request->input('change_type', '')), 0, 50),
|
||||
];
|
||||
}
|
||||
|
||||
public function create(Request $request): View|RedirectResponse
|
||||
{
|
||||
$quotes = $this->snapshot->load()['quotes'] ?? [];
|
||||
@ -222,8 +195,7 @@ class CourierQuoteChanges extends Controller
|
||||
}
|
||||
}
|
||||
$passed = count(array_filter($results, static fn ($result) => $result['success']));
|
||||
$back = array_filter($this->historyFilters($request), static fn ($v) => $v !== '');
|
||||
return redirect()->route('business.courier-quote-changes.index', ['page' => max(1, (int) $request->input('page', 1))] + $back)
|
||||
return redirect()->route('business.courier-quote-changes.index', ['page' => max(1, (int) $request->input('page', 1))])
|
||||
->with('success', '批量审核完成:成功 ' . $passed . ' 条,未通过 ' . (count($results) - $passed) . ' 条。')
|
||||
->with('batch_review_results', $results);
|
||||
}
|
||||
|
||||
@ -92,10 +92,6 @@ class CourierStockQuoteEntries extends Controller
|
||||
'entries.*.rule_group' => ['required', 'integer', 'min:0', 'max:9'],
|
||||
'entries.*.effective_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'entries.*.end_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'entries.*.periods' => ['sometimes', 'array', 'min:1', 'max:20'],
|
||||
'entries.*.periods.*' => ['array'],
|
||||
'entries.*.periods.*.effective_date' => ['required', 'date_format:Y-m-d'],
|
||||
'entries.*.periods.*.end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:entries.*.periods.*.effective_date'],
|
||||
'submit_for_review' => ['required', 'boolean'],
|
||||
'confirmed' => ['accepted_if:submit_for_review,true'],
|
||||
])->validate();
|
||||
@ -111,19 +107,14 @@ class CourierStockQuoteEntries extends Controller
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
...$this->metadataRules(true),
|
||||
...$this->metadataRules(),
|
||||
'courier_expcode' => ['nullable', 'string', 'max:64'],
|
||||
'courier_name' => ['required_without:courier_expcode', 'nullable', 'string', 'max:255'],
|
||||
'quote_type' => ['required', 'string', 'max:64'],
|
||||
'sheet_name' => ['nullable', 'string', 'max:255'],
|
||||
...$this->fileRules(true),
|
||||
]);
|
||||
$entries = $this->service->createPeriods($data, (int) user_id());
|
||||
if (count($entries) > 1) {
|
||||
return redirect()->route('business.courier-quote-changes.index')
|
||||
->with('success', '已按 ' . count($entries) . ' 个执行区间保存报价草稿,共用原报价与附件;请分别核对并提交审核。');
|
||||
}
|
||||
$entry = $entries[0];
|
||||
$entry = $this->service->create($data, (int) user_id());
|
||||
|
||||
return redirect()->route('business.courier-quote-changes.show', $entry->change)
|
||||
->with('success', '存量报价草稿已保存。请核对原报价、计费规则和实际执行日期后提交审核。');
|
||||
@ -186,16 +177,10 @@ class CourierStockQuoteEntries extends Controller
|
||||
return $disk->download($attachment['path'], $attachment['name'], $headers);
|
||||
}
|
||||
|
||||
private function metadataRules(bool $multiplePeriods = false): array
|
||||
private function metadataRules(): array
|
||||
{
|
||||
return [
|
||||
...($multiplePeriods ? [
|
||||
'periods' => ['sometimes', 'array', 'min:1', 'max:20'],
|
||||
'periods.*' => ['array'],
|
||||
'periods.*.effective_date' => ['required', 'date_format:Y-m-d'],
|
||||
'periods.*.end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:periods.*.effective_date'],
|
||||
] : []),
|
||||
'effective_date' => [$multiplePeriods ? 'required_without:periods' : 'required', 'date_format:Y-m-d'],
|
||||
'effective_date' => ['required', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'],
|
||||
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
|
||||
'summary' => ['required', 'string', 'min:2', 'max:2000'],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -32,15 +32,6 @@ class PlantingWorkOrders extends Controller
|
||||
default => '-扣款',
|
||||
};
|
||||
|
||||
// 发运营的拍A发B 名单直接按「种菜报销」的字段导出。
|
||||
if (request()->query('format') === 'reimbursement') {
|
||||
return $this->csv(
|
||||
'拍A发B发运营-' . $filters['month'],
|
||||
PlantingReimbursementPageData::COLUMNS,
|
||||
static fn () => $data->rows($filters),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->csv(
|
||||
PlantingLossPageData::TITLE . '-' . $filters['month'] . $suffix,
|
||||
$data->exportColumns(),
|
||||
|
||||
@ -2,41 +2,26 @@
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\SupplierFreeShippingStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierFreeShippingWorkbook;
|
||||
use Modules\EcommerceFinance\View\SupplierManagementPageData;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class SupplierManagement extends Controller
|
||||
{
|
||||
public function export(
|
||||
Request $request,
|
||||
SupplierManagementMirrorRepository $repository,
|
||||
SupplierManagementPageData $page,
|
||||
SupplierFreeShippingStore $freeShipping,
|
||||
): StreamedResponse {
|
||||
public function export(Request $request, SupplierManagementMirrorRepository $repository, SupplierManagementPageData $page)
|
||||
{
|
||||
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能导出。');
|
||||
$filters = $page->filters($request->query());
|
||||
$columns = SupplierManagementPageData::columns();
|
||||
$companyId = (int) (company_id() ?: 1);
|
||||
|
||||
return response()->streamDownload(static function () use ($repository, $filters, $columns, $freeShipping, $companyId): void {
|
||||
return response()->streamDownload(static function () use ($repository, $filters): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
fputs($csv, "\xEF\xBB\xBF");
|
||||
fputcsv($csv, array_values($columns));
|
||||
fputcsv($csv, array_values(SupplierManagementMirrorRepository::COLUMNS));
|
||||
foreach ($repository->exportRows($filters) as $row) {
|
||||
$row = $freeShipping->decorateRow($row, $companyId);
|
||||
$values = [];
|
||||
foreach (array_keys($columns) as $column) {
|
||||
$value = $column === SupplierManagementPageData::FREE_SHIPPING_COLUMN
|
||||
? ((bool) ($row->{$column} ?? false) ? '包邮' : '')
|
||||
: (string) ($row->{$column} ?? '');
|
||||
foreach (array_keys(SupplierManagementMirrorRepository::COLUMNS) as $column) {
|
||||
$value = (string) ($row->{$column} ?? '');
|
||||
if ($column === 'coopersgn') $value = ['1' => '合作', '2' => '不合作'][$value] ?? '未维护';
|
||||
// Excel 不能执行供应商提供的公式文本,且长编码保留为文本。
|
||||
if (preg_match('/^[=+\-@\t\r\n]/', ltrim($value)) || preg_match('/^\d{12,}$/', $value)) $value = "'" . $value;
|
||||
@ -48,114 +33,4 @@ class SupplierManagement extends Controller
|
||||
}, 'supplier_management_' . now()->format('Ymd_His') . '.csv',
|
||||
['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
|
||||
public function template(SupplierFreeShippingWorkbook $workbook): StreamedResponse
|
||||
{
|
||||
return $workbook->template();
|
||||
}
|
||||
|
||||
public function import(
|
||||
Request $request,
|
||||
SupplierManagementMirrorRepository $repository,
|
||||
SupplierFreeShippingStore $freeShipping,
|
||||
SupplierFreeShippingWorkbook $workbook,
|
||||
): RedirectResponse {
|
||||
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240']]);
|
||||
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能导入。');
|
||||
|
||||
$valuesByCode = $workbook->read($request->file('import'));
|
||||
$suppliers = $repository->suppliersByCodes(array_keys($valuesByCode));
|
||||
$matched = [];
|
||||
foreach ($suppliers as $supplier) {
|
||||
$code = mb_strtolower(trim((string) ($supplier->scmcode ?? '')));
|
||||
if ($code !== '') {
|
||||
$matched[$code][] = $supplier;
|
||||
}
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
$ambiguous = [];
|
||||
$valuesBySupplierId = [];
|
||||
foreach ($valuesByCode as $code => $isFreeShipping) {
|
||||
$key = mb_strtolower(trim((string) $code));
|
||||
if (! isset($matched[$key])) {
|
||||
$missing[] = (string) $code;
|
||||
continue;
|
||||
}
|
||||
if (count($matched[$key]) !== 1) {
|
||||
$ambiguous[] = (string) $code;
|
||||
continue;
|
||||
}
|
||||
$valuesBySupplierId[(int) $matched[$key][0]->Id] = $isFreeShipping;
|
||||
}
|
||||
if ($ambiguous !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '以下供应商代码在供应商主档中存在多条记录,未导入:' . implode('、', array_slice($ambiguous, 0, 8)),
|
||||
]);
|
||||
}
|
||||
if ($missing !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '以下供应商代码未找到,未导入:' . implode('、', array_slice($missing, 0, 8))
|
||||
. (count($missing) > 8 ? ' 等 ' . count($missing) . ' 条' : ''),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $freeShipping->saveMany(
|
||||
$valuesBySupplierId,
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
'import',
|
||||
);
|
||||
|
||||
return $this->redirectToPage($request)->with(
|
||||
'supplier_management_message',
|
||||
"导入完成:新增 {$result['created']} 家供应商维护记录,更新 {$result['updated']} 家供应商维护记录。",
|
||||
);
|
||||
}
|
||||
|
||||
public function updateFreeShipping(
|
||||
Request $request,
|
||||
int $supplier,
|
||||
SupplierManagementMirrorRepository $repository,
|
||||
SupplierFreeShippingStore $freeShipping,
|
||||
): JsonResponse|RedirectResponse {
|
||||
$request->validate(['value' => ['nullable', 'string', 'in:,包邮']]);
|
||||
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能编辑。');
|
||||
abort_if($repository->supplierById($supplier) === null, 404, '供应商不存在。');
|
||||
|
||||
$isFreeShipping = $request->input('value') === '包邮';
|
||||
$freeShipping->save(
|
||||
$supplier,
|
||||
$isFreeShipping,
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
'manual',
|
||||
);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'value' => $isFreeShipping ? '包邮' : '',
|
||||
'display' => $isFreeShipping ? '包邮' : '-',
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->redirectToPage($request)->with('supplier_management_message', '是否包邮厂家已保存。');
|
||||
}
|
||||
|
||||
private function redirectToPage(Request $request): RedirectResponse
|
||||
{
|
||||
$query = array_filter([
|
||||
...app(SupplierManagementPageData::class)->filters($request->query()),
|
||||
'page' => max((int) $request->query('page', 1), 1),
|
||||
], static fn (mixed $value): bool => $value !== '');
|
||||
|
||||
$url = route('business.pages.show', [
|
||||
'company_id' => company_id() ?: 1,
|
||||
'section' => 'supplier-payables',
|
||||
'page' => 'supplier-management',
|
||||
]);
|
||||
|
||||
return redirect()->to($url . ($query === [] ? '' : '?' . http_build_query($query)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Services\SupplierNegativeAlertDingTalkWorkflow;
|
||||
use Modules\EcommerceFinance\View\SupplierNegativeAlertPageData;
|
||||
use Throwable;
|
||||
|
||||
class SupplierNegativeAlertDingTalkActions extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SupplierNegativeAlertPageData $pageData,
|
||||
private readonly SupplierNegativeAlertDingTalkWorkflow $workflow,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
}
|
||||
|
||||
public function saveRobot(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'dingtalk_webhook' => ['nullable', 'string', 'max:2048'],
|
||||
'dingtalk_secret' => ['nullable', 'string', 'max:256'],
|
||||
'dingtalk_group_name' => ['required', 'string', 'max:191'],
|
||||
]);
|
||||
|
||||
$this->workflow->saveConfiguration(
|
||||
(string) ($data['dingtalk_webhook'] ?? ''),
|
||||
(string) ($data['dingtalk_secret'] ?? ''),
|
||||
(string) $data['dingtalk_group_name'],
|
||||
);
|
||||
|
||||
return back()->with('success', '供应商负数预警群机器人已保存。');
|
||||
}
|
||||
|
||||
public function send(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'purchaser' => ['nullable', 'string', 'max:191'],
|
||||
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
|
||||
'supplier' => ['nullable', 'string', 'max:191'],
|
||||
'checker' => ['nullable', 'string', 'max:191'],
|
||||
'settlement_method' => ['nullable', 'string', 'max:191'],
|
||||
]);
|
||||
$purchaser = trim((string) ($data['purchaser'] ?? ''));
|
||||
$filters = [
|
||||
'month' => (string) ($data['month'] ?? ''),
|
||||
'supplier' => (string) ($data['supplier'] ?? ''),
|
||||
'checker' => (string) ($data['checker'] ?? ''),
|
||||
'settlement_method' => (string) ($data['settlement_method'] ?? ''),
|
||||
'purchaser' => $purchaser,
|
||||
];
|
||||
$filteredRows = $this->pageData->rowsForFilters([
|
||||
...$filters,
|
||||
'purchaser' => '',
|
||||
]);
|
||||
$rows = $purchaser === ''
|
||||
? $filteredRows
|
||||
: array_values(array_filter(
|
||||
$filteredRows,
|
||||
static fn (array $row): bool => trim((string) ($row['purchaser'] ?? '')) === $purchaser,
|
||||
));
|
||||
if ($rows === []) {
|
||||
return back()->withErrors([
|
||||
'dingtalk_send' => '当前筛选没有可发送给该采购的供应商。',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($purchaser === '') {
|
||||
$result = $this->workflow->sendGrouped(
|
||||
$rows,
|
||||
$filters,
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
} else {
|
||||
$result = $this->workflow->send(
|
||||
$rows,
|
||||
$purchaser,
|
||||
$filters,
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return back()->withErrors(['dingtalk_send' => '发送失败:' . $exception->getMessage()]);
|
||||
}
|
||||
|
||||
if ($purchaser === '') {
|
||||
$message = "已一键发送至{$result['group_name']}:{$result['purchaser_count']} 位采购,"
|
||||
. "{$result['supplier_count']} 家供应商、{$result['row_count']} 条预警。";
|
||||
if ($result['unassigned_supplier_count'] > 0) {
|
||||
$message .= " 另有 {$result['unassigned_supplier_count']} 家供应商未分配采购,未发送。";
|
||||
}
|
||||
|
||||
return back()->with('success', $message);
|
||||
}
|
||||
|
||||
return back()->with('success', "已发送至{$result['group_name']}:采购 {$purchaser},"
|
||||
. "{$result['supplier_count']} 家供应商、{$result['row_count']} 条预警。");
|
||||
}
|
||||
}
|
||||
@ -1,139 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Models\Common\Company;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
use Modules\EcommerceFinance\Services\SupplierNegativeAlertDingTalkWorkflow;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class SupplierNegativeAlertDingTalkForms extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SupplierNegativeAlertDingTalkWorkflow $workflow,
|
||||
) {
|
||||
}
|
||||
|
||||
public function show(string $token): View|Response
|
||||
{
|
||||
$assignment = $this->resolve($token);
|
||||
if ($assignment === null) {
|
||||
return $this->unavailable();
|
||||
}
|
||||
|
||||
$snapshot = json_decode((string) $assignment->snapshot, true);
|
||||
$snapshot = is_array($snapshot) ? array_values($snapshot) : [];
|
||||
$latestPeriod = $this->workflow->latestPeriodSummary($snapshot);
|
||||
|
||||
return view('business.pages.negative-alerts.dingtalk-task', [
|
||||
'assignment' => $assignment,
|
||||
'suppliers' => $snapshot,
|
||||
'latestPeriod' => $latestPeriod,
|
||||
'processingDeadline' => $this->workflow->processingDeadline((string) $latestPeriod['month']),
|
||||
'supplierResults' => $this->workflow->resultsForAssignment((int) $assignment->id),
|
||||
'supplierAttachments' => $this->workflow->attachmentsForAssignment((int) $assignment->id),
|
||||
'token' => $token,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, string $token): RedirectResponse|Response
|
||||
{
|
||||
$assignment = $this->resolve($token);
|
||||
if ($assignment === null) {
|
||||
return $this->unavailable();
|
||||
}
|
||||
$snapshot = json_decode((string) $assignment->snapshot, true);
|
||||
$supplierCount = is_array($snapshot) ? count($snapshot) : 0;
|
||||
|
||||
$data = $request->validate([
|
||||
'results' => ['required', 'array', 'size:' . $supplierCount],
|
||||
'results.*' => ['nullable', 'string', 'max:2000'],
|
||||
'screenshots' => ['nullable', 'array', 'size:' . $supplierCount],
|
||||
'screenshots.*' => ['nullable', 'array', 'max:5'],
|
||||
'screenshots.*.*' => ['file', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
|
||||
], [
|
||||
'results.required' => '请为每个供应商填写处理结果。',
|
||||
'results.size' => '请为每个供应商填写处理结果。',
|
||||
'results.*.max' => '单个供应商的处理结果不能超过 2000 个字符。',
|
||||
'screenshots.*.*.mimes' => '处理截图仅支持 JPG、PNG、WEBP 图片。',
|
||||
'screenshots.*.*.max' => '单张处理截图不能超过 10MB。',
|
||||
]);
|
||||
if (! collect((array) $data['results'])->contains(
|
||||
static fn (mixed $value): bool => trim((string) $value) !== '',
|
||||
)) {
|
||||
throw ValidationException::withMessages([
|
||||
'results' => '请至少填写一个供应商的处理结果。',
|
||||
]);
|
||||
}
|
||||
$uploads = [];
|
||||
foreach ((array) $request->file('screenshots', []) as $index => $files) {
|
||||
$uploads[(int) $index] = array_values(array_filter(
|
||||
(array) $files,
|
||||
static fn (mixed $file): bool => $file !== null,
|
||||
));
|
||||
}
|
||||
$existingAttachments = $this->workflow->attachmentsForAssignment((int) $assignment->id);
|
||||
foreach (is_array($snapshot) ? $snapshot : [] as $index => $supplier) {
|
||||
$supplierName = trim((string) ($supplier['supplier'] ?? ''));
|
||||
$existingCount = count($existingAttachments[$supplierName] ?? []);
|
||||
if ($existingCount + count($uploads[$index] ?? []) > 5) {
|
||||
throw ValidationException::withMessages([
|
||||
'screenshots' => $supplierName . ' 最多上传 5 张处理截图。',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->workflow->submit($token, array_values((array) $data['results']));
|
||||
$this->workflow->storeAttachments($assignment, is_array($snapshot) ? $snapshot : [], $uploads);
|
||||
} catch (ValidationException $exception) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('supplier-negative-alert.task', ['token' => $token])
|
||||
->with('negative_alert_success', '已保存本次填写的供应商处理结果,未填写的供应商会继续留在待处理任务中。');
|
||||
}
|
||||
|
||||
public function attachment(string $token, int $attachment): StreamedResponse|Response
|
||||
{
|
||||
$assignment = $this->resolve($token);
|
||||
if ($assignment === null) {
|
||||
return $this->unavailable();
|
||||
}
|
||||
$file = $this->workflow->attachmentForAssignment((int) $assignment->id, $attachment);
|
||||
abort_if($file === null, 404);
|
||||
|
||||
$disk = Storage::disk('uploads');
|
||||
abort_unless($disk->exists((string) $file->path), 404, '处理截图不存在。');
|
||||
|
||||
return $disk->response((string) $file->path, (string) $file->original_name, [
|
||||
'Cache-Control' => 'private, no-store',
|
||||
'Content-Type' => (string) $file->mime_type,
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolve(string $token): ?object
|
||||
{
|
||||
$assignment = $this->workflow->findByToken($token);
|
||||
if ($assignment === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Company::find($assignment->company_id)?->makeCurrent();
|
||||
|
||||
return $assignment;
|
||||
}
|
||||
|
||||
private function unavailable(): Response
|
||||
{
|
||||
return response()->view('business.pages.negative-alerts.dingtalk-task-unavailable', [], 404);
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class SupplierPortal extends Controller
|
||||
{
|
||||
public function home(): RedirectResponse
|
||||
{
|
||||
return redirect()->route('business.pages.show', [
|
||||
'company_id' => company_id(),
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'supplier-data',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -2,12 +2,10 @@
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Services\Auth\SupplierPortalAccess;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Services\SupplierComparisonCsvExporter;
|
||||
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
|
||||
@ -31,8 +29,6 @@ class SupplierReconciliation extends Controller
|
||||
private readonly SupplierReconciliationPageData $pageData,
|
||||
private readonly SupplierReturnCsvExporter $returnCsvExporter,
|
||||
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
|
||||
private readonly SupplierComparisonCsvExporter $comparisonCsvExporter,
|
||||
private readonly SupplierPortalAccess $supplierPortalAccess,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
@ -50,9 +46,13 @@ class SupplierReconciliation extends Controller
|
||||
{
|
||||
$request->validate([
|
||||
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'],
|
||||
'supplier' => ['required', 'string', 'max:191'],
|
||||
]);
|
||||
|
||||
$supplier = $this->effectiveSupplier($request);
|
||||
$supplier = trim((string) $request->input('supplier'));
|
||||
if ($supplier === '') {
|
||||
abort(422, '请选择厂家。');
|
||||
}
|
||||
|
||||
$records = $this->template->parse($request->file('import'), $supplier);
|
||||
$rows = $this->statements->save(
|
||||
@ -105,23 +105,6 @@ class SupplierReconciliation extends Controller
|
||||
return $this->returnCsvExporter->download($rows, $supplier);
|
||||
}
|
||||
|
||||
public function exportComparison(Request $request): StreamedResponse
|
||||
{
|
||||
$request->validate([
|
||||
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
|
||||
'reconciler' => ['nullable', 'string', 'max:191'],
|
||||
]);
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->query());
|
||||
|
||||
return $this->comparisonCsvExporter->download(
|
||||
$detail,
|
||||
$supplier,
|
||||
trim((string) $request->query('month', '')),
|
||||
trim((string) $request->query('reconciler', '')),
|
||||
);
|
||||
}
|
||||
|
||||
public function saveDingTalkRobot(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
@ -148,10 +131,9 @@ class SupplierReconciliation extends Controller
|
||||
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
|
||||
'reconciler' => ['nullable', 'string', 'max:191'],
|
||||
'scope' => ['required', 'in:all,row,selected'],
|
||||
'purchase_no' => ['nullable', 'string', 'max:191'],
|
||||
'date' => ['nullable', 'string', 'max:32'],
|
||||
'barcode' => ['nullable', 'string', 'max:191'],
|
||||
'difference_type' => ['nullable', 'string', 'max:32'],
|
||||
'unit_price' => ['nullable', 'numeric'],
|
||||
'selected_rows' => ['nullable', 'array', 'max:600'],
|
||||
'selected_rows.*' => ['string', 'max:2048'],
|
||||
]);
|
||||
@ -163,16 +145,14 @@ class SupplierReconciliation extends Controller
|
||||
));
|
||||
|
||||
if ($request->input('scope') === 'row') {
|
||||
$purchaseNo = trim((string) $request->input('purchase_no'));
|
||||
$date = trim((string) $request->input('date'));
|
||||
$barcode = trim((string) $request->input('barcode'));
|
||||
$type = trim((string) $request->input('difference_type'));
|
||||
$unitPrice = (float) $request->input('unit_price');
|
||||
$differences = array_values(array_filter(
|
||||
$differences,
|
||||
static fn (array $row): bool => (string) ($row['purchaseNo'] ?? '') === $purchaseNo
|
||||
&& (string) ($row['date'] ?? '') === $date
|
||||
&& (string) ($row['barcode'] ?? '') === $barcode
|
||||
static fn (array $row): bool => (string) ($row['barcode'] ?? '') === $barcode
|
||||
&& (string) ($row['type'] ?? '') === $type
|
||||
&& abs((float) ($row['unitPrice'] ?? 0) - $unitPrice) < 0.005,
|
||||
));
|
||||
} elseif ($request->input('scope') === 'selected') {
|
||||
$selected = [];
|
||||
@ -211,14 +191,9 @@ class SupplierReconciliation extends Controller
|
||||
{
|
||||
$request->validate(['key' => ['required', 'string', 'max:191']]);
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$key = (string) $request->input('key');
|
||||
$owned = collect($this->pageData->returnRows($supplier, $companyId))
|
||||
->contains(static fn (array $row): bool => (string) ($row['_action_key'] ?? '') === $key);
|
||||
abort_unless($owned, 403, '该退货明细不属于当前厂家账号。');
|
||||
|
||||
$this->returnConfirmations->mark(
|
||||
$supplier,
|
||||
$key,
|
||||
(string) $request->input('key'),
|
||||
$status,
|
||||
$companyId,
|
||||
user_id() ? (int) user_id() : null,
|
||||
@ -230,34 +205,18 @@ class SupplierReconciliation extends Controller
|
||||
/** @return array{0:string,1:int} */
|
||||
private function returnTarget(Request $request): array
|
||||
{
|
||||
return [$this->effectiveSupplier($request), (int) (company_id() ?: 1)];
|
||||
}
|
||||
|
||||
private function effectiveSupplier(Request $request): string
|
||||
{
|
||||
$user = $request->user();
|
||||
if ($user && SupplierPortalAccess::isSupplierUser($user)) {
|
||||
$supplier = $this->supplierPortalAccess->supplierFor($user);
|
||||
abort_if(! $supplier, 403, '当前钉钉账号未绑定有效供应商,请联系财务人员。');
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
$request->validate(['supplier' => ['required', 'string', 'max:191']]);
|
||||
$supplier = trim((string) $request->input('supplier'));
|
||||
abort_if($supplier === '', 422, '请选择厂家。');
|
||||
|
||||
return $supplier;
|
||||
return [trim((string) $request->input('supplier')), (int) (company_id() ?: 1)];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function differenceRowKey(array $row): string
|
||||
{
|
||||
return implode("\0", [
|
||||
trim((string) ($row['purchaseNo'] ?? $row['purchase_no'] ?? '')),
|
||||
trim((string) ($row['date'] ?? '')),
|
||||
trim((string) ($row['barcode'] ?? '')),
|
||||
trim((string) ($row['type'] ?? '')),
|
||||
number_format((float) ($row['unitPrice'] ?? 0), 4, '.', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -34,11 +34,6 @@ class WithdrawalManagement extends Controller
|
||||
return $this->spreadsheet->template();
|
||||
}
|
||||
|
||||
public function withdrawalTemplate(Request $request): StreamedResponse
|
||||
{
|
||||
return $this->spreadsheet->withdrawalTemplate($this->report->results($request));
|
||||
}
|
||||
|
||||
public function importPreview(Request $request): RedirectResponse
|
||||
{
|
||||
$file = $request->validate([
|
||||
@ -98,7 +93,6 @@ class WithdrawalManagement extends Controller
|
||||
'storeEntity' => 'store_entity',
|
||||
'keyword' => 'keyword',
|
||||
'balanceStatus' => 'balance_status',
|
||||
'balanceDate' => 'balance_date',
|
||||
];
|
||||
|
||||
foreach ($queryNames as $filterName => $queryName) {
|
||||
|
||||
@ -160,7 +160,6 @@ class Kernel extends HttpKernel
|
||||
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
||||
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
'business.page' => \App\Http\Middleware\EnsureBusinessPageAccess::class,
|
||||
'business.page.viewer' => \App\Http\Middleware\EnsureBusinessPageViewerAccess::class,
|
||||
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
|
||||
'can' => \Illuminate\Auth\Middleware\Authorize::class,
|
||||
'cookies.encrypt' => \App\Http\Middleware\EncryptCookies::class,
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Utilities\SidebarPermissions;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EnsureBusinessPageViewerAccess
|
||||
{
|
||||
public function handle(Request $request, Closure $next): mixed
|
||||
{
|
||||
$user = $request->user();
|
||||
$section = (string) $request->route('section');
|
||||
$page = (string) $request->route('page');
|
||||
|
||||
if ($page === '') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
abort_unless(
|
||||
$user && SidebarPermissions::canViewPage($user, $section, $page),
|
||||
403,
|
||||
'当前账号没有该页面权限。',
|
||||
);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Auth\SupplierPortalAccess;
|
||||
use App\Utilities\SidebarPermissions;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
@ -13,35 +12,7 @@ class EnsureLimitedDingTalkPageAccess
|
||||
{
|
||||
$user = $request->user();
|
||||
$route = $request->route();
|
||||
if (! $user || ! SidebarPermissions::hasLimitedDingTalkAccess($user)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (SupplierPortalAccess::isSupplierUser($user)) {
|
||||
if ($route?->named('logout', 'business.supplier-portal.home')) {
|
||||
return $next($request);
|
||||
}
|
||||
if ($route?->named('dashboard') && $route->hasParameter('company_id')) {
|
||||
return redirect()->route('business.supplier-portal.home', [
|
||||
'company_id' => $route->parameter('company_id'),
|
||||
]);
|
||||
}
|
||||
if ($route?->named('business.pages.show')
|
||||
&& $route->parameter('section') === 'supplier-reconciliation'
|
||||
&& $route->parameter('page') === 'supplier-data') {
|
||||
return $next($request);
|
||||
}
|
||||
foreach ($route?->gatherMiddleware() ?? [] as $middleware) {
|
||||
if (is_string($middleware) && str_starts_with($middleware, 'business.page:')
|
||||
&& str_replace(',', '/', substr($middleware, strlen('business.page:'))) === SupplierPortalAccess::PAGE_KEY) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
abort(403, '当前账号没有该页面权限。');
|
||||
}
|
||||
|
||||
if (! $route?->hasParameter('company_id')) {
|
||||
if (! $user || ! $route?->hasParameter('company_id') || ! SidebarPermissions::hasLimitedDingTalkAccess($user)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
|
||||
@ -3,9 +3,8 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Auth\DingTalkIdentity;
|
||||
use App\Services\Auth\DingTalkClosedAccess;
|
||||
use App\Services\Auth\FinanceRoster;
|
||||
use App\Services\Auth\SupplierPortalAccess;
|
||||
use App\Services\Auth\DingTalkClosedAccess;
|
||||
use Closure;
|
||||
use Throwable;
|
||||
|
||||
@ -16,11 +15,7 @@ class LogoutIfDingTalkBindingRevoked
|
||||
$identityId = $request->session()->get('dingtalk_identity_id');
|
||||
$user = $request->user();
|
||||
if (! $identityId) {
|
||||
if (! $user || ! in_array($user->created_from, [
|
||||
'dingtalk_roster',
|
||||
'dingtalk_roster_limited',
|
||||
SupplierPortalAccess::CREATED_FROM,
|
||||
], true)) {
|
||||
if (! $user || ! in_array($user->created_from, ['dingtalk_roster', 'dingtalk_roster_limited'], true)) {
|
||||
return $next($request);
|
||||
}
|
||||
// A password reset must not bypass HR eligibility for provisioned
|
||||
@ -37,9 +32,7 @@ class LogoutIfDingTalkBindingRevoked
|
||||
return $next($request);
|
||||
}
|
||||
try {
|
||||
$eligible = SupplierPortalAccess::isSupplierUser($user)
|
||||
? app(SupplierPortalAccess::class)->revalidate($user) !== null
|
||||
: app(FinanceRoster::class)->findEligible((string) $identity->dingtalk_user_id);
|
||||
$eligible = app(FinanceRoster::class)->findEligible((string) $identity->dingtalk_user_id);
|
||||
} catch (Throwable) {
|
||||
// Retain the session during an HR outage, but do not serve data
|
||||
// using an expired authorization decision.
|
||||
@ -53,11 +46,7 @@ class LogoutIfDingTalkBindingRevoked
|
||||
}
|
||||
|
||||
app(DingTalkClosedAccess::class)->audit('session_revoked', 'denied', $user?->id, null, $identity?->union_id, $request, [
|
||||
'reason' => ! $user?->enabled
|
||||
? 'local_account_ineligible'
|
||||
: (! $identity
|
||||
? 'binding_revoked'
|
||||
: (SupplierPortalAccess::isSupplierUser($user) ? 'outside_supplier_roster' : 'outside_finance_roster')),
|
||||
'reason' => ! $user?->enabled ? 'local_account_ineligible' : (! $identity ? 'binding_revoked' : 'outside_finance_roster'),
|
||||
]);
|
||||
auth()->logout();
|
||||
$request->session()->invalidate();
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Common;
|
||||
|
||||
use Illuminate\Console\Events\CommandStarting;
|
||||
use RuntimeException;
|
||||
|
||||
class RequireImportBatchContainer
|
||||
{
|
||||
public function handle(CommandStarting $event): void
|
||||
{
|
||||
if (! app()->environment('production') || config('finance_batch.container')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$command = (string) $event->command;
|
||||
$registered = in_array($command, config('finance_batch.import_commands', []), true);
|
||||
// New import commands must be registered before they can run in production.
|
||||
$isImport = preg_match('/(^|:)import(?:$|[-:])/', $command) === 1;
|
||||
|
||||
if ($registered || $isImport) {
|
||||
throw new RuntimeException(
|
||||
'生产重型导入必须在受限独立批处理容器中执行,请使用:sh /opt/caiwuxitong/run-finance-batch.sh '
|
||||
. $command . ' <原参数>。网页容器未执行本次导入。'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -35,7 +35,7 @@ class ShowInAdmin
|
||||
|
||||
public static function groupBasicData(array $navigation): array
|
||||
{
|
||||
$keys = ['order-data', 'order-profit', 'refunds'];
|
||||
$keys = ['order-data', 'refunds'];
|
||||
$children = array_values(array_filter($navigation, static fn (array $section): bool => in_array($section['slug'], $keys, true)));
|
||||
if ($children === []) {
|
||||
return $navigation;
|
||||
@ -62,7 +62,7 @@ class ShowInAdmin
|
||||
|
||||
public static function groupPayables(array $navigation): array
|
||||
{
|
||||
$keys = ['negative-alerts', 'supplier-payables-final', 'supplier-reconciliation', 'courier-payables-final', 'supplier-payables', 'courier-payables', 'raw-board-payables',
|
||||
$keys = ['supplier-payables-final', 'supplier-reconciliation', 'courier-payables-final', 'supplier-payables', 'courier-payables', 'raw-board-payables',
|
||||
'lamination-factory-payables', 'paper-materials', 'edge-banding-materials'];
|
||||
$children = array_values(array_filter($navigation, static fn (array $section): bool => in_array($section['slug'], $keys, true)));
|
||||
if ($children === []) {
|
||||
@ -113,13 +113,9 @@ class ShowInAdmin
|
||||
continue;
|
||||
}
|
||||
|
||||
$sectionPages = array_values(array_filter(
|
||||
$section['pages'] ?? [],
|
||||
static fn (array $page): bool => ($page['show_in_section_menu'] ?? true) !== false
|
||||
));
|
||||
$pages = in_array($section['slug'], ['order-data', 'negative-alerts'], true)
|
||||
? $sectionPages
|
||||
: $this->sortByTitleLength($sectionPages);
|
||||
$pages = $section['slug'] === 'order-data'
|
||||
? array_values($section['pages'] ?? [])
|
||||
: $this->sortByTitleLength($section['pages'] ?? []);
|
||||
$order = ($sectionIndex + 1) * 10;
|
||||
|
||||
if (empty($pages) || in_array($section['slug'], self::WORKSPACE_SECTION_SLUGS, true)) {
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Auth;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SupplierPortalIdentity extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'user_id',
|
||||
'supplier_code',
|
||||
'supplier_name',
|
||||
'dingtalk_group_id',
|
||||
'dingtalk_group_name',
|
||||
'last_verified_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'dingtalk_group_id' => 'integer',
|
||||
'last_verified_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@ -83,11 +83,6 @@ class User extends Authenticatable implements HasLocalePreference
|
||||
return $this->hasOne(DingTalkIdentity::class, 'user_id');
|
||||
}
|
||||
|
||||
public function supplierPortalIdentity()
|
||||
{
|
||||
return $this->hasOne(SupplierPortalIdentity::class, 'user_id');
|
||||
}
|
||||
|
||||
public function contact()
|
||||
{
|
||||
return $this->hasOne('App\Models\Common\Contact', 'user_id', 'id');
|
||||
|
||||
@ -14,7 +14,6 @@ class CourierQuoteVersion extends Model
|
||||
'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name',
|
||||
'carrier_name', 'courier_expcode', 'billing_rules', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json',
|
||||
'status', 'signature_status', 'created_by', 'approved_by', 'void_reason', 'voided_at', 'voided_by',
|
||||
'billing_structure', 'billing_rows',
|
||||
];
|
||||
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
@ -27,8 +26,6 @@ class CourierQuoteVersion extends Model
|
||||
'effective_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'voided_at' => 'datetime',
|
||||
'billing_structure' => 'array',
|
||||
'billing_rows' => 'array',
|
||||
];
|
||||
|
||||
/** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */
|
||||
@ -59,14 +56,6 @@ class CourierQuoteVersion extends Model
|
||||
: $this->effective_date->toDateString() . ' 起';
|
||||
}
|
||||
|
||||
/** 是否已有可用于运费核算的结构化计费规则。 */
|
||||
public function hasBillingRules(): bool
|
||||
{
|
||||
$mode = $this->billing_structure['mode'] ?? null;
|
||||
|
||||
return is_string($mode) && $mode !== 'other' && is_array($this->billing_rows) && $this->billing_rows !== [];
|
||||
}
|
||||
|
||||
public function isVoided(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_VOIDED;
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class LaborExpenseSnapshot extends Model
|
||||
{
|
||||
protected $table = 'business_labor_expense_snapshots';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'source_name',
|
||||
'rows',
|
||||
'row_count',
|
||||
'saved_by',
|
||||
'saved_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'company_id' => 'integer',
|
||||
'rows' => 'array',
|
||||
'row_count' => 'integer',
|
||||
'saved_by' => 'integer',
|
||||
'saved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SystemizationTodo extends Model
|
||||
{
|
||||
public const STATUS_INCOMPLETE = '未完成';
|
||||
|
||||
public const STATUS_COMPLETED = '已完成';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_INCOMPLETE,
|
||||
self::STATUS_COMPLETED,
|
||||
];
|
||||
|
||||
public const LEGACY_INCOMPLETE_STATUSES = ['待办', '进行中', '已延期'];
|
||||
|
||||
// Keep old constant names for imports and existing callers without rewriting data.
|
||||
public const STATUS_PENDING = self::STATUS_INCOMPLETE;
|
||||
|
||||
public const STATUS_IN_PROGRESS = self::STATUS_INCOMPLETE;
|
||||
|
||||
public const STATUS_DELAYED = self::STATUS_INCOMPLETE;
|
||||
|
||||
protected $table = 'business_systemization_todos';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'category',
|
||||
'section',
|
||||
'title',
|
||||
'owner',
|
||||
'assignee',
|
||||
'start_date',
|
||||
'start_note',
|
||||
'due_date',
|
||||
'due_note',
|
||||
'status',
|
||||
'completed_at',
|
||||
'progress',
|
||||
'notes',
|
||||
'sort_order',
|
||||
'source_key',
|
||||
'imported_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'company_id' => 'integer',
|
||||
'start_date' => 'date',
|
||||
'due_date' => 'date',
|
||||
'completed_at' => 'date',
|
||||
'sort_order' => 'integer',
|
||||
'imported_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(SystemizationTodoAttachment::class, 'systemization_todo_id')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
public function statusLabel(): string
|
||||
{
|
||||
return $this->isCompleted() ? self::STATUS_COMPLETED : self::STATUS_INCOMPLETE;
|
||||
}
|
||||
|
||||
public static function mergeNotesIntoProgress(?string $progress, ?string $notes): ?string
|
||||
{
|
||||
$progress = trim(str_replace(["\r\n", "\r"], "\n", (string) $progress));
|
||||
$notes = trim(str_replace(["\r\n", "\r"], "\n", (string) $notes));
|
||||
if ($notes === '') {
|
||||
return $progress !== '' ? $progress : null;
|
||||
}
|
||||
if ($progress === '' || str_contains($progress, $notes)) {
|
||||
return $progress !== '' ? $progress : $notes;
|
||||
}
|
||||
|
||||
return $progress . "\n" . $notes;
|
||||
}
|
||||
|
||||
public function displayProgress(): string
|
||||
{
|
||||
$progress = trim((string) self::mergeNotesIntoProgress($this->progress, $this->notes));
|
||||
if ($progress === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$normalized = (string) preg_replace('/[\s。.]+$/u', '', $progress);
|
||||
|
||||
return $this->isCompleted() && $normalized === self::STATUS_COMPLETED
|
||||
? ''
|
||||
: $progress;
|
||||
}
|
||||
|
||||
public function reminder(): array
|
||||
{
|
||||
if ($this->isCompleted()) {
|
||||
return ['label' => '—', 'tone' => 'muted'];
|
||||
}
|
||||
if ($this->due_date === null) {
|
||||
return ['label' => '时间待定', 'tone' => 'muted'];
|
||||
}
|
||||
|
||||
$today = today()->startOfDay();
|
||||
$dueDate = $this->due_date->copy()->startOfDay();
|
||||
if ($dueDate->lt($today)) {
|
||||
return [
|
||||
'label' => '逾期 ' . $dueDate->diffInDays($today) . ' 天',
|
||||
'tone' => 'danger',
|
||||
];
|
||||
}
|
||||
if ($dueDate->equalTo($today)) {
|
||||
return ['label' => '今天到期', 'tone' => 'warning'];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => '剩余 ' . $today->diffInDays($dueDate) . ' 天',
|
||||
'tone' => 'info',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SystemizationTodoAttachment extends Model
|
||||
{
|
||||
protected $table = 'business_systemization_todo_attachments';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'systemization_todo_id',
|
||||
'path',
|
||||
'original_name',
|
||||
'mime_type',
|
||||
'size',
|
||||
'uploaded_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'company_id' => 'integer',
|
||||
'systemization_todo_id' => 'integer',
|
||||
'size' => 'integer',
|
||||
];
|
||||
|
||||
public function todo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SystemizationTodo::class, 'systemization_todo_id');
|
||||
}
|
||||
}
|
||||
@ -46,7 +46,6 @@ class Event extends Provider
|
||||
],
|
||||
//'Illuminate\Console\Events\ScheduledTaskStarting' => [
|
||||
\Illuminate\Console\Events\CommandStarting::class => [
|
||||
\App\Listeners\Common\RequireImportBatchContainer::class,
|
||||
\App\Listeners\Common\SkipScheduleInReadOnlyMode::class,
|
||||
],
|
||||
\App\Events\Auth\LandingPageShowing::class => [
|
||||
|
||||
@ -28,14 +28,9 @@ class DingTalkClosedAccess
|
||||
}
|
||||
|
||||
$roster = app(FinanceRoster::class)->findEligible((string) ($profile['dingtalk_user_id'] ?? ''));
|
||||
$restricted = $roster !== null && ($roster['restricted'] ?? false) === true;
|
||||
$supplierRoster = $roster ? null : app(SupplierPortalAccess::class)->resolve(
|
||||
(string) ($profile['dingtalk_user_id'] ?? ''),
|
||||
(string) ($profile['union_id'] ?? ''),
|
||||
);
|
||||
if (! $roster && ! $supplierRoster) {
|
||||
if (! $roster) {
|
||||
$this->audit('login_denied', 'denied', null, null, $profile['union_id'] ?? null, $request, [
|
||||
'reason' => 'outside_authorized_roster',
|
||||
'reason' => 'outside_finance_roster',
|
||||
]);
|
||||
$request->attributes->set('dingtalk_denial', 'outside_roster');
|
||||
return null;
|
||||
@ -54,23 +49,6 @@ class DingTalkClosedAccess
|
||||
|
||||
return null;
|
||||
}
|
||||
if (SupplierPortalAccess::isSupplierUser($identity->user)) {
|
||||
$company = $this->enabledCompany($identity->user);
|
||||
if (! $supplierRoster || ! $company) {
|
||||
$this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [
|
||||
'reason' => 'outside_supplier_roster',
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
app(SupplierPortalAccess::class)->bind($identity->user, (int) $company->id, $supplierRoster);
|
||||
} elseif (! $roster) {
|
||||
$this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [
|
||||
'reason' => 'outside_finance_roster',
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$identity->forceFill([
|
||||
'open_id' => $profile['open_id'] ?: $identity->open_id,
|
||||
@ -78,9 +56,6 @@ class DingTalkClosedAccess
|
||||
'nickname' => $profile['nickname'] ?: $identity->nickname,
|
||||
])->save();
|
||||
|
||||
// 按当前部门刷新访问范围:财务中心=全部,其它在职员工=风险管控两个页面。
|
||||
$this->applyAccessScope($identity->user, $restricted);
|
||||
|
||||
return $identity;
|
||||
}
|
||||
|
||||
@ -94,7 +69,7 @@ class DingTalkClosedAccess
|
||||
return null;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($profile, $roster, $restricted, $supplierRoster, $request): ?DingTalkIdentity {
|
||||
return DB::transaction(function () use ($profile, $roster, $request): ?DingTalkIdentity {
|
||||
// Serialize first-time provisioning on the target company. Both the
|
||||
// account and unique identity are committed together, including retries.
|
||||
$companies = Company::enabled()
|
||||
@ -117,11 +92,10 @@ class DingTalkClosedAccess
|
||||
return null;
|
||||
}
|
||||
|
||||
$supplierPortal = $supplierRoster !== null;
|
||||
$roleName = ! $supplierPortal && FinanceRoster::isDesignatedSuperAdmin((string) $profile['dingtalk_user_id'])
|
||||
$roleName = FinanceRoster::isDesignatedSuperAdmin((string) $profile['dingtalk_user_id'])
|
||||
? SystemAccess::SUPER_ADMIN : SystemAccess::USER;
|
||||
$role = Role::where('name', $roleName)->firstOrFail();
|
||||
$email = $supplierPortal ? null : $this->normalizeEmail($profile['email'] ?? null);
|
||||
$email = $this->normalizeEmail($profile['email'] ?? null);
|
||||
if (! $email || User::withTrashed()->where('email', $email)->exists()) {
|
||||
$email = 'ding-'.hash('sha256', $profile['union_id']).'@dingtalk.invalid';
|
||||
}
|
||||
@ -130,27 +104,21 @@ class DingTalkClosedAccess
|
||||
return null;
|
||||
}
|
||||
$account = User::create([
|
||||
'name' => Str::limit($profile['nickname'] ?: ($roster['display_name'] ?? '供应商人员'), 191, ''),
|
||||
'name' => Str::limit($profile['nickname'] ?: $roster['display_name'], 191, ''),
|
||||
'email' => $email,
|
||||
'password' => Str::random(64),
|
||||
'locale' => 'zh-CN',
|
||||
'enabled' => true,
|
||||
'landing_page' => $supplierPortal ? 'business.supplier-portal.home' : 'dashboard',
|
||||
'landing_page' => 'dashboard',
|
||||
'created_by' => 0,
|
||||
'created_from' => $supplierPortal
|
||||
? SupplierPortalAccess::CREATED_FROM
|
||||
: ($restricted
|
||||
? 'dingtalk_restricted'
|
||||
: (FinanceRoster::isLimitedLoginUser((string) $profile['dingtalk_user_id'])
|
||||
? 'dingtalk_roster_limited' : 'dingtalk_roster')),
|
||||
'created_from' => FinanceRoster::isLimitedLoginUser((string) $profile['dingtalk_user_id'])
|
||||
? 'dingtalk_roster_limited' : 'dingtalk_roster',
|
||||
]);
|
||||
$account->roles()->attach($role->id);
|
||||
// Explicit designation controls the role; ordinary roster users get shell access.
|
||||
$account->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id);
|
||||
$account->companies()->attach($companies->first()->id);
|
||||
if ($supplierPortal) {
|
||||
app(SupplierPortalAccess::class)->bind($account, (int) $companies->first()->id, $supplierRoster);
|
||||
} elseif ($mobile = $this->normalizeMobile($profile['mobile'] ?? null)) {
|
||||
if ($mobile = $this->normalizeMobile($profile['mobile'] ?? null)) {
|
||||
Contact::create([
|
||||
'company_id' => $companies->first()->id, 'type' => Contact::EMPLOYEE_TYPE,
|
||||
'user_id' => $account->id, 'name' => $account->name, 'email' => $email,
|
||||
@ -163,35 +131,12 @@ class DingTalkClosedAccess
|
||||
'open_id' => $profile['open_id'], 'dingtalk_user_id' => $profile['dingtalk_user_id'],
|
||||
'nickname' => $profile['nickname'], 'bound_at' => now(),
|
||||
]);
|
||||
$this->upsertRequest(
|
||||
$profile,
|
||||
DingTalkBindingRequest::APPROVED,
|
||||
$supplierPortal ? 'supplier_roster_provisioned' : 'finance_roster_provisioned',
|
||||
$account->id,
|
||||
);
|
||||
$this->upsertRequest($profile, DingTalkBindingRequest::APPROVED, 'finance_roster_provisioned', $account->id);
|
||||
$this->audit('account_auto_created', 'success', $account->id, null, $profile['union_id'], $request);
|
||||
return $identity->load('user');
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动开通的钉钉账号按当前部门刷新访问范围;手工账号不受影响。
|
||||
*/
|
||||
private function applyAccessScope(User $user, bool $restricted): void
|
||||
{
|
||||
$createdFrom = (string) $user->created_from;
|
||||
|
||||
if (! in_array($createdFrom, ['dingtalk_roster', 'dingtalk_restricted'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$target = $restricted ? 'dingtalk_restricted' : 'dingtalk_roster';
|
||||
|
||||
if ($createdFrom !== $target) {
|
||||
$user->forceFill(['created_from' => $target])->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function recordLogin(DingTalkIdentity $identity, Request $request): void
|
||||
{
|
||||
$this->audit('login_success', 'success', $identity->user_id, null, $identity->union_id, $request);
|
||||
@ -220,12 +165,9 @@ class DingTalkClosedAccess
|
||||
|
||||
private function isEligible(?User $user): bool
|
||||
{
|
||||
return $user && $user->enabled && $this->enabledCompany($user) !== null;
|
||||
}
|
||||
|
||||
private function enabledCompany(User $user): ?Company
|
||||
{
|
||||
return $user->withoutEvents(fn () => $user->companies()->enabled()->first());
|
||||
return $user
|
||||
&& $user->enabled
|
||||
&& $user->withoutEvents(fn () => $user->companies()->enabled()->exists());
|
||||
}
|
||||
|
||||
private function upsertRequest(array $profile, string $status, string $reason, ?int $matchedUserId): void
|
||||
|
||||
@ -87,17 +87,16 @@ class FinanceRoster
|
||||
return null;
|
||||
}
|
||||
$allowed = array_map('strval', config('dingtalk.finance_department_ids', []));
|
||||
// 财务中心(或指定超级管理员/受限登录名单)拥有完整权限;
|
||||
// 其它在职员工返回 restricted,只能访问风险管控下的两个页面。
|
||||
$inFinance = self::isDesignatedSuperAdmin($userId)
|
||||
|| self::isLimitedLoginUser($userId)
|
||||
|| (bool) array_intersect($allowed, array_map('strval', array_column($ancestors, 'Id')));
|
||||
if (! self::isDesignatedSuperAdmin($userId)
|
||||
&& ! self::isLimitedLoginUser($userId)
|
||||
&& ! array_intersect($allowed, array_map('strval', array_column($ancestors, 'Id')))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'roster_id' => (string) $flower['Id'],
|
||||
'display_name' => trim((string) ($flower['flowerName'] ?: $flower['name'])),
|
||||
'department_ids' => $departmentIds,
|
||||
'restricted' => ! $inFinance,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -1,194 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\Auth\DingTalkIdentity;
|
||||
use App\Models\Auth\SupplierPortalIdentity;
|
||||
use App\Models\Auth\User;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||||
|
||||
/**
|
||||
* 钉钉企业组织中的供应商成员映射。
|
||||
*
|
||||
* 只有当前有效的钉钉企业员工、且其部门祖先精确对应唯一供应商主档名称时,
|
||||
* 才建立供应商门户身份。财务花名册不是供应商人员的准入前提。
|
||||
*/
|
||||
class SupplierPortalAccess
|
||||
{
|
||||
public const CREATED_FROM = 'dingtalk_supplier_portal';
|
||||
|
||||
public const PAGE_KEY = 'supplier-reconciliation/supplier-data';
|
||||
|
||||
public function __construct(
|
||||
private readonly UpstreamReadOnlyQueryFactory $queries,
|
||||
private readonly SupplierManagementMirrorRepository $suppliers,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function isSupplierUser(User $user): bool
|
||||
{
|
||||
return $user->created_from === self::CREATED_FROM;
|
||||
}
|
||||
|
||||
/** @return array{supplier_name:string,supplier_code:string,dingtalk_group_id:int,dingtalk_group_name:string}|null */
|
||||
public function resolve(string $userId, string $unionId = ''): ?array
|
||||
{
|
||||
$userId = trim($userId);
|
||||
$unionId = trim($unionId);
|
||||
if ($userId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = $this->queries->forSource('operations_replica');
|
||||
$employees = $query->select(
|
||||
'SELECT TOP (2) userid, unionid, name, active, dept_id_list
|
||||
FROM rpt.hr_employee WHERE userid = ?',
|
||||
[$userId],
|
||||
);
|
||||
if (count($employees) !== 1 || ! (bool) ($employees[0]['active'] ?? false)) {
|
||||
return null;
|
||||
}
|
||||
$employee = $employees[0];
|
||||
$employeeUnionId = trim((string) ($employee['unionid'] ?? ''));
|
||||
if ($employeeUnionId === '' || ($unionId !== '' && ! hash_equals($employeeUnionId, $unionId))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$departmentIds = $this->departmentIds($employee['dept_id_list'] ?? null);
|
||||
if ($departmentIds === [] || count($departmentIds) > 64) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
|
||||
$ancestors = $query->select(
|
||||
'WITH lineage AS (
|
||||
SELECT Id, parent_id, name, 0 AS depth FROM rpt.hr_groups WHERE Id IN ('.$placeholders.')
|
||||
UNION ALL
|
||||
SELECT parent.Id, parent.parent_id, parent.name, child.depth + 1
|
||||
FROM rpt.hr_groups parent JOIN lineage child ON parent.Id = child.parent_id
|
||||
WHERE child.depth < 20
|
||||
) SELECT TOP (256) Id, parent_id, name, depth FROM lineage',
|
||||
$departmentIds,
|
||||
);
|
||||
if ($ancestors === [] || count($ancestors) >= 256
|
||||
|| array_filter($ancestors, static fn (array $row): bool => (int) ($row['depth'] ?? 0) >= 20)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$groupsByName = [];
|
||||
foreach ($ancestors as $ancestor) {
|
||||
$name = trim((string) ($ancestor['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$groupsByName[$name][] = $ancestor;
|
||||
}
|
||||
}
|
||||
if ($groupsByName === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$supplierRows = $this->suppliers->suppliersByNames(array_keys($groupsByName));
|
||||
$matchedSupplierNames = [];
|
||||
foreach ($supplierRows as $supplier) {
|
||||
$name = trim((string) ($supplier->scmname ?? ''));
|
||||
if ($name !== '' && isset($groupsByName[$name])) {
|
||||
$matchedSupplierNames[$name] = true;
|
||||
}
|
||||
}
|
||||
if (count($matchedSupplierNames) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$supplierName = (string) array_key_first($matchedSupplierNames);
|
||||
$matchingRows = array_values(array_filter(
|
||||
$supplierRows,
|
||||
static fn (object $supplier): bool => trim((string) ($supplier->scmname ?? '')) === $supplierName,
|
||||
));
|
||||
if (count($matchingRows) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($groupsByName[$supplierName], static fn (array $left, array $right): int =>
|
||||
(int) ($right['depth'] ?? 0) <=> (int) ($left['depth'] ?? 0));
|
||||
$group = $groupsByName[$supplierName][0];
|
||||
|
||||
return [
|
||||
'supplier_name' => $supplierName,
|
||||
'supplier_code' => trim((string) ($matchingRows[0]->scmcode ?? '')),
|
||||
'dingtalk_group_id' => (int) ($group['Id'] ?? 0),
|
||||
'dingtalk_group_name' => $supplierName,
|
||||
];
|
||||
}
|
||||
|
||||
public function bind(User $user, int $companyId, array $resolved): SupplierPortalIdentity
|
||||
{
|
||||
return SupplierPortalIdentity::query()->updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'company_id' => $companyId,
|
||||
'supplier_code' => $resolved['supplier_code'] ?? null,
|
||||
'supplier_name' => $resolved['supplier_name'],
|
||||
'dingtalk_group_id' => (int) $resolved['dingtalk_group_id'],
|
||||
'dingtalk_group_name' => $resolved['dingtalk_group_name'],
|
||||
'last_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function mappingFor(User $user): ?SupplierPortalIdentity
|
||||
{
|
||||
if (! self::isSupplierUser($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SupplierPortalIdentity::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('company_id', company_id())
|
||||
->first();
|
||||
}
|
||||
|
||||
public function supplierFor(User $user): ?string
|
||||
{
|
||||
$mapping = $this->mappingFor($user);
|
||||
$name = trim((string) ($mapping?->supplier_name ?? ''));
|
||||
|
||||
return $name !== '' ? $name : null;
|
||||
}
|
||||
|
||||
public function revalidate(User $user): ?SupplierPortalIdentity
|
||||
{
|
||||
if (! self::isSupplierUser($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dingTalk = DingTalkIdentity::query()->where('user_id', $user->id)->first();
|
||||
$company = $user->withoutEvents(fn () => $user->companies()->enabled()->first());
|
||||
if (! $dingTalk || ! $company) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resolved = $this->resolve(
|
||||
(string) $dingTalk->dingtalk_user_id,
|
||||
(string) $dingTalk->union_id,
|
||||
);
|
||||
|
||||
return $resolved ? $this->bind($user, (int) $company->id, $resolved) : null;
|
||||
}
|
||||
|
||||
private function departmentIds(mixed $value): array
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return [];
|
||||
}
|
||||
$parts = json_decode($value, true);
|
||||
if (! is_array($parts)) {
|
||||
$parts = preg_split('/[\[\]\/>,,;;||\s]+/u', $value, -1, PREG_SPLIT_NO_EMPTY);
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $id): string => is_scalar($id) ? trim((string) $id) : '',
|
||||
is_array($parts) ? $parts : [],
|
||||
), static fn (string $id): bool => ctype_digit($id) && (int) $id > 0));
|
||||
}
|
||||
}
|
||||
@ -12,18 +12,6 @@ final class EsignClient
|
||||
|
||||
private readonly EsignRequestSigner $signer;
|
||||
|
||||
/**
|
||||
* 骑缝章中心的纵向坐标(PDF 坐标,原点在左下角,A4 高 841.89)。
|
||||
* 按企业签署方顺序依次取用:第一家偏上、第二家居中偏下、第三家靠下。
|
||||
* 相邻两枚间隔 200,大于骑缝章高度 120,叠页后各自完整、互不重叠。
|
||||
*/
|
||||
private const ACROSS_PAGE_SEAL_Y = [620.0, 420.0, 220.0];
|
||||
|
||||
private const ACROSS_PAGE_SEAL_SIZE = '120';
|
||||
|
||||
/** @var array<string, int> 本实例上传过的文件 ID => 页数(0 表示未能识别)。 */
|
||||
private array $pageCounts = [];
|
||||
|
||||
public function __construct(?EsignConfiguration $configuration = null)
|
||||
{
|
||||
$configuration ??= EsignConfiguration::fromConfig();
|
||||
@ -89,7 +77,6 @@ final class EsignClient
|
||||
}
|
||||
|
||||
$this->waitUntilFileReady($fileId);
|
||||
$this->pageCounts[$fileId] = self::pdfPageCount($bytes);
|
||||
|
||||
return $fileId;
|
||||
}
|
||||
@ -132,7 +119,6 @@ final class EsignClient
|
||||
],
|
||||
]],
|
||||
]];
|
||||
$signerPayload = $this->withAcrossPageSeals($fileId, $signerPayload);
|
||||
|
||||
$docs = [[
|
||||
'fileId' => $fileId,
|
||||
@ -174,49 +160,6 @@ final class EsignClient
|
||||
return $signFlowId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给主文件上的每个企业签署方各加一个固定位置的骑缝签署区,并按顺序错开纵向位置。
|
||||
*
|
||||
* 原来骑缝章由签署人在自由签模式下自己添加,E 签宝把各方的骑缝章放在同一默认高度,
|
||||
* 双方的章会叠在一起,看起来像盖了两遍。现在由系统预先放好,每方一个高度。
|
||||
* 个人签署方不加骑缝;主文件少于 2 页或页数识别不出时也不加。
|
||||
*/
|
||||
private function withAcrossPageSeals(string $fileId, array $signerPayload): array
|
||||
{
|
||||
if (($this->pageCounts[$fileId] ?? 0) < 2) {
|
||||
return $signerPayload;
|
||||
}
|
||||
|
||||
$orgOrder = 0;
|
||||
foreach ($signerPayload as $index => $payload) {
|
||||
if (($payload['signerType'] ?? null) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$positionY = self::ACROSS_PAGE_SEAL_Y[$orgOrder++] ?? null;
|
||||
if ($positionY === null) {
|
||||
break;
|
||||
}
|
||||
$signerPayload[$index]['signFields'][] = [
|
||||
'fileId' => $fileId,
|
||||
'normalSignFieldConfig' => [
|
||||
'freeMode' => false,
|
||||
'movableSignField' => false,
|
||||
'signFieldStyle' => 2,
|
||||
'signFieldSize' => self::ACROSS_PAGE_SEAL_SIZE,
|
||||
'signFieldPosition' => ['acrossPageMode' => 'ALL', 'positionY' => $positionY],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $signerPayload;
|
||||
}
|
||||
|
||||
/** 统计 PDF 页数:数 /Type /Page 对象(不含 /Pages)。系统生成的 dompdf 文件可直接识别;识别不出返回 0。 */
|
||||
private static function pdfPageCount(string $bytes): int
|
||||
{
|
||||
return (int) preg_match_all('#/Type\s*/Page(?![a-zA-Z])#', $bytes);
|
||||
}
|
||||
|
||||
private function signerPayload(string $fileId, array $signer, int $index): array
|
||||
{
|
||||
$type = (int) ($signer['signer_type'] ?? 1);
|
||||
|
||||
@ -95,12 +95,6 @@ final class CourierQuoteChangeService
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($change, $userId): CourierQuoteChange {
|
||||
if ($change->isStockQuote() && $change->status === CourierQuoteChange::STATUS_RETURNED) {
|
||||
// 退回期间可能已重新录入,恢复待审核前重新取得日期占用。
|
||||
\App\Models\Common\Company::whereKey(company_id())->lockForUpdate()->firstOrFail();
|
||||
$entry = CourierStockQuoteEntry::findOrFail(data_get($change->quote_payload, 'stock_quote_entry_id'));
|
||||
app(CourierStockQuoteEntryService::class)->assertPeriod(['code' => $entry->courier_expcode, 'name' => $entry->courier_name], $entry->quote_type, $change->effective_date->toDateString(), $change->end_date?->toDateString(), $entry->id);
|
||||
}
|
||||
$from = $change->status;
|
||||
$retrospective = ! $change->isStockQuote() && $this->isRetrospectiveDate($change->effective_date);
|
||||
$change->update([
|
||||
|
||||
@ -1,157 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* 按「快递编码 + 发货日期」找到当天生效的报价版本,用结构化计费规则算一票的运费。
|
||||
*
|
||||
* 计费重量 = max(实重, 体积重, 起算重量) 再按取整方式处理;超过限重不计算。
|
||||
*
|
||||
* 生效版本的判断与报价管理页一致(CourierRequoteService::resolveAt:未作废、已审核或已签署归档、
|
||||
* 执行期间覆盖发货日期,短期变价优先)。没有结构化规则的版本(按体积、按条码等特殊计费)返回原因,交人工核算。
|
||||
*/
|
||||
class CourierQuoteFreightCalculator
|
||||
{
|
||||
/** @var array<string,Collection<int,CourierQuoteVersion>> */
|
||||
private array $versions = [];
|
||||
|
||||
/**
|
||||
* @param string $courierExpcode 快递编码;没有编码的存量报价可传快递名称(如「沐野竹木顺丰」)
|
||||
* @return array{
|
||||
* version_code:?string,company_name:?string,region:?string,gross_weight:float,volume_weight:?float,
|
||||
* billable_weight:?float,base_amount:?float,surcharge:float,amount:?float,formula:?string,reason:?string
|
||||
* }
|
||||
*/
|
||||
public function calculate(
|
||||
string $courierExpcode,
|
||||
DateTimeInterface $shipDate,
|
||||
string $province,
|
||||
float $grossWeight,
|
||||
?float $length = null,
|
||||
?float $width = null,
|
||||
?float $height = null,
|
||||
?string $city = null,
|
||||
?string $quoteType = null,
|
||||
): array {
|
||||
$result = [
|
||||
'version_code' => null, 'company_name' => null, 'region' => null, 'gross_weight' => $grossWeight,
|
||||
'volume_weight' => null, 'billable_weight' => null, 'base_amount' => null, 'surcharge' => 0.0,
|
||||
'amount' => null, 'formula' => null, 'reason' => null,
|
||||
];
|
||||
if (! is_finite($grossWeight) || $grossWeight < 0
|
||||
|| collect([$length, $width, $height])->contains(static fn ($v) => $v !== null && (! is_finite($v) || $v < 0))) {
|
||||
return ['reason' => '重量和尺寸必须是有效非负数字'] + $result;
|
||||
}
|
||||
|
||||
$version = $this->versionAt(trim($courierExpcode), $shipDate, $quoteType);
|
||||
if ($version === null) {
|
||||
return ['reason' => '发货日期没有生效的报价'] + $result;
|
||||
}
|
||||
$result['version_code'] = $version->version_code;
|
||||
$result['company_name'] = $version->company_name;
|
||||
if (! $version->hasBillingRules()) {
|
||||
return ['reason' => '该报价为特殊计费或尚未设置计费规则,需人工核算'] + $result;
|
||||
}
|
||||
|
||||
$structure = (array) $version->billing_structure;
|
||||
$row = $this->matchRow((array) $version->billing_rows, $province, $city);
|
||||
if ($row === null) {
|
||||
return ['reason' => '报价未覆盖目的地「' . trim($province . ' ' . $city) . '」或该地区不发'] + $result;
|
||||
}
|
||||
$result['region'] = (string) $row['region'];
|
||||
|
||||
$divisor = (float) ($structure['volumetric_divisor'] ?? 0);
|
||||
$volume = $divisor > 0 && $length > 0 && $width > 0 && $height > 0 ? round($length * $width * $height / $divisor, 4) : null;
|
||||
$settlement = max($grossWeight, (float) $volume);
|
||||
$result['volume_weight'] = $volume;
|
||||
if ($settlement <= 0) {
|
||||
return ['reason' => '缺少重量,无法计算运费'] + $result;
|
||||
}
|
||||
|
||||
$limit = (float) ($structure['weight_limit'] ?? 0);
|
||||
if ($limit > 0 && $settlement > $limit + 1e-9) {
|
||||
return ['reason' => '超出限重 ' . CourierQuoteStructure::kg($limit) . 'kg'] + $result;
|
||||
}
|
||||
|
||||
// 起算重量:不足按起算重量计(如菏泽顺帆顺丰不足 5kg 按 5kg)
|
||||
$minimum = (float) ($structure['min_weight'] ?? 0);
|
||||
$billable = CourierQuoteStructure::roundWeight(max($settlement, $minimum), (string) ($structure['rounding'] ?? 'none'));
|
||||
$result['billable_weight'] = $billable;
|
||||
$freight = CourierQuoteStructure::freight($structure, $row, $billable);
|
||||
if ($freight['amount'] === null) {
|
||||
return ['reason' => $freight['reason']] + $result;
|
||||
}
|
||||
|
||||
$surcharge = (float) ($structure['per_ticket_surcharge'] ?? 0);
|
||||
$result['base_amount'] = round($freight['amount'], 2);
|
||||
$result['surcharge'] = $surcharge;
|
||||
$result['amount'] = round($freight['amount'] + $surcharge, 2);
|
||||
$result['formula'] = $freight['formula'] . ($surcharge > 0 ? '+每票' . CourierQuoteStructure::kg($surcharge) : '');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** 省份名统一:去掉省/市/自治区后缀,黑龙→黑龙江、内蒙→内蒙古。 */
|
||||
public static function regionKey(string $name): string
|
||||
{
|
||||
$text = (string) preg_replace('/\s+/u', '', $name);
|
||||
$text = (string) preg_replace('/(维吾尔自治区|壮族自治区|回族自治区|自治区|特别行政区|省|市)$/u', '', $text);
|
||||
|
||||
return match ($text) {
|
||||
'黑龙' => '黑龙江',
|
||||
'内蒙' => '内蒙古',
|
||||
default => $text,
|
||||
};
|
||||
}
|
||||
|
||||
private function versionAt(string $courierExpcode, DateTimeInterface $date, ?string $quoteType): ?CourierQuoteVersion
|
||||
{
|
||||
if ($courierExpcode === '') {
|
||||
return null;
|
||||
}
|
||||
// 先按快递编码;部分存量版本没有编码,按快递名称(与报价管理页的分组口径一致)
|
||||
$cacheKey = company_id() . ':' . $courierExpcode;
|
||||
$versions = $this->versions[$cacheKey] ??= CourierQuoteVersion::query()
|
||||
->with(['signature', 'change'])
|
||||
->where(static fn ($query) => $query->where('courier_expcode', $courierExpcode)
|
||||
->orWhere(static fn ($q) => $q->where(static fn ($e) => $e->whereNull('courier_expcode')->orWhere('courier_expcode', ''))
|
||||
->where('company_name', $courierExpcode)))
|
||||
->get();
|
||||
|
||||
return CourierRequoteService::resolveAt(
|
||||
$quoteType === null ? $versions : $versions->where('quote_type', $quoteType),
|
||||
$date,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 先按城市匹配单列的城市行(如「深圳(广东)」),再按省份匹配。
|
||||
*
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function matchRow(array $rows, string $province, ?string $city): ?array
|
||||
{
|
||||
$cityKey = self::regionKey((string) $city);
|
||||
if ($cityKey !== '') {
|
||||
foreach ($rows as $row) {
|
||||
if (is_array($row) && preg_match('/^(.+?)[((](.+?)[))]$/u', trim((string) ($row['region'] ?? '')), $parts)
|
||||
&& self::regionKey($parts[1]) === $cityKey && self::regionKey($parts[2]) === self::regionKey($province)) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
$key = self::regionKey($province);
|
||||
foreach ($rows as $row) {
|
||||
if (is_array($row) && self::regionKey((string) ($row['region'] ?? '')) === $key) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -40,13 +40,6 @@ final class CourierQuoteStructure
|
||||
'round' => '四舍五入(如 4.4kg 按 4kg,4.5kg 按 5kg)',
|
||||
];
|
||||
|
||||
/**
|
||||
* 分段计费:不放进 MODES(入驻表单不提供),只用于存量报价导入。
|
||||
* 每个省份行为 {region, segments:[{lo, hi, type: flat|waybill|first_add, price|base, rate, fw}]},
|
||||
* 重量落在 (lo, hi](第一段含 0)就按该段计算;hi 为 null 表示不封顶。
|
||||
*/
|
||||
public const SEGMENTS = 'segments';
|
||||
|
||||
public const MAX_BANDS = 12;
|
||||
|
||||
public const MAX_TIERS = 6;
|
||||
@ -279,120 +272,10 @@ final class CourierQuoteStructure
|
||||
? "首重{$fw}kg价+(重量-{$fw})*续重"
|
||||
: '面单+重量*费率') . $tierNote,
|
||||
'other' => '特殊计费,见附件报价表',
|
||||
self::SEGMENTS => '分段计费:每个重量段单独设一口价、面单+费率或首重+续重(可表示累进计费)',
|
||||
default => '面单+重量*费率' . $tierNote,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按结构化报价计算一票的基础运费(不含计泡、限重、附加费,这些由调用方先处理)。
|
||||
*
|
||||
* 与 describe() 口径一致:费率分段按「总重量所在区间」取费率,区间上限含本数;
|
||||
* 一口价/费率为空表示该重量段不发。$weight 应为已取整的计费重量。
|
||||
*
|
||||
* @param array<string,mixed> $structure
|
||||
* @param array<string,mixed> $row
|
||||
* @return array{amount:?float,formula:?string,reason:?string}
|
||||
*/
|
||||
public static function freight(array $structure, array $row, float $weight): array
|
||||
{
|
||||
$mode = (string) ($structure['mode'] ?? 'waybill_rate');
|
||||
$none = static fn (string $reason): array => ['amount' => null, 'formula' => null, 'reason' => $reason];
|
||||
$ok = static fn (float $amount, string $formula): array => ['amount' => round($amount, 4), 'formula' => $formula, 'reason' => null];
|
||||
if (! is_finite($weight) || $weight <= 0) {
|
||||
return $none('缺少有效重量,无法计算运费');
|
||||
}
|
||||
$w = self::kg($weight);
|
||||
$within = static fn (float $value, float $edge): bool => $value <= $edge + 1e-9;
|
||||
|
||||
if ($mode === self::SEGMENTS) {
|
||||
foreach ((array) ($row['segments'] ?? []) as $segment) {
|
||||
$lo = (float) ($segment['lo'] ?? 0);
|
||||
$hi = isset($segment['hi']) ? (float) $segment['hi'] : INF;
|
||||
if (! (($weight > $lo + 1e-9 || $lo <= 0.0) && $within($weight, $hi))) {
|
||||
continue;
|
||||
}
|
||||
$type = (string) ($segment['type'] ?? '');
|
||||
if (! in_array($type, ['flat', 'waybill', 'first_add'], true)) {
|
||||
return $none('不支持的分段计费方式,需人工核算');
|
||||
}
|
||||
if ($type === 'flat') {
|
||||
return isset($segment['price']) ? $ok((float) $segment['price'], '一口价' . self::kg((float) $segment['price'])) : $none('该重量段不发');
|
||||
}
|
||||
if (! isset($segment['base'], $segment['rate'])) {
|
||||
return $none('该重量段不发');
|
||||
}
|
||||
$base = (float) $segment['base'];
|
||||
$rate = (float) $segment['rate'];
|
||||
if ($type === 'first_add') {
|
||||
$fw = (float) ($segment['fw'] ?? 0);
|
||||
|
||||
return $within($weight, $fw)
|
||||
? $ok($base, '首重' . self::kg($base))
|
||||
: $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate));
|
||||
}
|
||||
|
||||
return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate));
|
||||
}
|
||||
|
||||
return $none('超出报价重量范围');
|
||||
}
|
||||
|
||||
if (! in_array($mode, ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then'], true)) {
|
||||
return $none('特殊计费,需人工核算');
|
||||
}
|
||||
|
||||
$bands = array_map('floatval', array_values((array) ($structure['flat_bands'] ?? [])));
|
||||
$flat = array_values((array) ($row['flat'] ?? []));
|
||||
if (in_array($mode, ['flat_bands', 'flat_then'], true) && $bands !== [] && ($mode === 'flat_bands' || $within($weight, end($bands)))) {
|
||||
foreach ($bands as $index => $edge) {
|
||||
if ($within($weight, $edge)) {
|
||||
$price = $flat[$index] ?? null;
|
||||
|
||||
return is_numeric($price) ? $ok((float) $price, '≤' . self::kg($edge) . 'kg 一口价' . self::kg((float) $price)) : $none('该重量段不发');
|
||||
}
|
||||
}
|
||||
|
||||
return $none('超出报价重量范围');
|
||||
}
|
||||
|
||||
$tiers = array_map('floatval', array_values((array) ($structure['rate_tiers'] ?? [])));
|
||||
$index = count($tiers);
|
||||
foreach ($tiers as $i => $edge) {
|
||||
if ($within($weight, $edge)) {
|
||||
$index = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$rate = $row['rates'][$index] ?? null;
|
||||
$base = $row['base'] ?? null;
|
||||
if (! is_numeric($rate) || ! is_numeric($base)) {
|
||||
return $none('该重量段不发');
|
||||
}
|
||||
$rate = (float) $rate;
|
||||
$base = (float) $base;
|
||||
$then = $mode === 'flat_then' ? (string) ($structure['then_mode'] ?? 'waybill_rate') : $mode;
|
||||
if ($then === 'first_additional') {
|
||||
$fw = (float) ($structure['first_weight'] ?? 0);
|
||||
|
||||
return $within($weight, $fw)
|
||||
? $ok($base, '首重' . self::kg($base))
|
||||
: $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate));
|
||||
}
|
||||
|
||||
return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate));
|
||||
}
|
||||
|
||||
/** 按取整方式得到计费重量:ceil 向上取整,round 四舍五入(4.5 进为 5)。 */
|
||||
public static function roundWeight(float $weight, string $rounding): float
|
||||
{
|
||||
return match ($rounding) {
|
||||
'ceil' => (float) ceil(round($weight, 6)),
|
||||
'round' => (float) floor($weight + 0.5),
|
||||
default => $weight,
|
||||
};
|
||||
}
|
||||
|
||||
public static function usesRates(string $mode): bool
|
||||
{
|
||||
return in_array($mode, ['waybill_rate', 'first_additional', 'flat_then'], true);
|
||||
|
||||
@ -108,58 +108,51 @@ final class CourierStockQuoteBatchService
|
||||
$courierError = $exception->validator->errors()->first();
|
||||
}
|
||||
$rules = $data['rule_groups'][$row['rule_group'] ?? -1]['rules'] ?? '';
|
||||
$periods = $row['periods'] ?? [[
|
||||
'effective_date' => ($row['effective_date'] ?? '') ?: ($data['effective_date'] ?? ''),
|
||||
$metadata = [
|
||||
'quote_type' => trim($data['quote_type']), 'effective_date' => ($row['effective_date'] ?? '') ?: ($data['effective_date'] ?? ''),
|
||||
'end_date' => ($row['end_date'] ?? null) ?: (($data['end_date'] ?? null) ?: null),
|
||||
]];
|
||||
foreach ($periods as $periodIndex => $period) {
|
||||
$metadata = [
|
||||
'quote_type' => trim($data['quote_type']), 'effective_date' => $period['effective_date'],
|
||||
'end_date' => ($period['end_date'] ?? null) ?: null,
|
||||
'billing_rules' => trim($rules), 'summary' => $data['summary'],
|
||||
];
|
||||
$validation = Validator::make($metadata, [
|
||||
'effective_date' => ['required', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'],
|
||||
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
|
||||
], ['effective_date.required' => '请填写实际执行日期或设置共用日期。', 'billing_rules.required' => '请选择并填写计费规则。']);
|
||||
$reason = $sheet === null ? '工作表不存在。' : ($sheet['error'] ?? null);
|
||||
$reason ??= $courierError;
|
||||
$reason ??= $validation->fails() ? $validation->errors()->first() : null;
|
||||
foreach ($sheet['signed_images'] ?? [] as $image) {
|
||||
if (! Storage::disk('uploads')->exists($image['path'])) {
|
||||
$reason ??= '原件图片存档缺失,请重新上传原 Excel,不能带缺失附件录入。';
|
||||
}
|
||||
}
|
||||
if ($courier !== null) {
|
||||
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $existing, $metadata['effective_date'], $metadata['end_date']) ? '与已录入的同类报价执行期间重叠,请核对起止日期。' : null;
|
||||
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $identities, $metadata['effective_date'], $metadata['end_date']) ? '与本批同一家快递的执行期间重叠,请核对起止日期。' : null;
|
||||
$identities[] = [...$courier, 'quote_type' => $metadata['quote_type'], 'effective_date' => $metadata['effective_date'], 'end_date' => $metadata['end_date']];
|
||||
}
|
||||
if ($reason !== null) {
|
||||
$errors['entries.' . $index . (isset($row['periods']) ? '.periods.' . $periodIndex : '')] = '「' . ($sheet['sheet_name'] ?? $index) . '」第 ' . (array_search($periodIndex, array_keys($periods), true) + 1) . ' 段:' . $reason;
|
||||
} else {
|
||||
$prepared[] = compact('metadata', 'courier', 'sheet');
|
||||
'billing_rules' => trim($rules), 'summary' => $data['summary'],
|
||||
];
|
||||
$validation = Validator::make($metadata, [
|
||||
'effective_date' => ['required', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'],
|
||||
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
|
||||
], ['effective_date.required' => '请填写实际执行日期或设置共用日期。', 'billing_rules.required' => '请选择并填写计费规则。']);
|
||||
$reason = $sheet === null ? '工作表不存在。' : ($sheet['error'] ?? null);
|
||||
$reason ??= $courierError;
|
||||
$reason ??= $validation->fails() ? $validation->errors()->first() : null;
|
||||
foreach ($sheet['signed_images'] ?? [] as $image) {
|
||||
if (! Storage::disk('uploads')->exists($image['path'])) {
|
||||
$reason ??= '原件图片存档缺失,请重新上传原 Excel,不能带缺失附件录入。';
|
||||
}
|
||||
}
|
||||
if ($courier !== null) {
|
||||
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $existing, $metadata['effective_date'], $metadata['end_date']) ? '与已录入的同类报价执行期间重叠,请核对起止日期。' : null;
|
||||
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $identities, $metadata['effective_date'], $metadata['end_date']) ? '与本批同一家快递的执行期间重叠,请核对起止日期。' : null;
|
||||
$identities[] = [...$courier, 'quote_type' => $metadata['quote_type'], 'effective_date' => $metadata['effective_date'], 'end_date' => $metadata['end_date']];
|
||||
}
|
||||
if ($reason !== null) {
|
||||
$errors['entries.' . $index] = '「' . ($sheet['sheet_name'] ?? $index) . '」' . $reason;
|
||||
} else {
|
||||
$prepared[] = compact('metadata', 'courier', 'sheet');
|
||||
}
|
||||
}
|
||||
if ($errors !== []) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
abort_unless(Storage::disk('uploads')->exists($manifest['source_file']['path']), 404, '报价原文件不存在,请重新上传。');
|
||||
// 一批同进同退;校验失败时保留上传原件和所有已选配置,便于修正重试。
|
||||
return DB::transaction(function () use ($prepared, $selected, $manifest, $userId, $data, $batch): array {
|
||||
return DB::transaction(function () use ($prepared, $manifest, $userId, $data, $batch): array {
|
||||
$created = [];
|
||||
foreach ($prepared as $item) {
|
||||
$entry = $this->entries->createPrepared($item['metadata'], $userId, $item['courier'], $item['sheet'], [$manifest['source_file'], ...($item['sheet']['signed_images'] ?? [])]);
|
||||
if ($data['submit_for_review'] ?? false) {
|
||||
$this->changes->submit($entry->change, $userId);
|
||||
}
|
||||
$created[] = ['id' => $entry->id, 'change_id' => $entry->source_change_id, 'sheet_name' => $entry->sheet_name, 'courier_name' => $entry->courier_name, 'courier_expcode' => $entry->courier_expcode,
|
||||
'effective_date' => $entry->effective_date->toDateString(), 'end_date' => $entry->end_date?->toDateString()];
|
||||
$created[] = ['id' => $entry->id, 'change_id' => $entry->source_change_id, 'sheet_name' => $entry->sheet_name, 'courier_name' => $entry->courier_name, 'courier_expcode' => $entry->courier_expcode];
|
||||
}
|
||||
|
||||
$result = ['created' => $created, 'selected_sheets' => count($selected), 'skipped' => count($manifest['sheets']) - count($selected), 'submitted' => (bool) ($data['submit_for_review'] ?? false)];
|
||||
$result = ['created' => $created, 'skipped' => count($manifest['sheets']) - count($created), 'submitted' => (bool) ($data['submit_for_review'] ?? false)];
|
||||
$manifest['result'] = $result;
|
||||
$this->write($batch, $userId, $manifest);
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Business\CourierOnboarding;
|
||||
use App\Models\Business\CourierQuoteChange;
|
||||
use App\Models\Business\CourierStockQuoteEntry;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
@ -74,11 +73,10 @@ final class CourierStockQuoteEntryService
|
||||
|
||||
public function existingIdentities(?int $exceptId = null): array
|
||||
{
|
||||
return CourierStockQuoteEntry::withTrashed()->with(['change:id,company_id,status', 'change.version:id,company_id,source_change_id,status'])->when($exceptId !== null, fn ($query) => $query->where('id', '!=', $exceptId))
|
||||
->get(['id', 'company_id', 'source_change_id', 'courier_expcode', 'courier_name', 'quote_type', 'effective_date', 'end_date'])
|
||||
->filter(fn ($entry) => $entry->change?->status !== CourierQuoteChange::STATUS_RETURNED && !$entry->change?->version?->isVoided())
|
||||
return CourierStockQuoteEntry::withTrashed()->with(['change:id,company_id', 'change.version:id,company_id,source_change_id,status'])->when($exceptId !== null, fn ($query) => $query->where('id', '!=', $exceptId))
|
||||
->get(['id', 'company_id', 'source_change_id', 'courier_expcode', 'courier_name', 'quote_type', 'effective_date', 'end_date'])->filter(fn ($entry) => !$entry->change?->version?->isVoided())
|
||||
->map(fn ($entry) => ['code' => $entry->courier_expcode, 'name' => $entry->courier_name, 'quote_type' => $entry->quote_type,
|
||||
'effective_date' => $entry->effective_date?->toDateString(), 'end_date' => $entry->end_date?->toDateString()])->values()->all();
|
||||
'effective_date' => $entry->effective_date?->toDateString(), 'end_date' => $entry->end_date?->toDateString()])->all();
|
||||
}
|
||||
|
||||
public function isDuplicate(array $courier, string $quoteType, array $existing, ?string $start = null, ?string $end = null): bool
|
||||
@ -226,37 +224,6 @@ final class CourierStockQuoteEntryService
|
||||
return $this->createPrepared($data, $userId, $courier, $matrix);
|
||||
}
|
||||
|
||||
/** 同一份原报价按不连续期间分别建草稿,复用原件;任一区间失败整组回滚。 */
|
||||
public function createPeriods(array $data, int $userId): array
|
||||
{
|
||||
if (! isset($data['periods'])) {
|
||||
return [$this->create($data, $userId)];
|
||||
}
|
||||
$courier = $this->resolveCourier($data);
|
||||
$matrix = $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null);
|
||||
$savedFiles = [];
|
||||
try {
|
||||
return DB::transaction(function () use ($data, $userId, $courier, $matrix, &$savedFiles): array {
|
||||
$created = [];
|
||||
foreach ($data['periods'] as $period) {
|
||||
$metadata = [...$data, 'effective_date' => $period['effective_date'],
|
||||
'end_date' => ($period['end_date'] ?? null) ?: null, 'quote_type' => trim($data['quote_type'])];
|
||||
if ($created !== []) {
|
||||
unset($metadata['source_quote'], $metadata['confirmation_files'], $metadata['signed_files']);
|
||||
}
|
||||
$entry = $this->createPrepared($metadata, $userId, $courier, $matrix, $savedFiles);
|
||||
$savedFiles = $entry->files;
|
||||
$created[] = $entry;
|
||||
}
|
||||
|
||||
return $created;
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
Storage::disk('uploads')->delete(array_column($savedFiles, 'path'));
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量入口只接收服务器预览保存的原文与文件,不接受浏览器提供的金额。 */
|
||||
public function createPrepared(array $data, int $userId, array $courier, array $matrix, array $sourceFiles = []): CourierStockQuoteEntry
|
||||
{
|
||||
|
||||
@ -142,23 +142,6 @@ final class QuoteManagementSnapshot
|
||||
return $companyName !== '' ? $companyName : $name;
|
||||
}
|
||||
|
||||
/** 「查看」面板的计费规则一行:计费方式 + 取整/计泡/限重/每票加收。 */
|
||||
private function billingSummary(CourierQuoteVersion $version): string
|
||||
{
|
||||
$structure = (array) $version->billing_structure;
|
||||
$kg = static fn ($value): string => CourierQuoteStructure::kg((float) $value);
|
||||
$parts = array_filter([
|
||||
CourierQuoteStructure::describe($structure),
|
||||
($structure['rounding'] ?? 'none') !== 'none' ? (CourierQuoteStructure::ROUNDING[$structure['rounding']] ?? null) : null,
|
||||
filled($structure['min_weight'] ?? null) ? '不足' . $kg($structure['min_weight']) . 'kg按' . $kg($structure['min_weight']) . 'kg计' : null,
|
||||
filled($structure['volumetric_divisor'] ?? null) ? '计泡 ' . $structure['volumetric_divisor'] : null,
|
||||
filled($structure['weight_limit'] ?? null) ? '限重 ' . $kg($structure['weight_limit']) . 'kg' : null,
|
||||
filled($structure['per_ticket_surcharge'] ?? null) ? '每票加收 ' . $kg($structure['per_ticket_surcharge']) . ' 元' : null,
|
||||
]);
|
||||
|
||||
return implode(';', $parts) . '(' . count((array) $version->billing_rows) . ' 个地区)';
|
||||
}
|
||||
|
||||
private function normalizeVersion(CourierQuoteVersion $version): array
|
||||
{
|
||||
$values = $this->decodeMatrix($version->values_json);
|
||||
@ -172,7 +155,6 @@ final class QuoteManagementSnapshot
|
||||
'company_name' => $version->company_name,
|
||||
'courier_expcode' => $version->courier_expcode,
|
||||
'billing_rules' => $version->billing_rules,
|
||||
'billing_summary' => $version->hasBillingRules() ? $this->billingSummary($version) : null,
|
||||
'carrier' => $version->carrier_name,
|
||||
'quote_type' => $version->quote_type,
|
||||
'effective_date' => $version->effective_date?->toDateString(),
|
||||
|
||||
@ -10,53 +10,6 @@ class BusinessNavigation
|
||||
public static function all(): array
|
||||
{
|
||||
$navigation = config('business_navigation', []);
|
||||
$pageExtensions = (array) config('business_navigation_page_extensions', []);
|
||||
|
||||
foreach ((array) ($pageExtensions['module_sections'] ?? []) as $definition) {
|
||||
$section = (array) ($definition['section'] ?? []);
|
||||
$sectionSlug = trim((string) ($section['slug'] ?? ''));
|
||||
if ($sectionSlug === ''
|
||||
|| collect($navigation)->contains(
|
||||
static fn (array $existing): bool => ($existing['slug'] ?? null) === $sectionSlug,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$positionAfter = trim((string) ($definition['position_after'] ?? ''));
|
||||
$anchorIndex = $positionAfter === ''
|
||||
? false
|
||||
: collect($navigation)->search(
|
||||
static fn (array $existing): bool => ($existing['slug'] ?? null) === $positionAfter,
|
||||
);
|
||||
array_splice(
|
||||
$navigation,
|
||||
is_int($anchorIndex) ? $anchorIndex + 1 : count($navigation),
|
||||
0,
|
||||
[$section],
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($navigation as &$section) {
|
||||
$sectionSlug = (string) ($section['slug'] ?? '');
|
||||
|
||||
foreach ((array) ($pageExtensions[$sectionSlug] ?? []) as $extension) {
|
||||
$extensionSlug = trim((string) ($extension['slug'] ?? ''));
|
||||
|
||||
if ($extensionSlug === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$exists = collect($section['pages'] ?? [])->contains(
|
||||
static fn (array $page): bool => (string) ($page['slug'] ?? '') === $extensionSlug
|
||||
);
|
||||
|
||||
if (! $exists) {
|
||||
$section['pages'][] = $extension;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($section);
|
||||
|
||||
$titles = (array) setting('navigation.titles', []);
|
||||
$sectionTitles = (array) ($titles['sections'] ?? []);
|
||||
$pageTitles = (array) ($titles['pages'] ?? []);
|
||||
|
||||
@ -21,10 +21,6 @@ class FinanceLedgerAccess
|
||||
return false;
|
||||
}
|
||||
|
||||
if (SystemAccess::roleName($user) === SystemAccess::ADMIN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$roles = array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) config('finance_ledger.mapping_editor_roles', '财务台账管理员')),
|
||||
@ -32,40 +28,4 @@ class FinanceLedgerAccess
|
||||
|
||||
return in_array(SystemAccess::roleName($user), $roles, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户状态、账户类型与账户映射使用同一组财务台账编辑角色。
|
||||
*/
|
||||
public static function canEditAccountStatus(?User $user = null): bool
|
||||
{
|
||||
return self::canEditMapping($user);
|
||||
}
|
||||
|
||||
public static function canEditAccountType(?User $user = null): bool
|
||||
{
|
||||
return self::canEditMapping($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 动账明细写权限:在现有台账管理员之外,允许配置中的指定账号维护明细。
|
||||
* 该权限不扩展到账户资料、每日流水或账户映射页签。
|
||||
*/
|
||||
public static function canEditTransactions(?User $user = null): bool
|
||||
{
|
||||
if (self::canEditMapping($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user ??= user();
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userNames = array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) config('finance_ledger.transaction_editor_user_names', '玉兔')),
|
||||
)));
|
||||
|
||||
return in_array(trim((string) $user->name), $userNames, true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Utilities;
|
||||
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
class FinanceLedgerAccountShouldKeep
|
||||
{
|
||||
public const YES = '是';
|
||||
|
||||
public const NO = '否';
|
||||
|
||||
public const OPTIONS = [self::YES, self::NO];
|
||||
|
||||
public static function normalize(?string $value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$compact = mb_strtoupper((string) preg_replace('/\s+/u', '', $value));
|
||||
if (in_array($compact, ['是', '保留', '需要保留', '需要', 'Y', 'YES', 'TRUE', '1'], true)) {
|
||||
return self::YES;
|
||||
}
|
||||
if (in_array($compact, ['否', '不保留', '不需要保留', '不需要', 'N', 'NO', 'FALSE', '0'], true)) {
|
||||
return self::NO;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function display(?string $explicitValue, ?string $companyPlan): string
|
||||
{
|
||||
$normalized = self::normalize($explicitValue);
|
||||
if ($normalized !== '') {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
$plan = trim((string) $companyPlan);
|
||||
if ($plan === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return str_contains($plan, '保留') ? self::YES : self::NO;
|
||||
}
|
||||
|
||||
public static function applyUnretainedCondition(
|
||||
Builder $query,
|
||||
string $explicitColumn = 'account_should_keep',
|
||||
string $companyPlanColumn = 'company_plan',
|
||||
): Builder {
|
||||
return $query->where(function (Builder $keep) use ($explicitColumn, $companyPlanColumn): void {
|
||||
$keep->where($explicitColumn, self::NO)
|
||||
->orWhere(function (Builder $inferred) use ($explicitColumn, $companyPlanColumn): void {
|
||||
$inferred->where(function (Builder $blank) use ($explicitColumn): void {
|
||||
$blank->whereNull($explicitColumn)->orWhere($explicitColumn, '');
|
||||
})
|
||||
->whereNotNull($companyPlanColumn)
|
||||
->where($companyPlanColumn, '<>', '')
|
||||
->where($companyPlanColumn, 'not like', '%保留%');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Utilities;
|
||||
|
||||
final class FinanceLedgerAccountStatus
|
||||
{
|
||||
public const NORMAL = 'normal';
|
||||
public const ONLINE_BANKING_ABNORMAL = 'online_banking_abnormal';
|
||||
public const LIMITED = 'limited';
|
||||
public const BUSINESS_DEREGISTERED = 'business_deregistered';
|
||||
|
||||
public const OPTIONS = [
|
||||
self::NORMAL => '正常',
|
||||
self::ONLINE_BANKING_ABNORMAL => '网银异常',
|
||||
self::LIMITED => '限额',
|
||||
self::BUSINESS_DEREGISTERED => '工商注销',
|
||||
];
|
||||
|
||||
public static function normalize(?string $status): string
|
||||
{
|
||||
$status = trim((string) $status);
|
||||
|
||||
return array_key_exists($status, self::OPTIONS) ? $status : self::NORMAL;
|
||||
}
|
||||
|
||||
public static function label(?string $status): string
|
||||
{
|
||||
return self::OPTIONS[self::normalize($status)];
|
||||
}
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Utilities;
|
||||
|
||||
final class FinanceLedgerAccountType
|
||||
{
|
||||
public const ALIPAY = '支付宝';
|
||||
|
||||
public const BANK_ACCOUNT_TYPES = ['公户', '个人卡', '网商银行'];
|
||||
|
||||
public const DEFAULT_OPTIONS = [
|
||||
'个人卡',
|
||||
'公户',
|
||||
self::ALIPAY,
|
||||
'网商银行',
|
||||
];
|
||||
|
||||
public static function options(array ...$groups): array
|
||||
{
|
||||
$types = self::DEFAULT_OPTIONS;
|
||||
foreach ($groups as $group) {
|
||||
foreach ($group as $type) {
|
||||
$type = trim((string) $type);
|
||||
if ($type !== '') {
|
||||
$types[] = $type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$types = array_values(array_unique($types));
|
||||
sort($types, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
public static function last4(?string $accountType, ?string $accountNo, ?string $fallback = null): string
|
||||
{
|
||||
$type = trim((string) $accountType);
|
||||
if ($type === self::ALIPAY) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
if (in_array($type, self::BANK_ACCOUNT_TYPES, true)) {
|
||||
$digits = preg_replace('/\D+/', '', (string) $accountNo);
|
||||
if (strlen((string) $digits) >= 4) {
|
||||
return substr((string) $digits, -4);
|
||||
}
|
||||
}
|
||||
|
||||
$fallback = trim((string) $fallback);
|
||||
|
||||
return $fallback !== '' ? $fallback : '/';
|
||||
}
|
||||
}
|
||||
@ -12,12 +12,7 @@ class FocusMode
|
||||
{
|
||||
$section = $sectionSlug ?: (string) (request()->route('section') ?: request()->segment(3));
|
||||
|
||||
// 钉钉工作台打开时 UA 可能被替换成普通浏览器,Referer 仍指向钉钉域名。
|
||||
$referer = strtolower((string) request()->headers->get('referer'));
|
||||
$fromDingTalk = str_contains((string) request()->userAgent(), 'DingTalk')
|
||||
|| str_contains($referer, 'dingtalk');
|
||||
|
||||
return in_array($section, ['financial-risk-control'], true)
|
||||
&& (request()->boolean('focus') || $fromDingTalk);
|
||||
&& (request()->boolean('focus') || str_contains((string) request()->userAgent(), 'DingTalk'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,7 +20,6 @@ class SidebarMenu
|
||||
['workspace', 'overview', '经营总览', 'monitoring'],
|
||||
]],
|
||||
['title' => '经营报表', 'items' => [
|
||||
['financial-operations', 'ledger', '财务台账', 'menu_book'],
|
||||
['company-management', 'subject-operating-report', '主体维度经营报表', 'domain'],
|
||||
['company-management', 'company-cost-arrangement', '公司成本安排', 'account_balance_wallet'],
|
||||
['company-management', 'tax-forecast', '税金预测表', 'calculate'],
|
||||
@ -44,7 +43,6 @@ class SidebarMenu
|
||||
['company-management', 'company-progress', '公司进度表', 'timeline'],
|
||||
['store-management', 'change-progress', '店铺变更进度表', 'swap_horiz'],
|
||||
['trademark-management', 'trademark-progress', '商标进度表', 'format_list_bulleted'],
|
||||
['other', 'systemization-todos', '系统化待办', 'checklist'],
|
||||
['workspace', 'tasks', '待办事项', 'assignment_turned_in'],
|
||||
['workspace', 'warnings', '预警页面', 'warning_amber'],
|
||||
]],
|
||||
|
||||
@ -5,14 +5,11 @@ namespace App\Utilities;
|
||||
use App\Models\Auth\User;
|
||||
use App\Models\Auth\UserAccessGroup;
|
||||
use App\Services\Auth\FinanceRoster;
|
||||
use App\Services\Auth\SupplierPortalAccess;
|
||||
|
||||
class SidebarPermissions
|
||||
{
|
||||
private const LEGACY_PAGE_KEYS = [
|
||||
'refunds/small-payment-work-orders' => 'financial-operations/small-payment-work-orders',
|
||||
'financial-ledger' => 'financial-operations/ledger',
|
||||
'financial-ledger/ledger' => 'financial-operations/ledger',
|
||||
];
|
||||
|
||||
private const FINAL_SECTION_KEYS = [
|
||||
@ -46,15 +43,6 @@ class SidebarPermissions
|
||||
public static function allowedKeys(User $user): ?array
|
||||
{
|
||||
// Limited identities cannot inherit full navigation from roles, groups or user overrides.
|
||||
if (SupplierPortalAccess::isSupplierUser($user)) {
|
||||
return [SupplierPortalAccess::PAGE_KEY];
|
||||
}
|
||||
|
||||
// 非财务中心员工:只能看到财务经营风险管控下的两个页面。
|
||||
if (self::hasRestrictedDingTalkAccess($user)) {
|
||||
return self::normalizeAllowedKeys((array) config('dingtalk.restricted_page_keys', []));
|
||||
}
|
||||
|
||||
if (self::hasLimitedDingTalkAccess($user)) {
|
||||
return config('dingtalk.limited_login_page_keys', []);
|
||||
}
|
||||
@ -87,35 +75,20 @@ class SidebarPermissions
|
||||
|
||||
public static function hasLimitedDingTalkAccess(User $user): bool
|
||||
{
|
||||
return SupplierPortalAccess::isSupplierUser($user)
|
||||
|| $user->created_from === 'dingtalk_roster_limited'
|
||||
return $user->created_from === 'dingtalk_roster_limited'
|
||||
|| (config('dingtalk.limited_login_user_ids', []) !== []
|
||||
&& FinanceRoster::isLimitedLoginUser((string) $user->dingtalkIdentity?->dingtalk_user_id));
|
||||
}
|
||||
|
||||
public static function hasRestrictedDingTalkAccess(User $user): bool
|
||||
{
|
||||
return $user->created_from === 'dingtalk_restricted';
|
||||
}
|
||||
|
||||
public static function navigationFor(User $user): array
|
||||
{
|
||||
$navigation = BusinessNavigation::all();
|
||||
$allowed = self::allowedKeys($user);
|
||||
|
||||
if ($allowed === null) {
|
||||
return self::applyPageViewerAllowlists($navigation, $user);
|
||||
return $navigation;
|
||||
}
|
||||
|
||||
// 费用明细承接原「广告费用 / 营销费用」入口,并复用账单明细的数据权限。
|
||||
// 已保存过精确侧边栏权限的账号不会自动获得新页面键,因此按旧权限兼容显示。
|
||||
$inheritsExpenseDetails = array_intersect([
|
||||
'financial-operations/performance-details',
|
||||
'financial-operations/billing-details',
|
||||
'financial-operations/advertising-expenses',
|
||||
'financial-operations/marketing-expenses',
|
||||
], $allowed) !== [];
|
||||
|
||||
$navigation = array_values(array_filter(array_map(function (array $section) use ($allowed) {
|
||||
$sectionKey = $section['slug'];
|
||||
|
||||
@ -127,12 +100,8 @@ class SidebarPermissions
|
||||
$pageKey = $sectionKey . '/' . $page['slug'];
|
||||
|
||||
if (in_array($pageKey, $allowed, true)) {
|
||||
// 页面拆分为子页时,可显式沿用父页授权,避免旧权限配置丢失入口。
|
||||
// 若有 children,保留 children 的精确权限过滤
|
||||
if (! empty($page['children'])) {
|
||||
if (($page['children_inherit_parent_access'] ?? false) === true) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
$page['children'] = array_values(array_filter($page['children'], function (array $child) use ($allowed, $sectionKey) {
|
||||
return in_array($sectionKey . '/' . $child['slug'], $allowed, true);
|
||||
}));
|
||||
@ -163,50 +132,6 @@ class SidebarPermissions
|
||||
return null;
|
||||
}, $navigation)));
|
||||
|
||||
if ($inheritsExpenseDetails) {
|
||||
$configuredFinance = collect(BusinessNavigation::all())
|
||||
->firstWhere('slug', 'financial-operations');
|
||||
$expensePage = collect($configuredFinance['pages'] ?? [])
|
||||
->firstWhere('slug', 'expense-details');
|
||||
$financeSectionFound = false;
|
||||
|
||||
foreach ($navigation as &$financeSection) {
|
||||
if (($financeSection['slug'] ?? null) !== 'financial-operations') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$financeSectionFound = true;
|
||||
if (! $expensePage
|
||||
|| collect($financeSection['pages'] ?? [])->contains('slug', 'expense-details')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pageSlugs = array_column($financeSection['pages'] ?? [], 'slug');
|
||||
$performanceIndex = array_search('performance-details', $pageSlugs, true);
|
||||
$billingIndex = array_search('billing-details', $pageSlugs, true);
|
||||
$insertAt = is_int($performanceIndex)
|
||||
? $performanceIndex + 1
|
||||
: (is_int($billingIndex) ? $billingIndex : 0);
|
||||
array_splice($financeSection['pages'], $insertAt, 0, [$expensePage]);
|
||||
}
|
||||
unset($financeSection);
|
||||
|
||||
// 只被授予旧费用入口的账号仍应保留这个替代页面。
|
||||
if (! $financeSectionFound && $expensePage) {
|
||||
$fallbackSection = $configuredFinance;
|
||||
$fallbackSection['pages'] = [$expensePage];
|
||||
$riskIndex = collect($navigation)->search(
|
||||
static fn (array $section): bool => ($section['slug'] ?? null) === 'financial-risk-control'
|
||||
);
|
||||
array_splice(
|
||||
$navigation,
|
||||
is_int($riskIndex) ? $riskIndex : count($navigation),
|
||||
0,
|
||||
[$fallbackSection],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 新增的公司成本安排、税金预测表与主体经营报表共用公司经营报表权限。
|
||||
// 对已经保存过精确权限的账号兼容旧配置,避免新增页面被旧权限集合隐藏。
|
||||
foreach ($navigation as &$section) {
|
||||
@ -279,41 +204,6 @@ class SidebarPermissions
|
||||
}
|
||||
}
|
||||
|
||||
// 负数预警直接挂在「应付款项」下,并跟随现有应付款项授权。
|
||||
// 厂家门户仍只允许自己的供应商数据页,避免财务预警入口出现在外部厂家账号中。
|
||||
if (! SupplierPortalAccess::isSupplierUser($user)) {
|
||||
$payablesSectionSlugs = [
|
||||
'supplier-payables-final',
|
||||
'supplier-reconciliation',
|
||||
'courier-payables-final',
|
||||
'supplier-payables',
|
||||
'courier-payables',
|
||||
'raw-board-payables',
|
||||
'lamination-factory-payables',
|
||||
'paper-materials',
|
||||
'edge-banding-materials',
|
||||
];
|
||||
$visibleSectionSlugs = array_column($navigation, 'slug');
|
||||
|
||||
if (
|
||||
! in_array('negative-alerts', $visibleSectionSlugs, true)
|
||||
&& array_intersect($payablesSectionSlugs, $visibleSectionSlugs) !== []
|
||||
) {
|
||||
$alertSection = collect(BusinessNavigation::all())
|
||||
->firstWhere('slug', 'negative-alerts');
|
||||
if (! empty($alertSection)) {
|
||||
$insertAt = count($navigation);
|
||||
foreach ($navigation as $index => $visibleSection) {
|
||||
if (in_array($visibleSection['slug'] ?? null, $payablesSectionSlugs, true)) {
|
||||
$insertAt = $index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
array_splice($navigation, $insertAt, 0, [$alertSection]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$slugs = array_column($navigation, 'slug');
|
||||
if (! in_array('planting-data', $slugs, true) && in_array('billing-transactions', $slugs, true)) {
|
||||
$plantingSection = collect(BusinessNavigation::all())->firstWhere('slug', 'planting-data');
|
||||
@ -324,49 +214,7 @@ class SidebarPermissions
|
||||
}
|
||||
}
|
||||
|
||||
return self::applyPageViewerAllowlists($navigation, $user);
|
||||
}
|
||||
|
||||
public static function canViewPage(User $user, string $section, string $page): bool
|
||||
{
|
||||
$pageKey = trim($section, '/') . '/' . trim($page, '/');
|
||||
$allowedViewers = config('business_page_access.page_viewers')[$pageKey] ?? null;
|
||||
|
||||
if (! is_array($allowedViewers)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(trim((string) $user->name), $allowedViewers, true);
|
||||
}
|
||||
|
||||
private static function applyPageViewerAllowlists(array $navigation, User $user): array
|
||||
{
|
||||
if (empty(config('business_page_access.page_viewers', []))) {
|
||||
return $navigation;
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static function (array $section) use ($user): ?array {
|
||||
$sectionSlug = (string) ($section['slug'] ?? '');
|
||||
$pages = $section['pages'] ?? [];
|
||||
|
||||
if ($pages === []) {
|
||||
return $section;
|
||||
}
|
||||
|
||||
$section['pages'] = array_values(array_filter(
|
||||
$pages,
|
||||
static fn (array $page): bool => self::canViewPage(
|
||||
$user,
|
||||
$sectionSlug,
|
||||
(string) ($page['slug'] ?? ''),
|
||||
),
|
||||
));
|
||||
|
||||
return $section['pages'] === [] ? null : $section;
|
||||
},
|
||||
$navigation,
|
||||
)));
|
||||
return $navigation;
|
||||
}
|
||||
|
||||
public static function allKeys(): array
|
||||
|
||||
@ -94,9 +94,6 @@ services:
|
||||
pull_policy: never
|
||||
container_name: caiwu_app
|
||||
restart: unless-stopped
|
||||
# Web requests and small scheduled jobs have their own memory boundary.
|
||||
mem_limit: 2g
|
||||
memswap_limit: 2g
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@ -126,7 +123,7 @@ services:
|
||||
retries: 10
|
||||
start_period: 45s
|
||||
|
||||
# Heavy CLI jobs use a separate cgroup from Apache.
|
||||
# Heavy CLI jobs use a separate cgroup, not Apache's unbounded app container.
|
||||
batch:
|
||||
image: caiwu_akaunting:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
pull_policy: never
|
||||
@ -136,7 +133,6 @@ services:
|
||||
command: [list]
|
||||
restart: "no"
|
||||
environment:
|
||||
FINANCE_BATCH_CONTAINER: "1"
|
||||
SUPPLIER_PAYABLE_MAINTENANCE_DB_CONNECTION: business_mirror
|
||||
JST_DB_CONNECTION: business_mirror
|
||||
env_file:
|
||||
@ -146,8 +142,8 @@ services:
|
||||
- ./runtime/.env:/var/www/html/.env:ro
|
||||
- caiwu_storage:/var/www/html/storage
|
||||
cpus: "1.00"
|
||||
mem_limit: ${FINANCE_BATCH_MEMORY_LIMIT:-3g}
|
||||
memswap_limit: ${FINANCE_BATCH_MEMORY_LIMIT:-3g}
|
||||
mem_limit: 3g
|
||||
memswap_limit: 3g
|
||||
healthcheck:
|
||||
disable: true
|
||||
logging:
|
||||
|
||||
@ -17,16 +17,7 @@ $navigation = [
|
||||
'fields' => [
|
||||
['name' => 'city', 'label' => '市'],
|
||||
['name' => 'entity', 'label' => '主体'],
|
||||
['name' => 'company_type', 'label' => '公司类型', 'kind' => 'badge'],
|
||||
['name' => 'tax_type', 'label' => '税务类型', 'kind' => 'badge', 'tones' => [
|
||||
'小规模纳税人' => 'info',
|
||||
'一般纳税人' => 'accent',
|
||||
'个体工商户' => 'success',
|
||||
'无税务登记' => 'warning',
|
||||
'待维护' => 'warning',
|
||||
'/' => 'muted',
|
||||
]],
|
||||
['name' => 'business_registration_type', 'label' => '工商类型', 'kind' => 'badge'],
|
||||
['name' => 'company_type', 'label' => '公司类型'],
|
||||
['name' => 'rolling_12m_income', 'label' => '最近12个月累计收入', 'kind' => 'number'],
|
||||
...array_map(static fn (int $month): array => ['name' => sprintf('income_%02d', $month), 'label' => $month . '月收入', 'kind' => 'number'], range(1, 12)),
|
||||
['name' => 'can_change_entity', 'label' => '能否变更主体'],
|
||||
@ -45,11 +36,8 @@ $navigation = [
|
||||
'empty' => '公司成本安排尚未同步数据。',
|
||||
'fields' => [
|
||||
['name' => 'company', 'label' => '公司'],
|
||||
['name' => 'company_type', 'label' => '公司类型', 'kind' => 'badge'],
|
||||
['name' => 'invoice_type', 'label' => '发票类型', 'kind' => 'badge', 'tones' => [
|
||||
'专票' => 'accent',
|
||||
'普票' => 'info',
|
||||
]],
|
||||
['name' => 'company_type', 'label' => '公司类型'],
|
||||
['name' => 'invoice_type', 'label' => '发票类型'],
|
||||
['name' => 'current_month_income_forecast', 'label' => '当月收入预测', 'kind' => 'number'],
|
||||
['name' => 'historical_cost_gap', 'label' => '往期成本缺口', 'kind' => 'number'],
|
||||
['name' => 'supplier_cost_arrangement', 'label' => '供应商成本安排', 'kind' => 'number'],
|
||||
@ -71,21 +59,9 @@ $navigation = [
|
||||
'empty' => '税金预测表尚未接入取数:工商类型、征收方式(查账征收/核定征收)确认并补齐数据来源后开始计算。',
|
||||
'fields' => [
|
||||
['name' => 'company_name', 'label' => '公司名称'],
|
||||
['name' => 'taxpayer_type', 'label' => '公司税务类型', 'kind' => 'badge', 'tones' => [
|
||||
'小规模纳税人' => 'info',
|
||||
'一般纳税人' => 'accent',
|
||||
'个体工商户' => 'success',
|
||||
'无税务登记' => 'warning',
|
||||
'待维护' => 'warning',
|
||||
'/' => 'muted',
|
||||
]],
|
||||
['name' => 'business_registration_type', 'label' => '工商类型', 'kind' => 'badge'],
|
||||
['name' => 'collection_method', 'label' => '征收方式', 'kind' => 'badge', 'tones' => [
|
||||
'查账征收' => 'info',
|
||||
'核定征收' => 'accent',
|
||||
'待维护' => 'warning',
|
||||
'/' => 'muted',
|
||||
]],
|
||||
['name' => 'taxpayer_type', 'label' => '公司税务类型'],
|
||||
['name' => 'business_registration_type', 'label' => '工商类型'],
|
||||
['name' => 'collection_method', 'label' => '征收方式'],
|
||||
['name' => 'assessed_income_rate', 'label' => '核定应税所得率(%)', 'kind' => 'number'],
|
||||
['name' => 'forecast_month', 'label' => '预测月份'],
|
||||
['name' => 'revenue_incl_tax', 'label' => '当月含税收入预测', 'kind' => 'number'],
|
||||
@ -117,16 +93,8 @@ $navigation = [
|
||||
'fields' => [
|
||||
['name' => 'unified_social_credit_code', 'label' => '统一社会信用代码'],
|
||||
['name' => 'company_name', 'label' => '公司名称'],
|
||||
['name' => 'taxpayer_type', 'label' => '税务类型', 'source' => 'taxpayer_category', 'kind' => 'badge', 'tones' => [
|
||||
'小规模纳税人' => 'info',
|
||||
'一般纳税人' => 'accent',
|
||||
'个体工商户' => 'success',
|
||||
'无税务登记' => 'warning',
|
||||
'待维护' => 'warning',
|
||||
'/' => 'muted',
|
||||
]],
|
||||
['name' => 'business_registration_type', 'label' => '工商类型', 'kind' => 'badge'],
|
||||
['name' => 'company_status', 'label' => '公司状态', 'kind' => 'badge'],
|
||||
['name' => 'taxpayer_type', 'label' => '纳税人类型', 'source' => 'taxpayer_category'],
|
||||
['name' => 'company_status', 'label' => '公司状态'],
|
||||
['name' => 'subscribed_capital', 'label' => '认缴注册资本(万元)', 'kind' => 'number'],
|
||||
['name' => 'registered_at', 'label' => '注册时间', 'kind' => 'date', 'source' => 'establishment_date'],
|
||||
['name' => 'legal_representative', 'label' => '法人'],
|
||||
@ -152,8 +120,8 @@ $navigation = [
|
||||
'fields' => [
|
||||
['name' => 'person_name', 'label' => '人员名称'],
|
||||
['name' => 'alias', 'label' => '花名'],
|
||||
['name' => 'position', 'label' => '职位', 'kind' => 'badge'],
|
||||
['name' => 'tag', 'label' => '标签', 'kind' => 'badge'],
|
||||
['name' => 'position', 'label' => '职位'],
|
||||
['name' => 'tag', 'label' => '标签'],
|
||||
['name' => 'tax_account', 'label' => '税务局账号'],
|
||||
['name' => 'tax_password', 'label' => '税务局密码', 'kind' => 'secret'],
|
||||
['name' => 'department', 'label' => '部门'],
|
||||
@ -162,7 +130,7 @@ $navigation = [
|
||||
['name' => 'verified_mobile', 'label' => '实名手机号', 'kind' => 'secret'],
|
||||
['name' => 'sim_card_keeper', 'label' => '电话卡保管人'],
|
||||
['name' => 'id_address', 'label' => '身份证地址', 'kind' => 'secret'],
|
||||
['name' => 'company_type', 'label' => '公司类型', 'kind' => 'badge'],
|
||||
['name' => 'company_type', 'label' => '公司类型'],
|
||||
['name' => 'total_company_count', 'label' => '合计担任公司数量', 'kind' => 'number'],
|
||||
['name' => 'retained_company_count', 'label' => '保留担任公司数量', 'kind' => 'number'],
|
||||
['name' => 'available_company_count', 'label' => '可担任公司数量', 'kind' => 'number'],
|
||||
@ -198,7 +166,7 @@ $navigation = [
|
||||
'fields' => [
|
||||
['name' => 'trademark_name', 'label' => '商标名称'],
|
||||
['name' => 'trademark_number', 'label' => '商标号'],
|
||||
['name' => 'trademark_status', 'label' => '商标状态', 'kind' => 'badge'],
|
||||
['name' => 'trademark_status', 'label' => '商标状态'],
|
||||
['name' => 'registration_date', 'label' => '注册时间', 'kind' => 'date'],
|
||||
['name' => 'expiry_date', 'label' => '有效期', 'kind' => 'date'],
|
||||
['name' => 'international_classification', 'label' => '国际分类', 'source' => 'trademark_type'],
|
||||
@ -284,7 +252,7 @@ $navigation = [
|
||||
'directory' => [
|
||||
'icon' => 'query_stats',
|
||||
'status' => '共享镜像',
|
||||
'description' => '店铺资料、主体生效区间统一读取团队共享业务镜像;销量取业绩明细的「收入金额 + 优惠金额 + 冲减金额 + 违约赔付(冲减收入) + 退款金额」合计,按店铺和发生日期逐日汇总后落到主体期间。',
|
||||
'description' => '店铺资料、主体生效区间统一读取团队共享业务镜像;销量取业绩明细的「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按店铺和发生日期逐日汇总后落到主体期间。',
|
||||
'empty' => '店铺维度报表尚未完成共享数据导入。',
|
||||
'fields' => [
|
||||
['name' => 'erp_store_name', 'label' => 'ERP系统店铺名称'],
|
||||
@ -294,10 +262,10 @@ $navigation = [
|
||||
['name' => 'store_manager', 'label' => '店铺负责人'],
|
||||
['name' => 'department_manager', 'label' => '部门负责人'],
|
||||
['name' => 'opened_at', 'label' => '开店时间', 'kind' => 'date'],
|
||||
['name' => 'status', 'label' => '状态', 'kind' => 'badge'],
|
||||
['name' => 'status', 'label' => '状态'],
|
||||
['name' => 'can_change', 'label' => '能否变更'],
|
||||
['name' => 'entity', 'label' => '主体'],
|
||||
['name' => 'company_type', 'label' => '公司类型', 'kind' => 'badge'],
|
||||
['name' => 'company_type', 'label' => '公司类型'],
|
||||
['name' => 'effective_at', 'label' => '生效时间', 'kind' => 'date'],
|
||||
['name' => 'invalid_at', 'label' => '失效时间', 'kind' => 'date'],
|
||||
['name' => 'period_sales', 'label' => '期间销量', 'kind' => 'number'],
|
||||
@ -319,11 +287,7 @@ $navigation = [
|
||||
'empty' => '内部流转数据尚未导入。导入工作簿后这里会显示明细。',
|
||||
'fields' => [
|
||||
['name' => 'company_name', 'label' => '抬头'],
|
||||
['name' => 'tag', 'label' => '标签', 'kind' => 'badge', 'tones' => [
|
||||
'1' => 'accent',
|
||||
'2' => 'warning',
|
||||
'3' => 'info',
|
||||
]],
|
||||
['name' => 'tag', 'label' => '标签'],
|
||||
['name' => 'projected_sales', 'label' => '预计9月销量', 'kind' => 'number'],
|
||||
['name' => 'invoiced_to_buyer', 'label' => '开票-给买家开票', 'kind' => 'number'],
|
||||
['name' => 'internal_invoiced', 'label' => '开票-内部开票', 'kind' => 'number'],
|
||||
@ -349,11 +313,11 @@ $navigation = [
|
||||
'description' => '优先读取共享业务镜像中的店铺资料、店铺 ID 和续签时间;提现账户仅显示配置状态。',
|
||||
'empty' => '尚未读取到店铺基础资料。请检查共享镜像或上游同步状态。',
|
||||
'fields' => [
|
||||
['name' => 'platform', 'label' => '平台', 'kind' => 'badge'],
|
||||
['name' => 'platform', 'label' => '平台'],
|
||||
['name' => 'store_id', 'label' => '店铺ID'],
|
||||
['name' => 'store_name', 'label' => '店铺名称', 'source' => 'erp_store_name'],
|
||||
['name' => 'store_entity', 'label' => '店铺主体', 'source' => 'company_name'],
|
||||
['name' => 'store_status', 'label' => '店铺状态', 'kind' => 'badge'],
|
||||
['name' => 'store_status', 'label' => '店铺状态'],
|
||||
['name' => 'trademark', 'label' => '商标', 'source' => 'trademark_name'],
|
||||
['name' => 'trademark_number', 'label' => '商标号'],
|
||||
['name' => 'business_department', 'label' => '业务部门', 'source' => 'profit_center'],
|
||||
@ -379,32 +343,16 @@ $navigation = [
|
||||
['name' => 'operation_department_head', 'label' => '店铺运营部门负责人'],
|
||||
['name' => 'operation_director', 'label' => '店铺运营总监'],
|
||||
['name' => 'profit_center', 'label' => '店铺所属利润中心'],
|
||||
['name' => 'account_type', 'label' => '账号类型', 'kind' => 'badge', 'tones' => [
|
||||
'主账号' => 'accent',
|
||||
'子账号' => 'info',
|
||||
]],
|
||||
['name' => 'account_type', 'label' => '账号类型'],
|
||||
['name' => 'account', 'label' => '账号'],
|
||||
['name' => 'password_configured', 'label' => '密码', 'kind' => 'secret_status'],
|
||||
['name' => 'bound_phone', 'label' => '绑定手机号'],
|
||||
['name' => 'verification_contact', 'label' => '手机号验证码对接人'],
|
||||
['name' => 'account_group_name', 'label' => '账号分组名称'],
|
||||
['name' => 'authorization_status', 'label' => '授权状态', 'kind' => 'badge', 'tones' => [
|
||||
'已授权' => 'success',
|
||||
'已过期' => 'warning',
|
||||
'未授权' => 'danger',
|
||||
]],
|
||||
['name' => 'store_status', 'label' => '店铺状态', 'kind' => 'badge'],
|
||||
['name' => 'flow_type', 'label' => '流程类型', 'kind' => 'badge'],
|
||||
['name' => 'approval_status', 'label' => '审批状态', 'kind' => 'badge', 'tones' => [
|
||||
'完成' => 'success',
|
||||
'已签署' => 'success',
|
||||
'拒绝' => 'danger',
|
||||
'撤回' => 'muted',
|
||||
'流程中' => 'info',
|
||||
'待审批' => 'warning',
|
||||
'暂存' => 'warning',
|
||||
'待签署' => 'warning',
|
||||
]],
|
||||
['name' => 'authorization_status', 'label' => '授权状态'],
|
||||
['name' => 'store_status', 'label' => '店铺状态'],
|
||||
['name' => 'flow_type', 'label' => '流程类型'],
|
||||
['name' => 'approval_status', 'label' => '审批状态'],
|
||||
['name' => 'created_at', 'label' => '店铺创建时间', 'kind' => 'date'],
|
||||
['name' => 'updated_at', 'label' => '店铺修改时间', 'kind' => 'date'],
|
||||
],
|
||||
@ -560,7 +508,6 @@ $navigation = [
|
||||
'slug' => 'marketing-account-bills',
|
||||
'title' => '店铺营销账户账单',
|
||||
'tabs' => [
|
||||
['slug' => 'promotion-marketing', 'title' => '推广费(营销账户)'],
|
||||
['slug' => 'taobao-alipay', 'title' => '淘系支付宝账户'],
|
||||
['slug' => 'taobao-wechat', 'title' => '淘系微信账户'],
|
||||
['slug' => 'pinduoduo', 'title' => '拼多多账户'],
|
||||
@ -654,18 +601,6 @@ $navigation = [
|
||||
['slug' => 'bill-offset', 'title' => '账单冲减'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'order-profit',
|
||||
'title' => '订单利润',
|
||||
'icon' => 'trending_up',
|
||||
'pages' => [
|
||||
[
|
||||
'slug' => 'order-profit-list',
|
||||
'title' => '订单利润',
|
||||
'show_in_section_menu' => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'refunds',
|
||||
'title' => '退款数据',
|
||||
@ -687,15 +622,6 @@ $navigation = [
|
||||
['slug' => 'deposit-receivables-report', 'title' => '保证金应收报表'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'negative-alerts',
|
||||
'title' => '负数预警',
|
||||
'icon' => 'warning_amber',
|
||||
'pages' => [
|
||||
['slug' => 'supplier-negative-alert', 'title' => '供应商负数预警'],
|
||||
['slug' => 'courier-negative-alert', 'title' => '快递负数预警'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'supplier-payables-final',
|
||||
'title' => '供应商应付(终版)',
|
||||
@ -896,7 +822,7 @@ $navigation = [
|
||||
'directory' => [
|
||||
'icon' => 'receipt_long',
|
||||
'status' => '共享镜像',
|
||||
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」「违约赔付(冲减收入)」分别取对应总账备注的账单净额,不计入收入金额;「退款金额」只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额,只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额+违约赔付(冲减收入))−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的抵减项会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定;同一平台、店铺、交易单号在相邻月份合并收入/优惠/冲减/违约赔付后与订单实付对平的,优先标「差额=跨月收款(合并已对平)」,未对平仍标「差额待核实」;其余规则均未命中且相关金额在 2 分钱内精确对平时,退款及其与抵减项的组合复用「订单实付含已退款部分」,优惠、冲减与违约赔付组合复用「订单实付未减优惠和冲减」;没有可复用的原备注时继续标「差额待核实」。都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
|
||||
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、订单实付取供货价(仅京喜正差额:差额=该单直营服务费(供货模式净收)时标注)、差额=直营补贴款(待开票结算)(仅京喜账单·京喜自营工厂店负差额且无退款(出类拔萃等 10 家已逐店核验,其余工厂店按实付≈商品金额的供货价特征识别):京东直营补贴款待开票结算时标注)、差额=直营补贴款(订单退款已冲回)(同批店负差额但订单已退款:补贴款已随退款冲回)、差额=订单实付包含商家承担优惠和以旧换新国补(仅京东账单·索菲亚客厅/办公/书房/功能、亿家达全屋、朴美家居店负差额、且该单含国补(聚水潭应付−账单收入>0):差额为商家承担优惠(店铺东券/单品折扣等)与以旧换新国补之和,已用营销对账逐单核实)、差额=订单实付包含商家承担优惠(同店负差额但不含国补的单:差额即商家承担优惠)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)(以上三档等式对带口径标识的行让位给口径兜底)、订单实付含已退款部分(抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注;淘系支付宝/微信负差额:差额=退款合计、或退款合计=实付−收入(收款缺口)、或整单全退(退款合计≈实付)时同样标注(退款取聚水潭已确认记录));都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
|
||||
'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。',
|
||||
'fields' => [
|
||||
['name' => 'platform', 'label' => '平台'],
|
||||
@ -906,19 +832,17 @@ $navigation = [
|
||||
['name' => 'income_amount', 'label' => '收入金额', 'kind' => 'number'],
|
||||
['name' => 'discount_amount', 'label' => '优惠金额', 'kind' => 'number'],
|
||||
['name' => 'offset_amount', 'label' => '冲减金额', 'kind' => 'number'],
|
||||
['name' => 'default_compensation_amount', 'label' => '违约赔付(冲减收入)', 'kind' => 'number'],
|
||||
['name' => 'refund_amount', 'label' => '退款金额', 'kind' => 'number'],
|
||||
['name' => 'planting_amount', 'label' => '种菜金额', 'kind' => 'number'],
|
||||
['name' => 'paid_amount', 'label' => '订单核对金额', 'kind' => 'number'],
|
||||
['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'],
|
||||
['name' => 'paid_amount', 'label' => '订单实付/实收金额', 'kind' => 'number'],
|
||||
['name' => 'difference', 'label' => '差额', 'kind' => 'number'],
|
||||
['name' => 'remark', 'label' => '差额异常备注'],
|
||||
['name' => 'match_status', 'label' => '匹配状态'],
|
||||
['name' => 'unmatched_reason', 'label' => '匹配原因'],
|
||||
['name' => 'planting_label', 'label' => '标签'],
|
||||
],
|
||||
],
|
||||
],
|
||||
['slug' => 'expense-details', 'title' => '费用明细'],
|
||||
['slug' => 'billing-details', 'title' => '账单明细'],
|
||||
['slug' => 'product-costs', 'title' => '商品成本'],
|
||||
[
|
||||
@ -946,6 +870,8 @@ $navigation = [
|
||||
],
|
||||
['slug' => 'installation-details', 'title' => '安装明细'],
|
||||
['slug' => 'promotion-expenses', 'title' => '推广费用'],
|
||||
['slug' => 'advertising-expenses', 'title' => '广告费用'],
|
||||
['slug' => 'marketing-expenses', 'title' => '营销费用'],
|
||||
['slug' => 'after-sales-expenses', 'title' => '售后费用'],
|
||||
['slug' => 'small-payment-work-orders', 'title' => '小额打款'],
|
||||
['slug' => 'management-expenses', 'title' => '管理费用'],
|
||||
@ -953,7 +879,6 @@ $navigation = [
|
||||
['slug' => 'bookkeeping-details', 'title' => '记账明细'],
|
||||
['slug' => 'non-operating-expenses', 'title' => '营业外支出'],
|
||||
['slug' => 'financial-analysis', 'title' => '财务经营分析表'],
|
||||
['slug' => 'ledger', 'title' => '财务台账', 'show_in_section_menu' => false],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -1016,6 +941,14 @@ $navigation = [
|
||||
['slug' => 'account-mapping', 'title' => '科目映射表'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'financial-ledger',
|
||||
'title' => '财务台账',
|
||||
'icon' => 'menu_book',
|
||||
'pages' => [
|
||||
['slug' => 'ledger', 'title' => '财务台账'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'data-monitoring',
|
||||
'title' => '数据监控',
|
||||
@ -1030,29 +963,6 @@ $navigation = [
|
||||
'title' => '其他',
|
||||
'icon' => 'more_horiz',
|
||||
'pages' => [
|
||||
[
|
||||
'slug' => 'systemization-todos',
|
||||
'title' => '系统化待办',
|
||||
'directory' => [
|
||||
'icon' => 'checklist',
|
||||
'status' => '持续更新',
|
||||
'description' => '财务系统化工作的日常待办、责任人、进度和完成截图。',
|
||||
'empty' => '暂时没有系统化待办。',
|
||||
'fields' => [
|
||||
['name' => 'category', 'label' => '类型'],
|
||||
['name' => 'section', 'label' => '内容'],
|
||||
['name' => 'title', 'label' => '待办事项'],
|
||||
['name' => 'owner', 'label' => '总负责人'],
|
||||
['name' => 'assignee', 'label' => '责任人'],
|
||||
['name' => 'start_date', 'label' => '开始时间', 'kind' => 'date'],
|
||||
['name' => 'due_date', 'label' => '完成时间', 'kind' => 'date'],
|
||||
['name' => 'status', 'label' => '状态'],
|
||||
['name' => 'progress', 'label' => '完成情况'],
|
||||
['name' => 'attachments', 'label' => '完成截图'],
|
||||
['name' => 'notes', 'label' => '备注'],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'todos',
|
||||
'title' => '待办事项',
|
||||
@ -1083,38 +993,27 @@ $navigation = [
|
||||
[
|
||||
'slug' => 'warnings',
|
||||
'title' => '预警页面',
|
||||
'children_inherit_parent_access' => true,
|
||||
'children' => [
|
||||
[
|
||||
'slug' => 'warning-overview',
|
||||
'title' => '预警总览',
|
||||
'directory' => [
|
||||
'icon' => 'warning',
|
||||
'status' => '尚未同步',
|
||||
'description' => '集中展示销量、税务、主体、人员、商标和续签风险,并可关联待办编号追踪处理结果。',
|
||||
'empty' => '预警数据尚未同步。完成规则引擎和预警来源接入后,这里会显示风险记录。',
|
||||
'fields' => [
|
||||
['name' => 'warning_number', 'label' => '预警编号'],
|
||||
['name' => 'warning_type', 'label' => '预警类型'],
|
||||
['name' => 'risk_level', 'label' => '风险等级'],
|
||||
['name' => 'related_object', 'label' => '关联对象'],
|
||||
['name' => 'risk_description', 'label' => '风险描述'],
|
||||
['name' => 'impact_amount', 'label' => '影响金额', 'kind' => 'number'],
|
||||
['name' => 'trigger_rule', 'label' => '触发规则'],
|
||||
['name' => 'triggered_at', 'label' => '触发时间', 'kind' => 'date'],
|
||||
['name' => 'owner', 'label' => '责任人'],
|
||||
['name' => 'collaborator', 'label' => '协同人'],
|
||||
['name' => 'due_at', 'label' => '截止时间', 'kind' => 'date'],
|
||||
['name' => 'todo_number', 'label' => '待办编号'],
|
||||
['name' => 'status', 'label' => '处理状态'],
|
||||
['name' => 'evidence', 'label' => '处理证据'],
|
||||
['name' => 'closed_by_date', 'label' => '关闭人/日期'],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'after-sales-return-refund',
|
||||
'title' => '售后退货退款',
|
||||
'directory' => [
|
||||
'icon' => 'warning',
|
||||
'status' => '尚未同步',
|
||||
'description' => '集中展示销量、税务、主体、人员、商标和续签风险,并可关联待办编号追踪处理结果。',
|
||||
'empty' => '预警数据尚未同步。完成规则引擎和预警来源接入后,这里会显示风险记录。',
|
||||
'fields' => [
|
||||
['name' => 'warning_number', 'label' => '预警编号'],
|
||||
['name' => 'warning_type', 'label' => '预警类型'],
|
||||
['name' => 'risk_level', 'label' => '风险等级'],
|
||||
['name' => 'related_object', 'label' => '关联对象'],
|
||||
['name' => 'risk_description', 'label' => '风险描述'],
|
||||
['name' => 'impact_amount', 'label' => '影响金额', 'kind' => 'number'],
|
||||
['name' => 'trigger_rule', 'label' => '触发规则'],
|
||||
['name' => 'triggered_at', 'label' => '触发时间', 'kind' => 'date'],
|
||||
['name' => 'owner', 'label' => '责任人'],
|
||||
['name' => 'collaborator', 'label' => '协同人'],
|
||||
['name' => 'due_at', 'label' => '截止时间', 'kind' => 'date'],
|
||||
['name' => 'todo_number', 'label' => '待办编号'],
|
||||
['name' => 'status', 'label' => '处理状态'],
|
||||
['name' => 'evidence', 'label' => '处理证据'],
|
||||
['name' => 'closed_by_date', 'label' => '关闭人/日期'],
|
||||
],
|
||||
],
|
||||
],
|
||||
@ -1128,7 +1027,6 @@ $requestedSectionOrder = [
|
||||
'invoice-management',
|
||||
'fund-management',
|
||||
'order-data',
|
||||
'order-profit',
|
||||
'refunds',
|
||||
'billing-transactions',
|
||||
'planting-data',
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Module-owned page extensions
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Keep additive page entries out of the shared navigation configuration.
|
||||
| Entries are appended to an existing section in configuration order.
|
||||
|
|
||||
*/
|
||||
'module_sections' => [
|
||||
[
|
||||
'position_after' => 'inventory-management',
|
||||
'section' => [
|
||||
'slug' => 'process-data',
|
||||
'title' => '流程数据',
|
||||
'icon' => 'account_tree',
|
||||
'pages' => [
|
||||
['slug' => 'oa-process', 'title' => 'OA流程'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Page-specific viewer allowlists
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These rules apply after the regular sidebar permissions, including to
|
||||
| super administrators. Use the page key in "section/page" form.
|
||||
|
|
||||
*/
|
||||
'page_viewers' => [
|
||||
'financial-operations/labor-expenses' => [
|
||||
'晨曦',
|
||||
'Chenxi',
|
||||
'江陵',
|
||||
'Jiangling',
|
||||
],
|
||||
],
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user