diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..b54cdb4
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,17 @@
+# MES 环境配置文件
+# 复制此文件为 .env 并修改实际值
+# 请勿将 .env 提交到版本控制系统
+
+# 数据库连接
+DB_HOST=localhost
+DB_USER=root
+DB_PASS=
+DB_NAME=mes2
+DB_COMPANY=Default Company
+
+# 运行模式(生产环境设为 false)
+APP_DEBUG=true
+APP_ENV=development
+
+# Session 安全密钥(请修改为随机字符串)
+SESSION_SECRET=change_me_to_random_string
diff --git a/.gitignore b/.gitignore
index 60d7971..010b86e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,13 +1,40 @@
-# ---> AL
-.vscode/*
-!.vscode/settings.json
-!.vscode/tasks.json
-!.vscode/launch.json
-!.vscode/extensions.json
-*.code-workspace
+# 环境配置(含数据库密码)
+.env
+.env.local
+.env.*.local
-# Local History for Visual Studio Code
-.history/
-*.app
-.snapshots/*
+# 数据库备份
+backups/
+*.sql
+!sql/*.sql
+# 安装锁
+config/install.lock
+
+# 文档(含系统架构和默认密码)
+*.md
+!README.md
+
+# 依赖
+/vendor/
+node_modules/
+
+# 临时文件
+*.log
+*.tmp
+*.cache
+*.swp
+
+# IDE
+.idea/
+.vscode/
+*.sublime-*
+
+# 操作系统
+Thumbs.db
+.DS_Store
+Desktop.ini
+
+# 上传文件
+uploads/
+/static/uploads/
diff --git a/.htaccess b/.htaccess
new file mode 100644
index 0000000..5c82c66
--- /dev/null
+++ b/.htaccess
@@ -0,0 +1,49 @@
+RewriteEngine On
+
+# If the request is not for a real file or directory
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+
+# Redirect all requests to index.php
+RewriteRule ^(.*)$ index.php [QSA,L]
+
+# Prevent directory listing
+Options -Indexes
+
+# Set default charset
+AddDefaultCharset UTF-8
+
+# ===== 安全加固 =====
+
+# 禁止访问敏感文件
+
+ Order allow,deny
+ Deny from all
+
+
+# 禁止直接访问诊断/调试/安装/修复脚本
+
+ Order allow,deny
+ Deny from all
+
+
+# 禁止访问备份目录(含 backups/ 和 _backup_/)
+
+ RewriteRule ^backups/ - [F,L]
+ RewriteRule ^_backup_ - [F,L]
+
+
+# 安全头
+
+ Header always set X-Content-Type-Options "nosniff"
+ Header always set X-Frame-Options "SAMEORIGIN"
+ Header always set X-XSS-Protection "1; mode=block"
+ Header always set Referrer-Policy "strict-origin-when-cross-origin"
+ # P2-6 / v3-P2:CSP 已迁移至 index.php 按请求动态生成(含 nonce),此处不再静态设置,
+ # 以避免与 PHP 端的 nonce 策略形成「双重 CSP 策略」冲突(浏览器会要求两者同时满足)。
+ # 当前 script-src 仍保留 'unsafe-inline' 作为迁移过渡;待全站内联事件处理器
+ #(onclick 等)改造为 addEventListener 后,再于 index.php 移除 'unsafe-inline'。
+ Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
+ # 仅 HTTPS 时启用 HSTS(站点已 301 强制 HTTPS,可安全启用)
+ Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
+
diff --git a/.user.ini b/.user.ini
new file mode 100644
index 0000000..4795f9b
--- /dev/null
+++ b/.user.ini
@@ -0,0 +1,3 @@
+open_basedir=/www/wwwroot/mes2/:/tmp/:/www/php_session/51chip.net/
+session.save_path=/www/php_session/51chip.net/
+session.save_handler = files
\ No newline at end of file
diff --git a/.well-known/acme-challenge/7vSb6XUQAHK2bLMijB2yZdF7y4YRdhqt2HdkTdtm0lY b/.well-known/acme-challenge/7vSb6XUQAHK2bLMijB2yZdF7y4YRdhqt2HdkTdtm0lY
new file mode 100644
index 0000000..6af25bb
--- /dev/null
+++ b/.well-known/acme-challenge/7vSb6XUQAHK2bLMijB2yZdF7y4YRdhqt2HdkTdtm0lY
@@ -0,0 +1 @@
+7vSb6XUQAHK2bLMijB2yZdF7y4YRdhqt2HdkTdtm0lY.EgW56fMct7Vw7Pg8dB7a_Izx7j9jHmVtrm_VZFphzHg
\ No newline at end of file
diff --git a/.well-known/security.txt b/.well-known/security.txt
new file mode 100644
index 0000000..5c3652e
--- /dev/null
+++ b/.well-known/security.txt
@@ -0,0 +1,5 @@
+Contact: mailto:security@51chip.net
+Expires: 2026-12-31T23:59:59.000Z
+Preferred-Languages: zh, en
+Canonical: https://51chip.net/.well-known/security.txt
+Policy: https://51chip.net/security-policy.html
diff --git a/README-上传说明.txt b/README-上传说明.txt
new file mode 100644
index 0000000..1136f92
--- /dev/null
+++ b/README-上传说明.txt
@@ -0,0 +1,34 @@
+MES2 站点上线/上传包 — 说明
+================================
+
+生成时间:2026-08-01
+域名:51chip.net 服务器网站根目录(示例):/www/wwwroot/mes2/
+
+本包包含本轮所有改动,分两类:
+
+【A 类:直接覆盖网站目录的文件】
+将 app/ 目录整体解压到网站根目录即可,按相对路径覆盖同名文件(仅覆盖以下 5 个文件,不影响其它文件):
+ - app/views/Login/index.php 登录页页脚(备案号)
+ - app/views/Dashboard/index.php 控制台页脚(备案号)
+ - app/views/layouts/station_footer.php 工位前台页脚(备案号)
+ - app/views/layouts/admin_footer.php 后台统一页脚(备案号)
+ - app/views/layouts/front_footer.php 备用页脚(备案号,未被引用但保持同步)
+
+Linux 服务器解压命令(在网站根目录执行):
+ tar -xzf mes2-upload-20260801.tar.gz -C /www/wwwroot/mes2/
+(若用 FTP 先上传到服务器再解压,注意只覆盖上述 5 个文件即可)
+
+【B 类:参考/配置文件,不要丢进网站根目录】
+ - reference/nginx-vhost-mes2.conf 宝塔 Nginx 站点配置(Apache→Nginx 迁移用)
+ - reference/NGINX_MIGRATION_GUIDE.md 迁移与部署指南
+使用方法:宝塔面板 → 网站 → 51chip.net → 设置 → 配置文件,
+用 nginx-vhost-mes2.conf 的内容整体替换(注意把 enable-php-XX 改成实际 PHP 版本,当前为 8.3),
+保存后宝塔会自动 nginx -t 校验。详见 NGINX_MIGRATION_GUIDE.md。
+
+【本次改动摘要】
+1. 备案号:粤ICP备16118477号-1,已按规范显示在全部对外页脚并超链到 https://beian.miit.gov.cn/
+2. Nginx 配置已修复 /Admin/index 等漂亮 URL 的 404(前端控制器 try_files 内联,不再依赖空伪静态文件)
+
+【验证】
+ curl -i https://51chip.net/Admin/index 应返回 200/302(非 404)
+ 浏览器打开首页,页脚可见「备案号:粤ICP备16118477号-1」并指向 beian.miit.gov.cn
diff --git a/app/controllers/AdminController.php b/app/controllers/AdminController.php
new file mode 100644
index 0000000..b68945a
--- /dev/null
+++ b/app/controllers/AdminController.php
@@ -0,0 +1,3280 @@
+checkLogin();
+ $user = $this->getCurrentUser();
+
+ // 只有超级管理员 或 MES类目的管理员可访问
+ if ($user['role'] === self::ROLE_SUPER_ADMIN) {
+ // 超级管理员可以访问
+ } elseif ($user['role'] === self::ROLE_ADMIN && $user['category'] === self::CATEGORY_MES) {
+ // MES 类目管理员可以访问
+ } else {
+ header('Location: ' . BASE_URL . '/Front/index');
+ exit;
+ }
+
+ $config = new SysConfig();
+ $sysConfig = $config->getConfig();
+
+ // 统计数据
+ $employee = new Employee();
+ $employeeCount = count($employee->getAll());
+
+ $customer = new Customer();
+ $customerCount = count($customer->getAll());
+
+ $productModel = new ProductModel();
+ $modelCount = count($productModel->getAll());
+
+ $workstation = new Workstation();
+ $stationCount = count($workstation->getAll());
+
+ $this->assign('title', '后台管理');
+ $this->assign('user', $user);
+ $this->assign('sysConfig', $sysConfig);
+ $this->assign('employeeCount', $employeeCount);
+ $this->assign('customerCount', $customerCount);
+ $this->assign('modelCount', $modelCount);
+ $this->assign('stationCount', $stationCount);
+ $this->render();
+ }
+
+ // 员工管理
+ public function employee()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ // 仅超级管理员可访问员工管理
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $employee = new Employee();
+ $employees = $employee->getAll();
+
+ // 管理员看不到超级管理员(比自己权限高的不可见)
+ if ($user['role'] === self::ROLE_ADMIN) {
+ $employees = array_filter($employees, function($emp) {
+ return $emp['role'] !== self::ROLE_SUPER_ADMIN;
+ });
+ $employees = array_values($employees); // re-index
+ }
+
+ // Flash 消息(一次性提示,如密码生成提示)
+ $flash = $_SESSION['flash_message'] ?? null;
+ if ($flash) {
+ unset($_SESSION['flash_message']);
+ }
+
+ $this->assign('csrfToken', $this->csrfToken());
+
+ $this->assign('title', '员工管理');
+ $this->assign('user', $user);
+ $this->assign('employees', $employees);
+ $this->assign('flash', $flash);
+ $this->render();
+ }
+
+ public function employeeAdd()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ // 只有超级管理员可以添加员工(管理员由超管分配类目)
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
+ exit('无权限访问');
+ }
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+
+ // 输入验证
+ $empName = trim($_POST['emp_name'] ?? '');
+ if (empty($empName) || mb_strlen($empName) > 50) {
+ exit('员工姓名无效');
+ }
+
+ $role = $_POST['role'];
+ $category = $_POST['category'] ?? self::CATEGORY_MES;
+
+ // 管理员和操作员必须分配类目
+ if ($role !== self::ROLE_SUPER_ADMIN && empty($category)) {
+ exit('请为管理员/操作员选择管理类目');
+ }
+
+ // 自动生成员工编号:ZXY + 三位流水号
+ $emp_no = $this->generateEmpNo();
+
+ $data = array(
+ 'emp_no' => $emp_no,
+ 'emp_name' => $empName,
+ 'password' => $_POST['password'] ?? '',
+ 'role' => $role,
+ 'category' => ($role === self::ROLE_SUPER_ADMIN) ? self::CATEGORY_ALL : $category,
+ 'created_by' => $user['emp_name']
+ );
+
+ try {
+ $employee = new Employee();
+ $id = $employee->addEmployee($data);
+ if (!$id) {
+ // 插入失败(如编号重复),抛出异常让 catch 处理
+ $dbErr = $employee->getError() ?: '未知数据库错误';
+ throw new \RuntimeException('添加员工失败:' . $dbErr);
+ }
+ $genPwd = $employee->getLastGeneratedPwd();
+
+ if ($genPwd) {
+ // 自动生成密码时,跳转到列表并显示密码提示
+ $_SESSION['flash_message'] = "员工 {$empName}({$emp_no})已创建,初始密码为:{$genPwd},请通知该员工首次登录后修改密码";
+ }
+ } catch (\RuntimeException $e) {
+ $this->assign('error', $e->getMessage());
+ $this->assign('title', '添加员工');
+ $this->assign('user', $user);
+ $this->assign('manageableRoles', self::getManageableRoles($user['role']));
+ $this->assign('nextEmpNo', $emp_no);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ header('Location: ' . BASE_URL . '/Admin/employee');
+ exit;
+ }
+
+ // 传递可选角色列表和类目列表到视图
+ $manageableRoles = self::getManageableRoles($user['role']);
+ $categoryOptions = self::getCategoryOptions();
+
+ // 预览下一个编号
+ $nextEmpNo = $this->generateEmpNo();
+
+ $this->assign('title', '添加员工');
+ $this->assign('user', $user);
+ $this->assign('manageableRoles', $manageableRoles);
+ $this->assign('categoryOptions', $categoryOptions);
+ $this->assign('nextEmpNo', $nextEmpNo);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function employeeEdit($id)
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ // 只有超级管理员可以编辑
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $employee = new Employee();
+ $emp = $employee->where(['id = :id'], [':id' => $id])->fetch();
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ try {
+ $newRole = $_POST['role'];
+ $newCategory = $_POST['category'] ?? self::CATEGORY_MES;
+
+ $data = array(
+ 'emp_name' => $_POST['emp_name'],
+ 'role' => $newRole,
+ 'category' => ($newRole === self::ROLE_SUPER_ADMIN) ? self::CATEGORY_ALL : $newCategory
+ );
+
+ $employee->updateEmployee($id, $data);
+ header('Location: ' . BASE_URL . '/Admin/employee');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[employeeEdit] ' . $e->getMessage());
+ $this->assign('error', '更新失败:' . $e->getMessage());
+ $this->assign('title', '编辑员工');
+ $this->assign('user', $user);
+ $this->assign('emp', $emp);
+ $manageableRoles = self::getManageableRoles($user['role']);
+ $categoryOptions = self::getCategoryOptions();
+ $this->assign('manageableRoles', $manageableRoles);
+ $this->assign('categoryOptions', $categoryOptions);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $manageableRoles = self::getManageableRoles($user['role']);
+ $categoryOptions = self::getCategoryOptions();
+
+ $this->assign('title', '编辑员工');
+ $this->assign('user', $user);
+ $this->assign('emp', $emp);
+ $this->assign('manageableRoles', $manageableRoles);
+ $this->assign('categoryOptions', $categoryOptions);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function employeeDelete($id)
+ {
+ $this->checkLogin();
+ $this->requireMethod('POST');
+ $this->csrfVerify();
+ $user = $this->getCurrentUser();
+
+ // 只有超级管理员可以删除
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $employee = new Employee();
+ $emp = $employee->where(['id = :id'], [':id' => $id])->fetch();
+
+ // 不能删除自己
+ if ((int)$emp['id'] === (int)$user['id']) {
+ exit('不能删除自己的账号');
+ }
+
+ $employee->delete($id);
+ header('Location: ' . BASE_URL . '/Admin/employee');
+ }
+
+ public function employeeResetPwd($id)
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ // 只有超级管理员可以重置密码
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $employee = new Employee();
+ $emp = $employee->where(['id = :id'], [':id' => $id])->fetch();
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ try {
+ $new_pwd = $_POST['new_password'];
+ $employee = new Employee();
+ $employee->resetPassword($id, $new_pwd);
+ header('Location: ' . BASE_URL . '/Admin/employee');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[employeeResetPwd] ' . $e->getMessage());
+ $this->assign('error', '重置密码失败:' . $e->getMessage());
+ $this->assign('title', '重置密码');
+ $this->assign('user', $user);
+ $this->assign('emp', $emp);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '重置密码');
+ $this->assign('user', $user);
+ $this->assign('emp', $emp);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ /**
+ * 设置员工工位权限
+ */
+ public function employeePermission($id)
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ // 只有超级管理员可以设置工位权限
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $employee = new Employee();
+ $emp = $employee->where(['id = :id'], [':id' => $id])->fetch();
+
+ if (!$emp) {
+ exit('员工不存在');
+ }
+
+ $workstation = new Workstation();
+ $allStations = $workstation->getAll();
+
+ $permModel = new EmployeePermission();
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+
+ $permissions = [];
+ // $_POST['allow'] 是勾选的工位类型数组
+ $allowedTypes = isset($_POST['allow']) ? (array)$_POST['allow'] : [];
+
+ foreach ($allStations as $station) {
+ $stype = $station['station_type'];
+ $permissions[$stype] = in_array($stype, $allowedTypes) ? 1 : 0;
+ }
+
+ $permModel->savePermissions($id, $permissions);
+ header('Location: ' . BASE_URL . '/Admin/employeePermission/' . $id . '?saved=1');
+ exit;
+ }
+
+ // 获取当前权限(表不存在时友好提示,而非 500)
+ try {
+ $currentPermissions = $permModel->getByEmployee($id);
+ } catch (\PDOException $e) {
+ exit('数据表 DGZXY_employee_station_permission 不存在,请在数据库中执行 sql/create_employee_station_permission.sql');
+ }
+
+ $saved = isset($_GET['saved']) && $_GET['saved'] === '1';
+
+ $this->assign('title', '工位权限设置 - ' . $emp['emp_name']);
+ $this->assign('user', $user);
+ $this->assign('emp', $emp);
+ $this->assign('stations', $allStations);
+ $this->assign('currentPermissions', $currentPermissions);
+ $this->assign('saved', $saved);
+ $this->assign('activeMenu', 'employee');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ // 客户管理 — 超级管理员和管理员
+ public function customer()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $customer = new Customer();
+ $customers = $customer->getAll();
+
+ $this->assign('title', '客户管理');
+ $this->assign('user', $user);
+ $this->assign('customers', $customers);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function customerAdd()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ try {
+ $data = array(
+ 'company_name' => $_POST['company_name'],
+ 'contact1' => $_POST['contact1'],
+ 'phone1' => $_POST['phone1'],
+ 'contact2' => $_POST['contact2'],
+ 'phone2' => $_POST['phone2'],
+ 'created_by' => $user['emp_name']
+ );
+
+ $customer = new Customer();
+ $customer->addCustomer($data);
+ header('Location: ' . BASE_URL . '/Admin/customer');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[customerAdd] ' . $e->getMessage());
+ $this->assign('error', '添加失败:' . $e->getMessage());
+ $this->assign('title', '添加客户');
+ $this->assign('user', $user);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '添加客户');
+ $this->assign('user', $user);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function customerEdit($id)
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $customer = new Customer();
+ $cust = $customer->where(['id = :id'], [':id' => $id])->fetch();
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ try {
+ $data = array(
+ 'company_name' => $_POST['company_name'],
+ 'contact1' => $_POST['contact1'],
+ 'phone1' => $_POST['phone1'],
+ 'contact2' => $_POST['contact2'],
+ 'phone2' => $_POST['phone2']
+ );
+
+ $customer->updateCustomer($id, $data);
+ header('Location: ' . BASE_URL . '/Admin/customer');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[customerEdit] ' . $e->getMessage());
+ $this->assign('error', '更新失败:' . $e->getMessage());
+ $this->assign('title', '编辑客户');
+ $this->assign('user', $user);
+ $this->assign('cust', $cust);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '编辑客户');
+ $this->assign('user', $user);
+ $this->assign('cust', $cust);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function customerDelete($id)
+ {
+ $this->checkLogin();
+ $this->requireMethod('POST');
+ $this->csrfVerify();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $customer = new Customer();
+ $customer->delete($id);
+ header('Location: ' . BASE_URL . '/Admin/customer');
+ }
+
+ // 产品型号管理 — 仅超级管理员
+ public function productModel()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $productModel = new ProductModel();
+ $allModels = $productModel->getAll();
+
+ // 按 type 分组
+ $grouped = [];
+ foreach ($allModels as $m) {
+ $t = $m['type'] ?: '未分类';
+ $grouped[$t][] = $m;
+ }
+
+ // 从数据库读取类型配置(排序),表不存在时回退到硬编码默认值
+ $typeConfigModel = new ProductTypeConfig();
+ $typeConfigs = $typeConfigModel->getAll();
+ if (empty($typeConfigs)) {
+ $typeConfigs = ProductTypeConfig::getDefaultConfigs();
+ }
+ $orderedTypeNames = [];
+ $typeColorMap = [];
+ foreach ($typeConfigs as $cfg) {
+ $orderedTypeNames[] = $cfg['type_name'];
+ $typeColorMap[$cfg['type_name']] = $cfg['color'] ?? 'default';
+ }
+
+ // 按配置的顺序排列,未配置的追加在后面
+ $ordered = [];
+ foreach ($orderedTypeNames as $t) {
+ if (isset($grouped[$t])) {
+ $ordered[$t] = $grouped[$t];
+ unset($grouped[$t]);
+ }
+ }
+ $ordered += $grouped; // 剩下的(包括"未分类")追加在后面
+
+ $this->assign('title', '产品型号管理');
+ $this->assign('user', $user);
+ $this->assign('groupedModels', $ordered);
+ $this->assign('typeConfigs', $typeConfigs);
+ $this->assign('typeColorMap', $typeColorMap);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function productModelAdd()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+
+ $type = trim($_POST['type'] ?? '');
+ $product_type = trim($_POST['product_type'] ?? '');
+ $capacity = trim($_POST['capacity'] ?? '');
+ $color = trim($_POST['color'] ?? '');
+ $model_code = trim($_POST['model_code'] ?? '');
+ $model_desc = trim($_POST['description'] ?? '');
+
+ // 从数据库获取类型配置,表不存在时回退到默认配置
+ $typeConfigModel = new ProductTypeConfig();
+ $typeConfig = $typeConfigModel->getByTypeName($type);
+ if (!$typeConfig) {
+ // 数据库配置不可用时,使用硬编码默认配置验证类型
+ $defaultConfigs = ProductTypeConfig::getDefaultConfigs();
+ $typeNames = [];
+ foreach ($defaultConfigs as $dc) {
+ $typeNames[] = $dc['type_name'];
+ if ($dc['type_name'] === $type) {
+ $typeConfig = $dc;
+ }
+ }
+ if (!$typeConfig) {
+ $this->assign('error', '类型无效,请选择:' . implode('、', $typeNames));
+ $this->assign('title', '添加产品型号');
+ $this->assign('user', $user);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ // 根据配置决定 product_type 和 capacity 值
+ $productTypeField = $typeConfig['product_type_field'] ?? 'product_type';
+ if ($productTypeField === 'capacity' && $capacity !== '') {
+ // 电池类型:capacity 存入 capacity 字段,product_type 留空
+ $product_type = '';
+ } elseif ($productTypeField === 'color' && $color !== '') {
+ $product_type = $color;
+ }
+ // 隐藏产品类型输入框的类型 → 设为空
+ if (($typeConfig['product_type_state'] ?? 'editable') === 'hidden') {
+ $product_type = '';
+ }
+
+ // model_code 输入框隐藏时,用 product_type 值自动填充(如"外壳颜色"类型)
+ if (($typeConfig['model_code_state'] ?? 'editable') === 'hidden' && $model_code === '') {
+ $model_code = $product_type ?: $color;
+ }
+
+ $productModel = new ProductModel();
+
+ try {
+ // 检测同一产品类型+类型下型号代码是否已存在
+ $exist = $productModel->findByTypeAndCode($type, $product_type, $model_code);
+ if ($exist) {
+ $this->assign('error', '该类型+产品类型下型号「' . htmlspecialchars($model_code) . '」已存在,请勿重复录入!');
+ $this->assign('title', '添加产品型号');
+ $this->assign('user', $user);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+
+ $data = array(
+ 'type' => $type,
+ 'product_type' => $product_type,
+ 'model_code' => $model_code,
+ 'model_desc' => $model_desc,
+ 'capacity' => $capacity,
+ 'color' => $color,
+ );
+
+ $result = $productModel->addModel($data);
+ if ($result === false) {
+ $err = $productModel->getError();
+ $this->assign('error', '添加失败:' . ($err ?: '未知错误'));
+ $this->assign('title', '添加产品型号');
+ $this->assign('user', $user);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ } catch (\Throwable $e) {
+ error_log('[productModelAdd] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
+ $this->assign('error', '添加失败,数据库错误:' . $e->getMessage());
+ $this->assign('title', '添加产品型号');
+ $this->assign('user', $user);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+
+ header('Location: ' . BASE_URL . '/Admin/productModel');
+ exit;
+ }
+
+ // 传入类型配置列表给视图,表不存在时回退到硬编码默认值
+ $typeConfigModel = new ProductTypeConfig();
+ $typeConfigs = $typeConfigModel->getEnabled();
+ if (empty($typeConfigs)) {
+ $typeConfigs = ProductTypeConfig::getDefaultConfigs();
+ }
+
+ $this->assign('title', '添加产品型号');
+ $this->assign('user', $user);
+ $this->assign('typeConfigs', $typeConfigs);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function productModelEdit($id)
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $productModel = new ProductModel();
+ $model = $productModel->where(['id = :id'], [':id' => $id])->fetch();
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+
+ $type = trim($_POST['type'] ?? '');
+ $product_type = trim($_POST['product_type'] ?? '');
+ $capacity = trim($_POST['capacity'] ?? '');
+ $color = trim($_POST['color'] ?? '');
+ $model_code = trim($_POST['model_code'] ?? '');
+ $model_desc = trim($_POST['description'] ?? '');
+
+ // 从数据库获取类型配置,表不存在时回退到默认配置
+ $typeConfigModel = new ProductTypeConfig();
+ $typeConfig = $typeConfigModel->getByTypeName($type);
+ if (!$typeConfig) {
+ // 数据库配置不可用时,使用硬编码默认配置验证类型
+ $defaultConfigs = ProductTypeConfig::getDefaultConfigs();
+ $typeNames = [];
+ foreach ($defaultConfigs as $dc) {
+ $typeNames[] = $dc['type_name'];
+ if ($dc['type_name'] === $type) {
+ $typeConfig = $dc;
+ }
+ }
+ if (!$typeConfig) {
+ $this->assign('error', '类型无效,请选择:' . implode('、', $typeNames));
+ $this->assign('title', '编辑产品型号');
+ $this->assign('user', $user);
+ $this->assign('model', $model);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ // 根据配置决定 product_type 和 capacity 值
+ $productTypeField = $typeConfig['product_type_field'] ?? 'product_type';
+ if ($productTypeField === 'capacity' && $capacity !== '') {
+ // 电池类型:capacity 存入 capacity 字段,product_type 留空
+ $product_type = '';
+ } elseif ($productTypeField === 'color' && $color !== '') {
+ $product_type = $color;
+ }
+ // 隐藏产品类型输入框的类型 → 设为空
+ if (($typeConfig['product_type_state'] ?? 'editable') === 'hidden') {
+ $product_type = '';
+ }
+
+ try {
+ // 检测同一类型+产品类型下型号代码是否已存在(排除自身)
+ $exist = $productModel->findByTypeAndCode($type, $product_type, $model_code);
+ if ($exist && $exist['id'] != $id) {
+ $this->assign('error', '该类型+产品类型下型号「' . htmlspecialchars($model_code) . '」已存在,请勿重复录入!');
+ $this->assign('title', '编辑产品型号');
+ $this->assign('user', $user);
+ $this->assign('model', $model);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+
+ $data = array(
+ 'type' => $type,
+ 'product_type' => $product_type,
+ 'model_code' => $model_code,
+ 'model_desc' => $model_desc,
+ 'capacity' => $capacity,
+ 'color' => $color,
+ );
+
+ $result = $productModel->updateModel($id, $data);
+ if ($result === false) {
+ $err = $productModel->getError();
+ $this->assign('error', '更新失败:' . ($err ?: '未知错误'));
+ $this->assign('title', '编辑产品型号');
+ $this->assign('user', $user);
+ $this->assign('model', $model);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ } catch (\Throwable $e) {
+ error_log('[productModelEdit] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
+ $this->assign('error', '更新失败,数据库错误:' . $e->getMessage());
+ $this->assign('title', '编辑产品型号');
+ $this->assign('user', $user);
+ $this->assign('model', $model);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+
+ header('Location: ' . BASE_URL . '/Admin/productModel');
+ exit;
+ }
+
+ if (!$model) {
+ exit('产品型号不存在或已被删除');
+ }
+
+ // 传入类型配置列表给视图,表不存在时回退到硬编码默认值
+ $typeConfigModel = new ProductTypeConfig();
+ $typeConfigs = $typeConfigModel->getEnabled();
+ if (empty($typeConfigs)) {
+ $typeConfigs = ProductTypeConfig::getDefaultConfigs();
+ }
+
+ $this->assign('title', '编辑产品型号');
+ $this->assign('user', $user);
+ $this->assign('model', $model);
+ $this->assign('typeConfigs', $typeConfigs);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function productModelDelete($id)
+ {
+ $this->checkLogin();
+ $this->requireMethod('POST');
+ $this->csrfVerify();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $productModel = new ProductModel();
+ $productModel->deleteModel($id);
+ header('Location: ' . BASE_URL . '/Admin/productModel');
+ }
+
+ /**
+ * 产品类型配置管理页面
+ * 管理员可以在页面中增删改类型,定义每种类型的显示列、标签等
+ */
+ public function productTypeConfig()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $typeConfigModel = new ProductTypeConfig();
+
+ // AJAX: 保存(添加/更新)
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $action = $_POST['action'] ?? 'save';
+
+ if ($action === 'delete') {
+ $id = intval($_POST['id'] ?? 0);
+ if ($id > 0) {
+ $result = $typeConfigModel->deleteConfig($id);
+ if ($result === false) {
+ echo json_encode(['success' => false, 'error' => '删除失败:数据库错误,请检查 DGZXY_product_type_config 表是否存在']);
+ exit;
+ }
+ }
+ echo json_encode(['success' => true]);
+ exit;
+ }
+
+ // save 操作
+ $id = intval($_POST['id'] ?? 0);
+ $type_name = trim($_POST['type_name'] ?? '');
+ $sort_order = intval($_POST['sort_order'] ?? 0);
+ $display_columns = trim($_POST['display_columns'] ?? '');
+ $product_type_state = trim($_POST['product_type_state'] ?? 'editable');
+ $product_type_label = trim($_POST['product_type_label'] ?? '产品类型');
+ $product_type_field = trim($_POST['product_type_field'] ?? 'product_type');
+ $product_type_placeholder = trim($_POST['product_type_placeholder'] ?? '');
+ $model_code_state = trim($_POST['model_code_state'] ?? 'editable');
+ $color_state = trim($_POST['color_state'] ?? 'editable');
+ $color = trim($_POST['color'] ?? 'default');
+ $enabled = intval($_POST['enabled'] ?? 1);
+ $show_in_inbound = intval($_POST['show_in_inbound'] ?? 1);
+
+ // 校验三态值合法性
+ $validStates = ['hidden', 'readonly', 'editable'];
+ if (!in_array($product_type_state, $validStates)) $product_type_state = 'editable';
+ if (!in_array($model_code_state, $validStates)) $model_code_state = 'editable';
+ if (!in_array($color_state, $validStates)) $color_state = 'editable';
+
+ if ($type_name === '') {
+ echo json_encode(['success' => false, 'error' => '类型名称不能为空']);
+ exit;
+ }
+
+ $data = [
+ 'type_name' => $type_name,
+ 'sort_order' => $sort_order,
+ 'display_columns' => $display_columns,
+ 'product_type_state' => $product_type_state,
+ 'product_type_label' => $product_type_label,
+ 'product_type_field' => $product_type_field,
+ 'product_type_placeholder' => $product_type_placeholder,
+ 'model_code_state' => $model_code_state,
+ 'color_state' => $color_state,
+ 'color' => $color,
+ 'enabled' => $enabled,
+ 'show_in_inbound' => $show_in_inbound,
+ ];
+
+ if ($id > 0) {
+ $result = $typeConfigModel->updateConfig($id, $data);
+ if ($result === false) {
+ echo json_encode(['success' => false, 'error' => '更新失败:数据库错误,请检查 DGZXY_product_type_config 表是否存在']);
+ exit;
+ }
+ } else {
+ // 检查重名
+ $exist = $typeConfigModel->getByTypeName($type_name);
+ if ($exist) {
+ echo json_encode(['success' => false, 'error' => '类型「' . $type_name . '」已存在']);
+ exit;
+ }
+ $result = $typeConfigModel->addConfig($data);
+ if ($result === false) {
+ echo json_encode(['success' => false, 'error' => '添加失败:数据库错误,请检查 DGZXY_product_type_config 表是否存在']);
+ exit;
+ }
+ }
+
+ echo json_encode(['success' => true]);
+ exit;
+ }
+
+ // GET: 渲染页面
+ $configs = $typeConfigModel->getAll();
+
+ $this->assign('title', '产品类型配置');
+ $this->assign('user', $user);
+ $this->assign('configs', $configs);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ // 工位管理 — 仅超级管理员
+ public function workstation()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $workstation = new Workstation();
+ $stations = $workstation->getAll();
+
+ $this->assign('title', '工位管理');
+ $this->assign('user', $user);
+ $this->assign('stations', $stations);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function workstationAdd()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ // 准备模板字段配置数据(提前初始化,供错误分支和 GET 视图使用)
+ require_once APP_PATH . 'app/helpers/field_helper.php';
+ $templateTypes = ['inbound', 'pcb_test', 'battery', 'assembly', 'finished_test', 'warehouse', 'delivery'];
+ $templateFieldsMap = [];
+ foreach ($templateTypes as $type) {
+ $templateFieldsMap[$type] = getDefaultFieldsConfig($type);
+ }
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $stationType = trim($_POST['station_type']);
+
+ // 校验工位类型格式:必须以字母开头,只能包含字母数字和下划线
+ if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $stationType)) {
+ $this->assign('error', '工位类型格式不正确,必须以字母开头,只能包含字母、数字和下划线');
+ $this->assign('title', '添加工位');
+ $this->assign('user', $user);
+ $this->assign('templateFieldsMap', $templateFieldsMap);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+
+ // 状态值验证
+ $status = intval($_POST['status'] ?? 1);
+ if (!in_array($status, [0, 1], true)) {
+ $status = 1; // 默认启用
+ }
+
+ try {
+ $data = array(
+ 'station_name' => $_POST['station_name'],
+ 'station_code' => $_POST['station_code'],
+ 'station_type' => $stationType,
+ 'sort_order' => intval($_POST['sort_order'] ?? 1),
+ 'status' => $status,
+ 'fields_config' => $_POST['fields_config'] ?? ''
+ );
+
+ // 如果未传入配置,则尝试从模板复制或使用默认配置
+ if (empty($data['fields_config'])) {
+ $templateType = $_POST['template_station'] ?? '';
+
+ if (!empty($templateType)) {
+ $templateConfig = getDefaultFieldsConfig($templateType);
+ $data['fields_config'] = json_encode($templateConfig, JSON_UNESCAPED_UNICODE);
+ } else {
+ $data['fields_config'] = json_encode([], JSON_UNESCAPED_UNICODE);
+ }
+ }
+
+ $workstation = new Workstation();
+ $workstation->addStation($data);
+ header('Location: ' . BASE_URL . '/Admin/workstation');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[workstationAdd] ' . $e->getMessage());
+ $this->assign('error', '添加失败:' . $e->getMessage());
+ $this->assign('title', '添加工位');
+ $this->assign('user', $user);
+ $this->assign('templateFieldsMap', $templateFieldsMap);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '添加工位');
+ $this->assign('user', $user);
+ $this->assign('templateFieldsMap', $templateFieldsMap);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function workstationEdit($id)
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ require_once APP_PATH . 'app/helpers/field_helper.php';
+
+ $workstation = new Workstation();
+ $station = $workstation->where(['id = :id'], [':id' => $id])->fetch();
+
+ // 加载工位定义元数据
+ $stationDef = new StationDefinition();
+ $stationDefinition = $stationDef->getByType($station['station_type']);
+
+ // 解析字段配置用于编辑器(提前初始化,供错误分支使用)
+ $fieldEditor = renderFieldConfigEditor($station['station_type'], $station['fields_config'] ?? '', $stationDefinition);
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+
+ // 状态值验证:只允许 0(禁用)和 1(启用)
+ $status = intval($_POST['status']);
+ if (!in_array($status, [0, 1], true)) {
+ $this->assign('error', '状态值无效,请选择启用或禁用');
+ $this->assign('title', '编辑工位');
+ $this->assign('user', $user);
+ $this->assign('station', $station);
+ $this->assign('fieldEditor', $fieldEditor);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+
+ try {
+ $data = array(
+ 'station_name' => $_POST['station_name'],
+ 'sort_order' => $_POST['sort_order'],
+ 'status' => $status,
+ 'fields_config' => $_POST['fields_config'] ?? ''
+ );
+
+ $workstation->updateStation($id, $data);
+
+ // 同步更新 station_definitions 表(管控产品类型行为)
+ $sdData = [
+ 'has_product_type' => isset($_POST['sd_has_product_type']) ? 1 : 0,
+ 'has_product_model' => isset($_POST['sd_has_product_model']) ? 1 : 0,
+ 'filter_enabled' => isset($_POST['sd_filter_enabled']) ? 1 : 0,
+ 'show_today_count' => isset($_POST['sd_show_today_count']) ? 1 : 0,
+ 'show_total_count' => isset($_POST['sd_show_total_count']) ? 1 : 0,
+ ];
+
+ if ($stationDefinition && !empty($stationDefinition['id'])) {
+ // 更新已有定义
+ $sdFields = [];
+ $sdParams = [];
+ foreach ($sdData as $key => $val) {
+ $sdFields[] = "`{$key}` = :{$key}";
+ $sdParams[":{$key}"] = $val;
+ }
+ $sdParams[':id'] = $stationDefinition['id'];
+ $stationDef->execute(
+ "UPDATE `" . StationDefinition::TABLE_PREFIX . "station_definitions` SET " . implode(', ', $sdFields) . " WHERE id = :id",
+ $sdParams
+ );
+ // 更新已有工位:同步字段变更到数据表
+ StationDefinition::clearCache();
+ $updatedDef = $stationDef->getByType($station['station_type']);
+ $fieldConfigs = parseFieldsConfig($station['station_type'], $_POST['fields_config'] ?? '');
+ unset($fieldConfigs['_meta']);
+ $stationDef->ensureColumns($station['station_type'], array_values($fieldConfigs));
+ } else {
+ // 新建定义
+ $sdData['station_type'] = $station['station_type'];
+ $sdData['db_table'] = 'station_' . $station['station_type'];
+ $sdData['time_column'] = 'created_at';
+ $sdFields = implode('`, `', array_keys($sdData));
+ $sdPlaceholders = ':' . implode(', :', array_keys($sdData));
+ $stationDef->execute(
+ "INSERT INTO `" . StationDefinition::TABLE_PREFIX . "station_definitions` (`{$sdFields}`) VALUES ({$sdPlaceholders})",
+ $sdData
+ );
+ // 新工位:自动创建数据表
+ StationDefinition::clearCache();
+ $newDef = $stationDef->getByType($station['station_type']);
+ $fieldConfigs = parseFieldsConfig($station['station_type'], $_POST['fields_config'] ?? '');
+ unset($fieldConfigs['_meta']);
+ $stationDef->ensureTable($station['station_type'], array_values($fieldConfigs));
+ }
+
+ // 清除缓存,确保下次读取时获取最新数据
+ StationDefinition::clearCache();
+
+ header('Location: ' . BASE_URL . '/Admin/workstation');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[workstationEdit] ' . $e->getMessage());
+ $this->assign('error', '更新失败:' . $e->getMessage());
+ $this->assign('title', '编辑工位');
+ $this->assign('user', $user);
+ $this->assign('station', $station);
+ $this->assign('fieldEditor', $fieldEditor);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '编辑工位');
+ $this->assign('user', $user);
+ $this->assign('station', $station);
+ $this->assign('fieldEditor', $fieldEditor);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function workstationDelete($id)
+ {
+ $this->checkLogin();
+ $this->requireMethod('POST');
+ $this->csrfVerify();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $workstation = new Workstation();
+ $workstation->deleteStation($id);
+ header('Location: ' . BASE_URL . '/Admin/workstation');
+ }
+
+ // 系统配置 — 仅超级管理员可访问
+ public function config()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $config = new SysConfig();
+ $sysConfig = $config->getConfig();
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ try {
+ $data = array(
+ 'company_name' => $_POST['company_name'],
+ 'mes_name' => $_POST['mes_name']
+ );
+
+ $config->updateConfig($data);
+
+ // 保存代码前缀规则
+ if (isset($_POST['prefix_types']) && isset($_POST['prefix_values'])) {
+ $types = $_POST['prefix_types'];
+ $values = $_POST['prefix_values'];
+ $rules = [];
+ $typeCount = count($types);
+ for ($i = 0; $i < $typeCount; $i++) {
+ $typeName = trim($types[$i]);
+ $prefixVal = trim($values[$i]);
+ if ($typeName !== '') {
+ $rules[$typeName] = $prefixVal;
+ }
+ }
+ $config->updateCodePrefixRules($rules);
+ }
+
+ header('Location: ' . BASE_URL . '/Admin/config');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[config] ' . $e->getMessage());
+ $this->assign('error', '保存失败:' . $e->getMessage());
+ $this->assign('title', '系统配置');
+ $this->assign('user', $user);
+ $this->assign('sysConfig', $sysConfig);
+ $this->assign('codePrefixRules', $config->getCodePrefixRules());
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '系统配置');
+ $this->assign('user', $user);
+ $this->assign('sysConfig', $sysConfig);
+ $this->assign('codePrefixRules', $config->getCodePrefixRules());
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ // 批量导入 — 仅超级管理员
+ public function import()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $msg = '';
+
+ // 表类型 -> 数据库表名 映射
+ $tableMap = [
+ 'employee' => 'employee',
+ 'customer' => 'customer',
+ 'product_model' => 'product_model',
+ 'inbound' => 'station_inbound',
+ 'pcb_test' => 'pcb_test',
+ 'battery' => 'battery_status',
+ 'assembly' => 'product_assembly',
+ 'finished_test' => 'finished_test',
+ 'warehouse' => 'warehouse_in',
+ 'delivery' => 'delivery',
+ ];
+
+ // 需要排除的列(自动生成的字段)
+ $excludeColumns = ['id', 'created_at', 'updated_at'];
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['import_file'])) {
+ $this->csrfVerify();
+ $tableType = $_POST['table_type'] ?? '';
+ if (!isset($tableMap[$tableType])) {
+ $msg = '无效的数据表类型';
+ } else {
+ $tableName = $tableMap[$tableType];
+ $file = $_FILES['import_file'];
+
+ if ($file['error'] !== UPLOAD_ERR_OK) {
+ $msg = '文件上传失败,错误码:' . $file['error'];
+ } else {
+ // 验证文件类型:仅允许 CSV
+ $tmpPath = $file['tmp_name'];
+ $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
+ $mime = finfo_file(finfo_open(FILEINFO_MIME_TYPE), $tmpPath);
+ $allowedExt = ['csv'];
+ $allowedMime = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel'];
+ if (!in_array($ext, $allowedExt, true) || !in_array($mime, $allowedMime, true)) {
+ $msg = '仅支持 CSV 格式文件,当前文件类型:' . htmlspecialchars($ext) . ' (' . htmlspecialchars($mime) . ')';
+ error_log('[import] Upload rejected: ext=' . $ext . ' mime=' . $mime);
+ $this->assign('title', '批量导入');
+ $this->assign('user', $user);
+ $this->assign('msg', $msg);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+
+ $handle = fopen($tmpPath, 'r');
+ if (!$handle) {
+ $msg = '无法打开文件';
+ } else {
+ // 读取 CSV 头部
+ $headers = fgetcsv($handle);
+ if (!$headers) {
+ $msg = 'CSV 文件为空';
+ } else {
+ // BOM 处理
+ $headers[0] = trim($headers[0], "\xEF\xBB\xBF");
+ $headers = array_map('trim', $headers);
+
+ // 过滤掉排除列
+ $validHeaders = [];
+ foreach ($headers as $h) {
+ if (!in_array($h, $excludeColumns)) {
+ $validHeaders[] = $h;
+ }
+ }
+
+ // 解析每行数据
+ $success = 0;
+ $fail = 0;
+ $model = new \core\base\Model();
+ $model->table($tableName);
+
+ while (($row = fgetcsv($handle)) !== false) {
+ $data = [];
+ foreach ($headers as $i => $col) {
+ if (!in_array($col, $excludeColumns) && isset($row[$i])) {
+ $val = $row[$i] !== '' ? $row[$i] : null;
+ // 员工密码自动 bcrypt 加密
+ if ($tableName === 'employee' && $col === 'password' && $val !== null) {
+ $empModel = new \app\models\Employee();
+ $val = $empModel->hashPassword($val);
+ }
+ $data[$col] = $val;
+ }
+ }
+ if (!empty($data)) {
+ try {
+ $model->add($data);
+ $success++;
+ } catch (\Exception $e) {
+ $fail++;
+ }
+ }
+ }
+ $msg = "导入完成:成功 {$success} 条,失败 {$fail} 条";
+ }
+ fclose($handle);
+ }
+ }
+ }
+ }
+
+ $this->assign('title', '批量导入');
+ $this->assign('user', $user);
+ $this->assign('msg', $msg);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ // 下载导入模板 — 仅超级管理员
+ public function importTemplate($type)
+ {
+ $this->checkLogin();
+
+ $user = $this->getCurrentUser();
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $tableMap = [
+ 'employee' => 'employee',
+ 'customer' => 'customer',
+ 'product_model' => 'product_model',
+ 'inbound' => 'station_inbound',
+ 'pcb_test' => 'pcb_test',
+ 'battery' => 'battery_status',
+ 'assembly' => 'product_assembly',
+ 'finished_test' => 'finished_test',
+ 'warehouse' => 'warehouse_in',
+ 'delivery' => 'delivery',
+ ];
+
+ if (!isset($tableMap[$type])) {
+ exit('无效的模板类型');
+ }
+
+ $tableName = $tableMap[$type];
+ $excludeCols = ['id', 'created_at', 'updated_at'];
+
+ $model = new \core\base\Model();
+ $model->table($tableName);
+ $fullTableName = $model->getTable();
+ $columns = $model->query("SHOW COLUMNS FROM `{$fullTableName}`");
+
+ $headers = [];
+ foreach ($columns as $col) {
+ if (!in_array($col['Field'], $excludeCols)) {
+ $headers[] = $col['Field'];
+ }
+ }
+
+ header('Content-Type: text/csv; charset=utf-8');
+ header('Content-Disposition: attachment; filename="' . $type . '_template.csv"');
+ header('Pragma: no-cache');
+
+ // BOM for Excel UTF-8 compatibility
+ echo "\xEF\xBB\xBF";
+ $output = fopen('php://output', 'w');
+ fputcsv($output, $headers);
+ fclose($output);
+ exit;
+ }
+
+ // 产品追溯 - 跨表查询产品完整信息 — 仅超级管理员
+ public function productSearch()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $keyword = trim($_GET['keyword'] ?? '');
+ $results = [];
+ $searched = false;
+
+ if (!empty($keyword)) {
+ $searched = true;
+ $model = new \core\base\Model();
+
+ // 用 LIKE 跨 product_assembly 表搜索
+ $like = '%' . $keyword . '%';
+ $results = $model->query(
+ "SELECT pa.*,
+ COALESCE(bs.voltage_platform, '') AS voltage_platform,
+ COALESCE(bs.no_load_voltage, '') AS no_load_voltage,
+ COALESCE(bs.internal_resistance, '') AS internal_resistance,
+ COALESCE(pt.test_status, '') AS pcb_test_status,
+ COALESCE(ft.test_result, '') AS finished_test_result,
+ COALESCE(wi.in_time, '') AS warehouse_time,
+ COALESCE(d.customer_name, '') AS delivery_customer,
+ COALESCE(d.delivery_time, '') AS delivery_time
+ FROM DGZXY_product_assembly pa
+ LEFT JOIN DGZXY_battery_status bs ON pa.battery_serial = bs.serial_no
+ LEFT JOIN DGZXY_pcb_test pt ON pa.pcb_serial = pt.serial_no
+ LEFT JOIN DGZXY_finished_test ft ON pa.finished_serial = ft.finished_serial
+ LEFT JOIN DGZXY_warehouse_in wi ON pa.finished_serial = wi.finished_serial
+ LEFT JOIN DGZXY_delivery d ON pa.finished_serial = d.finished_serial
+ WHERE pa.finished_serial LIKE :kw1
+ OR pa.battery_serial LIKE :kw2
+ OR pa.pcb_serial LIKE :kw3
+ OR pa.product_model LIKE :kw4
+ ORDER BY pa.id DESC
+ LIMIT 200",
+ [':kw1' => $like, ':kw2' => $like, ':kw3' => $like, ':kw4' => $like]
+ );
+ }
+
+ $this->assign('title', '产品追溯');
+ $this->assign('user', $user);
+ $this->assign('keyword', $keyword);
+ $this->assign('results', $results);
+ $this->assign('searched', $searched);
+ $this->assign('activeMenu', 'productSearch');
+ $this->render();
+ }
+
+ // ========== 数据库敏感字段脱敏(P0:防止密码哈希等敏感信息泄露) ==========
+ // 命中以下字段名(含子串匹配)的值在数据库管理页面一律显示为 ******,且禁止通过编辑页修改
+ protected static $SENSITIVE_FIELDS = [
+ 'password', 'passwd', 'pwd', 'token', 'api_token', 'access_token',
+ 'refresh_token', 'secret', 'secret_key', 'api_key', 'private_key',
+ 'salt', 'auth_key', 'mini_token', 'session_token', 'reset_token',
+ 'verify_token', 'enc_key', 'cert',
+ ];
+
+ protected function isSensitiveField($field)
+ {
+ $f = strtolower(trim((string)$field));
+ foreach (self::$SENSITIVE_FIELDS as $s) {
+ if ($f === $s || strpos($f, $s) !== false) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // P2-10:敏感系统表(含 token/密钥/凭证语义)不在数据库管理界面暴露
+ protected static $SENSITIVE_TABLE_PATTERNS = [
+ 'token', 'api_token', 'secret', 'password', 'passwd', 'pwd',
+ 'session', 'miniapp', 'asd_api', 'oauth', 'credential', 'auth',
+ 'salt', 'key', 'encrypt', 'private',
+ 'permission', // 权限映射表(员工权限/员工工位权限)不暴露,防权限结构泄露
+ ];
+
+ protected function isSensitiveTable($table)
+ {
+ $t = strtolower(trim((string)$table));
+ foreach (self::$SENSITIVE_TABLE_PATTERNS as $p) {
+ if (strpos($t, $p) !== false) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // v3-P2:系统/框架级表(迁移、系统配置等)不在数据库管理界面暴露,
+ // 进一步缩小可见表范围,降低结构信息泄露面。
+ protected static $SYSTEM_TABLE_PATTERNS = [
+ 'migration', 'phinxlog', 'sys_config', 'sysconfig', 'system_config',
+ 'schema_version', 'seeds', 'cache', 'sessions', 'jobs', 'failed_jobs',
+ 'logs', 'sys_log', 'audit',
+ ];
+
+ protected function isSystemTable($table)
+ {
+ $t = strtolower(trim((string)$table));
+ foreach (self::$SYSTEM_TABLE_PATTERNS as $p) {
+ if (strpos($t, $p) !== false) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // v5-P2:数据库表业务名称映射(用于管理界面以中文业务名展示,降低原始表名结构泄露)
+ // key 为去掉前缀后的展示名,value 为中文业务名;未命中时回退为原始展示名。
+ protected static $TABLE_BUSINESS_NAMES = [
+ 'asd_device' => '昂盛达设备',
+ 'asd_test_data' => '昂盛达测试数据',
+ 'asd_test_detail' => '昂盛达测试明细',
+ 'asd_test_record' => '昂盛达测试记录',
+ 'asset' => '资产',
+ 'battery_status' => '电池状态',
+ 'customer' => '客户',
+ 'delivery' => '出货单',
+ 'document' => '文档',
+ 'employee' => '员工',
+ 'finance_record' => '财务记录',
+ 'finished_test' => '成品测试',
+ 'hr_employee' => '人事员工',
+ 'import_template' => '导入模板',
+ 'inventory' => '库存',
+ 'pcb_test' => 'PCB 测试',
+ 'product_assembly' => '产品装配',
+ 'product_model' => '产品型号',
+ 'product_type_config' => '产品类型配置',
+ 'purchase_item' => '采购明细',
+ 'purchase_order' => '采购订单',
+ 'receivable_payable' => '应收应付',
+ 'sales_item' => '销售明细',
+ 'sales_order' => '销售订单',
+ 'station_aging' => '工位老化',
+ 'station_definitions' => '工位定义',
+ 'station_inbound' => '工位入库',
+ 'station_records' => '工位记录',
+ 'station_semi_finished' => '工位半成品',
+ 'supplier' => '供应商',
+ 'warehouse_in' => '入库',
+ 'workstation' => '工位',
+ ];
+
+ protected function tableBusinessName($displayName)
+ {
+ return self::$TABLE_BUSINESS_NAMES[$displayName] ?? $displayName;
+ }
+
+
+ // v3-P2:数据库管理操作审计日志(轻量文件日志,便于事后追溯谁访问/改动了哪张表)
+ // 日志写入 runtime/db_audit.log,.htaccess 已禁止 .log 通过 Web 访问。
+ protected function dbAuditLog($action, $table = '', $extra = '')
+ {
+ try {
+ $user = $this->getCurrentUser();
+ $dir = APP_PATH . 'runtime';
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0750, true);
+ }
+ $ip = $_SERVER['REMOTE_ADDR'] ?? '-';
+ $line = sprintf(
+ "[%s] user=%s(%s) role=%s ip=%s action=%s table=%s %s\n",
+ date('Y-m-d H:i:s'),
+ $user['emp_no'] ?? '-',
+ $user['emp_name'] ?? '-',
+ $user['role'] ?? '-',
+ $ip,
+ $action,
+ $table !== '' ? $table : '-',
+ $extra
+ );
+ @file_put_contents($dir . '/db_audit.log', $line, FILE_APPEND | LOCK_EX);
+ } catch (\Throwable $e) {
+ // 审计失败不得影响主流程
+ }
+ }
+
+
+
+ // 对行集合中的敏感字段值脱敏(原地修改)
+ protected function maskSensitiveRows(array &$rows)
+ {
+ foreach ($rows as &$row) {
+ if (!is_array($row)) continue;
+ foreach ($row as $k => $v) {
+ if ($this->isSensitiveField($k)) {
+ $row[$k] = '******';
+ }
+ }
+ }
+ unset($row);
+ }
+
+ // 数据库管理 - 表列表 — 仅超级管理员可访问
+ public function database()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ // v3-P2 安全加固:原始数据库管理(可查看/编辑/删除任意表记录)属高危操作,
+ // 仅超级管理员可访问,对普通管理员完全隐藏表名与记录数。
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
+ exit('无权限访问');
+ }
+ $this->dbAuditLog('list_tables');
+
+ $tableList = $this->getVisibleTables();
+
+ $this->assign('title', '数据库管理');
+ $this->assign('user', $user);
+ $this->assign('tables', $tableList);
+ $this->assign('activeMenu', 'database');
+ $this->render();
+ }
+
+ // v7-P3:可见表列表(过滤敏感/系统表、去前缀、按展示名稳定排序)
+ // 同时供列表页与详情页按索引定位,避免 URL 中暴露原始表名
+ protected function getVisibleTables()
+ {
+ $model = new \core\base\Model();
+ $tables = $model->query("SHOW TABLES");
+ $prefix = \core\base\Model::TABLE_PREFIX;
+ $list = [];
+ foreach ($tables as $row) {
+ $tbl = reset($row); // 获取第一个值(表名)
+ // 跳过 jp_ 前缀的表(不显示)
+ if (strpos($tbl, 'jp_') === 0) {
+ continue;
+ }
+ // P2-10 安全加固:敏感系统表不在管理界面暴露(限制显示范围,防结构泄露)
+ if ($this->isSensitiveTable($tbl)) {
+ continue;
+ }
+ // v3-P2:框架/系统级表(迁移、系统配置、日志等)同样不暴露
+ if ($this->isSystemTable($tbl)) {
+ continue;
+ }
+ // 使用完整表名查询行数
+ $count = $model->query("SELECT COUNT(*) AS cnt FROM `{$tbl}`");
+ // 展示给用户的名称:去掉 DGZXY_ 前缀
+ $displayName = $tbl;
+ if (strpos($tbl, $prefix) === 0) {
+ $displayName = substr($tbl, strlen($prefix));
+ }
+ $list[] = [
+ 'name' => $displayName,
+ 'business_name' => $this->tableBusinessName($displayName),
+ 'full_name' => $tbl,
+ 'count' => $count[0]['cnt'] ?? 0,
+ ];
+ }
+ // 按展示名排序,保证索引在各请求间一致(修复 v7-P3:URL 不暴露原始表名)
+ usort($list, function ($a, $b) {
+ return strcmp($a['name'], $b['name']);
+ });
+ return $list;
+ }
+
+ // 数据库管理 - 查看表数据 — 仅超级管理员可访问
+ // v7-P3:$idx 为 getVisibleTables() 的稳定索引,URL 不再含原始表名
+ public function databaseView($idx = 0)
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ // v3-P2:原始数据库查看仅超级管理员可访问
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
+ exit('无权限访问');
+ }
+
+ // v7-P3:通过稳定索引解析表名,不在 URL 暴露原始表名
+ $tables = $this->getVisibleTables();
+ $idx = intval($idx);
+ if (!isset($tables[$idx])) {
+ echo '
无效的表索引
';
+ exit;
+ }
+ $table = $tables[$idx]['name'];
+
+ $page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
+ $perPage = 20;
+
+ $model = new \core\base\Model();
+ $model->table($table);
+ $fullTableName = $model->getTable();
+
+ // 验证表存在(使用参数化查询,用完整表名匹配)
+ $check = $model->query("SHOW TABLES LIKE :tbl", [':tbl' => $fullTableName]);
+ if (empty($check)) {
+ echo '表不存在
';
+ exit;
+ }
+
+ // 获取列信息
+ $columns = $model->query("SHOW COLUMNS FROM `{$fullTableName}`");
+
+ // 总数
+ $total = $model->query("SELECT COUNT(*) AS cnt FROM `{$fullTableName}`");
+ $totalCount = $total[0]['cnt'] ?? 0;
+ $totalPages = ceil($totalCount / $perPage);
+ $offset = ($page - 1) * $perPage;
+
+ // 获取数据(offset/limit 已 intval 安全)
+ $rows = $model->query("SELECT * FROM `{$fullTableName}` ORDER BY id DESC LIMIT {$offset}, {$perPage}");
+
+ // P0 脱敏:密码/token 等敏感字段不展示明文
+ $this->maskSensitiveRows($rows);
+
+ $this->assign('title', '查看表:' . $table);
+ $this->assign('user', $user);
+ $this->assign('table', $table);
+ $this->assign('idx', $idx);
+ $this->assign('columns', $columns);
+ $this->assign('rows', $rows);
+ $this->assign('page', $page);
+ $this->assign('totalPages', $totalPages);
+ $this->assign('totalCount', $totalCount);
+ $this->assign('activeMenu', 'database');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ // 数据库管理 - 编辑行 — 仅超级管理员可访问
+ // v7-P3:$idx 为 getVisibleTables() 的稳定索引
+ public function databaseEdit($idx = 0, $id = 0)
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ // v3-P2:原始数据库编辑仅超级管理员可访问
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
+ exit('无权限访问');
+ }
+
+ // v7-P3:通过稳定索引解析表名
+ $tables = $this->getVisibleTables();
+ $idx = intval($idx);
+ if (!isset($tables[$idx])) {
+ echo '无效的表索引
';
+ exit;
+ }
+ $table = $tables[$idx]['name'];
+ $this->dbAuditLog('edit_record', $table, 'id=' . intval($id));
+
+ $model = new \core\base\Model();
+ $model->table($table);
+ $fullTableName = $model->getTable();
+
+ $columns = $model->query("SHOW COLUMNS FROM `{$fullTableName}`");
+ $row = $model->query("SELECT * FROM `{$fullTableName}` WHERE id = :id", [':id' => intval($id)]);
+
+ if (empty($row)) {
+ echo '记录不存在
';
+ exit;
+ }
+ $row = $row[0];
+
+ // P0 脱敏:识别并标记敏感字段,显示值脱敏
+ $sensitiveFields = [];
+ foreach ($columns as $col) {
+ if ($this->isSensitiveField($col['Field'])) {
+ $sensitiveFields[] = $col['Field'];
+ }
+ }
+ foreach ($row as $k => $v) {
+ if ($this->isSensitiveField($k)) {
+ $row[$k] = '******';
+ }
+ }
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [];
+ foreach ($columns as $col) {
+ $field = $col['Field'];
+ if ($field === 'id') continue;
+ // 禁止通过数据库编辑页修改密码/token 等敏感字段
+ if ($this->isSensitiveField($field)) continue;
+ if (isset($_POST[$field])) {
+ $data[$field] = $_POST[$field] !== '' ? $_POST[$field] : null;
+ }
+ }
+
+ if (!empty($data)) {
+ $model->where('id = :id', [':id' => $id])->update($data);
+ }
+
+ header('Location: ' . BASE_URL . '/Admin/databaseView/' . $idx);
+ exit;
+ }
+
+ $this->assign('title', '编辑记录');
+ $this->assign('user', $user);
+ $this->assign('table', $table);
+ $this->assign('idx', $idx);
+ $this->assign('columns', $columns);
+ $this->assign('row', $row);
+ $this->assign('sensitiveFields', $sensitiveFields);
+ $this->assign('activeMenu', 'database');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ // 数据库管理 - 删除行 — 仅超级管理员可访问
+ // v7-P3:$idx 为 getVisibleTables() 的稳定索引
+ public function databaseDelete($idx = 0, $id = 0)
+ {
+ $this->checkLogin();
+ $this->requireMethod('POST');
+ $this->csrfVerify();
+ $user = $this->getCurrentUser();
+
+ // v3-P2:原始数据库删除仅超级管理员可访问
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
+ exit('无权限访问');
+ }
+
+ // v7-P3:通过稳定索引解析表名
+ $tables = $this->getVisibleTables();
+ $idx = intval($idx);
+ if (!isset($tables[$idx])) {
+ exit('无效的表索引');
+ }
+ $table = $tables[$idx]['name'];
+ $this->dbAuditLog('delete_record', $table, 'id=' . intval($id));
+
+ $model = new \core\base\Model();
+ $model->table($table);
+ $model->delete($id);
+
+ header('Location: ' . BASE_URL . '/Admin/databaseView/' . $idx);
+ exit;
+ }
+
+ // ========== 昂盛达测试数据导入管理 ==========
+
+ /**
+ * 测试数据列表 — 仅超级管理员
+ */
+ public function asdTestData()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $keyword = $_GET['keyword'] ?? '';
+ $asdModel = new \app\models\AsdTestData();
+
+ $records = $asdModel->getAllWithStats($keyword);
+ $totalCount = $asdModel->countRecords($keyword);
+
+ $this->assign('title', '测试数据管理');
+ $this->assign('user', $user);
+ $this->assign('records', $records);
+ $this->assign('totalCount', $totalCount);
+ $this->assign('keyword', $keyword);
+ $this->assign('activeMenu', 'asdTestData');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ /**
+ * 测试数据详情 — 仅超级管理员
+ */
+ public function asdTestDataView($id)
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $asdModel = new \app\models\AsdTestData();
+ $record = $asdModel->findById($id);
+ if (!$record) {
+ echo '记录不存在
';
+ exit;
+ }
+
+ $details = $asdModel->getDetails($id);
+
+ $this->assign('title', '测试数据详情');
+ $this->assign('user', $user);
+ $this->assign('record', $record);
+ $this->assign('details', $details);
+ $this->assign('activeMenu', 'asdTestData');
+ $this->render();
+ }
+
+ /**
+ * 删除测试数据 — 仅超级管理员
+ */
+ public function asdTestDataDelete($id)
+ {
+ $this->checkLogin();
+ $this->requireMethod('POST');
+ $this->csrfVerify();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $asdModel = new \app\models\AsdTestData();
+ $asdModel->deleteRecord($id);
+
+ header('Location: ' . BASE_URL . '/Admin/asdTestData');
+ exit;
+ }
+
+ /**
+ * 手动导入测试数据(模拟昂盛达上位机提交) — 仅超级管理员
+ */
+ public function asdTestDataImport()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ // 获取工位列表供选择(容错处理)
+ $stations = [];
+ try {
+ $workstation = new \app\models\Workstation();
+ $stations = $workstation->getAll();
+ if (!is_array($stations)) $stations = [];
+ } catch (\Throwable $e) {
+ $stations = [];
+ }
+
+ // 获取设备列表(供扫码选择下拉框使用)
+ $devices = [];
+ try {
+ $deviceModel = new \app\models\AsdDevice();
+ $devices = $deviceModel->getAllActive();
+ if (!is_array($devices)) $devices = [];
+ } catch (\Throwable $e) {
+ $devices = [];
+ }
+
+ // CSRF Token 传递给视图
+ $csrfToken = $this->csrfToken();
+ $this->assign('csrfToken', $csrfToken);
+
+ // 激活 Tab:优先从 GET 参数 tab 获取,支持 form/json/device
+ $activeTab = 'form';
+ $tabParam = $_GET['tab'] ?? '';
+ if (in_array($tabParam, ['form', 'json', 'device'], true)) {
+ $activeTab = $tabParam;
+ }
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'import_json') {
+ // 直接粘贴 JSON 数据导入
+ $activeTab = 'json'; // 保持在 JSON 导入 Tab
+ $jsonStr = $_POST['json_data'] ?? '';
+
+ if (empty(trim($jsonStr))) {
+ $this->assign('error', '请粘贴 JSON 数据');
+ } else {
+ $jsonData = json_decode($jsonStr, true);
+ if (!$jsonData || !is_array($jsonData)) {
+ $this->assign('error', 'JSON 格式错误: ' . json_last_error_msg());
+ } elseif (empty($jsonData['QrCode'])) {
+ $this->assign('error', 'JSON 缺少必填字段: QrCode(条码/二维码)');
+ } elseif (empty($jsonData['DataList']) || !is_array($jsonData['DataList'])) {
+ $this->assign('error', 'JSON 缺少必填字段: DataList(测试工步数据)');
+ } else {
+ $asdModel = new \app\models\AsdTestData();
+ $recordId = $asdModel->addRecord([
+ 'tester' => $jsonData['Tester'] ?? $user['emp_name'],
+ 'qr_code' => $jsonData['QrCode'],
+ 'mo' => $jsonData['Mo'] ?? '',
+ 'site' => $jsonData['Site'] ?? '',
+ 'station' => $jsonData['Station'] ?? '',
+ 'work_order' => $jsonData['WorkOrder'] ?? '',
+ 'project_name' => $jsonData['ProjectName'] ?? '',
+ 'status' => strtoupper($jsonData['Status'] ?? 'PASS'),
+ 'group_name' => $jsonData['Group'] ?? '',
+ 'device' => $jsonData['Device'] ?? '',
+ 'running_time' => $jsonData['RunningTime'] ?? '',
+ 'test_time' => $jsonData['TestTime'] ?? date('Y-m-d H:i:s'),
+ ]);
+
+ if (!$recordId) {
+ $err = $asdModel->getError();
+ $this->assign('error', '保存失败: ' . ($err ?: '数据库写入错误'));
+ } else {
+ $detailCount = 0;
+ foreach ($jsonData['DataList'] as $item) {
+ $r = $asdModel->addDetail([
+ 'record_id' => $recordId,
+ 'seq' => $item['Seq'] ?? 1,
+ 'test_name' => $item['TestName'] ?? '',
+ 'test_item' => $item['TestItem'] ?? '',
+ 'test_units' => $item['TestUnits'] ?? '',
+ 'data_value' => $item['DataValue'] ?? null,
+ 'lower_limit' => $item['LowerLimit'] ?? null,
+ 'upper_limit' => $item['UpperLimit'] ?? null,
+ 'test_value' => $item['TestValue'] ?? '',
+ 'test_limit' => $item['TestLimit'] ?? '',
+ 'test_result' => $item['TestResult'] ?? 'PASS',
+ ]);
+ if ($r) $detailCount++;
+ }
+ $this->assign('success', "JSON 导入成功!记录ID: {$recordId},工步数: {$detailCount}");
+ }
+ }
+ }
+ } else {
+ // 表单方式提交
+ $activeTab = 'form';
+ $dataList = [];
+ $stepNames = $_POST['step_name'] ?? [];
+ $stepItems = $_POST['step_item'] ?? [];
+ $stepUnits = $_POST['step_units'] ?? [];
+ $stepValues = $_POST['step_value'] ?? [];
+ $stepLower = $_POST['step_lower'] ?? [];
+ $stepUpper = $_POST['step_upper'] ?? [];
+ $stepResults = $_POST['step_result'] ?? [];
+
+ for ($i = 0; $i < count($stepNames); $i++) {
+ $dataList[] = [
+ 'Seq' => $i + 1,
+ 'TestName' => $stepNames[$i] ?? '',
+ 'TestItem' => $stepItems[$i] ?? '',
+ 'TestUnits' => $stepUnits[$i] ?? '',
+ 'DataValue' => is_numeric($stepValues[$i]) ? (float)$stepValues[$i] : null,
+ 'LowerLimit' => is_numeric($stepLower[$i]) ? (float)$stepLower[$i] : null,
+ 'UpperLimit' => is_numeric($stepUpper[$i]) ? (float)$stepUpper[$i] : null,
+ 'TestValue' => !is_numeric($stepValues[$i]) ? ($stepValues[$i] ?? '') : '',
+ 'TestResult' => $stepResults[$i] ?? 'PASS',
+ ];
+ }
+
+ $status = 'PASS';
+ foreach ($dataList as $step) {
+ if ($step['TestResult'] === 'FAIL') {
+ $status = 'NG';
+ break;
+ }
+ }
+
+ $asdModel = new \app\models\AsdTestData();
+ $recordId = $asdModel->addRecord([
+ 'tester' => $user['emp_name'],
+ 'qr_code' => $_POST['qr_code'] ?? '',
+ 'mo' => $_POST['mo'] ?? '',
+ 'site' => $_POST['site'] ?? '',
+ 'station' => $_POST['station'] ?? '',
+ 'work_order' => $_POST['work_order'] ?? '',
+ 'project_name' => $_POST['project_name'] ?? '',
+ 'status' => $status,
+ 'group_name' => $_POST['group_name'] ?? '',
+ 'device' => $_POST['device'] ?? '',
+ 'running_time' => $_POST['running_time'] ?? '',
+ 'test_time' => date('Y-m-d H:i:s'),
+ ]);
+
+ if ($recordId) {
+ foreach ($dataList as $item) {
+ $asdModel->addDetail([
+ 'record_id' => $recordId,
+ 'seq' => $item['Seq'],
+ 'test_name' => $item['TestName'],
+ 'test_item' => $item['TestItem'],
+ 'test_units' => $item['TestUnits'],
+ 'data_value' => $item['DataValue'],
+ 'lower_limit' => $item['LowerLimit'],
+ 'upper_limit' => $item['UpperLimit'],
+ 'test_value' => $item['TestValue'],
+ 'test_result' => $item['TestResult'],
+ ]);
+ }
+ $this->assign('success', "表单导入成功!记录ID: {$recordId}");
+ } else {
+ $err = $asdModel->getError();
+ $this->assign('error', '保存失败: ' . ($err ?: '数据库写入错误'));
+ }
+ }
+ }
+
+ $this->assign('title', '导入测试数据');
+ $this->assign('user', $user);
+ $this->assign('stations', $stations);
+ $this->assign('devices', $devices);
+ $this->assign('activeTab', $activeTab);
+ $this->assign('activeMenu', 'asdTestData');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ /**
+ * 批量删除测试数据(按设备或按条件) — 仅超级管理员
+ */
+ public function asdBatchDelete()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $action = $_POST['action'] ?? '';
+ $deviceCode = $_POST['device_code'] ?? '';
+ $ids = $_POST['ids'] ?? '';
+
+ $pdo = \core\db\Db::pdo();
+
+ if ($action === 'by_device' && !empty($deviceCode)) {
+ // 按设备编码删除
+ $idsStmt = $pdo->prepare("SELECT id FROM DGZXY_asd_test_record WHERE device = :code");
+ $idsStmt->execute([':code' => $deviceCode]);
+ $recordIds = $idsStmt->fetchAll(\PDO::FETCH_COLUMN);
+
+ if (!empty($recordIds)) {
+ $placeholders = implode(',', array_fill(0, count($recordIds), '?'));
+ $pdo->prepare("DELETE FROM DGZXY_asd_test_detail WHERE record_id IN ({$placeholders})")->execute($recordIds);
+ $pdo->prepare("DELETE FROM DGZXY_asd_test_record WHERE device = :code")->execute([':code' => $deviceCode]);
+ }
+
+ header('Location: ' . BASE_URL . '/Admin/asdTestData');
+ exit;
+ }
+
+ if ($action === 'by_ids' && !empty($ids)) {
+ // 按 ID 列表批量删除
+ $idArr = array_map('intval', explode(',', $ids));
+ $idArr = array_filter($idArr, function($v) { return $v > 0; });
+
+ if (!empty($idArr)) {
+ $placeholders = implode(',', array_fill(0, count($idArr), '?'));
+ $pdo->prepare("DELETE FROM DGZXY_asd_test_detail WHERE record_id IN ({$placeholders})")->execute($idArr);
+ $pdo->prepare("DELETE FROM DGZXY_asd_test_record WHERE id IN ({$placeholders})")->execute($idArr);
+ }
+
+ header('Location: ' . BASE_URL . '/Admin/asdTestData');
+ exit;
+ }
+
+ if ($action === 'clear_all') {
+ // 清空全部测试数据
+ $pdo->exec("DELETE FROM DGZXY_asd_test_detail");
+ $pdo->exec("DELETE FROM DGZXY_asd_test_record");
+ $pdo->exec("ALTER TABLE DGZXY_asd_test_record AUTO_INCREMENT = 1");
+ $pdo->exec("ALTER TABLE DGZXY_asd_test_detail AUTO_INCREMENT = 1");
+
+ header('Location: ' . BASE_URL . '/Admin/asdTestData');
+ exit;
+ }
+ }
+
+ // 获取设备列表供前端使用
+ $devices = [];
+ try {
+ $deviceModel = new \app\models\AsdDevice();
+ $devices = $deviceModel->getAllActive();
+ } catch (\Throwable $e) {}
+
+ $this->assign('title', '批量删除测试数据');
+ $this->assign('user', $user);
+ $this->assign('devices', $devices);
+ $this->assign('activeMenu', 'asdTestData');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ /**
+ * 自动生成员工编号:ZXY + 三位流水号(如 ZXY001, ZXY002...)
+ * 查询数据库中 ZXY 开头的最大编号,+1 作为新编号
+ */
+ private function generateEmpNo()
+ {
+ $model = new \core\base\Model();
+ // 循环生成,直到找到不重复的编号
+ for ($i = 0; $i < 999; $i++) {
+ $row = $model->query(
+ "SELECT emp_no FROM `DGZXY_employee` WHERE emp_no LIKE :prefix ORDER BY emp_no DESC LIMIT 1",
+ [':prefix' => 'ZXY%']
+ );
+ if (!empty($row) && isset($row[0]['emp_no'])) {
+ $lastNo = $row[0]['emp_no'];
+ if (preg_match('/\d+/', $lastNo, $matches)) {
+ $num = (int)$matches[0] + 1 + $i;
+ } else {
+ $num = 1 + $i;
+ }
+ } else {
+ $num = 1 + $i;
+ }
+ $emp_no = 'ZXY' . str_pad($num, 3, '0', STR_PAD_LEFT);
+ // 检查是否已存在(防止并发重复)
+ $exists = $model->query(
+ "SELECT id FROM `DGZXY_employee` WHERE emp_no = :no LIMIT 1",
+ [':no' => $emp_no]
+ );
+ if (empty($exists)) {
+ return $emp_no;
+ }
+ }
+ // 兜底:用时间戳生成
+ return 'ZXY' . date('His');
+ }
+
+ // ========== 昂盛达设备调试 ==========
+
+ /**
+ * 昂盛达设备调试页 — 测试链接状态、数据读写
+ * 仅超级管理员与管理员可访问
+ */
+ public function asdDeviceDebug()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $pdo = \core\db\Db::pdo();
+ $devices = [];
+ $result = null;
+ $selDeviceId = intval($_POST['device_id'] ?? ($_GET['device_id'] ?? 0));
+
+ try {
+ // 动态探测设备表真实列,避免硬编码 group_name/device_code 等线上不存在的列导致 1054
+ $cols = [];
+ try {
+ $dc = $pdo->query("DESCRIBE DGZXY_asd_device");
+ if ($dc) {
+ foreach ($dc->fetchAll(\PDO::FETCH_ASSOC) as $c) {
+ $cols[] = $c['Field'];
+ }
+ }
+ } catch (\Throwable $e) { /* 探测失败走兜底 */ }
+ $has = function ($name) use ($cols) { return in_array($name, $cols, true); };
+ $selectCols = $has('device_name') ? 'id, device_name, device_ip, device_port, status' : '*';
+ $orderCols = [];
+ foreach (['group_name', 'device_code', 'device_name', 'id'] as $oc) {
+ if ($has($oc)) { $orderCols[] = $oc . ' ASC'; }
+ }
+ $orderSql = $orderCols ? ('ORDER BY ' . implode(', ', $orderCols)) : '';
+
+ // 关键字查找设备(按名称/IP/编号模糊匹配)
+ $keyword = trim($_POST['keyword'] ?? ($_GET['keyword'] ?? ''));
+ $whereSql = '';
+ $params = [];
+ if ($keyword !== '') {
+ $likeParts = [];
+ foreach (['device_name', 'device_ip', 'device_code', 'group_name'] as $kc) {
+ if ($has($kc)) { $likeParts[] = $kc . ' LIKE :kw'; }
+ }
+ if ($likeParts) {
+ $whereSql = 'WHERE ' . implode(' OR ', $likeParts);
+ $params[':kw'] = '%' . $keyword . '%';
+ }
+ }
+
+ $sql = "SELECT {$selectCols} FROM DGZXY_asd_device {$whereSql} {$orderSql}";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute($params);
+ $devices = $stmt->fetchAll(\PDO::FETCH_ASSOC);
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'manualConnect') {
+ // 手动连接:直接用表单输入的 IP:端口,不依赖数据库里的记录
+ $mip = trim($_POST['manual_ip'] ?? '');
+ $mport = intval($_POST['manual_port'] ?? 502);
+ if ($mport <= 0) { $mport = 502; }
+ $result = $this->asdDebugTestLink([
+ 'device_ip' => $mip,
+ 'device_port' => $mport,
+ 'device_name' => '手动连接(' . $mip . ':' . $mport . ')',
+ ]);
+ } else {
+ $deviceId = intval($_POST['device_id'] ?? 0);
+ $device = null;
+ $ds = $pdo->prepare("SELECT * FROM DGZXY_asd_device WHERE id = :id");
+ $ds->execute([':id' => $deviceId]);
+ $device = $ds->fetch(\PDO::FETCH_ASSOC);
+ if (!$device) {
+ $result = ['error' => '设备不存在或已被删除(ID=' . $deviceId . ')'];
+ } elseif ($action === 'testLink') {
+ $result = $this->asdDebugTestLink($device);
+ } elseif ($action === 'readData') {
+ $result = $this->asdDebugReadData($device);
+ } elseif ($action === 'writeData') {
+ $result = $this->asdDebugWriteData($device);
+ } else {
+ $result = ['error' => '未知操作: ' . $action];
+ }
+ }
+ }
+ } catch (\Throwable $e) {
+ // 把隐藏异常转为可见提示,便于定位(调试页允许暴露具体错误)
+ $result = ['action' => 'error', 'error' => '调试页异常:' . $e->getMessage()];
+ }
+
+ $manualConnect = !empty($_POST['action']) && $_POST['action'] === 'manualConnect';
+
+ $this->assign('title', '昂盛达设备调试');
+ $this->assign('user', $user);
+ $this->assign('devices', $devices);
+ $this->assign('selDeviceId', $selDeviceId);
+ $this->assign('result', $result);
+ $this->assign('keyword', $keyword ?? '');
+ $this->assign('manualConnect', $manualConnect ?? false);
+ $this->assign('activeMenu', 'asdDeviceDebug');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ /**
+ * 测试设备 TCP 链接状态(端口取 device_port,缺省 502)
+ */
+ protected function asdDebugTestLink($device)
+ {
+ $ip = $device['device_ip'] ?? '';
+ $port = !empty($device['device_port']) ? intval($device['device_port']) : 502;
+ $reachable = false;
+ $latency = 0;
+ $err = '';
+ if (empty($ip)) {
+ $err = '设备 IP 未设置,请在设备管理中填写 device_ip';
+ } elseif (!function_exists('fsockopen')) {
+ $err = 'PHP 未启用 fsockopen 扩展,无法测试网络连通性';
+ } else {
+ $start = microtime(true);
+ $errno = 0;
+ $errstr = '';
+ $fp = @fsockopen($ip, $port, $errno, $errstr, 3);
+ if ($fp) {
+ $reachable = true;
+ $latency = round((microtime(true) - $start) * 1000, 1);
+ fclose($fp);
+ } else {
+ $err = '连接失败: ' . $errstr . ' (errno=' . $errno . ')';
+ }
+ }
+ return [
+ 'action' => 'testLink',
+ 'device' => $device,
+ 'ip' => $ip,
+ 'port' => $port,
+ 'reachable' => $reachable,
+ 'latency' => $latency,
+ 'error' => $err,
+ ];
+ }
+
+ /**
+ * 读取设备相关数据(asd_test_record 按 device_code 过滤 + 各表计数)
+ */
+ protected function asdDebugReadData($device)
+ {
+ $pdo = \core\db\Db::pdo();
+ $deviceCode = $device['device_code'] ?? '';
+ $rows = [];
+ try {
+ $stmt = $pdo->prepare("SELECT * FROM DGZXY_asd_test_record WHERE device_code = :dc ORDER BY id DESC LIMIT 10");
+ $stmt->execute([':dc' => $deviceCode]);
+ $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
+ } catch (\Throwable $e) {
+ // 读取异常交由视图提示
+ }
+ $counts = ['rec_cnt' => 0, 'data_cnt' => 0, 'dev_cnt' => 0];
+ try {
+ $c = $pdo->query("SELECT (SELECT COUNT(*) FROM DGZXY_asd_test_record) AS rec_cnt, (SELECT COUNT(*) FROM DGZXY_asd_test_data) AS data_cnt, (SELECT COUNT(*) FROM DGZXY_asd_device) AS dev_cnt")->fetch(\PDO::FETCH_ASSOC);
+ if ($c) {
+ $counts = $c;
+ }
+ } catch (\Throwable $e) {
+ // ignore
+ }
+ return [
+ 'action' => 'readData',
+ 'device' => $device,
+ 'device_code' => $deviceCode,
+ 'rows' => $rows,
+ 'counts' => $counts,
+ ];
+ }
+
+ /**
+ * 写入探针记录并回读后删除,验证数据读写链路
+ */
+ protected function asdDebugWriteData($device)
+ {
+ $deviceCode = $device['device_code'] ?? '';
+ $probeCode = 'DEBUG_PROBE_' . date('YmdHis') . '_' . rand(100, 999);
+ $model = new \app\models\AsdTestData();
+ $writtenId = 0;
+ $readBack = null;
+ $readBackMatch = false;
+ $deleted = false;
+ $err = '';
+ try {
+ $writtenId = $model->addRecord([
+ 'qr_code' => $probeCode,
+ 'device_code' => $deviceCode,
+ 'test_result' => 'PASS',
+ 'test_date' => date('Y-m-d H:i:s'),
+ 'remark' => 'ASD_DEBUG_PROBE',
+ ]);
+ if ($writtenId) {
+ $readBack = $model->findById($writtenId);
+ $readBackMatch = !empty($readBack) && ($readBack['qr_code'] ?? '') === $probeCode;
+ $deleted = (bool) $model->deleteRecord($writtenId);
+ } else {
+ $err = '写入失败(未获取到插入 ID)';
+ }
+ } catch (\Throwable $e) {
+ $err = '写入/回读异常: ' . $e->getMessage();
+ }
+ return [
+ 'action' => 'writeData',
+ 'device' => $device,
+ 'device_code' => $deviceCode,
+ 'probe_code' => $probeCode,
+ 'written_id' => $writtenId,
+ 'read_back' => $readBack,
+ 'read_back_match'=> $readBackMatch,
+ 'deleted' => $deleted,
+ 'error' => $err,
+ ];
+ }
+
+ // ========== 数据库升级/迁移 ==========
+
+
+ /**
+ * 数据库升级页面 — 仅超级管理员可访问
+ * 自动扫描 sql/ 目录下的 .sql 文件,支持一键执行
+ */
+ public function databaseUpgrade()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $pdo = \core\db\Db::pdo();
+
+ // 确保迁移记录表存在
+ $this->ensureMigrationsTable($pdo);
+
+ $sqlDir = APP_PATH . 'sql';
+ $message = '';
+ $messageType = ''; // success / danger / warning
+
+ // 处理 POST 请求
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'execute' && !empty($_POST['file'])) {
+ // 执行单个 SQL 文件
+ $file = basename($_POST['file']);
+ $filepath = $sqlDir . '/' . $file;
+
+ if (!file_exists($filepath)) {
+ $message = "文件不存在: {$file}";
+ $messageType = 'danger';
+ } else {
+ $result = $this->runSqlFile($pdo, $filepath, $file);
+ $message = $result['message'];
+ $messageType = $result['success'] ? 'success' : 'danger';
+ }
+ } elseif ($action === 'execute_all') {
+ // 执行所有未执行的 SQL 文件
+ $files = $this->scanSqlFiles($sqlDir);
+ $pendingFiles = $this->filterPendingFiles($pdo, $files);
+
+ if (empty($pendingFiles)) {
+ $message = '所有迁移脚本已执行完毕,没有需要执行的脚本。';
+ $messageType = 'warning';
+ } else {
+ $successCount = 0;
+ $failCount = 0;
+ $msgs = [];
+
+ foreach ($pendingFiles as $file) {
+ $filepath = $sqlDir . '/' . $file;
+ $result = $this->runSqlFile($pdo, $filepath, $file);
+ if ($result['success']) {
+ $successCount++;
+ } else {
+ $failCount++;
+ $msgs[] = $result['message'];
+ }
+ }
+
+ $message = "执行完毕:成功 {$successCount} 个";
+ if ($failCount > 0) {
+ $message .= ",失败 {$failCount} 个";
+ $message .= "\n" . implode("\n", $msgs);
+ $messageType = 'warning';
+ } else {
+ $message .= ",所有脚本执行成功!";
+ $messageType = 'success';
+ }
+ }
+ }
+ }
+
+ // 扫描 SQL 文件并检查执行状态
+ $allFiles = $this->scanSqlFiles($sqlDir);
+ $migratedMap = $this->getMigratedFiles($pdo);
+ $pendingCount = 0;
+
+ $fileList = [];
+ foreach ($allFiles as $file) {
+ $isMigrated = isset($migratedMap[$file]);
+ $info = $migratedMap[$file] ?? null;
+ if (!$isMigrated) $pendingCount++;
+
+ $fileList[] = [
+ 'name' => $file,
+ 'is_migrated' => $isMigrated,
+ 'executed_at' => $info['executed_at'] ?? '',
+ 'status' => $info['status'] ?? null,
+ 'error_msg' => $info['error_msg'] ?? '',
+ 'preview' => $this->getSqlPreview($sqlDir . '/' . $file),
+ ];
+ }
+
+ $this->assign('title', '数据库升级');
+ $this->assign('user', $user);
+ $this->assign('fileList', $fileList);
+ $this->assign('pendingCount', $pendingCount);
+ $this->assign('message', $message);
+ $this->assign('messageType', $messageType);
+ $this->assign('activeMenu', 'databaseUpgrade');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ /**
+ * 执行单条 SQL 并立即释放结果集游标。
+ *
+ * 不能使用 PDO::exec():exec() 拿不到语句句柄,在“非缓冲查询”环境下会残留
+ * 未取走的结果集,导致后续 prepare/execute 报 2014
+ * (Cannot execute queries while other unbuffered queries are active)。
+ * 这里改用 prepare -> execute -> closeCursor,彻底释放连接。
+ */
+ private function pdoExec($pdo, $sql, $params = [])
+ {
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute($params);
+ $stmt->closeCursor();
+ return true;
+ }
+
+ /**
+ * 幂等写入迁移记录。
+ *
+ * 使用 INSERT ... ON DUPLICATE KEY UPDATE:当同名文件(uk_filename 唯一约束)
+ * 已存在时,更新其状态/时间/错误信息,而不是抛出 1062 Duplicate entry。
+ * 这样即便之前有失败记录,重跑也不会因唯一约束冲突而报“执行失败”。
+ */
+ private function recordMigration($pdo, $filename, $status, $errorMsg = '')
+ {
+ $this->pdoExec(
+ $pdo,
+ "INSERT INTO `DGZXY_migrations` (filename, executed_at, status, error_msg)
+ VALUES (:fn, NOW(), :st, :err)
+ ON DUPLICATE KEY UPDATE executed_at = NOW(), status = VALUES(status), error_msg = VALUES(error_msg)",
+ [':fn' => $filename, ':st' => $status, ':err' => $errorMsg]
+ );
+ return true;
+ }
+
+ /**
+ * 判断 SQL 执行错误是否为「可忽略的幂等错误」。
+ *
+ * 在 SAFE 模式重复执行迁移时,以下错误是预期内的,不应算作失败:
+ * - 1060 Duplicate column name:ADD COLUMN 时列已存在
+ * - 1061 Duplicate key name:CREATE INDEX 时索引已存在
+ * - 1091 Can't DROP ... check that column/index exists:DROP 时对象已不存在
+ * - already exists:其他“已存在”类提示
+ */
+ private function isIgnorableSqlError($msg)
+ {
+ $patterns = [
+ 'Duplicate column name',
+ 'Duplicate key name',
+ 'already exists',
+ '1060',
+ '1061',
+ '1091',
+ ];
+ foreach ($patterns as $p) {
+ if (stripos($msg, $p) !== false) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * 确保 migrations 表存在
+ */
+ private function ensureMigrationsTable($pdo)
+ {
+ $this->pdoExec($pdo, "CREATE TABLE IF NOT EXISTS `DGZXY_migrations` (
+ `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ `filename` VARCHAR(255) NOT NULL COMMENT 'SQL文件名',
+ `executed_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '执行时间',
+ `status` TINYINT DEFAULT 1 COMMENT '1=成功 0=失败',
+ `error_msg` TEXT COMMENT '错误信息',
+ UNIQUE KEY `uk_filename` (`filename`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='数据库迁移记录表'");
+ }
+
+ /**
+ * 扫描 sql 目录下的 .sql 文件
+ */
+ private function scanSqlFiles($dir)
+ {
+ $files = [];
+ if (is_dir($dir)) {
+ $dh = opendir($dir);
+ while (($file = readdir($dh)) !== false) {
+ if (preg_match('/\.sql$/i', $file) && $file !== 'create_migrations.sql') {
+ $files[] = $file;
+ }
+ }
+ closedir($dh);
+ sort($files);
+ }
+ return $files;
+ }
+
+ /**
+ * 获取已迁移的文件列表
+ */
+ private function getMigratedFiles($pdo)
+ {
+ $stmt = $pdo->prepare("SELECT filename, executed_at, status, error_msg FROM `DGZXY_migrations` ORDER BY id ASC");
+ $stmt->execute();
+ $rows = $stmt->fetchAll();
+ $stmt->closeCursor();
+ $map = [];
+ foreach ($rows as $row) {
+ $map[$row['filename']] = $row;
+ }
+ return $map;
+ }
+
+ /**
+ * 过滤出未执行的文件
+ */
+ private function filterPendingFiles($pdo, $files)
+ {
+ $migratedMap = $this->getMigratedFiles($pdo);
+ $pending = [];
+ foreach ($files as $file) {
+ if (!isset($migratedMap[$file])) {
+ $pending[] = $file;
+ }
+ }
+ return $pending;
+ }
+
+ /**
+ * 获取 SQL 文件预览(前 20 行)
+ */
+ private function getSqlPreview($filepath)
+ {
+ if (!file_exists($filepath)) return '';
+ $lines = file($filepath);
+ $preview = '';
+ $maxLines = 20;
+ $count = 0;
+ foreach ($lines as $line) {
+ if ($count >= $maxLines) {
+ $preview .= '... (更多内容已省略)';
+ break;
+ }
+ // 跳过纯注释行(但保留有意义的行)
+ $trimmed = trim($line);
+ if ($trimmed === '' || preg_match('/^--/', $trimmed)) {
+ $preview .= htmlspecialchars($line);
+ continue;
+ }
+ $preview .= htmlspecialchars($line);
+ $count++;
+ }
+ return $preview;
+ }
+
+ /**
+ * 执行单个 SQL 文件
+ *
+ * 支持两种模式:
+ * 1. 普通模式(默认):所有语句在一个事务中执行,任一条失败则全部回滚
+ * 2. SAFE 模式(文件首行含 -- @SAFE 标记):每条语句独立执行,失败跳过继续
+ * 适用于幂等补丁(字段可能已存在、表可能已创建等场景)
+ */
+ private function runSqlFile($pdo, $filepath, $filename)
+ {
+ try {
+ // 读取文件内容
+ $sqlContent = file_get_contents($filepath);
+
+ // 安全检查:禁止执行危险的数据库操作
+ $dangerousPatterns = [
+ '/\bDROP\s+DATABASE\b/i',
+ '/\bDROP\s+SCHEMA\b/i',
+ '/\bTRUNCATE\s+TABLE\b/i',
+ '/\bALTER\s+TABLE\s+\S+\s+DROP\s+DATABASE\b/i',
+ ];
+ foreach ($dangerousPatterns as $pattern) {
+ if (preg_match($pattern, $sqlContent)) {
+ error_log("[databaseUpgrade] Blocked dangerous SQL in {$filename}: matched pattern");
+ return ['success' => false, 'message' => "{$filename} — 安全拦截:SQL 文件包含危险操作(DROP DATABASE / TRUNCATE),已被拒绝执行"];
+ }
+ }
+
+ if (empty(trim($sqlContent))) {
+ // 空文件,标记为已执行
+ $this->recordMigration($pdo, $filename, 1, '');
+ return ['success' => true, 'message' => "{$filename} — 空文件,已跳过"];
+ }
+
+ // 检测是否为 SAFE 模式(逐句独立执行,容错)
+ $isSafeMode = (strpos($sqlContent, '-- @SAFE') !== false);
+
+ // 按分号拆分 SQL 语句
+ $statements = $this->splitSqlStatements($sqlContent);
+
+ if ($isSafeMode) {
+ // SAFE 模式:逐句独立执行,失败跳过
+ $total = 0;
+ $success = 0;
+ $ignored = 0; // 可忽略的幂等错误(列/索引已存在等)
+ $realErrors = []; // 需要关注的真实错误
+
+ foreach ($statements as $stmt) {
+ $stmt = trim($stmt);
+ if (empty($stmt)) continue;
+ // 移除所有 -- 行注释后检查是否还有实质 SQL 内容
+ $cleanStmt = preg_replace('/^\s*--.*$/m', '', $stmt);
+ $cleanStmt = trim($cleanStmt);
+ if (empty($cleanStmt)) continue;
+
+ $total++;
+ try {
+ $this->pdoExec($pdo, $stmt);
+ $success++;
+ } catch (\PDOException $e) {
+ $em = $e->getMessage();
+ if ($this->isIgnorableSqlError($em)) {
+ // 列/索引已存在等幂等错误,SAFE 模式下可安全跳过
+ $ignored++;
+ } else {
+ $realErrors[] = "语句{$total}: " . substr($em, 0, 160);
+ }
+ }
+ }
+
+ // 记录执行结果
+ if ($total === 0) {
+ $this->recordMigration($pdo, $filename, 1, '');
+ return ['success' => true, 'message' => "{$filename} — 空文件(SAFE模式),已跳过"];
+ }
+
+ // SAFE 模式下无真实错误即视为成功(跳过的都是预期内的幂等错误)
+ $status = empty($realErrors) ? 1 : 1;
+ $msg = "{$filename} — SAFE模式:{$success} 条执行成功";
+ if ($ignored > 0) {
+ $msg .= ",{$ignored} 条已存在(自动跳过)";
+ }
+ if (!empty($realErrors)) {
+ $msg .= ",【需关注 " . count($realErrors) . " 条】" . implode(" | ", $realErrors);
+ }
+
+ $this->recordMigration($pdo, $filename, $status, implode("; ", $realErrors));
+
+ return ['success' => true, 'message' => $msg];
+ }
+
+ // 普通模式:事务执行
+ $pdo->beginTransaction();
+
+ foreach ($statements as $stmt) {
+ $stmt = trim($stmt);
+ if (empty($stmt)) continue;
+ // 移除所有 -- 行注释后检查是否还有实质 SQL 内容
+ $cleanStmt = preg_replace('/^\s*--.*$/m', '', $stmt);
+ $cleanStmt = trim($cleanStmt);
+ if (empty($cleanStmt)) continue;
+
+ $this->pdoExec($pdo, $stmt);
+ }
+
+ $pdo->commit();
+
+ // 记录执行成功
+ $this->recordMigration($pdo, $filename, 1, '');
+
+ return ['success' => true, 'message' => "{$filename} — 执行成功!"];
+ } catch (\PDOException $e) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+
+ $errorMsg = $e->getMessage();
+
+ // 尝试记录失败(幂等,重复执行时更新而非报错)
+ try {
+ $this->recordMigration($pdo, $filename, 0, $errorMsg);
+ } catch (\Exception $ignoreEx) {}
+
+ return ['success' => false, 'message' => "{$filename} — 执行失败: " . $errorMsg];
+ } catch (\Exception $e) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+
+ $errorMsg = $e->getMessage();
+ try {
+ $this->recordMigration($pdo, $filename, 0, $errorMsg);
+ } catch (\Exception $ignoreEx) {}
+
+ return ['success' => false, 'message' => "{$filename} — 执行失败: " . $errorMsg];
+ }
+ }
+
+ /**
+ * 按分号拆分 SQL 语句(处理字符串中的分号)
+ */
+ private function splitSqlStatements($sql)
+ {
+ $statements = [];
+ $current = '';
+ $inString = false;
+ $stringChar = '';
+ $len = strlen($sql);
+
+ for ($i = 0; $i < $len; $i++) {
+ $ch = $sql[$i];
+
+ // 处理字符串边界
+ if ($ch === "'" || $ch === '"') {
+ if (!$inString) {
+ $inString = true;
+ $stringChar = $ch;
+ } elseif ($ch === $stringChar) {
+ // 检查是否是转义引号
+ $escapeCount = 0;
+ $j = $i - 1;
+ while ($j >= 0 && $sql[$j] === '\\') {
+ $escapeCount++;
+ $j--;
+ }
+ if ($escapeCount % 2 === 0) {
+ $inString = false;
+ $stringChar = '';
+ }
+ }
+ }
+
+ // 只在字符串外识别分号
+ if ($ch === ';' && !$inString) {
+ $statements[] = $current;
+ $current = '';
+ } else {
+ $current .= $ch;
+ }
+ }
+
+ // 最后一部分
+ if (trim($current) !== '') {
+ $statements[] = $current;
+ }
+
+ return $statements;
+ }
+
+ // ==================== 数据库备份与恢复 ====================
+
+ /**
+ * 数据库备份管理页面
+ */
+ public function databaseBackup()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $backupDir = APP_PATH . 'backups';
+ $message = '';
+ $messageType = '';
+
+ // 处理操作
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'backup') {
+ // 执行备份
+ $result = $this->doBackup($backupDir);
+ $message = $result['message'];
+ $messageType = $result['success'] ? 'success' : 'danger';
+ } elseif ($action === 'restore' && !empty($_POST['file'])) {
+ // 执行恢复
+ $file = basename($_POST['file']);
+ $filepath = $backupDir . '/' . $file;
+
+ if (!file_exists($filepath)) {
+ $message = "备份文件不存在: {$file}";
+ $messageType = 'danger';
+ } else {
+ $result = $this->doRestore($filepath);
+ $message = $result['message'];
+ $messageType = $result['success'] ? 'success' : 'danger';
+ }
+ } elseif ($action === 'delete' && !empty($_POST['file'])) {
+ // 删除备份文件
+ $file = basename($_POST['file']);
+ $filepath = $backupDir . '/' . $file;
+
+ if (file_exists($filepath) && unlink($filepath)) {
+ $message = "备份文件 {$file} 已删除";
+ $messageType = 'success';
+ } else {
+ $message = "删除失败: {$file}";
+ $messageType = 'danger';
+ }
+ } elseif ($action === 'download' && !empty($_POST['file'])) {
+ // 下载备份文件
+ $file = basename($_POST['file']);
+ $filepath = $backupDir . '/' . $file;
+
+ if (file_exists($filepath)) {
+ header('Content-Type: application/octet-stream');
+ header('Content-Disposition: attachment; filename="' . $file . '"');
+ header('Content-Length: ' . filesize($filepath));
+ readfile($filepath);
+ exit;
+ }
+ }
+ }
+
+ // 扫描备份文件列表
+ $backupFiles = [];
+ if (is_dir($backupDir)) {
+ $files = scandir($backupDir);
+ foreach ($files as $file) {
+ if ($file === '.' || $file === '..') continue;
+ $filepath = $backupDir . '/' . $file;
+ if (is_file($filepath) && pathinfo($file, PATHINFO_EXTENSION) === 'sql') {
+ $backupFiles[] = [
+ 'name' => $file,
+ 'size' => $this->formatFileSize(filesize($filepath)),
+ 'size_bytes' => filesize($filepath),
+ 'time' => date('Y-m-d H:i:s', filemtime($filepath)),
+ ];
+ }
+ }
+ // 按时间倒序排列
+ usort($backupFiles, function ($a, $b) {
+ return strcmp($b['time'], $a['time']);
+ });
+ }
+
+ $this->assign('title', '数据库备份与恢复');
+ $this->assign('user', $user);
+ $this->assign('backupFiles', $backupFiles);
+ $this->assign('message', $message);
+ $this->assign('messageType', $messageType);
+ $this->assign('activeMenu', 'databaseBackup');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ /**
+ * 执行数据库备份
+ */
+ private function doBackup($backupDir)
+ {
+ try {
+ $dbHost = DB_HOST;
+ $dbName = DB_NAME;
+ $dbUser = DB_USER;
+ $dbPass = DB_PASS;
+
+ $filename = $dbName . '_' . date('Ymd_His') . '.sql';
+ $filepath = $backupDir . '/' . $filename;
+
+ // 方式1:尝试使用 mysqldump 命令行
+ $mysqldumpPath = $this->findMysqldump();
+ if ($mysqldumpPath) {
+ $command = sprintf(
+ '"%s" --host=%s --user=%s --password=%s --databases %s --add-drop-database --add-drop-table --default-character-set=utf8mb4 --skip-lock-tables --result-file="%s" 2>&1',
+ $mysqldumpPath,
+ escapeshellarg($dbHost),
+ escapeshellarg($dbUser),
+ escapeshellarg($dbPass),
+ escapeshellarg($dbName),
+ $filepath
+ );
+ exec($command, $output, $returnCode);
+
+ if ($returnCode === 0 && file_exists($filepath)) {
+ return [
+ 'success' => true,
+ 'message' => "备份成功!文件: {$filename} (" . $this->formatFileSize(filesize($filepath)) . ")"
+ ];
+ }
+ // mysqldump 失败,记录错误信息用于调试,回退到 PHP 方式
+ $dumpError = implode("\n", $output);
+ }
+
+ // 方式2:纯 PHP 实现备份
+ return $this->phpBackup($backupDir, $filename, $filepath, $dbHost, $dbName, $dbUser, $dbPass);
+
+ } catch (\Exception $e) {
+ return ['success' => false, 'message' => '备份失败: ' . $e->getMessage()];
+ }
+ }
+
+ /**
+ * 查找 mysqldump 可执行文件路径
+ */
+ private function findMysqldump()
+ {
+ // exec 被禁用时,直接返回 null,让 phpBackup() 接管
+ if (!function_exists('exec')) {
+ return null;
+ }
+
+ // 常见路径
+ $paths = [
+ 'mysqldump', // 系统 PATH 中
+ 'D:\phpEnv\mysql\bin\mysqldump.exe',
+ 'D:\phpEnv\MySQL\bin\mysqldump.exe',
+ 'C:\phpEnv\mysql\bin\mysqldump.exe',
+ 'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqldump.exe',
+ 'C:\Program Files\MySQL\MySQL Server 5.7\bin\mysqldump.exe',
+ '/usr/bin/mysqldump',
+ '/usr/local/mysql/bin/mysqldump',
+ ];
+
+ foreach ($paths as $path) {
+ // 检查文件是否存在,或命令是否可用
+ // 用 @ 抑制 open_basedir 警告
+ if (@file_exists($path)) {
+ return $path;
+ }
+ // 对于纯命令名,用 where/which 检测
+ if (!str_contains($path, '/') && !str_contains($path, '\\')) {
+ $checkCmd = (PHP_OS_FAMILY === 'Windows') ? "where {$path} 2>nul" : "which {$path} 2>/dev/null";
+ exec($checkCmd, $out, $ret);
+ if ($ret === 0 && !empty($out)) {
+ return trim($out[0]);
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 纯 PHP 方式备份数据库
+ */
+ private function phpBackup($backupDir, $filename, $filepath, $dbHost, $dbName, $dbUser, $dbPass)
+ {
+ $pdo = \core\db\Db::pdo();
+
+ // 获取数据库字符集
+ $stmt = $pdo->query("SELECT @@character_set_database AS charset");
+ $charset = $stmt->fetch()['charset'] ?? 'utf8mb4';
+
+ $sql = "-- MES 数据库备份\n";
+ $sql .= "-- 数据库: {$dbName}\n";
+ $sql .= "-- 备份时间: " . date('Y-m-d H:i:s') . "\n";
+ $sql .= "-- 字符集: {$charset}\n\n";
+
+ $sql .= "SET NAMES {$charset};\n";
+ $sql .= "SET FOREIGN_KEY_CHECKS = 0;\n\n";
+
+ // 获取所有表
+ $tables = $pdo->query("SHOW TABLES")->fetchAll(\PDO::FETCH_COLUMN);
+
+ foreach ($tables as $table) {
+ // 跳过 jp_ 前缀的表
+ if (strpos($table, 'jp_') === 0) continue;
+
+ // 表结构
+ $row = $pdo->query("SHOW CREATE TABLE `{$table}`")->fetch();
+ $sql .= "-- 表结构: {$table}\n";
+ $sql .= "DROP TABLE IF EXISTS `{$table}`;\n";
+ $sql .= $row['Create Table'] . ";\n\n";
+
+ // 表数据
+ $dataStmt = $pdo->query("SELECT * FROM `{$table}`");
+ $rows = $dataStmt->fetchAll(\PDO::FETCH_ASSOC);
+
+ if (!empty($rows)) {
+ $sql .= "-- 表数据: {$table}\n";
+ $columns = array_keys($rows[0]);
+ $colList = '`' . implode('`, `', $columns) . '`';
+
+ // 分批插入,每批 500 行
+ $chunks = array_chunk($rows, 500);
+ foreach ($chunks as $chunk) {
+ $values = [];
+ foreach ($chunk as $row) {
+ $escaped = [];
+ foreach ($row as $val) {
+ if ($val === null) {
+ $escaped[] = 'NULL';
+ } else {
+ $escaped[] = $pdo->quote($val);
+ }
+ }
+ $values[] = '(' . implode(', ', $escaped) . ')';
+ }
+ $sql .= "INSERT INTO `{$table}` ({$colList}) VALUES\n";
+ $sql .= implode(",\n", $values) . ";\n";
+ }
+ $sql .= "\n";
+ }
+ }
+
+ $sql .= "SET FOREIGN_KEY_CHECKS = 1;\n";
+
+ if (file_put_contents($filepath, $sql)) {
+ return [
+ 'success' => true,
+ 'message' => "备份成功(PHP方式)!文件: {$filename} (" . $this->formatFileSize(filesize($filepath)) . ")"
+ ];
+ }
+
+ return ['success' => false, 'message' => '备份失败:无法写入文件'];
+ }
+
+ /**
+ * 执行数据库恢复
+ */
+ private function doRestore($filepath)
+ {
+ try {
+ $pdo = \core\db\Db::pdo();
+ $dbName = DB_NAME;
+
+ // 读取 SQL 文件
+ $sql = file_get_contents($filepath);
+ if (empty($sql)) {
+ return ['success' => false, 'message' => '备份文件为空'];
+ }
+
+ // 尝试方式1:使用 mysql 命令行恢复(更快、更可靠)
+ $mysqlPath = $this->findMysql();
+ if ($mysqlPath) {
+ $command = sprintf(
+ '"%s" --host=%s --user=%s --password=%s --default-character-set=utf8mb4 %s < "%s" 2>&1',
+ $mysqlPath,
+ escapeshellarg(DB_HOST),
+ escapeshellarg(DB_USER),
+ escapeshellarg(DB_PASS),
+ escapeshellarg($dbName),
+ $filepath
+ );
+ exec($command, $output, $returnCode);
+
+ if ($returnCode === 0) {
+ return ['success' => true, 'message' => '数据库恢复成功!(命令行方式)'];
+ }
+ $cliError = implode("\n", $output);
+ }
+
+ // 方式2:纯 PHP 逐条执行 SQL
+ return $this->phpRestore($sql);
+
+ } catch (\Exception $e) {
+ return ['success' => false, 'message' => '恢复失败: ' . $e->getMessage()];
+ }
+ }
+
+ /**
+ * 查找 mysql 客户端可执行文件路径
+ */
+ private function findMysql()
+ {
+ // exec 被禁用时,直接返回 null,让 phpRestore() 接管
+ if (!function_exists('exec')) {
+ return null;
+ }
+
+ $paths = [
+ 'mysql',
+ 'D:\phpEnv\mysql\bin\mysql.exe',
+ 'D:\phpEnv\MySQL\bin\mysql.exe',
+ 'C:\phpEnv\mysql\bin\mysql.exe',
+ 'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe',
+ 'C:\Program Files\MySQL\MySQL Server 5.7\bin\mysql.exe',
+ '/usr/bin/mysql',
+ '/usr/local/mysql/bin/mysql',
+ ];
+
+ foreach ($paths as $path) {
+ // 用 @ 抑制 open_basedir 警告
+ if (@file_exists($path)) {
+ return $path;
+ }
+ if (!str_contains($path, '/') && !str_contains($path, '\\')) {
+ $checkCmd = (PHP_OS_FAMILY === 'Windows') ? "where {$path} 2>nul" : "which {$path} 2>/dev/null";
+ exec($checkCmd, $out, $ret);
+ if ($ret === 0 && !empty($out)) {
+ return trim($out[0]);
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 纯 PHP 方式恢复数据库
+ */
+ private function phpRestore($sql)
+ {
+ $pdo = \core\db\Db::pdo();
+
+ // 分割 SQL 语句
+ $statements = $this->splitSqlStatements($sql);
+
+ $successCount = 0;
+ $errors = [];
+
+ foreach ($statements as $statement) {
+ $statement = trim($statement);
+ if (empty($statement)) continue;
+
+ // 跳过纯注释行
+ if (preg_match('/^--/', $statement)) continue;
+
+ try {
+ $pdo->exec($statement);
+ $successCount++;
+ } catch (\PDOException $e) {
+ $errors[] = "错误: " . $e->getMessage() . "\nSQL: " . substr($statement, 0, 200);
+ }
+ }
+
+ if (empty($errors)) {
+ return [
+ 'success' => true,
+ 'message' => "数据库恢复成功!(PHP方式)共执行 {$successCount} 条语句"
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => "恢复过程中出现 " . count($errors) . " 个错误(已执行 {$successCount} 条):\n" . implode("\n", array_slice($errors, 0, 5))
+ ];
+ }
+
+ /**
+ * 格式化文件大小
+ */
+ private function formatFileSize($bytes)
+ {
+ $units = ['B', 'KB', 'MB', 'GB'];
+ $i = 0;
+ while ($bytes >= 1024 && $i < count($units) - 1) {
+ $bytes /= 1024;
+ $i++;
+ }
+ return round($bytes, 2) . ' ' . $units[$i];
+ }
+}
\ No newline at end of file
diff --git a/app/controllers/ApiController.php b/app/controllers/ApiController.php
new file mode 100644
index 0000000..b444f5e
--- /dev/null
+++ b/app/controllers/ApiController.php
@@ -0,0 +1,918 @@
+getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine();
+ }
+ echo json_encode(['Result' => 0, 'Message' => $msg], JSON_UNESCAPED_UNICODE);
+ exit;
+ });
+ }
+
+ /**
+ * 验证 API Token
+ *
+ * 与 generateToken() 一致,统一使用 DGZXY_asd_api_token 表。
+ * 开放模式(符合接口文档 v1.5「鉴权为可选项」):
+ * - 未携带 Token:放行(返回 null),便于设备直接上传;
+ * - 携带了 Token 但无效/过期:拒绝。
+ *
+ * @return array|null 成功/开放模式返回用户信息或 null,失败直接 exit
+ */
+ protected function apiAuth()
+ {
+ // 从 Authorization 头获取 Token
+ $token = '';
+ $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
+ if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
+ $token = trim($matches[1]);
+ }
+
+ // 也支持 POST/GET 参数传递 token(向后兼容)
+ if (empty($token)) {
+ $input = $this->getJsonInput();
+ $token = $input['token'] ?? $_GET['token'] ?? '';
+ }
+
+ // 开放模式:未携带 Token 直接放行
+ if (empty($token)) {
+ return null;
+ }
+
+ // 携带了 Token,则必须有效
+ try {
+ $pdo = \core\db\Db::pdo();
+ $stmt = $pdo->prepare("SELECT * FROM DGZXY_asd_api_token WHERE token = :token");
+ $stmt->execute([':token' => $token]);
+ $tokenInfo = $stmt->fetch(\PDO::FETCH_ASSOC);
+ } catch (\Throwable $e) {
+ if (APP_DEBUG) {
+ error_log('API auth error: ' . $e->getMessage());
+ }
+ $this->jsonResponse(0, '认证服务异常');
+ exit;
+ }
+
+ if (!$tokenInfo) {
+ $this->jsonResponse(0, 'Token无效或已过期');
+ exit;
+ }
+
+ return [
+ 'id' => 0,
+ 'emp_no' => $tokenInfo['emp_no'] ?? '',
+ 'emp_name' => $tokenInfo['tester'] ?? '',
+ ];
+ }
+
+ // ==================== 工具方法 ====================
+
+ /**
+ * 频率限制(基于文件滑动窗口,按 IP + key 计数)
+ * @param string $key 业务标识
+ * @param int $max 时间窗口内最大允许次数
+ * @param int $window 时间窗口(秒)
+ * @return bool true=已超限需拒绝
+ */
+ private function rateLimitExceeded($key, $max, $window)
+ {
+ $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
+ $dir = sys_get_temp_dir() . '/mes2_ratelimit';
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0700, true);
+ }
+ if (!is_dir($dir)) {
+ return false; // 无法记录时放行,不影响业务
+ }
+ $file = $dir . '/' . md5($key . '|' . $ip);
+ $now = time();
+ $hits = [];
+ if (file_exists($file)) {
+ $raw = @file_get_contents($file);
+ $hits = $raw ? json_decode($raw, true) ?: [] : [];
+ }
+ // 清除窗口外的记录
+ $hits = array_filter($hits, function ($t) use ($now, $window) {
+ return ($now - $t) < $window;
+ });
+ if (count($hits) >= $max) {
+ return true;
+ }
+ $hits[] = $now;
+ @file_put_contents($file, json_encode($hits));
+ return false;
+ }
+
+ /**
+ * API 默认入口:不暴露任何方法列表,返回干净的 404
+ */
+ public function index()
+ {
+ if (!headers_sent()) {
+ header('HTTP/1.1 404 Not Found');
+ }
+ $this->jsonResponse(0, 'API endpoint not found');
+ }
+
+ /**
+ * 通用 JSON 响应
+ */
+ private function jsonResponse($result, $message, $extra = [])
+ {
+ if (!headers_sent()) {
+ header('Content-Type: application/json; charset=utf-8');
+ }
+ $data = array_merge(['Result' => (int)$result, 'Message' => $message], $extra);
+ echo json_encode($data, JSON_UNESCAPED_UNICODE);
+ }
+
+ /**
+ * 获取原始 JSON 请求体
+ */
+ private function getJsonInput()
+ {
+ $input = file_get_contents('php://input');
+ $data = json_decode($input, true);
+ return is_array($data) ? $data : [];
+ }
+
+ /**
+ * 生成持久化 Token(存入数据库以便后续校验)
+ */
+ private function generateToken($empNo, $tester)
+ {
+ $token = bin2hex(random_bytes(32)); // 使用加密安全的随机数替代 md5
+ try {
+ $pdo = \core\db\Db::pdo();
+ $stmt = $pdo->prepare(
+ "INSERT INTO DGZXY_asd_api_token (token, emp_no, tester, created_at)
+ VALUES (:token, :empNo, :tester, NOW())
+ ON DUPLICATE KEY UPDATE tester = :tester2, created_at = NOW()"
+ );
+ $stmt->execute([
+ ':token' => $token,
+ ':empNo' => $empNo,
+ ':tester' => $tester,
+ ':tester2' => $tester,
+ ]);
+ } catch (\Throwable $e) {
+ // Token 表不存在时记录日志,但仍返回 token
+ // 注意:verifyToken 也会因表不存在而拒绝,保证一致性
+ if (APP_DEBUG) {
+ error_log('API Token table error: ' . $e->getMessage());
+ }
+ }
+ return $token;
+ }
+
+ /**
+ * 校验 Token 是否有效
+ * 安全修复:表不存在时拒绝通过(而非降级放行)
+ */
+ private function verifyToken($token, $tester = '')
+ {
+ if (empty($token)) return false;
+ try {
+ $pdo = \core\db\Db::pdo();
+ $sql = "SELECT * FROM DGZXY_asd_api_token WHERE token = :token";
+ $params = [':token' => $token];
+ if (!empty($tester)) {
+ $sql .= " AND tester = :tester";
+ $params[':tester'] = $tester;
+ }
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute($params);
+ return $stmt->fetch(\PDO::FETCH_ASSOC) ?: false;
+ } catch (\Throwable $e) {
+ // 安全修复:表不存在时拒绝通过,而非降级放行
+ if (APP_DEBUG) {
+ error_log('API Token verification error: ' . $e->getMessage());
+ }
+ return false;
+ }
+ }
+
+ // ==================== 接口 1: 用户登录身份信息校验 ====================
+
+ /**
+ * 1. AsdUserInfoVerification - 用户登录系统身份信息校验
+ * POST /Api/asdUserInfoVerification
+ *
+ * 请求参数 (JSON):
+ * Parameter1 - 参数1(员工编号/工号,扫码或手动输入)
+ * Parameter2 - 参数2(密码或其它验证信息,可选)
+ * Parameter3 - 参数3(可选)
+ * Parameter4 - 参数4(可选)
+ *
+ * 成功响应:
+ * Result=1, Message="验证成功", Tester="张三", Token="xxx"
+ * 失败响应:
+ * Result=0, Message="工号不存在" 或 "密码错误"
+ */
+ public function asdUserInfoVerification()
+ {
+ // 频率限制:同一 IP 60 秒内最多 10 次,防止枚举/暴力破解
+ if ($this->rateLimitExceeded('asdUserInfoVerification', 10, 60)) {
+ $this->jsonResponse(0, '尝试过于频繁,请稍后重试');
+ return;
+ }
+
+ $data = $this->getJsonInput();
+
+ $empNo = $data['Parameter1'] ?? '';
+ $password = $data['Parameter2'] ?? '';
+ $param3 = $data['Parameter3'] ?? '';
+ $param4 = $data['Parameter4'] ?? '';
+
+ // 安全修复(P0):API 认证必须同时提供工号与密码,禁止无密码签发 Token
+ if (empty($empNo)) {
+ $this->jsonResponse(0, '工号(Parameter1)不能为空');
+ return;
+ }
+ if (empty($password)) {
+ $this->jsonResponse(0, '缺少密码(Parameter2),API 认证必须提供密码');
+ return;
+ }
+
+ $employee = new \app\models\Employee();
+ $emp = $employee->findByEmpNo($empNo);
+
+ if (!$emp) {
+ $this->jsonResponse(0, '工号不存在: ' . $empNo);
+ return;
+ }
+
+ // 严格校验密码(bcrypt 验证)
+ $valid = $employee->validateLogin($empNo, $password);
+ if (!$valid) {
+ $this->jsonResponse(0, '密码错误');
+ return;
+ }
+
+ $token = $this->generateToken($empNo, $emp['emp_name']);
+ $this->jsonResponse(1, '验证成功', [
+ 'Tester' => $emp['emp_name'],
+ 'Token' => $token,
+ ]);
+ }
+
+ // ==================== 接口 2: 获取站点/工位/关联数据 ====================
+
+ /**
+ * 2. AsdGetWorkInfo - 获取站点、工位或其它关联数据
+ * POST /Api/asdGetWorkInfo
+ *
+ * 请求参数 (JSON):
+ * Parameter1 - 参数1(条码/二维码,用于查找关联工单)
+ * Parameter2 - 参数2(工位编码,可选)
+ * Parameter3 - 参数3(设备编码,可选)
+ * Parameter4 - 参数4(可选)
+ *
+ * 成功响应:
+ * Result=1, Message="获取成功", Mo="xxx", Site="xxx", Station="xxx", WorkOrder="xxx"
+ */
+ public function asdGetWorkInfo()
+ {
+ $this->apiAuth();
+ $data = $this->getJsonInput();
+
+ $param1 = $data['Parameter1'] ?? ''; // 条码/二维码
+ $param2 = $data['Parameter2'] ?? ''; // 工位编码
+ $param3 = $data['Parameter3'] ?? ''; // 设备编码
+ $param4 = $data['Parameter4'] ?? ''; // 备用
+
+ $mo = '';
+ $site = '';
+ $station = '';
+ $workOrder = '';
+
+ try {
+ $pdo = \core\db\Db::pdo();
+
+ // 1. 如果有条码,查询该条码已关联的制令单/工单
+ if (!empty($param1)) {
+ $stmt = $pdo->prepare(
+ "SELECT mo, site, station, work_order FROM DGZXY_asd_test_record
+ WHERE qr_code = :qc ORDER BY id DESC LIMIT 1"
+ );
+ $stmt->execute([':qc' => $param1]);
+ $record = $stmt->fetch(\PDO::FETCH_ASSOC);
+ if ($record && !empty($record['mo'])) {
+ $mo = $record['mo'];
+ $site = $record['site'];
+ $station = $record['station'];
+ $workOrder = $record['work_order'];
+ }
+ }
+
+ // 2. 根据设备编码查找默认工位
+ if (!empty($param3)) {
+ $deviceModel = new AsdDevice();
+ $device = $deviceModel->findByCode($param3);
+ if ($device) {
+ if (empty($station) && !empty($device['station_code'])) {
+ $station = $device['station_code'];
+ }
+ if (empty($site) && !empty($device['line_name'])) {
+ $site = $device['line_name'];
+ }
+ }
+ }
+
+ // 3. 根据传入工位编码查工位
+ if (empty($station) && !empty($param2)) {
+ $station = $param2;
+ }
+
+ // 4. 从工位表获取详细信息
+ $workstationModel = new \app\models\Workstation();
+ $allStations = $workstationModel->getAll();
+
+ // 自动补全 site 和 station
+ if ((empty($site) || empty($station)) && !empty($allStations)) {
+ $first = $allStations[0];
+ if (empty($site)) $site = $first['station_name'] ?? '';
+ if (empty($station)) $station = $first['station_code'] ?? '';
+ }
+
+ // 5. 生成制令单号(如果未找到已关联的)
+ if (empty($mo)) {
+ $stmt = $pdo->prepare(
+ "SELECT MAX(mo) AS last_mo FROM DGZXY_asd_test_record WHERE mo LIKE :prefix"
+ );
+ $todayPrefix = 'MO' . date('Ymd') . '%';
+ $stmt->execute([':prefix' => $todayPrefix]);
+ $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+ if (!empty($row['last_mo'])) {
+ $lastSeq = intval(substr($row['last_mo'], 10));
+ $mo = 'MO' . date('Ymd') . str_pad($lastSeq + 1, 3, '0', STR_PAD_LEFT);
+ } else {
+ $mo = 'MO' . date('Ymd') . '001';
+ }
+ }
+
+ // 6. 生成工单号
+ if (empty($workOrder)) {
+ $stmt = $pdo->prepare(
+ "SELECT MAX(work_order) AS last_wo FROM DGZXY_asd_test_record WHERE work_order LIKE :prefix"
+ );
+ $woPrefix = 'WO-' . date('Ymd') . '%';
+ $stmt->execute([':prefix' => $woPrefix]);
+ $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+ if (!empty($row['last_wo'])) {
+ $lastSeq = intval(substr($row['last_wo'], 12));
+ $workOrder = 'WO-' . date('Ymd') . '-' . str_pad($lastSeq + 1, 2, '0', STR_PAD_LEFT);
+ } else {
+ $workOrder = 'WO-' . date('Ymd') . '-01';
+ }
+ }
+
+ } catch (\Throwable $e) {
+ // 容错:即使数据库异常,也返回可用数据
+ if (empty($mo)) $mo = 'MO' . date('Ymd') . '001';
+ if (empty($workOrder)) $workOrder = 'WO-' . date('Ymd') . '-01';
+ }
+
+ $this->jsonResponse(1, '获取成功', [
+ 'Mo' => $mo,
+ 'Site' => $site,
+ 'Station' => $station,
+ 'WorkOrder' => $workOrder,
+ ]);
+ }
+
+ // ==================== 接口 3: 数据检验/工艺检查 ====================
+
+ /**
+ * 3. AsdCheckFlow - 数据检验、工艺检查
+ * POST /Api/asdCheckFlow
+ *
+ * 请求参数 (JSON):
+ * QrCode - 条码/二维码(有则必传)
+ * Mo - GetWorkInfo 返回的 Mo
+ * Site - GetWorkInfo 返回的 Site
+ * Station - GetWorkInfo 返回的 Station
+ * WorkOrder - GetWorkInfo 返回的 WorkOrder
+ * Group - 组别(必填)
+ * Device - 设备序列号(必填)
+ *
+ * 成功响应:
+ * Result=1, QrCode="xxx", Message="检验通过,可以开始测试"
+ * 失败响应:
+ * Result=0, QrCode="xxx", Message="该条码已完成测试" / "设备未注册" / ...
+ */
+ public function asdCheckFlow()
+ {
+ $this->apiAuth();
+ $data = $this->getJsonInput();
+
+ $qrCode = $data['QrCode'] ?? '';
+ $mo = $data['Mo'] ?? '';
+ $site = $data['Site'] ?? '';
+ $station = $data['Station'] ?? '';
+ $workOrder = $data['WorkOrder'] ?? '';
+ $group = $data['Group'] ?? '';
+ $device = $data['Device'] ?? '';
+
+ // 1. 必填字段校验:Group & Device
+ if (empty($group)) {
+ $this->jsonResponse(0, '组别(Group)不能为空', ['QrCode' => $qrCode]);
+ return;
+ }
+ if (empty($device)) {
+ $this->jsonResponse(0, '设备序列号(Device)不能为空', ['QrCode' => $qrCode]);
+ return;
+ }
+
+ // 2. 设备存在性校验(开放模式:设备未注册不阻断,仅做组别一致性校验)
+ $deviceModel = new AsdDevice();
+ try {
+ $deviceRecord = $deviceModel->isActive($device);
+ if ($deviceRecord
+ && !empty($group)
+ && !empty($deviceRecord['group_name'])
+ && $deviceRecord['group_name'] !== $group) {
+ // 设备已注册但组别与提交不一致:拒绝(数据完整性保护)
+ $this->jsonResponse(0, "设备 {$device} 属于组别 [{$deviceRecord['group_name']}],与提交组别 [{$group}] 不匹配", [
+ 'QrCode' => $qrCode,
+ ]);
+ return;
+ }
+ // 设备未注册时放行(开放接口,便于先上线采集再补录设备)
+ } catch (\Throwable $e) {
+ // 设备表异常时不阻止流程
+ }
+
+ // 3. 条码防重校验(如果有条码)
+ if (!empty($qrCode)) {
+ try {
+ $testData = new AsdTestData();
+ $existing = $testData->query(
+ "SELECT id, status FROM DGZXY_asd_test_record WHERE qr_code = :qc ORDER BY id DESC LIMIT 1",
+ [':qc' => $qrCode]
+ );
+ if (!empty($existing)) {
+ $this->jsonResponse(0, '该条码已完成测试,请勿重复测试', [
+ 'QrCode' => $qrCode,
+ ]);
+ return;
+ }
+ } catch (\Throwable $e) {
+ // 容错
+ }
+ }
+
+ $this->jsonResponse(1, '检验通过,可以开始测试', [
+ 'QrCode' => $qrCode,
+ ]);
+ }
+
+ // ==================== 接口 4: 提交测试数据 ====================
+
+ /**
+ * 4. AsdUpDataInfo - 提交测试数据(核心接口)
+ * POST /Api/asdUpDataInfo
+ *
+ * 请求参数 (JSON):
+ * Tester - 测试员
+ * QrCode - 条码/二维码
+ * Mo - 制令单
+ * Site - 工位
+ * Station - 站点
+ * WorkOrder - 工单
+ * ProjectName - 项目名称
+ * Status - 测试状态 PASS/NG(必填)
+ * Group - 组别(必填)
+ * Device - 设备号(必填)
+ * RunningTime - 运行时间(必填)
+ * TestTime - 测试时间(必填)
+ * DataList - 测试工步数据(必填)
+ *
+ * 成功响应:
+ * Result=1, Message="测试数据上传成功", RecordId="xxx", DetailCount=N
+ * 失败响应:
+ * Result=0, Message="错误描述"
+ */
+ public function asdUpDataInfo()
+ {
+ $this->apiAuth();
+ $data = $this->getJsonInput();
+
+ // ===== 必填字段校验 =====
+
+ // Status 必填
+ if (empty($data['Status'])) {
+ $this->jsonResponse(0, '测试状态(Status)是必填项,值必须为 PASS 或 NG');
+ return;
+ }
+ $status = strtoupper($data['Status']);
+ if (!in_array($status, ['PASS', 'NG'])) {
+ $this->jsonResponse(0, 'Status 值无效,必须为 PASS 或 NG,当前值: ' . $status);
+ return;
+ }
+
+ // Group 必填
+ if (empty($data['Group'])) {
+ $this->jsonResponse(0, '组别(Group)是必填项');
+ return;
+ }
+
+ // Device 必填
+ if (empty($data['Device'])) {
+ $this->jsonResponse(0, '设备号(Device)是必填项');
+ return;
+ }
+
+ // RunningTime 必填
+ if (empty($data['RunningTime'])) {
+ $this->jsonResponse(0, '运行时间(RunningTime)是必填项');
+ return;
+ }
+
+ // TestTime 必填
+ if (empty($data['TestTime'])) {
+ $this->jsonResponse(0, '测试时间(TestTime)是必填项');
+ return;
+ }
+
+ // DataList 必填且为数组
+ if (empty($data['DataList']) || !is_array($data['DataList'])) {
+ $this->jsonResponse(0, '测试数据(DataList)是必填项,且必须为数组');
+ return;
+ }
+
+ // ===== 设备存在性校验(可选但推荐) =====
+ $deviceCode = $data['Device'];
+ try {
+ $deviceModel = new AsdDevice();
+ $deviceRecord = $deviceModel->isActive($deviceCode);
+ if (!$deviceRecord) {
+ // 不阻止上传,但记录警告信息
+ // 需要在 asd_device 表中预先注册设备
+ }
+ } catch (\Throwable $e) {
+ // 设备表异常时不影响主流程
+ }
+
+ // ===== 写入数据库 =====
+ try {
+ $testData = new AsdTestData();
+
+ // 插入主记录
+ $recordId = $testData->addRecord([
+ 'tester' => $data['Tester'] ?? '',
+ 'qr_code' => $data['QrCode'] ?? '',
+ 'mo' => $data['Mo'] ?? '',
+ 'site' => $data['Site'] ?? '',
+ 'station' => $data['Station'] ?? '',
+ 'work_order' => $data['WorkOrder'] ?? '',
+ 'project_name' => $data['ProjectName'] ?? '',
+ 'status' => $status,
+ 'group_name' => $data['Group'],
+ 'device' => $deviceCode,
+ 'running_time' => $data['RunningTime'],
+ 'test_time' => $data['TestTime'],
+ ]);
+
+ if (!$recordId) {
+ $err = $testData->getError();
+ $this->jsonResponse(0, '保存测试记录失败: ' . ($err ?: '数据库写入错误'));
+ return;
+ }
+
+ // 插入工步详情
+ $detailCount = 0;
+ foreach ($data['DataList'] as $item) {
+ $detailResult = $testData->addDetail([
+ 'record_id' => $recordId,
+ 'seq' => $item['Seq'] ?? ++$detailCount,
+ 'test_name' => $item['TestName'] ?? '',
+ 'test_item' => $item['TestItem'] ?? '',
+ 'test_units' => $item['TestUnits'] ?? '',
+ 'data_value' => $item['DataValue'] ?? null,
+ 'lower_limit' => $item['LowerLimit'] ?? null,
+ 'upper_limit' => $item['UpperLimit'] ?? null,
+ 'test_value' => $item['TestValue'] ?? '',
+ 'test_limit' => $item['TestLimit'] ?? '',
+ 'test_result' => $item['TestResult'] ?? 'PASS',
+ ]);
+ if ($detailResult) {
+ $detailCount++;
+ }
+ }
+
+ $this->jsonResponse(1, '测试数据上传成功', [
+ 'RecordId' => $recordId,
+ 'DetailCount' => $detailCount,
+ ]);
+ } catch (\Throwable $e) {
+ // PDO 异常模式:缺列/约束等会抛异常,这里转成 JSON 错误而非 500
+ $this->jsonResponse(0, '保存测试记录异常: ' . $e->getMessage());
+ }
+ }
+
+ // ==================== 接口 5: 获取设备列表 ====================
+
+ /**
+ * 5. AsdGetDevices - 获取已注册设备列表(供扫码选择使用)
+ * POST /Api/asdGetDevices
+ *
+ * 请求参数 (JSON):
+ * Keyword - 搜索关键词(可选,模糊匹配设备编码/名称/IP/组别)
+ *
+ * 返回:
+ * Result=1, Devices=[{id, device_code, device_name, device_ip, group_name, status}, ...]
+ */
+ public function asdGetDevices()
+ {
+ $this->apiAuth();
+ $data = $this->getJsonInput();
+ $keyword = $data['Keyword'] ?? '';
+
+ try {
+ $pdo = \core\db\Db::pdo();
+ if (!empty($keyword)) {
+ $like = '%' . $keyword . '%';
+ $stmt = $pdo->prepare(
+ "SELECT id, device_code, device_name, device_ip, device_type, group_name, line_name, station_code, status, remark
+ FROM DGZXY_asd_device
+ WHERE device_code LIKE :kw1 OR device_name LIKE :kw2 OR device_ip LIKE :kw3 OR group_name LIKE :kw4
+ ORDER BY group_name ASC, device_code ASC"
+ );
+ $stmt->execute([':kw1' => $like, ':kw2' => $like, ':kw3' => $like, ':kw4' => $like]);
+ } else {
+ $stmt = $pdo->prepare("SELECT id,device_code,device_name,device_ip,device_type,group_name,line_name,station_code,status,remark FROM DGZXY_asd_device ORDER BY group_name ASC, device_code ASC");
+ $stmt->execute();
+ }
+ $devices = $stmt->fetchAll(\PDO::FETCH_ASSOC);
+ } catch (\Throwable $e) {
+ $devices = [];
+ }
+
+ $this->jsonResponse(1, '获取成功', ['Devices' => $devices]);
+ }
+
+ // ==================== 接口 6: 设备连接测试 ====================
+
+ /**
+ * 6. AsdDeviceConnect - 连接设备并获取设备信息
+ * POST /Api/asdDeviceConnect
+ *
+ * 请求参数 (JSON):
+ * DeviceCode - 设备编码(必填)
+ * DeviceIp - 设备IP(可选,传入后自动更新设备IP)
+ *
+ * 成功响应:
+ * Result=1, Message="设备连接成功",
+ * Device={device_code, device_name, device_ip, group_name, status},
+ * ConnectionInfo={ip, port, reachable, latency_ms}
+ */
+ public function asdDeviceConnect()
+ {
+ $this->apiAuth();
+ $data = $this->getJsonInput();
+ $deviceCode = $data['DeviceCode'] ?? '';
+ $deviceIp = $data['DeviceIp'] ?? '';
+
+ if (empty($deviceCode)) {
+ $this->jsonResponse(0, '设备编码(DeviceCode)不能为空');
+ return;
+ }
+
+ try {
+ $deviceModel = new AsdDevice();
+ $device = $deviceModel->findByCode($deviceCode);
+
+ if (!$device) {
+ $this->jsonResponse(0, '设备不存在: ' . $deviceCode);
+ return;
+ }
+ if ($device['status'] != 1) {
+ $this->jsonResponse(0, '设备已禁用: ' . $deviceCode);
+ return;
+ }
+
+ // 如果传入了新 IP,更新到数据库
+ if (!empty($deviceIp) && $deviceIp !== ($device['device_ip'] ?? '')) {
+ $deviceModel->updateDevice($device['id'], ['device_ip' => $deviceIp]);
+ $device['device_ip'] = $deviceIp;
+ }
+
+ // 检查 IP 是否存在
+ if (empty($device['device_ip'])) {
+ $this->jsonResponse(0, '设备 IP 未设置,请先填写设备 IP 地址', [
+ 'Device' => $device,
+ ]);
+ return;
+ }
+
+ // 尝试 TCP 连接(如果有 socket 扩展)
+ $reachable = false;
+ $latency = 0;
+ $port = 502; // 默认 Modbus TCP 端口
+
+ if (function_exists('fsockopen')) {
+ $start = microtime(true);
+ $errno = 0;
+ $errstr = '';
+ $fp = @fsockopen($device['device_ip'], $port, $errno, $errstr, 3);
+ if ($fp) {
+ $reachable = true;
+ $latency = round((microtime(true) - $start) * 1000, 1);
+ fclose($fp);
+ }
+ } else {
+ // 无 socket 时,仅做数据库校验即视为"逻辑连接成功"
+ $reachable = true;
+ $latency = 0;
+ $port = 0;
+ }
+
+ $this->jsonResponse(1, $reachable ? '设备连接成功' : '设备网络不可达,请检查 IP 和网络', [
+ 'Device' => $device,
+ 'ConnectionInfo' => [
+ 'ip' => $device['device_ip'],
+ 'port' => $reachable ? $port : 502,
+ 'reachable' => $reachable,
+ 'latency_ms' => $latency,
+ ],
+ ]);
+
+ } catch (\Throwable $e) {
+ $this->jsonResponse(0, '连接异常: ' . $e->getMessage());
+ }
+ }
+
+ // ==================== 接口 7: 批量读取设备数据 ====================
+
+ /**
+ * 7. AsdBatchRead - 批量读取指定设备的测试数据
+ * POST /Api/asdBatchRead
+ *
+ * 请求参数 (JSON):
+ * DeviceCodes - 设备编码列表(数组,如 ["ASD-989A-001","ASD-989A-002"])
+ * Limit - 每台设备最多返回条数(默认 20)
+ *
+ * 返回:
+ * Result=1, DevicesData={
+ * "ASD-989A-001": {device_info:{...}, records:[{id,qr_code,status,step_count,test_time,...}], total:N}
+ * }
+ */
+ public function asdBatchRead()
+ {
+ $this->apiAuth();
+ $data = $this->getJsonInput();
+ $deviceCodes = $data['DeviceCodes'] ?? [];
+ $limit = intval($data['Limit'] ?? 20);
+
+ if (empty($deviceCodes) || !is_array($deviceCodes)) {
+ $this->jsonResponse(0, '设备编码列表(DeviceCodes)不能为空,且必须为数组');
+ return;
+ }
+
+ $devicesData = [];
+
+ try {
+ $pdo = \core\db\Db::pdo();
+
+ foreach ($deviceCodes as $code) {
+ // 获取设备信息
+ $deviceModel = new AsdDevice();
+ $device = $deviceModel->findByCode($code);
+
+ // 获取该设备的测试记录(含统计)
+ $stmt = $pdo->prepare(
+ "SELECT r.id, r.qr_code, r.product_model AS project_name, r.tester, r.site,
+ r.device_code AS device, r.test_result AS status, r.test_date AS test_time, r.running_time,
+ COUNT(d.id) as step_count,
+ SUM(CASE WHEN d.test_result = 'FAIL' THEN 1 ELSE 0 END) as fail_count
+ FROM DGZXY_asd_test_record r
+ LEFT JOIN DGZXY_asd_test_detail d ON r.id = d.record_id
+ WHERE r.device_code = :code
+ GROUP BY r.id
+ ORDER BY r.id DESC
+ LIMIT " . intval($limit)
+ );
+ $stmt->execute([':code' => $code]);
+ $records = $stmt->fetchAll(\PDO::FETCH_ASSOC);
+
+ // 总数
+ $countStmt = $pdo->prepare("SELECT COUNT(*) as cnt FROM DGZXY_asd_test_record WHERE device = :code");
+ $countStmt->execute([':code' => $code]);
+ $total = $countStmt->fetch(\PDO::FETCH_ASSOC)['cnt'] ?? 0;
+
+ $devicesData[$code] = [
+ 'device_info' => $device,
+ 'records' => $records,
+ 'total' => (int)$total,
+ ];
+ }
+ } catch (\Throwable $e) {
+ $this->jsonResponse(0, '批量读取异常: ' . $e->getMessage());
+ return;
+ }
+
+ $this->jsonResponse(1, '批量读取成功', [
+ 'DevicesData' => $devicesData,
+ 'DeviceCount' => count($deviceCodes),
+ ]);
+ }
+
+ // ==================== 接口 8: 删除设备数据 ====================
+
+ /**
+ * 8. AsdDeviceDataDelete - 按设备删除测试数据
+ * POST /Api/asdDeviceDataDelete
+ *
+ * 请求参数 (JSON):
+ * DeviceCode - 设备编码(必填)
+ * Confirm - 确认删除(必须为 "YES")
+ *
+ * 返回:
+ * Result=1, DeletedCount=N, Message="已删除 N 条测试记录"
+ */
+ public function asdDeviceDataDelete()
+ {
+ $this->apiAuth();
+ $data = $this->getJsonInput();
+ $deviceCode = $data['DeviceCode'] ?? '';
+ $confirm = $data['Confirm'] ?? '';
+
+ if (empty($deviceCode)) {
+ $this->jsonResponse(0, '设备编码(DeviceCode)不能为空');
+ return;
+ }
+ if ($confirm !== 'YES') {
+ $this->jsonResponse(0, '请确认删除操作(Confirm=YES)');
+ return;
+ }
+
+ try {
+ $pdo = \core\db\Db::pdo();
+
+ // 先查该设备有多少条记录
+ $countStmt = $pdo->prepare("SELECT COUNT(*) as cnt FROM DGZXY_asd_test_record WHERE device_code = :code");
+ $countStmt->execute([':code' => $deviceCode]);
+ $recordCount = $countStmt->fetch(\PDO::FETCH_ASSOC)['cnt'] ?? 0;
+
+ if ($recordCount == 0) {
+ $this->jsonResponse(1, '该设备无数据可删除', ['DeletedCount' => 0]);
+ return;
+ }
+
+ // 获取所有记录 ID
+ $idsStmt = $pdo->prepare("SELECT id FROM DGZXY_asd_test_record WHERE device_code = :code");
+ $idsStmt->execute([':code' => $deviceCode]);
+ $ids = $idsStmt->fetchAll(\PDO::FETCH_COLUMN);
+
+ // 删除详情(级联)
+ $placeholders = implode(',', array_fill(0, count($ids), '?'));
+ $pdo->prepare("DELETE FROM DGZXY_asd_test_detail WHERE record_id IN ({$placeholders})")->execute($ids);
+
+ // 删除主记录
+ $pdo->prepare("DELETE FROM DGZXY_asd_test_record WHERE device_code = :code")->execute([':code' => $deviceCode]);
+
+ $this->jsonResponse(1, "已删除 {$recordCount} 条测试记录", [
+ 'DeletedCount' => (int)$recordCount,
+ ]);
+
+ } catch (\Throwable $e) {
+ $this->jsonResponse(0, '删除失败: ' . $e->getMessage());
+ }
+ }
+}
diff --git a/app/controllers/DashboardController.php b/app/controllers/DashboardController.php
new file mode 100644
index 0000000..76af185
--- /dev/null
+++ b/app/controllers/DashboardController.php
@@ -0,0 +1,55 @@
+checkLogin();
+ $user = $this->getCurrentUser();
+
+ $workstation = new Workstation();
+ $stations = $workstation->getAll();
+
+ // 统计各工位今日数据(使用 StationDefinition 统一模型)
+ $stats = [];
+ $today = date('Y-m-d');
+ $stationDef = new StationDefinition();
+
+ // 内置工位列表(station_definitions 中配置的)
+ $builtinTypes = ['inbound', 'pcb_test', 'battery', 'assembly', 'finished_test', 'warehouse', 'delivery'];
+
+ foreach ($stations as $station) {
+ $type = $station['station_type'];
+ $count = 0;
+ if (in_array($type, $builtinTypes)) {
+ $count = $stationDef->countTodayByOperator($type, $user['emp_name']);
+ }
+ $stats[$type] = [
+ 'name' => $station['station_name'],
+ 'code' => $station['station_code'],
+ 'count' => $count,
+ 'status' => $station['status'],
+ ];
+ }
+
+ // 总计
+ $totalRecords = array_sum(array_column($stats, 'count'));
+
+ $this->assign('title', '前台工位看板');
+ $this->assign('user', $user);
+ $this->assign('stations', $stations);
+ $this->assign('stats', $stats);
+ $this->assign('totalRecords', $totalRecords);
+ $this->assign('today', $today);
+ $this->render();
+ }
+}
diff --git a/app/controllers/FinanceController.php b/app/controllers/FinanceController.php
new file mode 100644
index 0000000..566f0c4
--- /dev/null
+++ b/app/controllers/FinanceController.php
@@ -0,0 +1,474 @@
+checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] === self::ROLE_SUPER_ADMIN) {
+ return $user;
+ }
+ if ($user['role'] === self::ROLE_ADMIN && ($user['category'] ?? '') === self::CATEGORY_FINANCE) {
+ return $user;
+ }
+ header('Location: ' . BASE_URL . '/Front/index');
+ exit;
+ }
+
+ public function index()
+ {
+ $user = $this->checkCategoryAccess();
+
+ $financeRecord = new FinanceRecord();
+ $receivablePayable = new ReceivablePayable();
+
+ // 本月收支汇总
+ $monthStart = date('Y-m-01');
+ $monthEnd = date('Y-m-t');
+ $summary = $financeRecord->getSummary($monthStart, $monthEnd);
+ $incomeTotal = 0;
+ $expenseTotal = 0;
+ foreach ($summary as $row) {
+ if ($row['type'] === 'income') $incomeTotal = (float)$row['total'];
+ if ($row['type'] === 'expense') $expenseTotal = (float)$row['total'];
+ }
+
+ // 应收应付统计
+ $receivables = $receivablePayable->getByType('receivable');
+ $payables = $receivablePayable->getByType('payable');
+ $receivableTotal = 0;
+ $payableTotal = 0;
+ foreach ($receivables as $r) {
+ $receivableTotal += (float)($r['amount'] ?? 0);
+ }
+ foreach ($payables as $p) {
+ $payableTotal += (float)($p['amount'] ?? 0);
+ }
+
+ $this->assign('title', '财务概览');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'finance_dashboard');
+ $this->assign('incomeTotal', $incomeTotal);
+ $this->assign('expenseTotal', $expenseTotal);
+ $this->assign('profit', $incomeTotal - $expenseTotal);
+ $this->assign('receivableTotal', $receivableTotal);
+ $this->assign('payableTotal', $payableTotal);
+ $this->assign('recordCount', $financeRecord->getCount());
+ $this->render();
+ }
+
+ // ========== 账务管理 ==========
+ public function accounts()
+ {
+ $user = $this->checkCategoryAccess();
+ $financeRecord = new FinanceRecord();
+
+ $typeFilter = $_GET['type'] ?? '';
+ $startDate = $_GET['start_date'] ?? date('Y-m-01');
+ $endDate = $_GET['end_date'] ?? date('Y-m-d');
+
+ if ($typeFilter || $startDate || $endDate) {
+ $records = $financeRecord->getByDateRange($startDate, $endDate, $typeFilter);
+ } else {
+ $records = $financeRecord->getAll();
+ }
+
+ $this->assign('title', '账务管理');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'accounts');
+ $this->assign('records', $records);
+ $this->assign('typeFilter', $typeFilter);
+ $this->assign('startDate', $startDate);
+ $this->assign('endDate', $endDate);
+ $this->render();
+ }
+
+ public function accountsAdd()
+ {
+ $user = $this->checkCategoryAccess();
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'record_no' => trim($_POST['record_no'] ?? ''),
+ 'type' => trim($_POST['type'] ?? 'expense'),
+ 'category' => trim($_POST['category'] ?? ''),
+ 'amount' => (float)($_POST['amount'] ?? 0),
+ 'record_date' => trim($_POST['record_date'] ?? date('Y-m-d')),
+ 'description' => trim($_POST['description'] ?? ''),
+ 'related_party' => trim($_POST['related_party'] ?? ''),
+ 'payment_method' => trim($_POST['payment_method'] ?? ''),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ 'operator' => $user['emp_name'],
+ ];
+ if (empty($data['description'])) {
+ $this->assign('error', '摘要不能为空');
+ $this->assign('title', '添加账务记录');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'accounts');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ if ($data['amount'] <= 0) {
+ $this->assign('error', '金额必须大于0');
+ $this->assign('title', '添加账务记录');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'accounts');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ try {
+ $financeRecord = new FinanceRecord();
+ $financeRecord->add($data);
+ header('Location: ' . BASE_URL . '/Finance/accounts');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[accountsAdd] ' . $e->getMessage());
+ $this->assign('error', '添加失败:' . $e->getMessage());
+ $this->assign('title', '添加账务记录');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'accounts');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '添加账务记录');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'accounts');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function accountsEdit()
+ {
+ $user = $this->checkCategoryAccess();
+ $id = (int)($_GET['id'] ?? 0);
+ $financeRecord = new FinanceRecord();
+ $record = $financeRecord->getById($id);
+ if (!$record) exit('记录不存在');
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'record_no' => trim($_POST['record_no'] ?? ''),
+ 'type' => trim($_POST['type'] ?? 'expense'),
+ 'category' => trim($_POST['category'] ?? ''),
+ 'amount' => (float)($_POST['amount'] ?? 0),
+ 'record_date' => trim($_POST['record_date'] ?? ''),
+ 'description' => trim($_POST['description'] ?? ''),
+ 'related_party' => trim($_POST['related_party'] ?? ''),
+ 'payment_method' => trim($_POST['payment_method'] ?? ''),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ ];
+ try {
+ $financeRecord->where(['id = :id'], [':id' => $id])->update($data);
+ header('Location: ' . BASE_URL . '/Finance/accounts');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[accountsEdit] ' . $e->getMessage());
+ $this->assign('error', '更新失败:' . $e->getMessage());
+ $this->assign('title', '编辑账务记录');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'accounts');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '编辑账务记录');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'accounts');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function accountsDelete()
+ {
+ $this->checkLogin();
+ $this->requireMethod('post');
+ $this->csrfVerify();
+ $id = (int)($_POST['id'] ?? 0);
+ $financeRecord = new FinanceRecord();
+ $financeRecord->delete($id);
+ header('Location: ' . BASE_URL . '/Finance/accounts');
+ exit;
+ }
+
+ // ========== 应收账款 ==========
+ public function receivable()
+ {
+ $user = $this->checkCategoryAccess();
+ $receivablePayable = new ReceivablePayable();
+ $records = $receivablePayable->getByType('receivable');
+
+ $this->assign('title', '应收账款');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'receivable');
+ $this->assign('records', $records);
+ $this->render();
+ }
+
+ public function receivableAdd()
+ {
+ $user = $this->checkCategoryAccess();
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'type' => 'receivable',
+ 'party_name' => trim($_POST['party_name'] ?? ''),
+ 'amount' => (float)($_POST['amount'] ?? 0),
+ 'paid_amount' => (float)($_POST['paid_amount'] ?? 0),
+ 'due_date' => trim($_POST['due_date'] ?? ''),
+ 'status' => trim($_POST['status'] ?? 'pending'),
+ 'description' => trim($_POST['description'] ?? ''),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ 'operator' => $user['emp_name'],
+ ];
+ try {
+ $receivablePayable = new ReceivablePayable();
+ $receivablePayable->add($data);
+ header('Location: ' . BASE_URL . '/Finance/receivable');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[receivableAdd] ' . $e->getMessage());
+ $this->assign('error', '添加失败:' . $e->getMessage());
+ $this->assign('title', '添加应收账款');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'receivable');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '添加应收账款');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'receivable');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function receivableEdit()
+ {
+ $user = $this->checkCategoryAccess();
+ $id = (int)($_GET['id'] ?? 0);
+ $receivablePayable = new ReceivablePayable();
+ $record = $receivablePayable->getById($id);
+ if (!$record) exit('记录不存在');
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'party_name' => trim($_POST['party_name'] ?? ''),
+ 'amount' => (float)($_POST['amount'] ?? 0),
+ 'paid_amount' => (float)($_POST['paid_amount'] ?? 0),
+ 'due_date' => trim($_POST['due_date'] ?? ''),
+ 'status' => trim($_POST['status'] ?? 'pending'),
+ 'description' => trim($_POST['description'] ?? ''),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ ];
+ try {
+ $receivablePayable->where(['id = :id'], [':id' => $id])->update($data);
+ header('Location: ' . BASE_URL . '/Finance/receivable');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[receivableEdit] ' . $e->getMessage());
+ $this->assign('error', '更新失败:' . $e->getMessage());
+ $this->assign('title', '编辑应收账款');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'receivable');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '编辑应收账款');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'receivable');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function receivableDelete()
+ {
+ $this->checkLogin();
+ $this->requireMethod('post');
+ $this->csrfVerify();
+ $id = (int)($_POST['id'] ?? 0);
+ $receivablePayable = new ReceivablePayable();
+ $receivablePayable->delete($id);
+ header('Location: ' . BASE_URL . '/Finance/receivable');
+ exit;
+ }
+
+ // ========== 应付账款 ==========
+ public function payable()
+ {
+ $user = $this->checkCategoryAccess();
+ $receivablePayable = new ReceivablePayable();
+ $records = $receivablePayable->getByType('payable');
+
+ $this->assign('title', '应付账款');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'payable');
+ $this->assign('records', $records);
+ $this->render();
+ }
+
+ public function payableAdd()
+ {
+ $user = $this->checkCategoryAccess();
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'type' => 'payable',
+ 'party_name' => trim($_POST['party_name'] ?? ''),
+ 'amount' => (float)($_POST['amount'] ?? 0),
+ 'paid_amount' => (float)($_POST['paid_amount'] ?? 0),
+ 'due_date' => trim($_POST['due_date'] ?? ''),
+ 'status' => trim($_POST['status'] ?? 'pending'),
+ 'description' => trim($_POST['description'] ?? ''),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ 'operator' => $user['emp_name'],
+ ];
+ try {
+ $receivablePayable = new ReceivablePayable();
+ $receivablePayable->add($data);
+ header('Location: ' . BASE_URL . '/Finance/payable');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[payableAdd] ' . $e->getMessage());
+ $this->assign('error', '添加失败:' . $e->getMessage());
+ $this->assign('title', '添加应付账款');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'payable');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '添加应付账款');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'payable');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function payableEdit()
+ {
+ $user = $this->checkCategoryAccess();
+ $id = (int)($_GET['id'] ?? 0);
+ $receivablePayable = new ReceivablePayable();
+ $record = $receivablePayable->getById($id);
+ if (!$record) exit('记录不存在');
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'party_name' => trim($_POST['party_name'] ?? ''),
+ 'amount' => (float)($_POST['amount'] ?? 0),
+ 'paid_amount' => (float)($_POST['paid_amount'] ?? 0),
+ 'due_date' => trim($_POST['due_date'] ?? ''),
+ 'status' => trim($_POST['status'] ?? 'pending'),
+ 'description' => trim($_POST['description'] ?? ''),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ ];
+ try {
+ $receivablePayable->where(['id = :id'], [':id' => $id])->update($data);
+ header('Location: ' . BASE_URL . '/Finance/payable');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[payableEdit] ' . $e->getMessage());
+ $this->assign('error', '更新失败:' . $e->getMessage());
+ $this->assign('title', '编辑应付账款');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'payable');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '编辑应付账款');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'payable');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function payableDelete()
+ {
+ $this->checkLogin();
+ $this->requireMethod('post');
+ $this->csrfVerify();
+ $id = (int)($_POST['id'] ?? 0);
+ $receivablePayable = new ReceivablePayable();
+ $receivablePayable->delete($id);
+ header('Location: ' . BASE_URL . '/Finance/payable');
+ exit;
+ }
+
+ // ========== 财务报表 ==========
+ public function report()
+ {
+ $user = $this->checkCategoryAccess();
+ $financeRecord = new FinanceRecord();
+
+ $month = $_GET['month'] ?? date('Y-m');
+ $startDate = $month . '-01';
+ $endDate = date('Y-m-t', strtotime($startDate));
+
+ $summary = $financeRecord->getSummary($startDate, $endDate);
+ $incomeTotal = 0;
+ $expenseTotal = 0;
+ foreach ($summary as $row) {
+ if ($row['type'] === 'income') $incomeTotal = (float)$row['total'];
+ if ($row['type'] === 'expense') $expenseTotal = (float)$row['total'];
+ }
+
+ // 按类别统计
+ $records = $financeRecord->getByDateRange($startDate, $endDate);
+ $categorySummary = [];
+ foreach ($records as $r) {
+ $cat = $r['category'] ?: '未分类';
+ $type = $r['type'];
+ if (!isset($categorySummary[$cat])) {
+ $categorySummary[$cat] = ['income' => 0, 'expense' => 0];
+ }
+ $categorySummary[$cat][$type] += (float)$r['amount'];
+ }
+
+ $this->assign('title', '财务报表');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'report');
+ $this->assign('incomeTotal', $incomeTotal);
+ $this->assign('expenseTotal', $expenseTotal);
+ $this->assign('profit', $incomeTotal - $expenseTotal);
+ $this->assign('categorySummary', $categorySummary);
+ $this->assign('month', $month);
+ $this->render();
+ }
+}
diff --git a/app/controllers/FrontController.php b/app/controllers/FrontController.php
new file mode 100644
index 0000000..59ec9b5
--- /dev/null
+++ b/app/controllers/FrontController.php
@@ -0,0 +1,1269 @@
+getByType($stationType);
+ if ($station && !empty($station['fields_config'])) {
+ return parseFieldsConfig($stationType, $station['fields_config']);
+ }
+ return getDefaultFieldsConfig($stationType);
+ }
+ /**
+ * 获取当前用户允许访问的工位列表
+ */
+ private function getAllowedStations($user)
+ {
+ $workstation = new Workstation();
+ $allStations = $workstation->getAll();
+
+ // 超级管理员和管理员可以看到所有工位
+ if ($user['role'] === \core\base\Controller::ROLE_SUPER_ADMIN || $user['role'] === \core\base\Controller::ROLE_ADMIN) {
+ return $allStations;
+ }
+
+ // 操作员:过滤出有权限的工位
+ $permModel = new EmployeePermission();
+ $employeeId = $_SESSION['user_id'] ?? 0;
+ $allowedTypes = $permModel->getAllowedStations($employeeId);
+
+ // 如果没有配置任何权限,默认允许所有工位
+ if (empty($allowedTypes)) {
+ return $allStations;
+ }
+
+ // 过滤
+ $filtered = [];
+ foreach ($allStations as $station) {
+ if (in_array($station['station_type'], $allowedTypes)) {
+ $filtered[] = $station;
+ }
+ }
+ return $filtered;
+ }
+
+ /**
+ * 检查当前用户是否有访问指定工位的权限
+ */
+ private function checkStationPermission($stationType)
+ {
+ $user = $this->getCurrentUser();
+
+ // 超级管理员和管理员始终允许
+ if ($user['role'] === \core\base\Controller::ROLE_SUPER_ADMIN || $user['role'] === \core\base\Controller::ROLE_ADMIN) {
+ return true;
+ }
+
+ $permModel = new EmployeePermission();
+ $employeeId = $_SESSION['user_id'] ?? 0;
+
+ if (!$permModel->canAccess($employeeId, $user['role'], $stationType)) {
+ // 渲染无权限视图
+ $config = new SysConfig();
+ $sysConfig = $config->getConfig();
+ $noPermView = new \core\base\View('Front', 'noPermission');
+ $noPermView->assign('title', '无权限访问');
+ $noPermView->assign('user', $user);
+ $noPermView->assign('sysConfig', $sysConfig);
+ $noPermView->assign('stationType', $stationType);
+ $noPermView->render();
+ exit;
+ }
+ return true;
+ }
+
+ // AJAX 接口:根据产品类型获取对应型号列表
+ public function getModelsByType()
+ {
+ $this->checkLogin();
+ $product_type = isset($_GET['product_type']) ? trim($_GET['product_type']) : '';
+ $productModel = new ProductModel();
+
+ if ($product_type === '') {
+ // 未选类型,返回全部型号
+ $models = $productModel->getAll();
+ } else {
+ $models = $productModel->getByProductType($product_type);
+ }
+
+ echo json_encode(['success' => true, 'models' => $models]);
+ exit;
+ }
+
+ public function index()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ $stations = $this->getAllowedStations($user);
+
+ $productModel = new ProductModel();
+ $models = $productModel->getAll();
+ $types = $productModel->getProductTypes();
+
+ $config = new SysConfig();
+ $sysConfig = $config->getConfig();
+
+ $this->assign('title', '前台工位');
+ $this->assign('user', $user);
+ $this->assign('sysConfig', $sysConfig);
+ $this->assign('stations', $stations);
+ $this->assign('models', $models);
+ $this->assign('types', $types);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ // ========== 统一工位处理器 ==========
+
+ /**
+ * 统一工位处理方法 - 通过 __call 动态路由所有工位
+ *
+ * 所有工位(内置和动态添加)都通过此方法统一处理。
+ * 内置工位在 station_business_helper.php 中定义业务配置。
+ * 动态工位使用 station_records 表的通用存储。
+ */
+ public function __call($method, $args)
+ {
+ return $this->handleStation($method);
+ }
+
+ /**
+ * 核心工位处理器
+ * @param string $stationType 工位类型标识
+ */
+ private function handleStation($stationType)
+ {
+ try {
+ return $this->_handleStation($stationType);
+ } catch (\Throwable $e) {
+ error_log('[' . $stationType . '] 工位加载失败: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
+
+ // AJAX 提交:返回 JSON 错误,避免渲染 HTML 导致前端解析失败
+ if ($this->isAjax()) {
+ if (ob_get_level() > 0) {
+ @ob_clean();
+ }
+ header('Content-Type: application/json; charset=utf-8');
+ $msg = $e->getMessage();
+ $decoded = json_decode($msg, true);
+ // 结构化校验错误(code_prefix / duplicate)直接透传
+ if (is_array($decoded) && (isset($decoded['type']) || isset($decoded['codePrefixErrors']) || isset($decoded['duplicateErrors']) || isset($decoded['errors']))) {
+ echo $msg;
+ } else {
+ echo json_encode(['success' => false, 'error' => $msg]);
+ }
+ exit;
+ }
+
+ // 如果已经有输出内容,清除之前的输出缓冲再渲染错误页
+ if (ob_get_level() > 0) {
+ @ob_clean();
+ }
+
+ // 重置 header 防御标志(如果之前部分渲染已设置了标志)
+ unset($GLOBALS['_station_header_rendered'], $GLOBALS['_station_footer_rendered']);
+
+ // 尝试渲染友好错误页面
+ $user = $this->getCurrentUser();
+ $config = new SysConfig();
+ $sysConfig = $config->getConfig();
+ $workstation = new Workstation();
+ $stations = $workstation->getAll();
+ $errorView = new \core\base\View('Front', 'station_generic');
+ $errorView->assign('title', '工位加载失败');
+ $errorView->assign('user', $user);
+ $errorView->assign('sysConfig', $sysConfig);
+ $errorView->assign('stations', $stations);
+ $errorView->assign('stationType', $stationType);
+ $errorView->assign('currentStation', null);
+ $errorView->assign('bizConfig', null);
+ $errorView->assign('fieldConfigs', []);
+ $errorView->assign('models', []);
+ $errorView->assign('types', []);
+ $errorView->assign('customers', []);
+ $errorView->assign('records', []);
+ $errorView->assign('filterType', '');
+ $errorView->assign('filterModel', '');
+ $errorView->assign('todayCount', 0);
+ $errorView->assign('csrfToken', $this->csrfToken());
+ $errorView->assign('stationError', '工位「' . htmlspecialchars($stationType) . '」尚未配置,请联系管理员在后台添加该工位。错误详情:' . $e->getMessage());
+ $errorView->render();
+ exit;
+ }
+ }
+
+ private function _handleStation($stationType)
+ {
+ $this->checkLogin();
+ $this->checkStationPermission($stationType);
+ $user = $this->getCurrentUser();
+
+ // ===== document_manage 工位:直接渲染专用视图 =====
+ if ($stationType === 'document_manage') {
+ $this->renderDocumentManageStation($user);
+ exit;
+ }
+
+ // 获取业务配置(内置工位有,动态工位为 null)
+ $bizConfig = getStationBusinessConfig($stationType);
+
+ // 如果 station_business_helper 中没有配置,但有 station_definitions 记录,
+ // 自动生成通用业务配置,统一走内置工位路径(专用物理表)
+ if (!$bizConfig) {
+ $stationDef = new StationDefinition();
+ $def = $stationDef->getByType($stationType);
+ if ($def) {
+ $bizConfig = $this->buildGenericBizConfig($stationType, $def);
+ }
+ }
+
+ // 处理重定向工位
+ if ($bizConfig && isset($bizConfig['redirect'])) {
+ header('Location: ' . BASE_URL . '/Front/' . $bizConfig['redirect']);
+ exit;
+ }
+
+ // 获取字段配置
+ $rawFieldConfigs = $this->getStationFieldConfig($stationType);
+
+ // 分离元信息(_meta)和字段列表,确保后续遍历不受影响
+ $fieldMeta = $rawFieldConfigs['_meta'] ?? [];
+ unset($rawFieldConfigs['_meta']);
+ $fieldConfigs = array_values($rawFieldConfigs);
+
+ // ========== 准备数据源 ==========
+ $productModel = new ProductModel();
+ $models = $productModel->getAll();
+ $types = $productModel->getProductTypes();
+
+ $customer = new Customer();
+ $customers = $customer->getAll();
+
+ $workstation = new Workstation();
+ $stations = $workstation->getAll();
+
+ $config = new SysConfig();
+ $sysConfig = $config->getConfig();
+
+ // 当前工位信息
+ $currentStation = $workstation->getByType($stationType);
+
+ // 型号筛选:优先从 fields_config._meta.model_type_filter 读取
+ $modelTypeFilter = $fieldMeta['model_type_filter'] ?? null;
+ if ($modelTypeFilter) {
+ $models = $productModel->getByProductType($modelTypeFilter);
+ } elseif ($bizConfig) {
+ // 向后兼容:bizConfig 中的 fixedType/defaultType
+ if (!empty($bizConfig['fixedType'])) {
+ $models = $productModel->getByProductType($bizConfig['fixedType']);
+ } elseif (!empty($bizConfig['defaultType'])) {
+ $models = $productModel->getByProductType($bizConfig['defaultType']);
+ }
+ }
+
+ // 类型筛选:优先从 fields_config._meta.type_filter 读取
+ $typeFilter = $fieldMeta['type_filter'] ?? null;
+ if ($typeFilter) {
+ $typeConfigModel = new ProductTypeConfig();
+ if ($typeFilter === 'inbound') {
+ $types = $typeConfigModel->getInboundVisible();
+ if (empty($types)) {
+ // fallback:使用默认配置,但过滤掉非产品类型(外壳颜色等)
+ $allDefaults = ProductTypeConfig::getDefaultConfigs();
+ $types = array_filter($allDefaults, function($c) {
+ return ($c['enabled'] ?? 1)
+ && ($c['show_in_inbound'] ?? 1)
+ && !in_array($c['type_name'], ['外壳颜色']); // 外壳颜色不是产品类型
+ });
+ $types = array_values($types);
+ }
+ } else {
+ $types = $typeConfigModel->getEnabled();
+ if (empty($types)) {
+ $allDefaults = ProductTypeConfig::getDefaultConfigs();
+ $types = array_filter($allDefaults, function($c) {
+ return ($c['enabled'] ?? 1)
+ && !in_array($c['type_name'], ['外壳颜色']);
+ });
+ $types = array_values($types);
+ }
+ }
+ } elseif ($bizConfig) {
+ // 向后兼容:bizConfig 中的 typeFilter
+ if (!empty($bizConfig['typeFilter'])) {
+ $typeConfigModel = new ProductTypeConfig();
+ if ($bizConfig['typeFilter'] === 'inbound') {
+ $types = $typeConfigModel->getInboundVisible();
+ if (empty($types)) {
+ $allDefaults = ProductTypeConfig::getDefaultConfigs();
+ $types = array_filter($allDefaults, function($c) {
+ return ($c['enabled'] ?? 1)
+ && ($c['show_in_inbound'] ?? 1)
+ && !in_array($c['type_name'], ['外壳颜色']);
+ });
+ $types = array_values($types);
+ }
+ } else {
+ $types = $typeConfigModel->getEnabled();
+ if (empty($types)) {
+ $allDefaults = ProductTypeConfig::getDefaultConfigs();
+ $types = array_filter($allDefaults, function($c) {
+ return ($c['enabled'] ?? 1)
+ && !in_array($c['type_name'], ['外壳颜色']);
+ });
+ $types = array_values($types);
+ }
+ }
+ }
+ }
+
+ // 外壳颜色:优先从 fields_config._meta 读取
+ $shellColors = [];
+ $needShellColors = $fieldMeta['extra_data_sources']['shellColors'] ?? false;
+ if ($needShellColors) {
+ $shellColors = $productModel->getByType('外壳颜色');
+ } elseif ($bizConfig && !empty($bizConfig['extraDataSources']['shellColors'])) {
+ // 向后兼容
+ $shellColors = $productModel->getByType('外壳颜色');
+ }
+
+ // ========== 获取已有记录 ==========
+ $records = [];
+ $filterType = '';
+ $filterModel = '';
+ $todayCount = 0;
+ $totalCount = 0;
+
+ if ($bizConfig && !empty($bizConfig['model'])) {
+ // 内置工位:使用 StationDefinition 统一模型
+ $stationDef = new StationDefinition();
+
+ // ★ 关键修复:在查询数据之前,主动确保 station_definitions 数据完整
+ // 之前的设计依赖 getRecords() 抛异常来触发 ensureTable(),
+ // 但 getByType() 返回 null 时直接返回空数组,不抛异常,
+ // 导致数据不完整时永远无法自动恢复。
+ $defCheck = $stationDef->getByType($stationType);
+ if (!$defCheck) {
+ error_log('[' . $stationType . '] station_definitions record missing, auto-initializing...');
+ $stationDef->autoCreateTable();
+ StationDefinition::clearCache();
+ }
+
+ // 筛选参数
+ $filterType = isset($_GET['product_type']) ? trim($_GET['product_type']) : '';
+ $filterModel = isset($_GET['product_model']) ? trim($_GET['product_model']) : '';
+
+ if (!empty($bizConfig['view']['enableFilter'])) {
+ if ($filterType || $filterModel) {
+ try {
+ $records = $stationDef->getRecords($stationType, $filterType, $filterModel);
+ } catch (\Throwable $e) {
+ error_log('[' . $stationType . '] getRecords failed: ' . $e->getMessage());
+ $stationDef->ensureTable($stationType, $fieldConfigs);
+ try {
+ $records = $stationDef->getRecords($stationType, $filterType, $filterModel);
+ } catch (\Throwable $e2) {
+ error_log('[' . $stationType . '] getRecords retry failed: ' . $e2->getMessage());
+ $records = [];
+ }
+ }
+ }
+ // enableFilter 模式:初始不加载记录
+ } else {
+ try {
+ $records = $stationDef->getAllRecords($stationType);
+ } catch (\Throwable $e) {
+ error_log('[' . $stationType . '] getAllRecords failed: ' . $e->getMessage());
+ $stationDef->ensureTable($stationType, $fieldConfigs);
+ try {
+ $records = $stationDef->getAllRecords($stationType);
+ } catch (\Throwable $e2) {
+ error_log('[' . $stationType . '] getAllRecords retry failed: ' . $e2->getMessage());
+ $records = [];
+ }
+ }
+ }
+
+ // 今日计数
+ if (!empty($bizConfig['showTodayCount'])) {
+ try {
+ $todayCount = $stationDef->countTodayByOperator($stationType, $user['emp_name']);
+ } catch (\Throwable $e) {
+ error_log('[' . $stationType . '] countTodayByOperator failed: ' . $e->getMessage());
+ // 尝试自动创建表
+ $stationDef->ensureTable($stationType, $fieldConfigs);
+ try {
+ $todayCount = $stationDef->countTodayByOperator($stationType, $user['emp_name']);
+ } catch (\Throwable $e2) {
+ error_log('[' . $stationType . '] countTodayByOperator retry failed: ' . $e2->getMessage());
+ $todayCount = 0;
+ }
+ }
+ }
+
+ // 总计数(与当前筛选对齐)
+ if (!empty($bizConfig['showTotalCount'])) {
+ try {
+ $totalCount = $stationDef->countTotal($stationType, $filterType, $filterModel);
+ } catch (\Throwable $e) {
+ error_log('[' . $stationType . '] countTotal failed: ' . $e->getMessage());
+ // 尝试自动创建表
+ $stationDef->ensureTable($stationType, $fieldConfigs);
+ try {
+ $totalCount = $stationDef->countTotal($stationType, $filterType, $filterModel);
+ } catch (\Throwable $e2) {
+ error_log('[' . $stationType . '] countTotal retry failed: ' . $e2->getMessage());
+ $totalCount = count($records);
+ }
+ }
+ } else {
+ $totalCount = count($records);
+ }
+ } else {
+ // 动态工位:使用 station_records
+ $stationRecord = new StationRecord();
+ $records = $stationRecord->getByStationType($stationType);
+ $totalCount = count($records);
+ }
+
+ // ========== POST 请求处理 ==========
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $action = isset($_POST['action']) ? $_POST['action'] : 'add';
+
+ // 处理特殊 actions(导出、PDF、lookup 等)
+ if ($action !== 'add' && $bizConfig) {
+ $this->handleSpecialAction($action, $bizConfig, $stationType, $records, $user);
+ exit;
+ }
+
+ // ===== add 操作:提交数据 =====
+ if ($action === 'add') {
+ try {
+ if ($bizConfig && !empty($bizConfig['model'])) {
+ // 内置工位:写入专用表
+ $this->handleBuiltinSubmit($bizConfig, $stationType, $user);
+ } else {
+ // 动态工位:写入 station_records
+ $recordData = [];
+ foreach ($fieldConfigs as $field) {
+ if (!empty($field['enabled'])) {
+ $name = $field['name'];
+ $recordData[$name] = $_POST[$name] ?? '';
+ }
+ }
+ $sr = new StationRecord();
+ $sr->addRecord($stationType, $recordData, $user['emp_name']);
+ }
+
+ if ($this->isAjax()) {
+ echo json_encode(['success' => true]); exit;
+ }
+ // 非 AJAX 提交:保留筛选参数
+ $redirectUrl = BASE_URL . '/Front/' . $stationType;
+ $queryParams = [];
+ if (!empty($_GET['product_type'])) $queryParams[] = 'product_type=' . urlencode($_GET['product_type']);
+ if (!empty($_GET['product_model'])) $queryParams[] = 'product_model=' . urlencode($_GET['product_model']);
+ if (!empty($queryParams)) $redirectUrl .= '?' . implode('&', $queryParams);
+ header('Location: ' . $redirectUrl);
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[' . $stationType . '] ' . $e->getMessage());
+ if ($this->isAjax()) {
+ @ob_clean();
+ header('Content-Type: application/json; charset=utf-8');
+ // 尝试解析结构化错误(code_prefix / duplicate)
+ $errMsg = $e->getMessage();
+ $codePrefixErrors = null;
+ $duplicateErrors = null;
+ $isUserError = false;
+ $decoded = json_decode($errMsg, true);
+ if ($decoded && isset($decoded['type'])) {
+ if ($decoded['type'] === 'code_prefix') {
+ $codePrefixErrors = $decoded['errors'];
+ $errMsg = implode(';', array_values($codePrefixErrors));
+ $isUserError = true;
+ } elseif ($decoded['type'] === 'duplicate') {
+ $duplicateErrors = $decoded['errors'];
+ $errMsg = implode(';', array_values($duplicateErrors));
+ $isUserError = true;
+ }
+ }
+ // 非用户错误(如 SQL 异常)不暴露原始信息给前端
+ if (!$isUserError && ($e instanceof \PDOException)) {
+ $errMsg = '系统错误,请联系管理员';
+ }
+ echo json_encode([
+ 'success' => false,
+ 'error' => '提交失败:' . $errMsg,
+ 'codePrefixErrors' => $codePrefixErrors,
+ 'duplicateErrors' => $duplicateErrors,
+ ]); exit;
+ }
+ $GLOBALS['stationError'] = '提交失败:' . $e->getMessage();
+ }
+ }
+ }
+
+ // ========== 渲染视图 ==========
+ // 构建视图变量
+ $viewVars = [
+ 'title' => ($currentStation['station_name'] ?? $stationType),
+ 'user' => $user,
+ 'sysConfig' => $sysConfig,
+ 'stations' => $stations,
+ 'stationType' => $stationType,
+ 'currentStation' => $currentStation,
+ 'models' => $models,
+ 'types' => $types,
+ 'customers' => $customers,
+ 'fieldConfigs' => $fieldConfigs,
+ 'records' => $records,
+ 'bizConfig' => $bizConfig,
+ 'filterType' => $filterType,
+ 'filterModel' => $filterModel,
+ 'todayCount' => $todayCount,
+ 'csrfToken' => $this->csrfToken(),
+ ];
+
+ // 额外数据源
+ if (isset($shellColors)) {
+ $viewVars['shellColors'] = $shellColors;
+ }
+
+ // 始终使用通用模板视图
+ $this->_view = new \core\base\View('Front', 'station_generic');
+ foreach ($viewVars as $k => $v) {
+ $this->_view->assign($k, $v);
+ }
+ $this->_view->render();
+ exit;
+ }
+
+ /**
+ * 为没有 station_business_helper 配置的工位自动生成通用业务配置
+ *
+ * 只要 station_definitions 中有记录,就自动生成配置,
+ * 使该工位统一走内置工位路径(专用物理表),无需在 helper 中手动添加。
+ */
+ private function buildGenericBizConfig($stationType, $def)
+ {
+ $workstation = new Workstation();
+ $station = $workstation->getByType($stationType);
+ $stationName = $station['station_name'] ?? $stationType;
+
+ // 从 fields_config 中检测是否有 hidden 类型的 product_type 字段
+ // 如果有,提取 fixed_value 作为 fixedType,确保页面渲染时输出 hidden input
+ $fixedType = null;
+ $rawFieldConfigs = $this->getStationFieldConfig($stationType);
+ $fieldMeta = $rawFieldConfigs['_meta'] ?? [];
+ unset($rawFieldConfigs['_meta']);
+ $fieldList = array_values($rawFieldConfigs);
+ foreach ($fieldList as $field) {
+ if (($field['name'] ?? '') === 'product_type' && ($field['type'] ?? '') === 'hidden') {
+ $fixedType = $field['fixed_value'] ?? '半成品';
+ break;
+ }
+ }
+
+ // ===== 自动生成 codePrefix 配置(序列号前缀防错) =====
+ // 优先从 _meta.prefix_field_map 读取显式映射
+ // 其次:对 is_scan=true 的字段,尝试从 hidden product_type 字段推断产品类型
+ // 最后:使用 *dynamic* 标记,由提交时的 product_type 动态匹配前缀
+ $prefixFieldMap = $fieldMeta['prefix_field_map'] ?? [];
+
+ // 装配页(assembly)没有 product_type 下拉,三栏序列号各自固定对应产品类型,
+ // 不能直接用 *dynamic*(提交时 product_type 为空会失控)。在此显式绑定字段→类型,
+ // 落实「所有工位序列号都受前缀管控」原则。
+ if ($stationType === 'assembly') {
+ $prefixFieldMap = array_merge($prefixFieldMap, [
+ 'finished_serial' => '成品',
+ 'battery_serial' => '电池',
+ 'pcb_serial' => 'PCBA',
+ ]);
+ }
+
+ $codePrefix = [];
+ if (!empty($prefixFieldMap)) {
+ foreach ($prefixFieldMap as $fieldName => $productType) {
+ $codePrefix[$fieldName] = [
+ $productType === '*dynamic*' ? '*dynamic*' : $productType,
+ '', // message 由 buildCodePrefixRules 或后端动态生成
+ ];
+ }
+ } else {
+ // 自动推断:对 is_scan=true 的字段,若工位有固定 product_type,则映射到该类型
+ // 否则使用 *dynamic* 让后端根据提交时的 product_type 动态判断
+ foreach ($fieldList as $field) {
+ if (!empty($field['enabled']) && !empty($field['name'])
+ && !empty($field['is_scan']) && ($field['type'] ?? 'text') === 'text') {
+ // 如果工位有固定的 product_type (hidden),使用它
+ if ($fixedType && $fixedType !== '半成品') {
+ $codePrefix[$field['name']] = [$fixedType, "{$field['label']}必须以 {$fixedType} 对应前缀开头"];
+ } else {
+ // 没有固定类型时使用动态匹配
+ $codePrefix[$field['name']] = ['*dynamic*', '序列号前缀与产品类型不匹配'];
+ }
+ }
+ }
+ }
+ // 通过 buildCodePrefixRules 将产品类型转换为实际前缀
+ if (!empty($codePrefix)) {
+ $prefixTypeMap = [];
+ try {
+ $sysConfigModel = new SysConfig();
+ $prefixTypeMap = $sysConfigModel->getCodePrefixRules();
+ } catch (\Throwable $e) {
+ error_log('[' . $stationType . '] getCodePrefixRules failed: ' . $e->getMessage());
+ $prefixTypeMap = [];
+ }
+ $codePrefix = buildCodePrefixRules(
+ array_combine(
+ array_keys($codePrefix),
+ array_map(function($r) { return $r[0]; }, array_values($codePrefix))
+ ),
+ $prefixTypeMap
+ );
+ }
+
+ // ===== 自动生成 duplicateCheck 配置(防重复) =====
+ // 对每个 is_scan=true 或 unique=true 的文本字段生成重复检查
+ // 使用数组格式支持多字段检查
+ $duplicateChecks = [];
+ foreach ($fieldList as $field) {
+ if (!empty($field['enabled']) && !empty($field['name'])) {
+ $isUnique = !empty($field['unique']);
+ $isScanText = !empty($field['is_scan']) && ($field['type'] ?? 'text') === 'text';
+ if ($isUnique || $isScanText) {
+ $label = $field['label'] ?? $field['name'];
+ $duplicateChecks[] = [
+ 'field' => $field['name'],
+ 'message' => "{$label}「{value}」已存在,不允许重复录入!",
+ ];
+ }
+ }
+ }
+
+ $config = [
+ 'model' => 'StationDefinition', // 使用 StationDefinition 统一模型
+ 'fixedType' => $fixedType,
+ 'view' => [
+ 'stationKey' => $stationType,
+ 'pageTitle' => $stationName,
+ 'pageIcon' => 'cube',
+ 'cardTitle' => '快速录入',
+ 'tableTitle' => $stationName . '记录',
+ 'submitMode' => 'auto',
+ 'codeFields' => [],
+ 'labelFields' => [],
+ 'enableFilter' => !empty($def['filter_enabled']),
+ 'filterUrl' => '/Front/' . $stationType,
+ 'reloadAfterSubmit' => true,
+ 'emptyText' => '暂无记录,请在下方录入第一条数据',
+ ],
+ 'showTodayCount' => !empty($def['show_today_count']),
+ 'showTotalCount' => !empty($def['show_total_count']),
+ 'dataMapping' => '__auto__', // 标记:自动从 fields_config 收集字段
+ ];
+
+ if (!empty($codePrefix)) {
+ $config['codePrefix'] = $codePrefix;
+ }
+ if (!empty($duplicateChecks)) {
+ // 多字段 duplicateCheck:数组格式,后端逐个检查
+ $config['duplicateChecks'] = $duplicateChecks;
+ }
+
+ return $config;
+ }
+
+ /**
+ * 处理内置工位的提交
+ */
+ private function handleBuiltinSubmit($bizConfig, $stationType, $user)
+ {
+ $stationDef = new StationDefinition();
+
+ // ===== delivery 特殊处理:序列号→箱序列号批量 =====
+ if ($stationType === 'delivery') {
+ $serial = trim($_POST['finished_serial'] ?? '');
+ $customerName = trim($_POST['customer_name'] ?? '');
+
+ // 检查是否是箱序列号(从 warehouse_in 表查找)
+ $boxItems = $stationDef->findByField('warehouse', 'box_serial', $serial);
+ // 箱序列号批量处理需要从 warehouse_in 表查该 box_serial 下所有记录
+ $boxRecords = $this->getBoxItemsBySerial($serial);
+ if (!empty($boxRecords)) {
+ $result = $this->handleDeliveryBoxSubmit($serial, $customerName, $user, $stationDef);
+ if ($result && $this->isAjax()) {
+ echo json_encode([
+ 'success' => true, 'type' => 'box',
+ 'imported' => $result['imported'],
+ 'total' => $result['total'],
+ 'skipped' => $result['skipped'],
+ 'message' => "箱序列号 {$serial}:共导入 {$result['imported']} 件(总 {$result['total']} 件,跳过 {$result['skipped']} 件已出货)",
+ ]);
+ exit;
+ }
+ return; // 非 AJAX 重定向在调用方处理
+ }
+
+ // 单件成品序列号:从 warehouse_in 查找 product_model
+ $item = $stationDef->findByField('warehouse', 'finished_serial', $serial);
+ $productModel = $item ? $item['product_model'] : ($_POST['product_model'] ?? '');
+
+ $data = [
+ 'finished_serial' => $serial,
+ 'customer_name' => $customerName,
+ 'product_model' => $productModel,
+ 'operator' => $user['emp_name'],
+ ];
+ $stationDef->addRecord('delivery', $data);
+ return;
+ }
+
+ // ===== 通用提交逻辑 =====
+
+ // 确定写入目标:检查是否需要切换到备用表
+ $targetStationType = $stationType;
+ $dataMapping = $bizConfig['dataMapping'] ?? [];
+
+ if (!empty($bizConfig['alt_condition'])) {
+ $cond = $bizConfig['alt_condition'];
+ $condValue = $_POST[$cond['field']] ?? '';
+ if ($condValue === $cond['value']) {
+ // inbound 选"成品"时,写入 warehouse_in 表
+ $targetStationType = 'warehouse';
+ if (!empty($bizConfig['alt_dataMapping'])) {
+ $dataMapping = $bizConfig['alt_dataMapping'];
+ }
+ }
+ }
+
+ // 验证
+ if (!empty($bizConfig['validators'])) {
+ $errors = [];
+ foreach ($bizConfig['validators'] as $fieldName => $rules) {
+ $val = trim($_POST[$fieldName] ?? '');
+ if (!empty($rules['required']) && $val === '') {
+ $errors[] = $rules['required'];
+ }
+ }
+ if (!empty($errors)) {
+ throw new \Exception(implode(';', $errors));
+ }
+ }
+
+ // 代码前缀验证
+ if (!empty($bizConfig['codePrefix'])) {
+ $prefixErrors = [];
+ foreach ($bizConfig['codePrefix'] as $fieldName => $rule) {
+ $prefix = $rule[0];
+ $message = $rule[1] ?? "{$fieldName} 前缀错误";
+ $val = trim($_POST[$fieldName] ?? '');
+ if ($val === '') continue; // 空值跳过
+
+ // 动态前缀:根据提交时的 product_type 动态获取前缀
+ if ($prefix === '*dynamic*') {
+ $productType = trim($_POST['product_type'] ?? '');
+ // 防御:前端可能因缺失 product_type 下拉而取到 undefined,按“未关联类型”跳过而非报错卡死
+ if ($productType === '' || $productType === 'undefined') continue;
+ $expectedPrefix = '';
+ try {
+ $sysConfig = new SysConfig();
+ $expectedPrefix = $sysConfig->getPrefixByType($productType);
+ } catch (\Throwable $e) {
+ error_log('[' . $stationType . '] getPrefixByType failed: ' . $e->getMessage());
+ $expectedPrefix = '';
+ }
+ // 原则上所有工位序列号都受前缀管控:未配置前缀视为配置缺失,明确报错而非静默放行
+ if ($expectedPrefix === '') {
+ $prefixErrors[$fieldName] = "产品类型「{$productType}」未配置序列号前缀,请联系管理员在后台设置「序列号前缀规则」后再操作";
+ continue;
+ }
+ if (stripos($val, $expectedPrefix) !== 0) {
+ $prefixErrors[$fieldName] = "{$productType}序列号必须以 {$expectedPrefix} 开头";
+ }
+ } else {
+ if (stripos($val, $prefix) !== 0) {
+ $prefixErrors[$fieldName] = $message;
+ }
+ }
+ }
+ if (!empty($prefixErrors)) {
+ throw new \Exception(json_encode([
+ 'type' => 'code_prefix',
+ 'errors' => $prefixErrors,
+ ], JSON_UNESCAPED_UNICODE));
+ }
+ }
+
+ // 防重复检查(支持两种格式)
+ // 新格式 duplicateChecks: [{field, message}, ...] 多字段
+ // 旧格式 duplicateCheck: {field, message} 单字段(兼容)
+ $dupChecks = [];
+ if (!empty($bizConfig['duplicateChecks'])) {
+ $dupChecks = $bizConfig['duplicateChecks'];
+ } elseif (!empty($bizConfig['duplicateCheck'])) {
+ $dupChecks = [$bizConfig['duplicateCheck']];
+ }
+ if (!empty($dupChecks)) {
+ $dupErrors = [];
+ foreach ($dupChecks as $dc) {
+ $checkValue = trim($_POST[$dc['field']] ?? '');
+ if ($checkValue === '') continue;
+ $existing = $stationDef->findByField($targetStationType, $dc['field'], $checkValue);
+ if ($existing) {
+ $msg = str_replace('{value}', $checkValue, $dc['message']);
+ $dupErrors[$dc['field']] = $msg;
+ }
+ }
+ if (!empty($dupErrors)) {
+ throw new \Exception(json_encode([
+ 'type' => 'duplicate',
+ 'errors' => $dupErrors,
+ ], JSON_UNESCAPED_UNICODE));
+ }
+ }
+
+ // 构建数据
+ $data = [];
+ if ($dataMapping === '__auto__') {
+ // 通用工位:自动从 fields_config 收集所有启用字段
+ $rawFieldConfigs = $this->getStationFieldConfig($stationType);
+ unset($rawFieldConfigs['_meta']);
+ $fieldConfigs = array_values($rawFieldConfigs);
+ foreach ($fieldConfigs as $field) {
+ if (!empty($field['enabled']) && !empty($field['name'])) {
+ $name = $field['name'];
+ $val = $_POST[$name] ?? '';
+ $data[$name] = $val !== '' ? $val : null;
+ }
+ }
+ $data['operator'] = $user['emp_name'];
+ } else {
+ foreach ($dataMapping as $dbField => $postKey) {
+ if ($postKey === '__operator__') {
+ $data[$dbField] = $user['emp_name'];
+ } else {
+ $val = $_POST[$postKey] ?? '';
+ $data[$dbField] = $val !== '' ? $val : null;
+ }
+ }
+ }
+
+ // 特殊处理:inbound 走 station_inbound 时,移除 box_serial
+ if ($stationType === 'inbound' && $targetStationType === 'inbound') {
+ unset($data['box_serial']);
+ // test_status 默认值
+ if (!isset($data['test_status'])) {
+ $data['test_status'] = 'pending';
+ }
+ }
+
+ $stationDef->addRecord($targetStationType, $data);
+ }
+
+ /**
+ * 从 warehouse_in 表查找箱序列号下的所有成品
+ */
+ private function getBoxItemsBySerial($boxSerial)
+ {
+ $stationDef = new StationDefinition();
+ $def = $stationDef->getByType('warehouse');
+ if (!$def) return [];
+
+ $table = \core\base\Model::TABLE_PREFIX . $def['db_table'];
+ $sql = "SELECT * FROM `{$table}` WHERE `box_serial` = :bs";
+ return $stationDef->query($sql, [':bs' => $boxSerial]);
+ }
+
+ /**
+ * 处理特殊 action(导出CSV、PDF、序列号查找等)
+ */
+ private function handleSpecialAction($action, $bizConfig, $stationType, $records, $user)
+ {
+ // CSV 导出
+ if ($action === 'export' && !empty($bizConfig['export'])) {
+ $exp = $bizConfig['export'];
+ header('Content-Type: text/csv; charset=utf-8');
+ header('Content-Disposition: attachment; filename=' . $exp['filename']);
+ $output = fopen('php://output', 'w');
+ fprintf($output, chr(0xEF).chr(0xBB).chr(0xBF));
+ fputcsv($output, $exp['headers']);
+ foreach ($records as $record) {
+ $row = [];
+ foreach ($exp['columns'] as $col) {
+ $row[] = $record[$col] ?? '';
+ }
+ fputcsv($output, $row);
+ }
+ fclose($output);
+ exit;
+ }
+
+ // PDF 生成
+ if ($action === 'pdf' && !empty($bizConfig['pdfGenerator'])) {
+ $method = $bizConfig['pdfGenerator'];
+ if (method_exists($this, $method)) {
+ $this->$method($records, $user);
+ }
+ exit;
+ }
+
+ // 序列号查找(delivery 工位)
+ if ($action === 'lookup' && !empty($bizConfig['serialLookup'])) {
+ $serial = trim($_POST['finished_serial'] ?? '');
+ if ($serial === '') {
+ echo json_encode(['success' => false, 'error' => '请输入序列号']);
+ exit;
+ }
+
+ $stationDef = new StationDefinition();
+
+ // 单件查找:从 warehouse_in 表
+ $item = $stationDef->findByField('warehouse', 'finished_serial', $serial);
+ if ($item) {
+ echo json_encode([
+ 'success' => true, 'type' => 'single',
+ 'product_model' => $item['product_model'],
+ 'finished_serial' => $item['finished_serial'],
+ ]);
+ exit;
+ }
+
+ // 箱序列号查找
+ if (!empty($bizConfig['boxBatch'])) {
+ $boxItems = $this->getBoxItemsBySerial($serial);
+ if (!empty($boxItems)) {
+ $summary = [];
+ foreach ($boxItems as $bi) {
+ $pm = $bi['product_model'];
+ if (!isset($summary[$pm])) {
+ $summary[$pm] = ['count' => 0, 'models' => []];
+ }
+ $summary[$pm]['count']++;
+ $summary[$pm]['models'][] = $bi['finished_serial'];
+ }
+ echo json_encode([
+ 'success' => true, 'type' => 'box',
+ 'box_serial' => $serial, 'total' => count($boxItems),
+ 'summary' => $summary,
+ ]);
+ exit;
+ }
+ }
+ echo json_encode(['success' => false, 'error' => '未找到该序列号']);
+ exit;
+ }
+
+ // 未知 action
+ echo json_encode(['success' => false, 'error' => '未知操作: ' . $action]);
+ exit;
+ }
+
+ /**
+ * 处理 delivery 的箱序列号批量提交
+ */
+ private function handleDeliveryBoxSubmit($serial, $customerName, $user, $stationDef)
+ {
+ $boxItems = $this->getBoxItemsBySerial($serial);
+ if (empty($boxItems)) return false;
+
+ $batchRecords = [];
+ foreach ($boxItems as $bi) {
+ $existing = $stationDef->findByField('delivery', 'finished_serial', $bi['finished_serial']);
+ if ($existing) continue;
+ $batchRecords[] = [
+ 'finished_serial' => $bi['finished_serial'],
+ 'customer_name' => $customerName,
+ 'product_model' => $bi['product_model'],
+ 'operator' => $user['emp_name'],
+ ];
+ }
+ $imported = 0;
+ foreach ($batchRecords as $rec) {
+ $stationDef->addRecord('delivery', $rec);
+ $imported++;
+ }
+ return [
+ 'imported' => $imported,
+ 'total' => count($boxItems),
+ 'skipped' => count($boxItems) - $imported,
+ ];
+ }
+
+ // ========== PDF 生成方法(从原硬编码方法中提取) ==========
+
+ private function generateInboundPDF($records, $user)
+ {
+ header('Content-Type: text/html; charset=utf-8');
+ echo '入库单';
+ echo '';
+ echo '入库单
';
+ echo '操作人:' . htmlspecialchars($user['emp_name']) . '
';
+ echo '生成时间:' . date('Y-m-d H:i:s') . '
';
+ echo '| 序号 | 成品序列号 | 箱序列号 | 入库时间 | 产品型号 | 操作人 |
';
+
+ foreach ($records as $i => $record) {
+ echo '| ' . ($i + 1) . ' | ';
+ echo '' . htmlspecialchars($record['finished_serial']) . ' | ';
+ echo '' . htmlspecialchars($record['box_serial'] ?? '') . ' | ';
+ echo '' . htmlspecialchars($record['in_time']) . ' | ';
+ echo '' . htmlspecialchars($record['product_model']) . ' | ';
+ echo '' . htmlspecialchars($record['operator']) . ' |
';
+ }
+ echo '
';
+ }
+
+ private function generateDeliveryPDF($records, $user)
+ {
+ $summary = [];
+ foreach ($records as $record) {
+ $pm = $record['product_model'] ?: '未知型号';
+ if (!isset($summary[$pm])) {
+ $summary[$pm] = [
+ 'product_model' => $pm, 'count' => 0,
+ 'customer_name' => $record['customer_name'] ?? '',
+ ];
+ }
+ $summary[$pm]['count']++;
+ }
+
+ header('Content-Type: text/html; charset=utf-8');
+ echo '出库单';
+ echo '';
+ echo '出库单
';
+ echo '操作人:' . htmlspecialchars($user['emp_name']) . '
';
+ echo '生成时间:' . date('Y-m-d H:i:s') . '
';
+ echo '| 序号 | 产品型号 | 数量 | 客户名称 |
';
+
+ $idx = 1; $totalQty = 0;
+ foreach ($summary as $item) {
+ echo '| ' . $idx . ' | ';
+ echo '' . htmlspecialchars($item['product_model']) . ' | ';
+ echo '' . $item['count'] . ' | ';
+ echo '' . htmlspecialchars($item['customer_name']) . ' |
';
+ $idx++; $totalQty += $item['count'];
+ }
+ echo '| 合计 | ' . $totalQty . ' | |
';
+ echo '
';
+ }
+
+ // ========== 文件管理工位 ==========
+
+ /**
+ * 渲染文件管理工位视图
+ */
+ private function renderDocumentManageStation($user)
+ {
+ $document = new Document();
+ $documents = $document->getAll();
+ $categories = $document->getCategories();
+
+ $config = new SysConfig();
+ $sysConfig = $config->getConfig();
+
+ $workstation = new Workstation();
+ $stations = $workstation->getAll();
+
+ $view = new \core\base\View('Front', 'station_document_manage');
+ $view->assign('title', '文件管理');
+ $view->assign('user', $user);
+ $view->assign('sysConfig', $sysConfig);
+ $view->assign('stations', $stations);
+ $view->assign('documents', $documents);
+ $view->assign('categories', $categories);
+ $view->assign('csrfToken', $this->csrfToken());
+ $view->render();
+ }
+
+ /**
+ * 文件上传(管理员专用)
+ */
+ public function documentManageUpload()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ // 权限检查:只有超级管理员和管理员可以上传
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ $this->jsonExit(['success' => false, 'error' => '无权限:仅管理员可以上传文件']);
+ }
+
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ $this->jsonExit(['success' => false, 'error' => '无效请求']);
+ }
+
+ // 大文件超过 post_max_size 时 PHP 会丢弃整个请求体($_POST/$_FILES 均为空),
+ // 进而使 CSRF 校验失败并返回非 JSON,前端误报“网络错误”。先给出明确提示。
+ if (empty($_POST) && empty($_FILES)) {
+ $this->jsonExit(['success' => false, 'error' => '上传失败:提交内容为空,通常是文件体积超过服务器 post_max_size 限制。请压缩文件后重试,或联系管理员调大上传上限。']);
+ }
+
+ $this->csrfVerify();
+
+ $data = [
+ 'title' => trim($_POST['title'] ?? ''),
+ 'doc_no' => trim($_POST['doc_no'] ?? ''),
+ 'category' => trim($_POST['category'] ?? ''),
+ 'version' => trim($_POST['version'] ?? '1.0'),
+ 'author' => $user['emp_name'],
+ 'department' => '',
+ 'status' => 'active',
+ 'keywords' => '',
+ 'description' => trim($_POST['description'] ?? ''),
+ 'operator' => $user['emp_name'],
+ ];
+
+ if (empty($data['title'])) {
+ $this->jsonExit(['success' => false, 'error' => '文档标题不能为空']);
+ }
+
+ // 文件上传验证
+ if (!isset($_FILES['doc_file']) || $_FILES['doc_file']['error'] !== UPLOAD_ERR_OK) {
+ $this->jsonExit(['success' => false, 'error' => '请选择要上传的文件']);
+ }
+
+ $allowedExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'zip', 'rar', 'jpg', 'jpeg', 'png', 'gif'];
+ $allowedMimeTypes = [
+ 'application/pdf',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'application/vnd.ms-powerpoint',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'text/plain',
+ 'text/csv',
+ 'application/zip',
+ 'application/x-zip-compressed',
+ 'application/x-rar-compressed',
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ ];
+
+ $originalName = $_FILES['doc_file']['name'];
+ $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
+ $tmpPath = $_FILES['doc_file']['tmp_name'];
+
+ $mime = '';
+ if (function_exists('finfo_open')) {
+ $finfo = @finfo_open(FILEINFO_MIME_TYPE);
+ if ($finfo !== false) {
+ $mime = @finfo_file($finfo, $tmpPath);
+ finfo_close($finfo);
+ }
+ }
+ // Fileinfo 不可用时仅按扩展名校验,避免误杀
+ $mimeOk = $mime === '' ? true : in_array($mime, $allowedMimeTypes, true);
+
+ if (!in_array($ext, $allowedExtensions, true) || !$mimeOk) {
+ error_log('[documentManageUpload] Upload rejected: ext=' . $ext . ' mime=' . $mime . ' file=' . $originalName);
+ $this->jsonExit(['success' => false, 'error' => '不支持的文件类型,仅允许常见文档、图片和压缩包格式']);
+ }
+
+ $uploadDir = APP_PATH . 'static/uploads/documents/';
+ if (!is_dir($uploadDir)) {
+ mkdir($uploadDir, 0755, true);
+ }
+ $saveName = date('YmdHis') . '_' . uniqid() . '.' . $ext;
+ $savePath = $uploadDir . $saveName;
+
+ if (!move_uploaded_file($tmpPath, $savePath)) {
+ $this->jsonExit(['success' => false, 'error' => '文件保存失败']);
+ }
+
+ $data['file_name'] = $originalName;
+ $data['file_path'] = '/static/uploads/documents/' . $saveName;
+ $data['file_size'] = $_FILES['doc_file']['size'];
+ $data['file_type'] = $ext;
+
+ try {
+ $document = new Document();
+ $document->add($data);
+ $this->jsonExit(['success' => true]);
+ } catch (\Throwable $e) {
+ error_log('[documentManageUpload] DB error: ' . $e->getMessage());
+ // 清理已上传的文件
+ @unlink($savePath);
+ $this->jsonExit(['success' => false, 'error' => '保存失败:' . $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 文件删除(管理员专用)
+ */
+ public function documentManageDelete()
+ {
+ $this->checkLogin();
+ $user = $this->getCurrentUser();
+
+ // 权限检查
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ $this->jsonExit(['success' => false, 'error' => '无权限']);
+ }
+
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ $this->jsonExit(['success' => false, 'error' => '无效请求']);
+ }
+
+ $this->csrfVerify();
+
+ $id = (int)($_POST['id'] ?? 0);
+ if ($id <= 0) {
+ $this->jsonExit(['success' => false, 'error' => '无效的文档ID']);
+ }
+
+ try {
+ $document = new Document();
+ $record = $document->getById($id);
+ if (!$record) {
+ $this->jsonExit(['success' => false, 'error' => '文档不存在']);
+ }
+
+ // 删除物理文件
+ if (!empty($record['file_path'])) {
+ $filePath = APP_PATH . ltrim($record['file_path'], '/');
+ if (file_exists($filePath)) {
+ @unlink($filePath);
+ }
+ }
+
+ $document->delete($id);
+ $this->jsonExit(['success' => true]);
+ } catch (\Throwable $e) {
+ error_log('[documentManageDelete] Error: ' . $e->getMessage());
+ $this->jsonExit(['success' => false, 'error' => '删除失败:' . $e->getMessage()]);
+ }
+ }
+}
diff --git a/app/controllers/GeneralController.php b/app/controllers/GeneralController.php
new file mode 100644
index 0000000..ed10f43
--- /dev/null
+++ b/app/controllers/GeneralController.php
@@ -0,0 +1,540 @@
+checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] === self::ROLE_SUPER_ADMIN) {
+ return $user;
+ }
+ if ($user['role'] === self::ROLE_ADMIN && ($user['category'] ?? '') === self::CATEGORY_GENERAL) {
+ return $user;
+ }
+ header('Location: ' . BASE_URL . '/Front/index');
+ exit;
+ }
+
+ public function index()
+ {
+ $user = $this->checkCategoryAccess();
+
+ $hrEmployee = new HrEmployee();
+ $asset = new Asset();
+ $document = new Document();
+
+ $this->assign('title', '综合管理概览');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'general_dashboard');
+ $this->assign('employeeCount', $hrEmployee->getCount());
+ $this->assign('assetStats', $asset->getCount());
+ $this->assign('documentCount', $document->getCount());
+ $this->render();
+ }
+
+ // ========== 人事管理 ==========
+ public function hr()
+ {
+ $user = $this->checkCategoryAccess();
+ $hrEmployee = new HrEmployee();
+ $employees = $hrEmployee->getAll();
+
+ $this->assign('title', '人事管理');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'hr');
+ $this->assign('employees', $employees);
+ $this->render();
+ }
+
+ public function hrAdd()
+ {
+ $user = $this->checkCategoryAccess();
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'emp_no' => trim($_POST['emp_no'] ?? ''),
+ 'name' => trim($_POST['name'] ?? ''),
+ 'gender' => trim($_POST['gender'] ?? ''),
+ 'department' => trim($_POST['department'] ?? ''),
+ 'position' => trim($_POST['position'] ?? ''),
+ 'phone' => trim($_POST['phone'] ?? ''),
+ 'email' => trim($_POST['email'] ?? ''),
+ 'id_card' => trim($_POST['id_card'] ?? ''),
+ 'entry_date' => trim($_POST['entry_date'] ?? ''),
+ 'status' => trim($_POST['status'] ?? 'active'),
+ 'education' => trim($_POST['education'] ?? ''),
+ 'emergency_contact' => trim($_POST['emergency_contact'] ?? ''),
+ 'emergency_phone' => trim($_POST['emergency_phone'] ?? ''),
+ 'address' => trim($_POST['address'] ?? ''),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ ];
+ if (empty($data['name'])) {
+ $this->assign('error', '姓名不能为空');
+ $this->assign('title', '添加员工');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'hr');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ try {
+ $hrEmployee = new HrEmployee();
+ $hrEmployee->add($data);
+ header('Location: ' . BASE_URL . '/General/hr');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[hrAdd] ' . $e->getMessage());
+ $this->assign('error', '添加失败:' . $e->getMessage());
+ $this->assign('title', '添加员工');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'hr');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '添加员工');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'hr');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function hrEdit()
+ {
+ $user = $this->checkCategoryAccess();
+ $id = (int)($_GET['id'] ?? 0);
+ $hrEmployee = new HrEmployee();
+ $record = $hrEmployee->getById($id);
+ if (!$record) exit('员工不存在');
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'emp_no' => trim($_POST['emp_no'] ?? ''),
+ 'name' => trim($_POST['name'] ?? ''),
+ 'gender' => trim($_POST['gender'] ?? ''),
+ 'department' => trim($_POST['department'] ?? ''),
+ 'position' => trim($_POST['position'] ?? ''),
+ 'phone' => trim($_POST['phone'] ?? ''),
+ 'email' => trim($_POST['email'] ?? ''),
+ 'id_card' => trim($_POST['id_card'] ?? ''),
+ 'entry_date' => trim($_POST['entry_date'] ?? ''),
+ 'leave_date' => trim($_POST['leave_date'] ?? ''),
+ 'status' => trim($_POST['status'] ?? 'active'),
+ 'education' => trim($_POST['education'] ?? ''),
+ 'emergency_contact' => trim($_POST['emergency_contact'] ?? ''),
+ 'emergency_phone' => trim($_POST['emergency_phone'] ?? ''),
+ 'address' => trim($_POST['address'] ?? ''),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ ];
+ try {
+ $hrEmployee->where(['id = :id'], [':id' => $id])->update($data);
+ header('Location: ' . BASE_URL . '/General/hr');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[hrEdit] ' . $e->getMessage());
+ $this->assign('error', '更新失败:' . $e->getMessage());
+ $this->assign('title', '编辑员工');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'hr');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '编辑员工');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'hr');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function hrDelete()
+ {
+ $this->checkLogin();
+ $this->requireMethod('post');
+ $this->csrfVerify();
+ $id = (int)($_POST['id'] ?? 0);
+ $hrEmployee = new HrEmployee();
+ $hrEmployee->delete($id);
+ header('Location: ' . BASE_URL . '/General/hr');
+ exit;
+ }
+
+ // ========== 资产管理 ==========
+ public function asset()
+ {
+ $user = $this->checkCategoryAccess();
+ $asset = new Asset();
+ $assets = $asset->getAll();
+
+ $this->assign('title', '资产管理');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'asset');
+ $this->assign('assets', $assets);
+ $this->render();
+ }
+
+ public function assetAdd()
+ {
+ $user = $this->checkCategoryAccess();
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'asset_no' => trim($_POST['asset_no'] ?? ''),
+ 'name' => trim($_POST['name'] ?? ''),
+ 'category' => trim($_POST['category'] ?? ''),
+ 'model' => trim($_POST['model'] ?? ''),
+ 'quantity' => (int)($_POST['quantity'] ?? 1),
+ 'unit' => trim($_POST['unit'] ?? '台'),
+ 'purchase_price' => (float)($_POST['purchase_price'] ?? 0),
+ 'purchase_date' => trim($_POST['purchase_date'] ?? ''),
+ 'supplier' => trim($_POST['supplier'] ?? ''),
+ 'department' => trim($_POST['department'] ?? ''),
+ 'user_name' => trim($_POST['user_name'] ?? ''),
+ 'location' => trim($_POST['location'] ?? ''),
+ 'status' => trim($_POST['status'] ?? 'normal'),
+ 'warranty_expire' => trim($_POST['warranty_expire'] ?? ''),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ ];
+ if (empty($data['name'])) {
+ $this->assign('error', '资产名称不能为空');
+ $this->assign('title', '添加资产');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'asset');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ try {
+ $asset = new Asset();
+ $asset->add($data);
+ header('Location: ' . BASE_URL . '/General/asset');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[assetAdd] ' . $e->getMessage());
+ $this->assign('error', '添加失败:' . $e->getMessage());
+ $this->assign('title', '添加资产');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'asset');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '添加资产');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'asset');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function assetEdit()
+ {
+ $user = $this->checkCategoryAccess();
+ $id = (int)($_GET['id'] ?? 0);
+ $asset = new Asset();
+ $record = $asset->getById($id);
+ if (!$record) exit('资产不存在');
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'asset_no' => trim($_POST['asset_no'] ?? ''),
+ 'name' => trim($_POST['name'] ?? ''),
+ 'category' => trim($_POST['category'] ?? ''),
+ 'model' => trim($_POST['model'] ?? ''),
+ 'quantity' => (int)($_POST['quantity'] ?? 1),
+ 'unit' => trim($_POST['unit'] ?? '台'),
+ 'purchase_price' => (float)($_POST['purchase_price'] ?? 0),
+ 'purchase_date' => trim($_POST['purchase_date'] ?? ''),
+ 'supplier' => trim($_POST['supplier'] ?? ''),
+ 'department' => trim($_POST['department'] ?? ''),
+ 'user_name' => trim($_POST['user_name'] ?? ''),
+ 'location' => trim($_POST['location'] ?? ''),
+ 'status' => trim($_POST['status'] ?? 'normal'),
+ 'warranty_expire' => trim($_POST['warranty_expire'] ?? ''),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ ];
+ try {
+ $asset->where(['id = :id'], [':id' => $id])->update($data);
+ header('Location: ' . BASE_URL . '/General/asset');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[assetEdit] ' . $e->getMessage());
+ $this->assign('error', '更新失败:' . $e->getMessage());
+ $this->assign('title', '编辑资产');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'asset');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '编辑资产');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'asset');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function assetDelete()
+ {
+ $this->checkLogin();
+ $this->requireMethod('post');
+ $this->csrfVerify();
+ $id = (int)($_POST['id'] ?? 0);
+ $asset = new Asset();
+ $asset->delete($id);
+ header('Location: ' . BASE_URL . '/General/asset');
+ exit;
+ }
+
+ // ========== 文档管理 ==========
+ public function document()
+ {
+ $user = $this->checkCategoryAccess();
+ $document = new Document();
+ $documents = $document->getAll();
+ $categories = $document->getCategories();
+
+ $this->assign('title', '文档管理');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'document');
+ $this->assign('documents', $documents);
+ $this->assign('categories', $categories);
+ $this->render();
+ }
+
+ public function documentAdd()
+ {
+ $user = $this->checkCategoryAccess();
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'title' => trim($_POST['title'] ?? ''),
+ 'doc_no' => trim($_POST['doc_no'] ?? ''),
+ 'category' => trim($_POST['category'] ?? ''),
+ 'version' => trim($_POST['version'] ?? '1.0'),
+ 'author' => trim($_POST['author'] ?? ''),
+ 'department' => trim($_POST['department'] ?? ''),
+ 'status' => trim($_POST['status'] ?? 'active'),
+ 'keywords' => trim($_POST['keywords'] ?? ''),
+ 'description' => trim($_POST['description'] ?? ''),
+ 'operator' => $user['emp_name'],
+ ];
+
+ // 文件上传 — 仅允许安全文档类型
+ if (isset($_FILES['doc_file']) && $_FILES['doc_file']['error'] === UPLOAD_ERR_OK) {
+ $allowedExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'zip', 'rar', 'jpg', 'jpeg', 'png', 'gif'];
+ $allowedMimeTypes = [
+ 'application/pdf',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'application/vnd.ms-powerpoint',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'text/plain',
+ 'text/csv',
+ 'application/zip',
+ 'application/x-zip-compressed',
+ 'application/x-rar-compressed',
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ ];
+ $originalName = $_FILES['doc_file']['name'];
+ $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
+ $tmpPath = $_FILES['doc_file']['tmp_name'];
+ $mime = finfo_file(finfo_open(FILEINFO_MIME_TYPE), $tmpPath);
+
+ if (!in_array($ext, $allowedExtensions, true) || !in_array($mime, $allowedMimeTypes, true)) {
+ error_log('[documentAdd] Upload rejected: ext=' . $ext . ' mime=' . $mime . ' file=' . $originalName);
+ $this->assign('error', '不支持的文件类型,仅允许常见文档、图片和压缩包格式');
+ $this->assign('title', '添加文档');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'document');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+
+ $uploadDir = APP_PATH . 'static/uploads/documents/';
+ if (!is_dir($uploadDir)) {
+ mkdir($uploadDir, 0755, true);
+ }
+ $saveName = date('YmdHis') . '_' . uniqid() . '.' . $ext;
+ $savePath = $uploadDir . $saveName;
+ if (move_uploaded_file($tmpPath, $savePath)) {
+ $data['file_name'] = $originalName;
+ $data['file_path'] = '/static/uploads/documents/' . $saveName;
+ $data['file_size'] = $_FILES['doc_file']['size'];
+ $data['file_type'] = $ext;
+ }
+ }
+
+ if (empty($data['title'])) {
+ $this->assign('error', '文档标题不能为空');
+ $this->assign('title', '添加文档');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'document');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ try {
+ $document = new Document();
+ $document->add($data);
+ header('Location: ' . BASE_URL . '/General/document');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[documentAdd] ' . $e->getMessage());
+ $this->assign('error', '添加失败:' . $e->getMessage());
+ $this->assign('title', '添加文档');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'document');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '添加文档');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'document');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function documentEdit()
+ {
+ $user = $this->checkCategoryAccess();
+ $id = (int)($_GET['id'] ?? 0);
+ $document = new Document();
+ $record = $document->getById($id);
+ if (!$record) exit('文档不存在');
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'title' => trim($_POST['title'] ?? ''),
+ 'doc_no' => trim($_POST['doc_no'] ?? ''),
+ 'category' => trim($_POST['category'] ?? ''),
+ 'version' => trim($_POST['version'] ?? '1.0'),
+ 'author' => trim($_POST['author'] ?? ''),
+ 'department' => trim($_POST['department'] ?? ''),
+ 'status' => trim($_POST['status'] ?? 'active'),
+ 'keywords' => trim($_POST['keywords'] ?? ''),
+ 'description' => trim($_POST['description'] ?? ''),
+ ];
+
+ // 文件上传 — 仅允许安全文档类型
+ if (isset($_FILES['doc_file']) && $_FILES['doc_file']['error'] === UPLOAD_ERR_OK) {
+ $allowedExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'zip', 'rar', 'jpg', 'jpeg', 'png', 'gif'];
+ $allowedMimeTypes = [
+ 'application/pdf',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'application/vnd.ms-powerpoint',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'text/plain',
+ 'text/csv',
+ 'application/zip',
+ 'application/x-zip-compressed',
+ 'application/x-rar-compressed',
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ ];
+ $originalName = $_FILES['doc_file']['name'];
+ $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
+ $tmpPath = $_FILES['doc_file']['tmp_name'];
+ $mime = finfo_file(finfo_open(FILEINFO_MIME_TYPE), $tmpPath);
+
+ if (!in_array($ext, $allowedExtensions, true) || !in_array($mime, $allowedMimeTypes, true)) {
+ error_log('[documentEdit] Upload rejected: ext=' . $ext . ' mime=' . $mime . ' file=' . $originalName);
+ $this->assign('error', '不支持的文件类型,仅允许常见文档、图片和压缩包格式');
+ $this->assign('title', '编辑文档');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'document');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+
+ $uploadDir = APP_PATH . 'static/uploads/documents/';
+ if (!is_dir($uploadDir)) {
+ mkdir($uploadDir, 0755, true);
+ }
+ $saveName = date('YmdHis') . '_' . uniqid() . '.' . $ext;
+ $savePath = $uploadDir . $saveName;
+ if (move_uploaded_file($tmpPath, $savePath)) {
+ $data['file_name'] = $originalName;
+ $data['file_path'] = '/static/uploads/documents/' . $saveName;
+ $data['file_size'] = $_FILES['doc_file']['size'];
+ $data['file_type'] = $ext;
+ }
+ }
+
+ try {
+ $document->where(['id = :id'], [':id' => $id])->update($data);
+ header('Location: ' . BASE_URL . '/General/document');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[documentEdit] ' . $e->getMessage());
+ $this->assign('error', '更新失败:' . $e->getMessage());
+ $this->assign('title', '编辑文档');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'document');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '编辑文档');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'document');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function documentDelete()
+ {
+ $this->checkLogin();
+ $this->requireMethod('post');
+ $this->csrfVerify();
+ $id = (int)($_POST['id'] ?? 0);
+ $document = new Document();
+ $document->delete($id);
+ header('Location: ' . BASE_URL . '/General/document');
+ exit;
+ }
+}
diff --git a/app/controllers/InventoryController.php b/app/controllers/InventoryController.php
new file mode 100644
index 0000000..27a23aa
--- /dev/null
+++ b/app/controllers/InventoryController.php
@@ -0,0 +1,726 @@
+checkLogin();
+ $user = $this->getCurrentUser();
+
+ if ($user['role'] === self::ROLE_SUPER_ADMIN) {
+ return $user;
+ }
+ if ($user['role'] === self::ROLE_ADMIN && ($user['category'] ?? '') === self::CATEGORY_INVENTORY) {
+ return $user;
+ }
+ header('Location: ' . BASE_URL . '/Front/index');
+ exit;
+ }
+
+ public function index()
+ {
+ $user = $this->checkCategoryAccess();
+
+ $supplier = new Supplier();
+ $purchaseOrder = new PurchaseOrder();
+ $inventory = new Inventory();
+ $salesOrder = new SalesOrder();
+
+ $this->assign('title', '进销存概览');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'inventory_dashboard');
+ $this->assign('supplierCount', count($supplier->getAll()));
+ $this->assign('purchaseCount', $purchaseOrder->getCount());
+ $this->assign('stockCount', $inventory->getCount());
+ $this->assign('salesCount', $salesOrder->getCount());
+ $this->assign('lowStockItems', $inventory->getLowStock());
+ $this->render();
+ }
+
+ // ========== 供应商管理 ==========
+ public function supplier()
+ {
+ $user = $this->checkCategoryAccess();
+ $supplier = new Supplier();
+ $suppliers = $supplier->getAll();
+
+ $this->assign('title', '供应商管理');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'supplier');
+ $this->assign('suppliers', $suppliers);
+ $this->render();
+ }
+
+ public function supplierAdd()
+ {
+ $user = $this->checkCategoryAccess();
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'name' => trim($_POST['name'] ?? ''),
+ 'contact_person' => trim($_POST['contact_person'] ?? ''),
+ 'phone' => trim($_POST['phone'] ?? ''),
+ 'address' => trim($_POST['address'] ?? ''),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ 'status' => (int)($_POST['status'] ?? 1),
+ ];
+ if (empty($data['name'])) {
+ $this->assign('error', '供应商名称不能为空');
+ $this->assign('title', '添加供应商');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'supplier');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ try {
+ $supplier = new Supplier();
+ $supplier->add($data);
+ header('Location: ' . BASE_URL . '/Inventory/supplier');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[supplierAdd] ' . $e->getMessage());
+ $this->assign('error', '添加失败:' . $e->getMessage());
+ $this->assign('title', '添加供应商');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'supplier');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '添加供应商');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'supplier');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function supplierEdit()
+ {
+ $user = $this->checkCategoryAccess();
+ $id = (int)($_GET['id'] ?? 0);
+ $supplier = new Supplier();
+ $record = $supplier->getById($id);
+ if (!$record) exit('供应商不存在');
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'name' => trim($_POST['name'] ?? ''),
+ 'contact_person' => trim($_POST['contact_person'] ?? ''),
+ 'phone' => trim($_POST['phone'] ?? ''),
+ 'address' => trim($_POST['address'] ?? ''),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ 'status' => (int)($_POST['status'] ?? 1),
+ ];
+ if (empty($data['name'])) {
+ $this->assign('error', '供应商名称不能为空');
+ $this->assign('title', '编辑供应商');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'supplier');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ try {
+ $supplier->where(['id = :id'], [':id' => $id])->update($data);
+ header('Location: ' . BASE_URL . '/Inventory/supplier');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[supplierEdit] ' . $e->getMessage());
+ $this->assign('error', '更新失败:' . $e->getMessage());
+ $this->assign('title', '编辑供应商');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'supplier');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '编辑供应商');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'supplier');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function supplierDelete()
+ {
+ $this->checkLogin();
+ $this->requireMethod('post');
+ $this->csrfVerify();
+ $id = (int)($_POST['id'] ?? 0);
+ $supplier = new Supplier();
+ $supplier->delete($id);
+ header('Location: ' . BASE_URL . '/Inventory/supplier');
+ exit;
+ }
+
+ // ========== 采购管理 ==========
+ public function purchase()
+ {
+ $user = $this->checkCategoryAccess();
+ $purchaseOrder = new PurchaseOrder();
+ $orders = $purchaseOrder->getAll();
+
+ $this->assign('title', '采购管理');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'purchase');
+ $this->assign('orders', $orders);
+ $this->render();
+ }
+
+ public function purchaseAdd()
+ {
+ $user = $this->checkCategoryAccess();
+ if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
+ exit('无权限访问');
+ }
+
+ $supplierModel = new Supplier();
+ $suppliers = $supplierModel->getActiveList();
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $supplierId = (int)($_POST['supplier_id'] ?? 0);
+ $supplierInfo = $supplierModel->getById($supplierId);
+
+ $orderData = [
+ 'order_no' => trim($_POST['order_no'] ?? ''),
+ 'supplier_id' => $supplierId,
+ 'supplier_name' => $supplierInfo['name'] ?? '',
+ 'order_date' => trim($_POST['order_date'] ?? date('Y-m-d')),
+ 'total_amount' => 0,
+ 'status' => trim($_POST['status'] ?? 'pending'),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ 'operator' => $user['emp_name'],
+ ];
+
+ if (empty($orderData['order_no'])) {
+ $this->assign('error', '采购单号不能为空');
+ $this->assign('title', '添加采购订单');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'purchase');
+ $this->assign('suppliers', $suppliers);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+
+ try {
+ $purchaseOrder = new PurchaseOrder();
+ $purchaseOrder->add($orderData);
+ $orderId = $this->pdo->lastInsertId();
+
+ $productNames = $_POST['product_name'] ?? [];
+ $productModels = $_POST['product_model'] ?? [];
+ $quantities = $_POST['quantity'] ?? [];
+ $units = $_POST['unit'] ?? [];
+ $unitPrices = $_POST['unit_price'] ?? [];
+
+ $totalAmount = 0;
+ $purchaseItem = new PurchaseItem();
+ foreach ($productNames as $i => $pname) {
+ if (empty(trim($pname))) continue;
+ $qty = (int)($quantities[$i] ?? 0);
+ $price = (float)($unitPrices[$i] ?? 0);
+ $amount = $qty * $price;
+ $totalAmount += $amount;
+ $purchaseItem->add([
+ 'order_id' => $orderId,
+ 'product_name' => trim($pname),
+ 'product_model' => trim($productModels[$i] ?? ''),
+ 'quantity' => $qty,
+ 'unit' => trim($units[$i] ?? '个'),
+ 'unit_price' => $price,
+ 'amount' => $amount,
+ ]);
+ }
+
+ $purchaseOrder->where(['id = :id'], [':id' => $orderId])->update(['total_amount' => $totalAmount]);
+
+ header('Location: ' . BASE_URL . '/Inventory/purchase');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[purchaseAdd] ' . $e->getMessage());
+ $this->assign('error', '添加失败:' . $e->getMessage());
+ $this->assign('title', '添加采购订单');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'purchase');
+ $this->assign('suppliers', $suppliers);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '添加采购订单');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'purchase');
+ $this->assign('suppliers', $suppliers);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function purchaseDetail()
+ {
+ $user = $this->checkCategoryAccess();
+ $id = (int)($_GET['id'] ?? 0);
+ $purchaseOrder = new PurchaseOrder();
+ $order = $purchaseOrder->getById($id);
+ if (!$order) exit('订单不存在');
+
+ $purchaseItem = new PurchaseItem();
+ $items = $purchaseItem->getByOrder($id);
+
+ $this->assign('title', '采购订单详情');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'purchase');
+ $this->assign('order', $order);
+ $this->assign('items', $items);
+ $this->render();
+ }
+
+ public function purchaseEdit()
+ {
+ $user = $this->checkCategoryAccess();
+ $id = (int)($_GET['id'] ?? 0);
+ $purchaseOrder = new PurchaseOrder();
+ $order = $purchaseOrder->getById($id);
+ if (!$order) exit('订单不存在');
+
+ $supplierModel = new Supplier();
+ $suppliers = $supplierModel->getActiveList();
+
+ $purchaseItem = new PurchaseItem();
+ $items = $purchaseItem->getByOrder($id);
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $supplierId = (int)($_POST['supplier_id'] ?? 0);
+ $supplierInfo = $supplierModel->getById($supplierId);
+
+ $orderData = [
+ 'order_no' => trim($_POST['order_no'] ?? ''),
+ 'supplier_id' => $supplierId,
+ 'supplier_name' => $supplierInfo['name'] ?? '',
+ 'order_date' => trim($_POST['order_date'] ?? ''),
+ 'status' => trim($_POST['status'] ?? 'pending'),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ ];
+
+ try {
+ $purchaseOrder->where(['id = :id'], [':id' => $id])->update($orderData);
+
+ $purchaseItem->deleteByOrder($id);
+ $productNames = $_POST['product_name'] ?? [];
+ $productModels = $_POST['product_model'] ?? [];
+ $quantities = $_POST['quantity'] ?? [];
+ $units = $_POST['unit'] ?? [];
+ $unitPrices = $_POST['unit_price'] ?? [];
+
+ $totalAmount = 0;
+ foreach ($productNames as $i => $pname) {
+ if (empty(trim($pname))) continue;
+ $qty = (int)($quantities[$i] ?? 0);
+ $price = (float)($unitPrices[$i] ?? 0);
+ $amount = $qty * $price;
+ $totalAmount += $amount;
+ $purchaseItem->add([
+ 'order_id' => $id,
+ 'product_name' => trim($pname),
+ 'product_model' => trim($productModels[$i] ?? ''),
+ 'quantity' => $qty,
+ 'unit' => trim($units[$i] ?? '个'),
+ 'unit_price' => $price,
+ 'amount' => $amount,
+ ]);
+ }
+ $purchaseOrder->where(['id = :id'], [':id' => $id])->update(['total_amount' => $totalAmount]);
+
+ header('Location: ' . BASE_URL . '/Inventory/purchase');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[purchaseEdit] ' . $e->getMessage());
+ $this->assign('error', '更新失败:' . $e->getMessage());
+ $this->assign('title', '编辑采购订单');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'purchase');
+ $this->assign('order', $order);
+ $this->assign('items', $items);
+ $this->assign('suppliers', $suppliers);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '编辑采购订单');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'purchase');
+ $this->assign('order', $order);
+ $this->assign('items', $items);
+ $this->assign('suppliers', $suppliers);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function purchaseDelete()
+ {
+ $this->checkLogin();
+ $this->requireMethod('post');
+ $this->csrfVerify();
+ $id = (int)($_POST['id'] ?? 0);
+ $purchaseItem = new PurchaseItem();
+ $purchaseItem->deleteByOrder($id);
+ $purchaseOrder = new PurchaseOrder();
+ $purchaseOrder->delete($id);
+ header('Location: ' . BASE_URL . '/Inventory/purchase');
+ exit;
+ }
+
+ // ========== 库存管理 ==========
+ public function stock()
+ {
+ $user = $this->checkCategoryAccess();
+ $inventory = new Inventory();
+ $stocks = $inventory->getAll();
+
+ $this->assign('title', '库存管理');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'stock');
+ $this->assign('stocks', $stocks);
+ $this->render();
+ }
+
+ public function stockAdd()
+ {
+ $user = $this->checkCategoryAccess();
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'product_name' => trim($_POST['product_name'] ?? ''),
+ 'product_model' => trim($_POST['product_model'] ?? ''),
+ 'category' => trim($_POST['category'] ?? ''),
+ 'quantity' => (int)($_POST['quantity'] ?? 0),
+ 'unit' => trim($_POST['unit'] ?? '个'),
+ 'unit_price' => (float)($_POST['unit_price'] ?? 0),
+ 'min_stock' => (int)($_POST['min_stock'] ?? 0),
+ 'max_stock' => (int)($_POST['max_stock'] ?? 0),
+ 'warehouse' => trim($_POST['warehouse'] ?? ''),
+ 'location' => trim($_POST['location'] ?? ''),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ ];
+ if (empty($data['product_name'])) {
+ $this->assign('error', '产品名称不能为空');
+ $this->assign('title', '添加库存');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'stock');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ try {
+ $inventory = new Inventory();
+ $inventory->add($data);
+ header('Location: ' . BASE_URL . '/Inventory/stock');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[stockAdd] ' . $e->getMessage());
+ $this->assign('error', '添加失败:' . $e->getMessage());
+ $this->assign('title', '添加库存');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'stock');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '添加库存');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'stock');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function stockEdit()
+ {
+ $user = $this->checkCategoryAccess();
+ $id = (int)($_GET['id'] ?? 0);
+ $inventory = new Inventory();
+ $record = $inventory->getById($id);
+ if (!$record) exit('库存记录不存在');
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $data = [
+ 'product_name' => trim($_POST['product_name'] ?? ''),
+ 'product_model' => trim($_POST['product_model'] ?? ''),
+ 'category' => trim($_POST['category'] ?? ''),
+ 'quantity' => (int)($_POST['quantity'] ?? 0),
+ 'unit' => trim($_POST['unit'] ?? '个'),
+ 'unit_price' => (float)($_POST['unit_price'] ?? 0),
+ 'min_stock' => (int)($_POST['min_stock'] ?? 0),
+ 'max_stock' => (int)($_POST['max_stock'] ?? 0),
+ 'warehouse' => trim($_POST['warehouse'] ?? ''),
+ 'location' => trim($_POST['location'] ?? ''),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ ];
+ try {
+ $inventory->where(['id = :id'], [':id' => $id])->update($data);
+ header('Location: ' . BASE_URL . '/Inventory/stock');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[stockEdit] ' . $e->getMessage());
+ $this->assign('error', '更新失败:' . $e->getMessage());
+ $this->assign('title', '编辑库存');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'stock');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '编辑库存');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'stock');
+ $this->assign('record', $record);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function stockDelete()
+ {
+ $this->checkLogin();
+ $this->requireMethod('post');
+ $this->csrfVerify();
+ $id = (int)($_POST['id'] ?? 0);
+ $inventory = new Inventory();
+ $inventory->delete($id);
+ header('Location: ' . BASE_URL . '/Inventory/stock');
+ exit;
+ }
+
+ // ========== 销售管理 ==========
+ public function sales()
+ {
+ $user = $this->checkCategoryAccess();
+ $salesOrder = new SalesOrder();
+ $orders = $salesOrder->getAll();
+
+ $this->assign('title', '销售管理');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'sales');
+ $this->assign('orders', $orders);
+ $this->render();
+ }
+
+ public function salesAdd()
+ {
+ $user = $this->checkCategoryAccess();
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $orderData = [
+ 'order_no' => trim($_POST['order_no'] ?? ''),
+ 'customer_name' => trim($_POST['customer_name'] ?? ''),
+ 'order_date' => trim($_POST['order_date'] ?? date('Y-m-d')),
+ 'total_amount' => 0,
+ 'status' => trim($_POST['status'] ?? 'pending'),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ 'operator' => $user['emp_name'],
+ ];
+ if (empty($orderData['order_no'])) {
+ $this->assign('error', '销售单号不能为空');
+ $this->assign('title', '添加销售订单');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'sales');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+
+ try {
+ $salesOrder = new SalesOrder();
+ $salesOrder->add($orderData);
+ $orderId = $this->pdo->lastInsertId();
+
+ $productNames = $_POST['product_name'] ?? [];
+ $productModels = $_POST['product_model'] ?? [];
+ $quantities = $_POST['quantity'] ?? [];
+ $units = $_POST['unit'] ?? [];
+ $unitPrices = $_POST['unit_price'] ?? [];
+
+ $totalAmount = 0;
+ $salesItem = new SalesItem();
+ foreach ($productNames as $i => $pname) {
+ if (empty(trim($pname))) continue;
+ $qty = (int)($quantities[$i] ?? 0);
+ $price = (float)($unitPrices[$i] ?? 0);
+ $amount = $qty * $price;
+ $totalAmount += $amount;
+ $salesItem->add([
+ 'order_id' => $orderId,
+ 'product_name' => trim($pname),
+ 'product_model' => trim($productModels[$i] ?? ''),
+ 'quantity' => $qty,
+ 'unit' => trim($units[$i] ?? '个'),
+ 'unit_price' => $price,
+ 'amount' => $amount,
+ ]);
+ }
+ $salesOrder->where(['id = :id'], [':id' => $orderId])->update(['total_amount' => $totalAmount]);
+
+ header('Location: ' . BASE_URL . '/Inventory/sales');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[salesAdd] ' . $e->getMessage());
+ $this->assign('error', '添加失败:' . $e->getMessage());
+ $this->assign('title', '添加销售订单');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'sales');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '添加销售订单');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'sales');
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function salesDetail()
+ {
+ $user = $this->checkCategoryAccess();
+ $id = (int)($_GET['id'] ?? 0);
+ $salesOrder = new SalesOrder();
+ $order = $salesOrder->getById($id);
+ if (!$order) exit('订单不存在');
+
+ $salesItem = new SalesItem();
+ $items = $salesItem->getByOrder($id);
+
+ $this->assign('title', '销售订单详情');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'sales');
+ $this->assign('order', $order);
+ $this->assign('items', $items);
+ $this->render();
+ }
+
+ public function salesEdit()
+ {
+ $user = $this->checkCategoryAccess();
+ $id = (int)($_GET['id'] ?? 0);
+ $salesOrder = new SalesOrder();
+ $order = $salesOrder->getById($id);
+ if (!$order) exit('订单不存在');
+
+ $salesItem = new SalesItem();
+ $items = $salesItem->getByOrder($id);
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->csrfVerify();
+ $orderData = [
+ 'order_no' => trim($_POST['order_no'] ?? ''),
+ 'customer_name' => trim($_POST['customer_name'] ?? ''),
+ 'order_date' => trim($_POST['order_date'] ?? ''),
+ 'status' => trim($_POST['status'] ?? 'pending'),
+ 'remark' => trim($_POST['remark'] ?? ''),
+ ];
+ try {
+ $salesOrder->where(['id = :id'], [':id' => $id])->update($orderData);
+
+ $salesItem->deleteByOrder($id);
+ $productNames = $_POST['product_name'] ?? [];
+ $productModels = $_POST['product_model'] ?? [];
+ $quantities = $_POST['quantity'] ?? [];
+ $units = $_POST['unit'] ?? [];
+ $unitPrices = $_POST['unit_price'] ?? [];
+
+ $totalAmount = 0;
+ foreach ($productNames as $i => $pname) {
+ if (empty(trim($pname))) continue;
+ $qty = (int)($quantities[$i] ?? 0);
+ $price = (float)($unitPrices[$i] ?? 0);
+ $amount = $qty * $price;
+ $totalAmount += $amount;
+ $salesItem->add([
+ 'order_id' => $id,
+ 'product_name' => trim($pname),
+ 'product_model' => trim($productModels[$i] ?? ''),
+ 'quantity' => $qty,
+ 'unit' => trim($units[$i] ?? '个'),
+ 'unit_price' => $price,
+ 'amount' => $amount,
+ ]);
+ }
+ $salesOrder->where(['id = :id'], [':id' => $id])->update(['total_amount' => $totalAmount]);
+
+ header('Location: ' . BASE_URL . '/Inventory/sales');
+ exit;
+ } catch (\Throwable $e) {
+ error_log('[salesEdit] ' . $e->getMessage());
+ $this->assign('error', '更新失败:' . $e->getMessage());
+ $this->assign('title', '编辑销售订单');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'sales');
+ $this->assign('order', $order);
+ $this->assign('items', $items);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ return;
+ }
+ }
+
+ $this->assign('title', '编辑销售订单');
+ $this->assign('user', $user);
+ $this->assign('activeMenu', 'sales');
+ $this->assign('order', $order);
+ $this->assign('items', $items);
+ $this->assign('csrfToken', $this->csrfToken());
+ $this->render();
+ }
+
+ public function salesDelete()
+ {
+ $this->checkLogin();
+ $this->requireMethod('post');
+ $this->csrfVerify();
+ $id = (int)($_POST['id'] ?? 0);
+ $salesItem = new SalesItem();
+ $salesItem->deleteByOrder($id);
+ $salesOrder = new SalesOrder();
+ $salesOrder->delete($id);
+ header('Location: ' . BASE_URL . '/Inventory/sales');
+ exit;
+ }
+}
diff --git a/app/controllers/LoginController.php b/app/controllers/LoginController.php
new file mode 100644
index 0000000..5896595
--- /dev/null
+++ b/app/controllers/LoginController.php
@@ -0,0 +1,159 @@
+checkBruteForce($emp_no);
+ if ($lockError) {
+ $error = $lockError;
+ } else {
+ $employee = new Employee();
+ $user = $employee->validateLogin($emp_no, $password);
+
+ if ($user) {
+ // 登录成功 — 清除失败记录
+ $this->clearLoginAttempts($emp_no);
+
+ // 重新生成 Session ID 防止会话固定攻击
+ session_regenerate_id(true);
+
+ $_SESSION['user_id'] = $user['id'];
+ $_SESSION['emp_no'] = $user['emp_no'];
+ $_SESSION['emp_name'] = $user['emp_name'];
+ $_SESSION['role'] = $user['role'];
+ $_SESSION['category'] = $user['category'] ?? self::CATEGORY_MES;
+
+ // 根据角色和类目跳转到不同页面
+ if ($user['role'] === self::ROLE_SUPER_ADMIN) {
+ // 超级管理员:默认进入 MES 后台,可通过顶部切换类目
+ header('Location: ' . BASE_URL . '/Admin/index');
+ } elseif ($user['role'] === self::ROLE_ADMIN) {
+ // 管理员:根据分配的类目跳转到对应管理界面
+ $cat = $user['category'] ?? self::CATEGORY_MES;
+ $homeRoute = self::CATEGORY_HOME[$cat] ?? 'Admin/index';
+ header('Location: ' . BASE_URL . '/' . $homeRoute);
+ } else {
+ // 操作员:进入前台工位界面
+ header('Location: ' . BASE_URL . '/Front/index');
+ }
+ exit;
+ } else {
+ // 记录失败
+ $this->recordLoginAttempt($emp_no);
+ $remaining = $this->getRemainingAttempts($emp_no);
+ // 诊断日志:validateLogin 返回 false(详细日志已在 Employee 中记录)
+ $ua = isset($_SERVER['HTTP_USER_AGENT']) ? substr($_SERVER['HTTP_USER_AGENT'], 0, 150) : 'unknown';
+ $sess = session_id() ? hash('sha256', session_id()) : 'none';
+ error_log("[LOGIN_DIAG] RESULT=FAIL emp_no_len=" . strlen($emp_no) . " remaining={$remaining} ua={$ua} sess_hash={$sess}");
+ $error = '员工编号或密码错误' . ($remaining > 0 ? "(还可尝试 {$remaining} 次)" : '(账号已临时锁定)');
+ }
+ }
+ }
+ }
+
+ $this->assign('title', 'MES 系统登录');
+ $this->assign('error', $error);
+ $this->assign('csrf_token', $this->csrfToken());
+ $this->render();
+ }
+
+ /**
+ * 检查暴力破解状态
+ */
+ private function checkBruteForce($emp_no)
+ {
+ $key = 'login_attempts_' . md5($emp_no);
+ $attempts = $_SESSION[$key] ?? [];
+
+ // 清理过期记录
+ $now = time();
+ $attempts = array_filter($attempts, function($t) use ($now) {
+ return $t > ($now - self::ATTEMPT_WINDOW);
+ });
+
+ if (count($attempts) >= self::MAX_LOGIN_ATTEMPTS) {
+ $lastAttempt = max($attempts);
+ $waitTime = self::LOCKOUT_DURATION - ($now - $lastAttempt);
+ if ($waitTime > 0) {
+ $minutes = ceil($waitTime / 60);
+ return "登录尝试次数过多,请 {$minutes} 分钟后再试";
+ }
+ // 锁定时间已过,清除记录
+ unset($_SESSION[$key]);
+ return null;
+ }
+
+ $_SESSION[$key] = array_values($attempts);
+ return null;
+ }
+
+ /**
+ * 记录失败登录尝试
+ */
+ private function recordLoginAttempt($emp_no)
+ {
+ $key = 'login_attempts_' . md5($emp_no);
+ $attempts = $_SESSION[$key] ?? [];
+ $now = time();
+ $attempts = array_filter($attempts, function($t) use ($now) {
+ return $t > ($now - self::ATTEMPT_WINDOW);
+ });
+ $attempts[] = $now;
+ $_SESSION[$key] = array_values($attempts);
+ }
+
+ /**
+ * 清除登录尝试记录
+ */
+ private function clearLoginAttempts($emp_no)
+ {
+ $key = 'login_attempts_' . md5($emp_no);
+ unset($_SESSION[$key]);
+ }
+
+ /**
+ * 获取剩余尝试次数
+ */
+ private function getRemainingAttempts($emp_no)
+ {
+ $key = 'login_attempts_' . md5($emp_no);
+ $attempts = $_SESSION[$key] ?? [];
+ return max(0, self::MAX_LOGIN_ATTEMPTS - count($attempts));
+ }
+
+ public function logout()
+ {
+ session_destroy();
+ header('Location: ' . BASE_URL . '/');
+ exit;
+ }
+}
diff --git a/app/controllers/MiniappController.php b/app/controllers/MiniappController.php
new file mode 100644
index 0000000..59686c7
--- /dev/null
+++ b/app/controllers/MiniappController.php
@@ -0,0 +1,1033 @@
+ 1,
+ 'message' => $message,
+ 'data' => $data,
+ ], JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+
+ /**
+ * 失败响应
+ */
+ private function fail($message, $code = 0, $data = [])
+ {
+ echo json_encode([
+ 'code' => $code,
+ 'message' => $message,
+ 'data' => $data,
+ ], JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+
+ /**
+ * 获取 JSON 请求体
+ */
+ private function getJsonInput()
+ {
+ $input = file_get_contents('php://input');
+ $data = json_decode($input, true);
+ return is_array($data) ? $data : [];
+ }
+
+ /**
+ * Token 认证 — 验证请求中的 Bearer Token
+ * @return array 用户信息 {id, emp_no, emp_name, role}
+ */
+ protected function tokenAuth()
+ {
+ $token = '';
+ $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
+ if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
+ $token = $matches[1];
+ }
+
+ if (empty($token)) {
+ $this->fail('未登录,请先登录');
+ }
+
+ try {
+ $pdo = \core\db\Db::pdo();
+ $stmt = $pdo->prepare(
+ "SELECT t.*, e.emp_name, e.role
+ FROM DGZXY_miniapp_token t
+ JOIN DGZXY_employee e ON t.employee_id = e.id
+ WHERE t.token = :token AND t.expires_at > NOW() AND t.status = 1"
+ );
+ $stmt->execute([':token' => $token]);
+ $tokenInfo = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+ if (!$tokenInfo) {
+ $this->fail('Token无效或已过期,请重新登录', 401);
+ }
+
+ return [
+ 'id' => (int)$tokenInfo['employee_id'],
+ 'emp_no' => $tokenInfo['emp_no'],
+ 'emp_name' => $tokenInfo['emp_name'],
+ 'role' => $tokenInfo['role'],
+ ];
+ } catch (\Throwable $e) {
+ $this->fail('认证服务异常');
+ }
+ }
+
+ /**
+ * 生成 Token(登录成功后调用)
+ */
+ private function generateToken($employeeId, $empNo)
+ {
+ $token = bin2hex(random_bytes(32));
+ $expiresAt = date('Y-m-d H:i:s', time() + 7 * 24 * 3600); // 7天有效期
+
+ try {
+ $pdo = \core\db\Db::pdo();
+ // 使旧 token 失效
+ $pdo->prepare("UPDATE DGZXY_miniapp_token SET status = 0 WHERE employee_id = :eid")
+ ->execute([':eid' => $employeeId]);
+ // 插入新 token
+ $pdo->prepare(
+ "INSERT INTO DGZXY_miniapp_token (token, employee_id, emp_no, expires_at, status, created_at)
+ VALUES (:token, :eid, :emp_no, :expires, 1, NOW())"
+ )->execute([
+ ':token' => $token,
+ ':eid' => $employeeId,
+ ':emp_no' => $empNo,
+ ':expires' => $expiresAt,
+ ]);
+ } catch (\Throwable $e) {
+ // Token 表不存在时创建
+ if (strpos($e->getMessage(), 'exist') !== false || strpos($e->getMessage(), 'not found') !== false) {
+ $this->createTokenTable();
+ return $this->generateToken($employeeId, $empNo);
+ }
+ }
+
+ return [
+ 'token' => $token,
+ 'expires_at' => $expiresAt,
+ ];
+ }
+
+ /**
+ * 创建 Token 表(首次运行时)
+ */
+ private function createTokenTable()
+ {
+ $pdo = \core\db\Db::pdo();
+ $pdo->exec("
+ CREATE TABLE IF NOT EXISTS DGZXY_miniapp_token (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ token VARCHAR(128) NOT NULL,
+ employee_id INT NOT NULL,
+ emp_no VARCHAR(50) NOT NULL,
+ expires_at DATETIME NOT NULL,
+ status TINYINT DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_token (token),
+ INDEX idx_employee (employee_id),
+ INDEX idx_expires (expires_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+ ");
+ }
+
+ /**
+ * 检查工位权限
+ */
+ private function checkStationPermission($user, $stationType)
+ {
+ if ($user['role'] === Controller::ROLE_SUPER_ADMIN || $user['role'] === Controller::ROLE_ADMIN) {
+ return true;
+ }
+ $permModel = new EmployeePermission();
+ return $permModel->canAccess($user['id'], $user['role'], $stationType);
+ }
+
+ /**
+ * 获取工位字段配置
+ */
+ private function getStationFieldConfig($stationType)
+ {
+ $ws = new Workstation();
+ $station = $ws->getByType($stationType);
+ if ($station && !empty($station['fields_config'])) {
+ return parseFieldsConfig($stationType, $station['fields_config']);
+ }
+ return getDefaultFieldsConfig($stationType);
+ }
+
+ /**
+ * 获取用户可访问的工位列表
+ */
+ private function getAllowedStations($user)
+ {
+ $workstation = new Workstation();
+ $allStations = $workstation->getAll();
+
+ if ($user['role'] === Controller::ROLE_SUPER_ADMIN || $user['role'] === Controller::ROLE_ADMIN) {
+ return $allStations;
+ }
+
+ $permModel = new EmployeePermission();
+ $allowedTypes = $permModel->getAllowedStations($user['id']);
+
+ if (empty($allowedTypes)) {
+ return $allStations;
+ }
+
+ return array_values(array_filter($allStations, function($s) use ($allowedTypes) {
+ return in_array($s['station_type'], $allowedTypes);
+ }));
+ }
+
+ // ==================== 1. 登录 ====================
+
+ /**
+ * POST /MiniApp/login
+ * 请求: { emp_no: "员工编号", password: "密码" }
+ * 响应: { code:1, data: { token, expires_at, user: { id, emp_no, emp_name, role } } }
+ */
+ public function login()
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ $this->fail('请使用 POST 请求');
+ }
+
+ $data = $this->getJsonInput();
+ $empNo = trim($data['emp_no'] ?? '');
+ $password = $data['password'] ?? '';
+
+ if (empty($empNo) || empty($password)) {
+ $this->fail('员工编号和密码不能为空');
+ }
+
+ // 暴力破解防护(基于数据库记录)
+ try {
+ $pdo = \core\db\Db::pdo();
+ // 检查最近 15 分钟内的失败次数
+ $stmt = $pdo->prepare(
+ "SELECT COUNT(*) as cnt FROM DGZXY_miniapp_login_log
+ WHERE emp_no = :eno AND success = 0 AND created_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE)"
+ );
+ $stmt->execute([':eno' => $empNo]);
+ $failCount = $stmt->fetch(\PDO::FETCH_ASSOC)['cnt'] ?? 0;
+
+ if ($failCount >= 5) {
+ $this->fail('登录尝试次数过多,请15分钟后再试');
+ }
+ } catch (\Throwable $e) {
+ // 日志表不存在时忽略
+ }
+
+ $employee = new Employee();
+ $user = $employee->validateLogin($empNo, $password);
+
+ if (!$user) {
+ // 记录失败日志
+ try {
+ $pdo = \core\db\Db::pdo();
+ $pdo->prepare(
+ "INSERT INTO DGZXY_miniapp_login_log (emp_no, success, ip, created_at)
+ VALUES (:eno, 0, :ip, NOW())"
+ )->execute([':eno' => $empNo, ':ip' => $_SERVER['REMOTE_ADDR'] ?? '']);
+ } catch (\Throwable $e) {}
+ $this->fail('员工编号或密码错误');
+ }
+
+ // 生成 Token
+ $tokenInfo = $this->generateToken($user['id'], $user['emp_no']);
+
+ // 记录成功日志
+ try {
+ $pdo = \core\db\Db::pdo();
+ $pdo->prepare(
+ "INSERT INTO DGZXY_miniapp_login_log (emp_no, success, ip, created_at)
+ VALUES (:eno, 1, :ip, NOW())"
+ )->execute([':eno' => $empNo, ':ip' => $_SERVER['REMOTE_ADDR'] ?? '']);
+ } catch (\Throwable $e) {}
+
+ $this->success([
+ 'token' => $tokenInfo['token'],
+ 'expires_at' => $tokenInfo['expires_at'],
+ 'user' => [
+ 'id' => (int)$user['id'],
+ 'emp_no' => $user['emp_no'],
+ 'emp_name' => $user['emp_name'],
+ 'role' => $user['role'],
+ ],
+ ], '登录成功');
+ }
+
+ // ==================== 2. 退出登录 ====================
+
+ /**
+ * POST /MiniApp/logout
+ * Headers: Authorization: Bearer
+ */
+ public function logout()
+ {
+ $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
+ $token = '';
+ if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
+ $token = $matches[1];
+ }
+
+ if (!empty($token)) {
+ try {
+ $pdo = \core\db\Db::pdo();
+ $pdo->prepare("UPDATE DGZXY_miniapp_token SET status = 0 WHERE token = :t")
+ ->execute([':t' => $token]);
+ } catch (\Throwable $e) {}
+ }
+
+ $this->success([], '已退出登录');
+ }
+
+ // ==================== 3. 获取用户信息 ====================
+
+ /**
+ * GET /MiniApp/userInfo
+ * Headers: Authorization: Bearer
+ */
+ public function userInfo()
+ {
+ $user = $this->tokenAuth();
+ $this->success(['user' => $user]);
+ }
+
+ // ==================== 4. 获取工位列表 ====================
+
+ /**
+ * GET /MiniApp/stations
+ * Headers: Authorization: Bearer
+ * 响应: { stations: [{id, station_name, station_type, station_code, icon, sort_order}] }
+ */
+ public function stations()
+ {
+ $user = $this->tokenAuth();
+ $stations = $this->getAllowedStations($user);
+
+ // 只返回小程序需要的字段
+ $result = array_map(function($s) {
+ return [
+ 'id' => (int)$s['id'],
+ 'station_name' => $s['station_name'] ?? '',
+ 'station_type' => $s['station_type'] ?? '',
+ 'station_code' => $s['station_code'] ?? '',
+ 'icon' => $s['icon'] ?? 'cube',
+ 'sort_order' => (int)($s['sort_order'] ?? 0),
+ ];
+ }, $stations);
+
+ $this->success(['stations' => $result]);
+ }
+
+ // ==================== 5. 获取工位字段配置 ====================
+
+ /**
+ * GET /MiniApp/stationConfig?type=inbound
+ * Headers: Authorization: Bearer
+ * 响应: { station_type, fields: [...], data_sources: { types, models, customers } }
+ */
+ public function stationConfig()
+ {
+ $user = $this->tokenAuth();
+ $stationType = trim($_GET['type'] ?? '');
+
+ if (empty($stationType)) {
+ $this->fail('缺少工位类型参数 type');
+ }
+
+ // 权限检查
+ if (!$this->checkStationPermission($user, $stationType)) {
+ $this->fail('无权限访问该工位', 403);
+ }
+
+ // 获取字段配置
+ $rawFieldConfigs = $this->getStationFieldConfig($stationType);
+ $fieldMeta = $rawFieldConfigs['_meta'] ?? [];
+ unset($rawFieldConfigs['_meta']);
+ $fieldConfigs = array_values($rawFieldConfigs);
+
+ // 获取数据源
+ $productModel = new ProductModel();
+ $customer = new Customer();
+
+ // 从 fields_config._meta.model_type_filter 读取型号筛选类型
+ $defaultProductType = $fieldMeta['model_type_filter'] ?? '';
+ // 向后兼容:bizConfig 硬编码兜底
+ if (empty($defaultProductType)) {
+ if ($stationType === 'inbound') $defaultProductType = '半成品';
+ if ($stationType === 'warehouse') $defaultProductType = '成品';
+ }
+
+ $models = !empty($defaultProductType)
+ ? $productModel->getByProductType($defaultProductType)
+ : $productModel->getAll();
+
+ $dataSources = [
+ 'types' => $productModel->getProductTypes(),
+ 'models' => $models,
+ 'customers' => ($stationType === 'delivery') ? $customer->getAll() : [],
+ ];
+
+ $this->success([
+ 'station_type' => $stationType,
+ 'fields' => $fieldConfigs,
+ 'data_sources' => $dataSources,
+ ]);
+ }
+
+ // ==================== 6. 获取型号列表(按产品类型筛选) ====================
+
+ /**
+ * GET /MiniApp/models?product_type=半成品
+ * Headers: Authorization: Bearer
+ */
+ public function models()
+ {
+ $this->tokenAuth();
+ $productType = trim($_GET['product_type'] ?? '');
+
+ $productModel = new ProductModel();
+ if ($productType === '') {
+ $models = $productModel->getAll();
+ } else {
+ $models = $productModel->getByProductType($productType);
+ }
+
+ $this->success(['models' => $models]);
+ }
+
+ // ==================== 7. 半成品入库 ====================
+
+ /**
+ * POST /MiniApp/inbound
+ * Headers: Authorization: Bearer
+ * 请求: { product_type, product_model, serial_no }
+ */
+ public function inbound()
+ {
+ $user = $this->tokenAuth();
+
+ if (!$this->checkStationPermission($user, 'inbound')) {
+ $this->fail('无权限操作该工位', 403);
+ }
+
+ $data = $this->getJsonInput();
+
+ // 必填校验
+ $errors = [];
+ if (empty($data['product_type'])) $errors[] = '产品类型不能为空';
+ if (empty($data['product_model'])) $errors[] = '产品型号不能为空';
+ if (empty($data['serial_no'])) $errors[] = '产品序列号不能为空';
+
+ if (!empty($errors)) {
+ $this->fail(implode(';', $errors));
+ }
+
+ // 检查序列号是否已存在
+ $stationDef = new StationDefinition();
+ $existing = $stationDef->findByField('inbound', 'serial_no', $data['serial_no']);
+ if ($existing) {
+ $this->fail('该序列号已入库');
+ }
+
+ $record = [
+ 'product_type' => $data['product_type'],
+ 'product_model' => $data['product_model'],
+ 'serial_no' => $data['serial_no'],
+ 'test_status' => 'pending',
+ 'operator' => $user['emp_name'],
+ ];
+
+ $result = $stationDef->addRecord('inbound', $record);
+ if (!$result) {
+ $this->fail('入库失败');
+ }
+
+ $this->success(['id' => $id], '入库成功');
+ }
+
+ // ==================== 8. PCB 测试 ====================
+
+ /**
+ * POST /MiniApp/pcbTest
+ * Headers: Authorization: Bearer
+ * 请求: { product_type, product_model, serial_no, static_power?, input_output?, test_status? }
+ */
+ public function pcbTest()
+ {
+ $user = $this->tokenAuth();
+
+ if (!$this->checkStationPermission($user, 'pcb_test')) {
+ $this->fail('无权限操作该工位', 403);
+ }
+
+ $data = $this->getJsonInput();
+
+ if (empty($data['serial_no'])) {
+ $this->fail('PCB序列号不能为空');
+ }
+
+ $stationDef = new StationDefinition();
+ $record = [
+ 'product_type' => $data['product_type'] ?? '',
+ 'product_model' => $data['product_model'] ?? '',
+ 'serial_no' => $data['serial_no'],
+ 'static_power' => isset($data['static_power']) && $data['static_power'] !== '' ? $data['static_power'] : null,
+ 'input_output' => $data['input_output'] ?? null,
+ 'test_status' => $data['test_status'] ?? null,
+ 'operator' => $user['emp_name'],
+ ];
+
+ $result = $stationDef->addRecord('pcb_test', $record);
+ if (!$result) {
+ $this->fail('提交失败');
+ }
+
+ $this->success([], 'PCB测试数据提交成功');
+ }
+
+ // ==================== 9. 电池状态 ====================
+
+ /**
+ * POST /MiniApp/battery
+ * Headers: Authorization: Bearer
+ * 请求: { serial_no, voltage_platform?, no_load_voltage?, internal_resistance? }
+ */
+ public function battery()
+ {
+ $user = $this->tokenAuth();
+
+ if (!$this->checkStationPermission($user, 'battery')) {
+ $this->fail('无权限操作该工位', 403);
+ }
+
+ $data = $this->getJsonInput();
+
+ if (empty($data['serial_no'])) {
+ $this->fail('产品序列号不能为空');
+ }
+
+ $stationDef = new StationDefinition();
+ $record = [
+ 'serial_no' => $data['serial_no'],
+ 'voltage_platform' => isset($data['voltage_platform']) && $data['voltage_platform'] !== '' ? $data['voltage_platform'] : null,
+ 'no_load_voltage' => isset($data['no_load_voltage']) && $data['no_load_voltage'] !== '' ? $data['no_load_voltage'] : null,
+ 'internal_resistance' => isset($data['internal_resistance']) && $data['internal_resistance'] !== '' ? $data['internal_resistance'] : null,
+ 'operator' => $user['emp_name'],
+ ];
+
+ $result = $stationDef->addRecord('battery', $record);
+ if (!$result) {
+ $this->fail('提交失败');
+ }
+
+ $this->success([], '电池数据提交成功');
+ }
+
+ // ==================== 10. 产品组装(三码关联) ====================
+
+ /**
+ * POST /MiniApp/assembly
+ * Headers: Authorization: Bearer
+ * 请求: { product_model, finished_serial, battery_serial, pcb_serial }
+ */
+ public function assembly()
+ {
+ $user = $this->tokenAuth();
+
+ if (!$this->checkStationPermission($user, 'assembly')) {
+ $this->fail('无权限操作该工位', 403);
+ }
+
+ $data = $this->getJsonInput();
+
+ $errors = [];
+ if (empty($data['finished_serial'])) $errors[] = '成品序列号不能为空';
+ if (empty($data['battery_serial'])) $errors[] = '电池序列号不能为空';
+ if (empty($data['pcb_serial'])) $errors[] = 'PCB序列号不能为空';
+ if (empty($data['product_model'])) $errors[] = '产品型号不能为空';
+
+ if (!empty($errors)) {
+ $this->fail(implode(';', $errors));
+ }
+
+ $stationDef = new StationDefinition();
+
+ // 检查成品序列号是否已组装
+ $existing = $stationDef->findByField('assembly', 'finished_serial', $data['finished_serial']);
+ if ($existing) {
+ $this->fail('该成品序列号(' . $data['finished_serial'] . ')已组装,不能重复提交');
+ }
+
+ $record = [
+ 'finished_serial' => $data['finished_serial'],
+ 'battery_serial' => $data['battery_serial'],
+ 'pcb_serial' => $data['pcb_serial'],
+ 'product_model' => $data['product_model'],
+ 'operator' => $user['emp_name'],
+ ];
+
+ $result = $stationDef->addRecord('assembly', $record);
+ if (!$result) {
+ $this->fail('组装记录提交失败');
+ }
+
+ $this->success([], '组装记录提交成功');
+ }
+
+ // ==================== 11. 成品测试 ====================
+
+ /**
+ * POST /MiniApp/finishedTest
+ * Headers: Authorization: Bearer
+ * 请求: { finished_serial, test_item, test_result, test_steps?, product_model? }
+ */
+ public function finishedTest()
+ {
+ $user = $this->tokenAuth();
+
+ if (!$this->checkStationPermission($user, 'finished_test')) {
+ $this->fail('无权限操作该工位', 403);
+ }
+
+ $data = $this->getJsonInput();
+
+ if (empty($data['finished_serial'])) $this->fail('成品序列号不能为空');
+ if (empty($data['test_item'])) $this->fail('测试项目不能为空');
+ if (empty($data['test_result'])) $this->fail('测试结果不能为空');
+
+ $stationDef = new StationDefinition();
+ $record = [
+ 'finished_serial' => $data['finished_serial'],
+ 'test_item' => $data['test_item'],
+ 'test_result' => $data['test_result'],
+ 'test_steps' => $data['test_steps'] ?? '',
+ 'product_model' => $data['product_model'] ?? '',
+ 'operator' => $user['emp_name'],
+ ];
+
+ $result = $stationDef->addRecord('finished_test', $record);
+ if (!$result) {
+ $this->fail('提交失败');
+ }
+
+ $this->success([], '测试数据提交成功');
+ }
+
+ // ==================== 12. 成品入库 ====================
+
+ /**
+ * POST /MiniApp/warehouse
+ * Headers: Authorization: Bearer
+ * 请求: { finished_serial, product_model }
+ */
+ public function warehouse()
+ {
+ $user = $this->tokenAuth();
+
+ if (!$this->checkStationPermission($user, 'warehouse')) {
+ $this->fail('无权限操作该工位', 403);
+ }
+
+ $data = $this->getJsonInput();
+
+ if (empty($data['finished_serial'])) $this->fail('成品序列号不能为空');
+
+ $stationDef = new StationDefinition();
+ $record = [
+ 'finished_serial' => $data['finished_serial'],
+ 'product_model' => $data['product_model'] ?? '',
+ 'operator' => $user['emp_name'],
+ ];
+
+ $result = $stationDef->addRecord('warehouse', $record);
+ if (!$result) {
+ $this->fail('入库失败');
+ }
+
+ $this->success([], '入库成功');
+ }
+
+ // ==================== 13. 出货登记 ====================
+
+ /**
+ * POST /MiniApp/delivery
+ * Headers: Authorization: Bearer
+ * 请求: { finished_serial, customer_name, product_model }
+ */
+ public function delivery()
+ {
+ $user = $this->tokenAuth();
+
+ if (!$this->checkStationPermission($user, 'delivery')) {
+ $this->fail('无权限操作该工位', 403);
+ }
+
+ $data = $this->getJsonInput();
+
+ if (empty($data['finished_serial'])) $this->fail('成品序列号不能为空');
+ if (empty($data['customer_name'])) $this->fail('客户名称不能为空');
+
+ $stationDef = new StationDefinition();
+ $record = [
+ 'finished_serial' => $data['finished_serial'],
+ 'customer_name' => $data['customer_name'],
+ 'product_model' => $data['product_model'] ?? '',
+ 'operator' => $user['emp_name'],
+ ];
+
+ $result = $stationDef->addRecord('delivery', $record);
+ if (!$result) {
+ $this->fail('出货登记失败');
+ }
+
+ $this->success([], '出货登记成功');
+ }
+
+ // ==================== 14. 工位记录查询 ====================
+
+ /**
+ * GET /MiniApp/records?type=inbound&page=1&limit=20
+ * Headers: Authorization: Bearer
+ */
+ public function records()
+ {
+ $user = $this->tokenAuth();
+
+ $stationType = trim($_GET['type'] ?? '');
+ $page = max(1, intval($_GET['page'] ?? 1));
+ $limit = min(100, max(1, intval($_GET['limit'] ?? 20)));
+ $offset = ($page - 1) * $limit;
+
+ if (empty($stationType)) {
+ $this->fail('缺少工位类型参数 type');
+ }
+
+ if (!$this->checkStationPermission($user, $stationType)) {
+ $this->fail('无权限查看该工位记录', 403);
+ }
+
+ // 获取字段配置(用于表头信息)
+ $rawFieldConfigs = $this->getStationFieldConfig($stationType);
+ unset($rawFieldConfigs['_meta']);
+ $fieldConfigs = array_values($rawFieldConfigs);
+
+ try {
+ $pdo = \core\db\Db::pdo();
+
+ $tableMap = [
+ 'inbound' => 'station_inbound',
+ 'pcb_test' => 'pcb_test',
+ 'battery' => 'station_battery',
+ 'assembly' => 'product_assembly',
+ 'finished_test' => 'station_finished_test',
+ 'warehouse' => 'station_warehouse',
+ 'delivery' => 'station_delivery',
+ ];
+
+ $table = $tableMap[$stationType] ?? null;
+ if (!$table) {
+ $this->fail('无效的工位类型');
+ }
+
+ $fullTable = 'DGZXY_' . $table;
+
+ // 总数
+ $countSql = "SELECT COUNT(*) as total FROM {$fullTable}";
+ $countStmt = $pdo->query($countSql);
+ $total = $countStmt->fetch(\PDO::FETCH_ASSOC)['total'] ?? 0;
+
+ // 分页查询
+ $stmt = $pdo->prepare("SELECT * FROM {$fullTable} ORDER BY id DESC LIMIT :limit OFFSET :offset");
+ $stmt->bindValue(':limit', $limit, \PDO::PARAM_INT);
+ $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT);
+ $stmt->execute();
+ $records = $stmt->fetchAll(\PDO::FETCH_ASSOC);
+
+ } catch (\Throwable $e) {
+ $this->fail('查询失败: ' . $e->getMessage());
+ }
+
+ $this->success([
+ 'records' => $records,
+ 'fields' => $fieldConfigs,
+ 'total' => (int)$total,
+ 'page' => $page,
+ 'limit' => $limit,
+ 'total_pages' => ceil($total / $limit),
+ ]);
+ }
+
+ // ==================== 15. 今日统计 ====================
+
+ /**
+ * GET /MiniApp/dashboard
+ * Headers: Authorization: Bearer
+ * 响应: { today: { inbound, pcb_test, battery, assembly, finished_test, warehouse, delivery }, total }
+ */
+ public function dashboard()
+ {
+ $user = $this->tokenAuth();
+
+ try {
+ $pdo = \core\db\Db::pdo();
+
+ $tables = [
+ 'inbound' => 'station_inbound',
+ 'pcb_test' => 'pcb_test',
+ 'battery' => 'station_battery',
+ 'assembly' => 'product_assembly',
+ 'finished_test' => 'station_finished_test',
+ 'warehouse' => 'station_warehouse',
+ 'delivery' => 'station_delivery',
+ ];
+
+ $today = [];
+ foreach ($tables as $key => $table) {
+ $stmt = $pdo->prepare(
+ "SELECT COUNT(*) as cnt FROM DGZXY_{$table} WHERE operator = :op AND DATE(created_at) = CURDATE()"
+ );
+ $stmt->execute([':op' => $user['emp_name']]);
+ $today[$key] = (int)($stmt->fetch(\PDO::FETCH_ASSOC)['cnt'] ?? 0);
+ }
+
+ $total = array_sum($today);
+
+ } catch (\Throwable $e) {
+ $today = [];
+ $total = 0;
+ }
+
+ $this->success([
+ 'today' => $today,
+ 'total' => $total,
+ ]);
+ }
+
+ // ==================== 16. 产品追溯查询 ====================
+
+ /**
+ * GET /MiniApp/productSearch?keyword=序列号
+ * Headers: Authorization: Bearer
+ * 跨工位搜索序列号,返回产品全链路信息
+ */
+ public function productSearch()
+ {
+ $user = $this->tokenAuth();
+ $keyword = trim($_GET['keyword'] ?? '');
+
+ if (empty($keyword)) {
+ $this->fail('请输入搜索关键词');
+ }
+
+ $pdo = \core\db\Db::pdo();
+ $result = [
+ 'keyword' => $keyword,
+ 'traces' => [],
+ ];
+
+ // 每个表查询独立 try-catch,某个表不存在不影响其他查询
+ $queries = [
+ ['source' => 'inbound', 'sql' =>
+ "SELECT 'inbound' as source, id, serial_no, product_type, product_model, test_status, operator, created_at
+ FROM DGZXY_station_inbound WHERE serial_no = :kw LIMIT 1", 'params' => [':kw' => $keyword]],
+ ['source' => 'pcb_test', 'sql' =>
+ "SELECT 'pcb_test' as source, id, serial_no, product_type, product_model, static_power, input_output, test_status, operator, created_at
+ FROM DGZXY_pcb_test WHERE serial_no = :kw LIMIT 1", 'params' => [':kw' => $keyword]],
+ ['source' => 'battery', 'sql' =>
+ "SELECT 'battery' as source, id, serial_no, voltage_platform, no_load_voltage, internal_resistance, operator, created_at
+ FROM DGZXY_station_battery WHERE serial_no = :kw LIMIT 1", 'params' => [':kw' => $keyword]],
+ ['source' => 'assembly', 'sql' =>
+ "SELECT 'assembly' as source, id, finished_serial, battery_serial, pcb_serial, product_model, operator, created_at
+ FROM DGZXY_product_assembly
+ WHERE finished_serial = :kw OR battery_serial = :kw2 OR pcb_serial = :kw3
+ LIMIT 5", 'params' => [':kw' => $keyword, ':kw2' => $keyword, ':kw3' => $keyword]],
+ ['source' => 'finished_test', 'sql' =>
+ "SELECT 'finished_test' as source, id, finished_serial, test_item, test_result, test_steps, product_model, operator, created_at
+ FROM DGZXY_station_finished_test WHERE finished_serial = :kw LIMIT 5", 'params' => [':kw' => $keyword]],
+ ['source' => 'warehouse', 'sql' =>
+ "SELECT 'warehouse' as source, id, finished_serial, product_model, operator, created_at as in_time
+ FROM DGZXY_station_warehouse WHERE finished_serial = :kw LIMIT 1", 'params' => [':kw' => $keyword]],
+ ['source' => 'delivery', 'sql' =>
+ "SELECT 'delivery' as source, id, finished_serial, customer_name, product_model, operator, created_at
+ FROM DGZXY_station_delivery WHERE finished_serial = :kw LIMIT 1", 'params' => [':kw' => $keyword]],
+ ];
+
+ foreach ($queries as $q) {
+ try {
+ $stmt = $pdo->prepare($q['sql']);
+ $stmt->execute($q['params']);
+ while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
+ $result['traces'][] = $row;
+ }
+ } catch (\Throwable $e) {
+ // 表不存在时跳过,继续查询其他表
+ continue;
+ }
+ }
+
+ $result['count'] = count($result['traces']);
+
+ $this->success($result);
+ }
+
+ // ==================== 17. 修改密码 ====================
+
+ /**
+ * POST /MiniApp/changePassword
+ * Headers: Authorization: Bearer
+ * 请求: { old_password, new_password }
+ */
+ public function changePassword()
+ {
+ $user = $this->tokenAuth();
+ $data = $this->getJsonInput();
+
+ $oldPassword = $data['old_password'] ?? '';
+ $newPassword = $data['new_password'] ?? '';
+
+ if (empty($oldPassword)) $this->fail('请输入旧密码');
+ if (empty($newPassword)) $this->fail('请输入新密码');
+ if (strlen($newPassword) < 8) $this->fail('新密码长度不能少于8位');
+
+ // 验证旧密码
+ $employee = new Employee();
+ $valid = $employee->validateLogin($user['emp_no'], $oldPassword);
+ if (!$valid) {
+ $this->fail('旧密码错误');
+ }
+
+ // 更新密码
+ $employee->resetPassword($user['id'], $newPassword);
+
+ // 使所有 token 失效(强制重新登录)
+ try {
+ $pdo = \core\db\Db::pdo();
+ $pdo->prepare("UPDATE DGZXY_miniapp_token SET status = 0 WHERE employee_id = :eid")
+ ->execute([':eid' => $user['id']]);
+ } catch (\Throwable $e) {}
+
+ $this->success([], '密码修改成功,请重新登录');
+ }
+}
+
+// ==================== 辅助函数 ====================
+
+/**
+ * 解析工位字段配置(JSON字符串转数组)
+ */
+function parseFieldsConfig($stationType, $fieldsConfigJson)
+{
+ $config = is_string($fieldsConfigJson) ? json_decode($fieldsConfigJson, true) : $fieldsConfigJson;
+ if (!is_array($config)) {
+ return getDefaultFieldsConfig($stationType);
+ }
+ return $config;
+}
+
+/**
+ * 获取工位默认字段配置
+ * 根据工位类型返回对应字段定义
+ */
+function getDefaultFieldsConfig($stationType)
+{
+ $configs = [
+ 'inbound' => [
+ ['field' => 'product_type', 'label' => '产品类型', 'type' => 'select', 'required' => true, 'source' => 'types'],
+ ['field' => 'product_model', 'label' => '产品型号', 'type' => 'select', 'required' => true, 'source' => 'models'],
+ ['field' => 'serial_no', 'label' => '产品序列号', 'type' => 'text', 'required' => true],
+ ],
+ 'pcb_test' => [
+ ['field' => 'product_type', 'label' => '产品类型', 'type' => 'select', 'required' => false, 'source' => 'types'],
+ ['field' => 'product_model', 'label' => '产品型号', 'type' => 'select', 'required' => false, 'source' => 'models'],
+ ['field' => 'serial_no', 'label' => 'PCB序列号', 'type' => 'text', 'required' => true],
+ ['field' => 'static_power', 'label' => '静态功耗', 'type' => 'number', 'required' => false],
+ ['field' => 'input_output', 'label' => '输入输出', 'type' => 'text', 'required' => false],
+ ['field' => 'test_status', 'label' => '测试状态', 'type' => 'select', 'required' => false, 'options' => ['pass', 'fail', 'pending']],
+ ],
+ 'battery' => [
+ ['field' => 'serial_no', 'label' => '产品序列号', 'type' => 'text', 'required' => true],
+ ['field' => 'voltage_platform', 'label' => '电压平台(V)', 'type' => 'number', 'required' => false],
+ ['field' => 'no_load_voltage', 'label' => '空载电压(V)', 'type' => 'number', 'required' => false],
+ ['field' => 'internal_resistance', 'label' => '内阻(mOhm)', 'type' => 'number', 'required' => false],
+ ],
+ 'assembly' => [
+ ['field' => 'finished_serial', 'label' => '成品序列号', 'type' => 'text', 'required' => true],
+ ['field' => 'battery_serial', 'label' => '电池序列号', 'type' => 'text', 'required' => true],
+ ['field' => 'pcb_serial', 'label' => 'PCB序列号', 'type' => 'text', 'required' => true],
+ ['field' => 'product_model', 'label' => '产品型号', 'type' => 'select', 'required' => true, 'source' => 'models'],
+ ],
+ 'finished_test' => [
+ ['field' => 'finished_serial', 'label' => '成品序列号', 'type' => 'text', 'required' => true],
+ ['field' => 'test_item', 'label' => '测试项目', 'type' => 'text', 'required' => true],
+ ['field' => 'test_result', 'label' => '测试结果', 'type' => 'select', 'required' => true, 'options' => ['pass', 'fail']],
+ ['field' => 'test_steps', 'label' => '测试步骤', 'type' => 'textarea', 'required' => false],
+ ['field' => 'product_model', 'label' => '产品型号', 'type' => 'select', 'required' => false, 'source' => 'models'],
+ ],
+ 'warehouse' => [
+ ['field' => 'finished_serial', 'label' => '成品序列号', 'type' => 'text', 'required' => true],
+ ['field' => 'product_model', 'label' => '产品型号', 'type' => 'select', 'required' => false, 'source' => 'models'],
+ ],
+ 'delivery' => [
+ ['field' => 'finished_serial', 'label' => '成品序列号', 'type' => 'text', 'required' => true],
+ ['field' => 'customer_name', 'label' => '客户名称', 'type' => 'select', 'required' => true, 'source' => 'customers'],
+ ['field' => 'product_model', 'label' => '产品型号', 'type' => 'select', 'required' => false, 'source' => 'models'],
+ ],
+ ];
+
+ return $configs[$stationType] ?? [];
+}
diff --git a/app/helpers/field_helper.php b/app/helpers/field_helper.php
new file mode 100644
index 0000000..3e27faf
--- /dev/null
+++ b/app/helpers/field_helper.php
@@ -0,0 +1,975 @@
+ [
+ ['name'=>'product_type','label'=>'类型','type'=>'select','source'=>'type_list','required'=>true,'enabled'=>true,'placeholder'=>'请选择类型','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'类型'],
+ ['name'=>'product_model','label'=>'产品型号','type'=>'select','source'=>'product_models','required'=>true,'enabled'=>true,'placeholder'=>'请选择产品型号','col_width'=>2,'is_scan'=>false,'show_in_table'=>false,'table_label'=>'产品型号'],
+ ['name'=>'serial_no','label'=>'产品序列号','type'=>'text','required'=>true,'enabled'=>true,'placeholder'=>'扫描/输入后按回车','col_width'=>3,'is_scan'=>true,'show_in_table'=>true,'table_label'=>'产品序列号'],
+ ],
+ 'pcb_test' => [
+ ['name'=>'product_type','label'=>'产品类型','type'=>'hidden','required'=>true,'enabled'=>true,'placeholder'=>'PCBA','col_width'=>0,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'产品类型','fixed_value'=>'PCBA'],
+ ['name'=>'product_model','label'=>'产品型号','type'=>'select','source'=>'product_models','required'=>true,'enabled'=>true,'placeholder'=>'请选择产品型号','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'产品型号'],
+ ['name'=>'static_power','label'=>'静态功耗','type'=>'number','required'=>false,'enabled'=>true,'placeholder'=>'0.0000','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'静态功耗'],
+ ['name'=>'input_output','label'=>'输入输出','type'=>'text','required'=>false,'enabled'=>true,'placeholder'=>'正常/异常','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'输入输出'],
+ ['name'=>'test_status','label'=>'测试状态','type'=>'select','source'=>'test_status_options','required'=>false,'enabled'=>true,'placeholder'=>'请选择','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'测试状态'],
+ ['name'=>'serial_no','label'=>'PCBA序列号','type'=>'text','required'=>true,'enabled'=>true,'placeholder'=>'扫描 PCBA 序列号后按回车','col_width'=>6,'is_scan'=>true,'show_in_table'=>true,'table_label'=>'PCBA序列号'],
+ ],
+ 'battery' => [
+ ['name'=>'product_type','label'=>'类型','type'=>'hidden','required'=>true,'enabled'=>true,'placeholder'=>'电池','col_width'=>0,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'类型','fixed_value'=>'电池'],
+ ['name'=>'product_model','label'=>'产品型号','type'=>'select','source'=>'product_models','required'=>true,'enabled'=>true,'placeholder'=>'请选择产品型号','col_width'=>3,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'产品型号'],
+ ['name'=>'voltage_platform','label'=>'电压平台','type'=>'text','required'=>false,'enabled'=>true,'placeholder'=>'如:3.7V','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'电压平台'],
+ ['name'=>'no_load_voltage','label'=>'空载电压','type'=>'number','required'=>false,'enabled'=>true,'placeholder'=>'0.0000','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'空载电压'],
+ ['name'=>'internal_resistance','label'=>'电池内阻','type'=>'number','required'=>false,'enabled'=>true,'placeholder'=>'0.0000','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'电池内阻'],
+ ['name'=>'serial_no','label'=>'产品序列号','type'=>'text','required'=>true,'enabled'=>true,'placeholder'=>'扫描/输入后按回车','col_width'=>4,'is_scan'=>true,'show_in_table'=>true,'table_label'=>'产品序列号'],
+ ],
+ 'assembly' => [
+ ['name'=>'product_model','label'=>'产品型号','type'=>'select','source'=>'product_models','required'=>true,'enabled'=>true,'placeholder'=>'请选择产品型号','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'产品型号'],
+ ['name'=>'shell_color','label'=>'外壳颜色','type'=>'select','source'=>'shell_color_options','required'=>false,'enabled'=>true,'placeholder'=>'请选择外壳颜色','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'外壳颜色'],
+ ['name'=>'finished_serial','label'=>'成品序列号','type'=>'text','required'=>true,'enabled'=>true,'placeholder'=>'扫描成品序列号','col_width'=>3,'is_scan'=>true,'show_in_table'=>true,'table_label'=>'成品序列号'],
+ ['name'=>'battery_serial','label'=>'电池序列号','type'=>'text','required'=>true,'enabled'=>true,'placeholder'=>'扫描电池序列号','col_width'=>3,'is_scan'=>true,'show_in_table'=>true,'table_label'=>'电池序列号'],
+ ['name'=>'pcb_serial','label'=>'PCB序列号','type'=>'text','required'=>true,'enabled'=>true,'placeholder'=>'扫描PCB序列号','col_width'=>2,'is_scan'=>true,'show_in_table'=>true,'table_label'=>'PCB序列号'],
+ ],
+ 'finished_test' => [
+ ['name'=>'product_model','label'=>'产品型号','type'=>'select','source'=>'product_models','required'=>false,'enabled'=>true,'placeholder'=>'请选择产品型号','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'产品型号'],
+ ['name'=>'test_item','label'=>'测试项目','type'=>'text','required'=>true,'enabled'=>true,'placeholder'=>'如:功能测试','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'测试项目'],
+ ['name'=>'test_result','label'=>'测试结果','type'=>'select','source'=>'test_result_options','required'=>false,'enabled'=>true,'placeholder'=>'请选择','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'测试结果'],
+ ['name'=>'test_steps','label'=>'测试步骤','type'=>'text','required'=>false,'enabled'=>true,'placeholder'=>'步骤描述','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'测试步骤'],
+ ['name'=>'finished_serial','label'=>'成品序列号','type'=>'text','required'=>true,'enabled'=>true,'placeholder'=>'扫描序列号后按回车','col_width'=>4,'is_scan'=>true,'show_in_table'=>true,'table_label'=>'成品序列号'],
+ ],
+ 'warehouse' => [
+ ['name'=>'product_model','label'=>'产品型号','type'=>'select','source'=>'product_models','required'=>false,'enabled'=>true,'placeholder'=>'请选择产品型号','col_width'=>3,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'产品型号'],
+ ['name'=>'finished_serial','label'=>'成品序列号','type'=>'text','required'=>true,'enabled'=>true,'placeholder'=>'扫描成品序列号后按回车','col_width'=>4,'is_scan'=>true,'show_in_table'=>true,'table_label'=>'成品序列号'],
+ ['name'=>'box_serial','label'=>'箱序列号','type'=>'text','required'=>false,'enabled'=>true,'placeholder'=>'箱序列号','col_width'=>3,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'箱序列号'],
+ ],
+ 'delivery' => [
+ ['name'=>'customer_name','label'=>'客户名称','type'=>'select','source'=>'customers','required'=>true,'enabled'=>true,'placeholder'=>'请选择客户','col_width'=>3,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'客户名称'],
+ ['name'=>'finished_serial','label'=>'成品/箱序列号','type'=>'text','required'=>true,'enabled'=>true,'placeholder'=>'扫描序列号后按回车(自动识别型号)','col_width'=>4,'is_scan'=>true,'show_in_table'=>true,'table_label'=>'序列号'],
+ ['name'=>'product_model','label'=>'产品型号','type'=>'hidden','required'=>false,'enabled'=>true,'placeholder'=>'','col_width'=>0,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'产品型号'],
+ ],
+ 'semi_finished' => [
+ ['name'=>'product_type','label'=>'类型','type'=>'hidden','required'=>true,'enabled'=>true,'placeholder'=>'半成品','col_width'=>0,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'类型','fixed_value'=>'半成品'],
+ ['name'=>'product_model','label'=>'产品型号','type'=>'select','source'=>'product_models','required'=>true,'enabled'=>true,'placeholder'=>'请选择产品型号','col_width'=>3,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'产品型号'],
+ ['name'=>'serial_no','label'=>'产品序列号','type'=>'text','required'=>true,'enabled'=>true,'placeholder'=>'扫描/输入后按回车','col_width'=>4,'is_scan'=>true,'show_in_table'=>true,'table_label'=>'产品序列号'],
+ ],
+ 'aging' => [
+ ['name'=>'product_type','label'=>'类型','type'=>'hidden','required'=>true,'enabled'=>true,'placeholder'=>'半成品','col_width'=>0,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'类型','fixed_value'=>'半成品'],
+ ['name'=>'product_model','label'=>'产品型号','type'=>'select','source'=>'product_models','required'=>true,'enabled'=>true,'placeholder'=>'请选择产品型号','col_width'=>3,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'产品型号'],
+ ['name'=>'serial_no','label'=>'产品序列号','type'=>'text','required'=>true,'enabled'=>true,'placeholder'=>'扫描/输入后按回车','col_width'=>4,'is_scan'=>true,'show_in_table'=>true,'table_label'=>'产品序列号'],
+ ['name'=>'aging_duration','label'=>'老化时长(h)','type'=>'number','required'=>false,'enabled'=>true,'placeholder'=>'如:24','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'老化时长'],
+ ['name'=>'aging_temp','label'=>'老化温度(℃)','type'=>'number','required'=>false,'enabled'=>true,'placeholder'=>'如:85','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'老化温度'],
+ ['name'=>'aging_result','label'=>'老化结果','type'=>'select','source'=>'test_result_options','required'=>false,'enabled'=>true,'placeholder'=>'请选择','col_width'=>2,'is_scan'=>false,'show_in_table'=>true,'table_label'=>'老化结果'],
+ ],
+ ];
+ $fields = isset($configs[$stationType]) ? $configs[$stationType] : [];
+
+ // ★ 关键修复:附加默认 _meta 信息
+ // 当 workstation.fields_config 为空时,FrontController::getStationFieldConfig()
+ // 会 fallback 到 getDefaultFieldsConfig(),但之前没有附加 _meta,
+ // 导致 model_type_filter、type_filter 等元信息丢失。
+ $meta = getDefaultMetaConfig($stationType);
+ if (!empty($meta)) {
+ $fields['_meta'] = $meta;
+ }
+
+ return $fields;
+}
+
+/**
+ * 获取各工位类型的默认元信息配置
+ * 当数据库 fields_config 为空时作为兜底
+ */
+function getDefaultMetaConfig($stationType) {
+ $metas = [
+ 'inbound' => [
+ 'model_type_filter' => '半成品',
+ 'type_filter' => 'inbound',
+ ],
+ 'pcb_test' => [
+ 'model_type_filter' => 'PCBA',
+ ],
+ 'battery' => [
+ 'model_type_filter' => '电池',
+ ],
+ 'assembly' => [
+ 'model_type_filter' => '成品',
+ 'extra_data_sources' => ['shellColors' => true],
+ ],
+ 'finished_test' => [
+ 'model_type_filter' => '成品',
+ ],
+ 'warehouse' => [
+ 'model_type_filter' => '成品',
+ ],
+ 'delivery' => [],
+ 'semi_finished' => [
+ 'model_type_filter' => '半成品',
+ ],
+ 'aging' => [
+ 'model_type_filter' => '半成品',
+ ],
+ ];
+ return $metas[$stationType] ?? [];
+}
+
+/**
+ * 解析工位字段配置
+ * 返回数组,如果为空则使用默认配置
+ *
+ * JSON 格式支持两种结构:
+ * 1. 纯字段数组:[{name,label,...}, ...] (旧格式,兼容)
+ * 2. 元信息 + 字段:{"_meta":{...}, "fields":[{...}, ...]} (新格式)
+ *
+ * _meta 支持的字段:
+ * - model_type_filter: string 产品型号筛选类型(如 "成品"、"PCBA"、"电池")
+ * - type_filter: string 类型列表筛选方式(如 "inbound"、"enabled")
+ * - extra_data_sources: object 额外数据源配置
+ */
+function parseFieldsConfig($stationType, $fieldsConfigJson = '') {
+ if (!empty($fieldsConfigJson)) {
+ $config = json_decode($fieldsConfigJson, true);
+ if (is_array($config) && !empty($config)) {
+ // 新格式:{"_meta":{...}, "fields":[...]}
+ if (isset($config['_meta']) || isset($config['fields'])) {
+ $result = $config['fields'] ?? [];
+ $meta = $config['_meta'] ?? [];
+ // 用默认元信息补全缺失的 key(如 model_type_filter)
+ $defaultMeta = getDefaultMetaConfig($stationType);
+ if (!empty($defaultMeta)) {
+ foreach ($defaultMeta as $k => $v) {
+ if (!array_key_exists($k, $meta)) {
+ $meta[$k] = $v;
+ }
+ }
+ }
+ $result['_meta'] = $meta;
+ return $result;
+ }
+ // 旧格式:纯字段数组 — 附加默认元信息
+ $meta = getDefaultMetaConfig($stationType);
+ if (!empty($meta)) {
+ $config['_meta'] = $meta;
+ }
+ return $config;
+ }
+ }
+ // 回退到默认配置,附加默认元信息
+ $defaultFields = getDefaultFieldsConfig($stationType);
+ $meta = getDefaultMetaConfig($stationType);
+ if (!empty($meta)) {
+ $defaultFields['_meta'] = $meta;
+ }
+ return $defaultFields;
+}
+
+/**
+ * 渲染前端表单输入字段 (根据配置动态生成)
+ * $fieldConfigs: 解析后的字段配置数组
+ * $dataSources: ['types' => [], 'models' => [], 'customers' => []]
+ * $isReadonly: 操作人显示名称
+ */
+function renderStationFields($fieldConfigs, $dataSources = [], $operatorName = '', $excludeFields = []) {
+ $html = '';
+ $enabledFields = array_filter($fieldConfigs, function($f) { return !empty($f['enabled']); });
+
+ // 排除指定字段
+ if (!empty($excludeFields)) {
+ $enabledFields = array_filter($enabledFields, function($f) use ($excludeFields) {
+ return !in_array($f['name'], $excludeFields);
+ });
+ }
+
+ // 分组:扫描字段和其他字段
+ $scanFields = array_filter($enabledFields, function($f) { return !empty($f['is_scan']); });
+ $normalFields = array_filter($enabledFields, function($f) { return empty($f['is_scan']); });
+
+ // 输出所有非扫描字段 + 操作人(CSS Grid 自适应布局)
+ $html .= '';
+
+ foreach ($normalFields as $field) {
+ $html .= '
';
+ $html .= renderSingleField($field, $dataSources);
+ $html .= '
';
+ }
+
+ // 操作人(固定显示)
+ if ($operatorName) {
+ $html .= '
';
+ $html .= '';
+ $html .= '';
+ $html .= '
';
+ }
+
+ $html .= '
';
+
+ // 扫描字段(序列号类)- 单独一行,宽度自适应
+ if (!empty($scanFields)) {
+ $html .= '';
+ foreach ($scanFields as $field) {
+ $html .= '
';
+ $html .= renderSingleField($field, $dataSources, true);
+ $html .= '
';
+ }
+ $html .= '
';
+ }
+
+ return $html;
+}
+
+/**
+ * 渲染单个字段
+ */
+function renderSingleField($field, $dataSources = [], $isScan = false) {
+ $name = htmlspecialchars($field['name']);
+ $label = htmlspecialchars($field['label']);
+ $type = $field['type'] ?? 'text';
+ $required = !empty($field['required']) ? 'required' : '';
+ $placeholder = htmlspecialchars($field['placeholder'] ?? '');
+ $html = '';
+
+ // hidden 类型:只输出隐藏域,不显示标签
+ if ($type === 'hidden') {
+ $fixedValue = htmlspecialchars($field['fixed_value'] ?? '');
+ $html = '';
+ return $html;
+ }
+
+ $html .= '';
+
+ if ($type === 'select') {
+ $source = $field['source'] ?? '';
+ $cssClass = $isScan ? 'form-control scan-submit' : 'form-control sticky-save';
+ $html .= '';
+ } elseif ($type === 'number') {
+ $cssClass = $isScan ? 'form-control scan-submit' : 'form-control sticky-save';
+ $html .= '';
+ } elseif ($type === 'textarea') {
+ $html .= '';
+ } else {
+ // text 类型
+ $cssClass = $isScan ? 'form-control scan-submit' : 'form-control sticky-save';
+ $autofocus = $isScan ? ' autofocus' : '';
+ $html .= '';
+ }
+
+ return $html;
+}
+
+/**
+ * 渲染记录表格表头
+ */
+function renderRecordTableHeader($fieldConfigs) {
+ $html = '序号 | ';
+ $colIdx = 0;
+ foreach ($fieldConfigs as $field) {
+ if (!empty($field['enabled']) && !empty($field['show_in_table'])) {
+ $colKey = htmlspecialchars($field['name']);
+ $html .= '' . htmlspecialchars($field['table_label'] ?? $field['label']) . ' | ';
+ $colIdx++;
+ }
+ }
+ $html .= '操作人 | 时间 | ';
+ return $html;
+}
+
+/**
+ * 渲染记录表格数据行
+ */
+function renderRecordTableRow($record, $fieldConfigs, $specialFormats = []) {
+ $html = '';
+ $html .= '| ' . $record['id'] . ' | ';
+
+ foreach ($fieldConfigs as $field) {
+ if (empty($field['enabled']) || empty($field['show_in_table'])) continue;
+ $name = $field['name'];
+ $val = $record[$name] ?? '';
+ $colKey = htmlspecialchars($name);
+
+ if (isset($specialFormats[$name])) {
+ $html .= '' . $specialFormats[$name]($val, $record) . ' | ';
+ } else {
+ $html .= '' . htmlspecialchars($val) . ' | ';
+ }
+ }
+
+ $html .= '' . htmlspecialchars($record['operator'] ?? '') . ' | ';
+ // 兼容各种时间列名:created_at, create_time, assembly_time, test_time, delivery_time, in_time
+ $timeValue = $record['created_at']
+ ?? $record['create_time']
+ ?? $record['assembly_time']
+ ?? $record['test_time']
+ ?? $record['delivery_time']
+ ?? $record['in_time']
+ ?? '';
+ $html .= '' . htmlspecialchars($timeValue) . ' | ';
+ $html .= '
';
+ return $html;
+}
+
+/**
+ * 渲染表格列选择器(下拉多选按钮)
+ * @param string $pageKey 页面唯一标识,用于 localStorage 区分不同页面
+ * @param array $fieldConfigs 字段配置数组
+ * @return string HTML
+ */
+function renderTableColumnSelector($pageKey, $fieldConfigs) {
+ $cols = [['key' => 'seq', 'label' => '序号']];
+ foreach ($fieldConfigs as $field) {
+ if (!empty($field['enabled']) && !empty($field['show_in_table'])) {
+ $cols[] = [
+ 'key' => $field['name'],
+ 'label'=> $field['table_label'] ?? $field['label'],
+ ];
+ }
+ }
+ $cols[] = ['key' => 'operator', 'label' => '操作人'];
+ $cols[] = ['key' => 'time', 'label' => '时间'];
+
+ $colsJson = json_encode($cols, JSON_UNESCAPED_UNICODE);
+ $pageKeyJson = json_encode($pageKey);
+
+ $html = '';
+ $html .= '';
+ $html .= '';
+ $html .= '';
+ $html .= '
';
+ foreach ($cols as $col) {
+ $html .= '';
+ }
+ $html .= '
';
+ $html .= '
';
+ $html .= '';
+
+ // 内联初始化脚本(在元素渲染后立即执行)
+ $html .= '';
+
+ return $html;
+}
+
+/**
+ * 生成字段配置编辑UI的JSON字符串(供JS使用)
+ */
+function renderFieldConfigEditor($stationType, $fieldsConfigJson = '', $stationDefinition = null) {
+ $rawConfig = parseFieldsConfig($stationType, $fieldsConfigJson);
+ // 分离 _meta 和 fields
+ $meta = $rawConfig['_meta'] ?? [];
+ $fields = $rawConfig;
+ unset($fields['_meta']);
+ $fields = array_values($fields);
+
+ $fieldsJson = json_encode($fields, JSON_UNESCAPED_UNICODE);
+ $metaJson = json_encode($meta, JSON_UNESCAPED_UNICODE);
+ $stationTypeJson = json_encode($stationType);
+
+ // 工位定义元数据(用于数据表结构管控)
+ $stationDef = $stationDefinition ?: [];
+ $hasProductType = !empty($stationDef['has_product_type']);
+ $hasProductModel = !empty($stationDef['has_product_model']);
+ $filterEnabled = !empty($stationDef['filter_enabled']);
+ $showTodayCount = !empty($stationDef['show_today_count']);
+ $showTotalCount = !empty($stationDef['show_total_count']);
+ $defId = $stationDef['id'] ?? 0;
+
+ // 产品类型选项(用于型号筛选下拉)
+ $productTypeOptions = json_encode(['', '半成品', '成品', 'PCBA', '电池', '外壳', '外壳颜色'], JSON_UNESCAPED_UNICODE);
+
+ ob_start();
+ ?>
+
+
输入数据项配置
+
+
+
+
+
+
+
数据表结构管控
+ 工位定义 ID:
+
+
+
+
+
+
+
数据筛选设置
+
+
+
+
+ 选择后产品型号下拉只显示该类型
+
+
+
+ 控制"类型"下拉框显示哪些选项
+
+
+
+
组装工位使用,加载外壳颜色选项
+
+
+
+
+
+
+
+
+
+
+
+ 为扫描字段(is_scan=true)指定对应的产品类型,提交时将自动验证序列号前缀是否正确。
+ 选择「动态匹配」则根据提交时选择的 product_type 自动匹配前缀。
+
+
+
+
+
+ | 扫描字段 |
+ 对应产品类型 |
+ 预期前缀 |
+
+
+
+
+
+
+
+ 提示:留空则自动从工位的 product_type 字段推断(如有 hidden 类型的 product_type 则使用其 fixed_value,否则动态匹配)
+
+
+
+
+
+
+
+
+ | 启用 |
+ 字段名 |
+ 显示标签 |
+ 类型 |
+ 数据源 |
+ 必填 |
+ 唯一 |
+ 占位提示 |
+ 列宽 |
+ 扫码 |
+ 表中 |
+ 表头标签 |
+ 操作 |
+
+
+
+
+
+
+
勾选「扫码」的字段会以扫码枪回车提交方式处理。勾选「表中」会在记录表格中显示该列。
+
+
+
+
+ function initScanChain() {
+ var $form = $('');
+ if (!$form.length) return;
+
+ // 找到表单内所有输入元素(按DOM顺序)
+ function getInputsInOrder() {
+ var inputs = [];
+ $form.find('input:not([disabled]):not([type="hidden"]):not(:submit):not(:button), select:not([disabled]), textarea:not([disabled])').each(function() {
+ var $el = $(this);
+ if ($el.closest('#fieldConfigEditor').length) return; // 跳过配置编辑器
+ inputs.push(this);
+ });
+ // 按tabindex排序(如果有的话),否则按DOM顺序
+ inputs.sort(function(a, b) {
+ var ta = parseInt($(a).attr('tabindex')) || 0;
+ var tb = parseInt($(b).attr('tabindex')) || 0;
+ if (ta && tb) return ta - tb;
+ if (ta) return -1;
+ if (tb) return 1;
+ return 0;
+ });
+ return inputs;
+ }
+
+ // 检查是否所有扫描字段都已填值
+ function allScanFieldsFilled() {
+ var allFilled = true;
+ $form.find('.scan-submit').each(function() {
+ if ($(this).val().trim() === '') allFilled = false;
+ });
+ return allFilled;
+ }
+
+ function doSubmit() {
+
+ $('').click();
+
+ $form.submit();
+
+ }
+
+ // 回车事件:扫描字段 → 跳到下一个空位;最后一位 → 提交
+ $form.on('keypress', '.scan-submit', function(e) {
+ if (e.which !== 13) return;
+ e.preventDefault();
+ var $this = $(this);
+ var val = $this.val().trim();
+
+ if (val === '') return; // 空值不处理
+
+ // 找到所有扫描字段
+ var scanInputs = [];
+ $form.find('.scan-submit').each(function() { scanInputs.push(this); });
+
+ // 如果所有扫描字段都填满了 → 提交
+ if (allScanFieldsFilled()) {
+ doSubmit();
+ return;
+ }
+
+ // 找到下一个空的扫描字段
+ var found = false;
+ for (var i = 0; i < scanInputs.length; i++) {
+ if (scanInputs[i] === this) {
+ // 从当前之后找第一个空的
+ for (var j = 1; j < scanInputs.length; j++) {
+ var idx = (i + j) % scanInputs.length;
+ if ($(scanInputs[idx]).val().trim() === '') {
+ $(scanInputs[idx]).focus().select();
+ found = true;
+ break;
+ }
+ }
+ break;
+ }
+ }
+ if (!found) {
+ // 没找到空的,提交
+ doSubmit();
+ }
+ });
+
+ // 非扫描字段的回车 → 跳到下一个输入框
+ $form.on('keypress', 'input:not(.scan-submit):not([type="hidden"]):not(:submit):not(:button), select, textarea', function(e) {
+ if (e.which !== 13) return;
+ var $el = $(this);
+ if ($el.closest('#fieldConfigEditor').length) return;
+
+ e.preventDefault();
+ var inputs = getInputsInOrder();
+ var idx = inputs.indexOf(this);
+ if (idx >= 0 && idx < inputs.length - 1) {
+ $(inputs[idx + 1]).focus().select();
+ }
+ });
+
+ // 提交后清空扫描字段(重新聚焦第一个)
+ $form.on('submit', function() {
+
+ setTimeout(function() {
+ var $firstScan = $form.find('.scan-submit').first();
+ if ($firstScan.length) $firstScan.focus();
+ }, 100);
+
+ });
+ }
+
+ $(function() { initScanChain(); });
+ [
+ * '字段名' => '产品类型', // 如 'serial_no' => 'PCBA'
+ * ]
+ *
+ * 运行时从 sys_config.code_prefix_rules 读取产品类型对应的前缀,
+ * 自动生成 codePrefix 验证规则。如果该产品类型未配置前缀(空字符串),则跳过验证。
+ *
+ * @param array|null $fieldMappings 字段到产品类型的映射
+ * @return array 格式化后的 codePrefix 规则
+ */
+function buildCodePrefixRules($fieldMappings, $prefixTypeMap = null) {
+ if (empty($fieldMappings)) return [];
+
+ // 如果未传入前缀映射,从 SysConfig 读取(兼容旧用法)
+ if ($prefixTypeMap === null) {
+ try {
+ $config = new \app\models\SysConfig();
+ $prefixTypeMap = $config->getCodePrefixRules();
+ } catch (\Throwable $e) {
+ error_log('[buildCodePrefixRules] getCodePrefixRules failed: ' . $e->getMessage());
+ $prefixTypeMap = [];
+ }
+ }
+ if (!is_array($prefixTypeMap)) $prefixTypeMap = [];
+
+ $rules = [];
+ foreach ($fieldMappings as $fieldName => $productType) {
+ // 动态前缀:*dynamic* 标记表示根据提交时的 product_type 动态匹配
+ if ($productType === '*dynamic*') {
+ $rules[$fieldName] = ['*dynamic*', '序列号前缀与产品类型不匹配'];
+ continue;
+ }
+ $prefix = $prefixTypeMap[$productType] ?? '';
+ // 原则:所有工位序列号都受前缀管控。类型已配置但前缀为空时,
+ // 自动回退为产品类型名拼音首字母(如 外壳→WK、包装→BZ、半成品→BCP、配件→PJ)。
+ if ($prefix === '') {
+ try {
+ $sc = new \app\models\SysConfig();
+ $prefix = $sc->pinyinInitials($productType);
+ } catch (\Throwable $e) {
+ error_log('[buildCodePrefixRules] pinyinInitials failed: ' . $e->getMessage());
+ $prefix = '';
+ }
+ }
+ if ($prefix === '') continue; // 实在无法解析(如类型根本未配置)才跳过
+
+ $rules[$fieldName] = [$prefix, "{$productType}序列号必须以 {$prefix} 开头"];
+ }
+ return $rules;
+}
+
+/**
+ * 获取指定工位的业务配置
+ * @param string $stationType 工位类型标识
+ * @return array|null 业务配置数组,null 表示使用通用 station_records 存储
+ */
+function getStationBusinessConfig($stationType) {
+ // ========== 所有工位统一走自动生成路径 ==========
+ //
+ // 之前的内置工位配置(inbound, pcb_test, battery, assembly, finished_test,
+ // delivery, warehouse)已全部移除。
+ //
+ // 现在所有工位(包括内置和动态)都通过 FrontController::buildGenericBizConfig()
+ // 自动生成业务配置,确保与 aging/semi_finished 等已验证正常的工位使用完全相同的代码路径。
+ //
+ // 特殊业务逻辑(如 delivery 的箱序列号、inbound 的成品入库切换等)
+ // 将逐步在 FrontController::handleBuiltinSubmit() 中按 stationType 判断实现。
+ //
+ // 如需恢复某个工位的特殊配置,请以 aging/semi_finished 的自动生成配置为基准,
+ // 在此基础上增量添加,避免重写整个配置导致不一致。
+
+ $configs = [
+ // ========== 成品入库工位(已合并到入库) ==========
+ // warehouse 重定向到 inbound,保留此逻辑
+ 'warehouse' => [
+ 'model' => 'StationDefinition',
+ 'redirect' => 'inbound',
+ ],
+ ];
+
+ if (!isset($configs[$stationType])) {
+ return null;
+ }
+
+ $bizConfig = $configs[$stationType];
+
+ // 动态构建 codePrefix:从 codePrefixFields 映射到实际前缀规则
+ if (!empty($bizConfig['codePrefixFields'])) {
+ $bizConfig['codePrefix'] = buildCodePrefixRules($bizConfig['codePrefixFields']);
+ }
+
+ return $bizConfig;
+}
+
+/**
+ * 获取工位的测试状态颜色映射(用于 labelFields 动态颜色)
+ */
+function getTestStatusColor($status) {
+ $map = [
+ 'passed' => 'success',
+ 'failed' => 'danger',
+ 'pending' => 'warning',
+ '通过' => 'success',
+ '失败' => 'danger',
+ '待测试' => 'warning',
+ ];
+ return $map[$status] ?? 'default';
+}
diff --git a/app/helpers/station_template_helper.php b/app/helpers/station_template_helper.php
new file mode 100644
index 0000000..0f73e23
--- /dev/null
+++ b/app/helpers/station_template_helper.php
@@ -0,0 +1,1242 @@
+[], 'models'=>[], 'customers'=>[]],默认空
+ * showOperator bool 是否在表单中显示操作人,默认 true
+ * excludeFields array 排除的字段名列表(不在表单中渲染),默认空
+ *
+ * 提交方式:
+ * submitMode string 'auto'(扫描字段回车直接提交)| 'button'(需点击按钮),默认 'auto'
+ * submitBtn array 提交按钮配置 ['text'=>'提交', 'icon'=>'fa-check', 'class'=>'btn-success']
+ * extraBtns array 额外按钮列表,每个元素: ['id'=>'btnId', 'text'=>'...', 'icon'=>'fa-...', 'class'=>'btn-info']
+ * 注意:为符合 CSP(无 unsafe-inline),额外按钮不再支持内联 onclick,
+ * 请在 customInit/customJs 中按 id 绑定事件(如 $('#btnId').on('click', ...))。
+ * reloadAfterSubmit bool 提交后是否刷新页面,默认 false(只清空扫描字段)
+ *
+ * 表格显示:
+ * codeFields array 需要用 样式显示的字段名列表,默认 ['serial_no']
+ * labelFields array 需要用 label 样式显示的字段名列表,格式: ['field_name' => 'label-success|label-warning|label-danger|label-info']
+ * showRecordBadge bool 是否显示"共 N 条"徽章,默认 true
+ * emptyText string 空记录提示文字,默认"暂无记录"
+ *
+ * 特殊业务:
+ * customHeader string 额外的卡片头部 HTML(如筛选按钮、导出按钮),默认空
+ * customBeforeForm string 在 renderStationFields 之前插入的 HTML(如固定产品类型字段),默认空
+ * customInit string 额外的 $(function(){}) 初始化 JS 代码,默认空
+ * customJs string 完全自定义的额外 JS 代码(放在 script 标签末尾),默认空
+ * extraCard string 额外的卡片 HTML(插入在记录表格之后),默认空
+ *
+ * 筛选联动(仅 submitMode='auto' 时可用):
+ * enableFilter bool 是否启用产品类型→型号联动筛选,默认 false
+ * filterUrl string 筛选记录加载的 URL(相对于 BASE_URL),如 '/Front/inbound'
+ *
+ * 内置工位扩展:
+ * bizConfig array 来自 station_business_helper 的业务配置(自动注入)
+ */
+function renderStationTemplate($config) {
+ // ========== 从 GLOBALS 获取业务配置 ==========
+ $bizConfig = $GLOBALS['bizConfig'] ?? null;
+ $stationType = $GLOBALS['stationType'] ?? ($config['stationKey'] ?? '');
+
+ // 如果有 bizConfig,用它增强基础 config
+ if ($bizConfig && !empty($bizConfig['view'])) {
+ $config = array_merge($config, $bizConfig['view']);
+ }
+
+ // ========== 默认值合并 ==========
+ $defaults = [
+ 'cardTitle' => '快速录入',
+ 'tableTitle' => '操作记录',
+ 'dataSources' => ['types' => [], 'models' => [], 'customers' => []],
+ 'showOperator' => true,
+ 'excludeFields' => [],
+ 'submitMode' => 'auto',
+ 'submitBtn' => ['text' => '确认提交', 'icon' => 'fa-check', 'class' => 'btn-success'],
+ 'extraBtns' => [],
+ 'reloadAfterSubmit'=> false,
+ 'codeFields' => ['serial_no'],
+ 'labelFields' => [],
+ 'showRecordBadge' => true,
+ 'emptyText' => '暂无记录',
+ 'customHeader' => '',
+ 'customBeforeForm' => '',
+ 'customInit' => '',
+ 'customJs' => '',
+ 'extraCard' => '',
+ 'enableFilter' => false,
+ 'filterUrl' => '',
+ ];
+
+ // 获取外部变量
+ $activeStation = $stationType;
+ $sysConfig = $GLOBALS['sysConfig'] ?? null;
+ $user = $GLOBALS['user'] ?? [];
+ $fieldConfigs = $GLOBALS['fieldConfigs'] ?? [];
+ $records = $GLOBALS['records'] ?? [];
+ $types = $GLOBALS['types'] ?? [];
+ $models = $GLOBALS['models'] ?? [];
+ $customers = $GLOBALS['customers'] ?? [];
+ $filterType = $GLOBALS['filterType'] ?? '';
+ $filterModel = $GLOBALS['filterModel'] ?? '';
+ $todayCount = $GLOBALS['todayCount'] ?? 0;
+ $totalCount = $GLOBALS['totalCount'] ?? count($records);
+ $shellColors = $GLOBALS['shellColors'] ?? [];
+ $stationError = $GLOBALS['stationError'] ?? '';
+
+ // 预计算前缀规则(避免在 JS 函数模板中重复创建 SysConfig 对象)
+ $prefixTypeMap = [];
+ if ($sysConfig && is_array($sysConfig)) {
+ try {
+ $sysConfigModel = new \app\models\SysConfig();
+ $prefixTypeMap = $sysConfigModel->getCodePrefixRules();
+ } catch (\Throwable $e) {
+ error_log('[renderStationTemplate] getCodePrefixRules failed: ' . $e->getMessage());
+ $prefixTypeMap = [];
+ }
+ }
+ $codePrefixRulesJson = json_encode($prefixTypeMap, JSON_UNESCAPED_UNICODE);
+
+ // 前端拼音首字母回退(与后端 SysConfig::pinyinInitials 保持一致),
+ // 用于动态前缀类型在后台未填前缀时,仍能按类型名拼音首字母校验。
+ $pinyinMapJson = json_encode([
+ '外壳' => 'WK', '底壳' => 'WK', '包装' => 'BZ', '半成品' => 'BCP', '半成' => 'BCP',
+ '配件' => 'PJ', '成品' => 'CP', '电池' => 'DC', '电芯' => 'DC', 'PCBA' => 'XLB',
+ ], JSON_UNESCAPED_UNICODE);
+
+
+ // 前缀 -> 类型 反向映射(用于扫码自动判断产品类型)。键统一为大写前缀
+ // 注意:SysConfig::getCodePrefixRules() 会对别名(电芯/底壳/半成)做双向自动补全,
+ // 导致同一前缀可能同时对应主类型和别名(如 dc 同时对应 电池 与 电芯)。
+ // 自动判类型必须唯一命中主类型,故此处分两遍构建:主类型优先占用前缀,别名仅填补空缺。
+ $aliasTypes = ['电芯', '底壳', '半成'];
+ $prefixToTypeMap = [];
+ foreach ($prefixTypeMap as $type => $prefix) {
+ if (empty($prefix) || in_array($type, $aliasTypes, true)) continue;
+ $prefixToTypeMap[strtoupper($prefix)] = $type;
+ }
+ foreach ($prefixTypeMap as $type => $prefix) {
+ if (empty($prefix) || !in_array($type, $aliasTypes, true)) continue;
+ $k = strtoupper($prefix);
+ if (!isset($prefixToTypeMap[$k])) {
+ $prefixToTypeMap[$k] = $type;
+ }
+ }
+ $prefixToTypeJson = json_encode($prefixToTypeMap, JSON_UNESCAPED_UNICODE);
+
+ $config = array_merge($defaults, $config);
+
+ // 确保数据源正确
+ $config['dataSources'] = array_merge(
+ ['types' => $types, 'models' => $models, 'customers' => $customers],
+ $config['dataSources']
+ );
+
+ // 外壳颜色注入到数据源
+ if (!empty($shellColors)) {
+ $config['dataSources']['shell_colors'] = $shellColors;
+ }
+
+ $stationKey = $config['stationKey'];
+ $pageTitle = $config['pageTitle'];
+ $pageIcon = $config['pageIcon'];
+ $operatorName = $config['showOperator'] ? ($user['emp_name'] ?? '') : '';
+ $submitMode = $config['submitMode'];
+ $enableFilter = $config['enableFilter'];
+ $filterUrl = $config['filterUrl'];
+
+ $recordCount = count($records);
+
+ // 构建特殊格式化回调
+ $specialFormats = [];
+ foreach ($config['codeFields'] as $fn) {
+ $specialFormats[$fn] = function($val) { return $val ? '' . htmlspecialchars($val) . '' : '-'; };
+ }
+ foreach ($config['labelFields'] as $fn => $cls) {
+ if ($cls === '__dynamic__') {
+ // 动态颜色:根据值自动选择
+ $specialFormats[$fn] = function($val) {
+ if (!$val) return '-';
+ $colorMap = ['passed' => 'success', 'failed' => 'danger', 'pending' => 'warning', '通过' => 'success', '失败' => 'danger', '待测试' => 'warning'];
+ $cls = $colorMap[$val] ?? 'default';
+ return '' . htmlspecialchars($val) . '';
+ };
+ } else {
+ $specialFormats[$fn] = function($val) use ($cls) {
+ return $val ? '' . htmlspecialchars($val) . '' : '-';
+ };
+ }
+ }
+
+ // ========== 输出 HTML(含统一框架) ==========
+ // 安全检查:防止 station_header.php 被重复包含
+ static $headerIncluded = false;
+ if ($headerIncluded) {
+ // 已输出过框架,记录错误但继续(避免双重框架)
+ error_log('[renderStationTemplate] WARNING: station_header already included for ' . $stationKey . ' - possible double render detected');
+ return;
+ }
+ $headerIncluded = true;
+ include APP_PATH . 'app/views/layouts/station_header.php';
+ ?>
+
+
返回工位选择
+
+
+
+
+
+
+
+
+ 0 || $totalCount > 0): ?>
+
+
+ 今日录入: 条
+ | 总共: 条
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 请先选择产品型号,系统将自动加载对应记录
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+where(['device_code = :code'], [':code' => $device_code])->fetch();
+ }
+
+ /**
+ * 检查设备是否存在且启用
+ */
+ public function isActive($device_code)
+ {
+ $device = $this->where([
+ 'device_code = :code',
+ 'status = 1'
+ ], [':code' => $device_code])->fetch();
+ return $device ? $device : false;
+ }
+
+ /**
+ * 根据设备编码和组别校验
+ */
+ public function validate($device_code, $group_name = '')
+ {
+ $conditions = ['device_code = :code', 'status = 1'];
+ $params = [':code' => $device_code];
+ if (!empty($group_name)) {
+ $conditions[] = 'group_name = :group';
+ $params[':group'] = $group_name;
+ }
+ return $this->where($conditions, $params)->fetch();
+ }
+
+ /**
+ * 获取所有启用设备
+ */
+ public function getAllActive()
+ {
+ return $this->where(['status = 1'])->order(['group_name ASC', 'device_code ASC'])->fetchAll();
+ }
+
+ /**
+ * 添加设备
+ */
+ public function addDevice($data)
+ {
+ return $this->add($data);
+ }
+
+ /**
+ * 更新设备
+ */
+ public function updateDevice($id, $data)
+ {
+ return $this->where(['id = :id'], [':id' => $id])->update($data);
+ }
+
+ /**
+ * 根据 IP 查找设备
+ */
+ public function findByIp($device_ip)
+ {
+ return $this->where(['device_ip = :ip'], [':ip' => $device_ip])->fetch();
+ }
+
+ /**
+ * 根据 ID 查找设备
+ */
+ public function findById($id)
+ {
+ return $this->where(['id = :id'], [':id' => intval($id)])->fetch();
+ }
+
+ /**
+ * 获取全部设备(调试页用,含禁用设备)
+ */
+ public function getAll()
+ {
+ return $this->order(['group_name ASC', 'device_code ASC'])->fetchAll();
+ }
+}
diff --git a/app/models/AsdTestData.php b/app/models/AsdTestData.php
new file mode 100644
index 0000000..3b0d34a
--- /dev/null
+++ b/app/models/AsdTestData.php
@@ -0,0 +1,149 @@
+db()->prepare($sql);
+ $stmt->execute($params);
+ return $stmt->fetchAll(\PDO::FETCH_ASSOC);
+ }
+
+ /**
+ * 新增测试记录主表,返回插入ID
+ *
+ * 兼容接口字段名(v1.5)与数据库实际列名的差异:
+ * status -> test_result (整体测试结果 PASS/NG)
+ * device -> device_code (设备序列号)
+ * test_time -> test_date (测试时间)
+ * project_name -> product_model (产品/项目名称)
+ * 其余接口字段(tester/mo/site/station/work_order/group_name/running_time)
+ * 已通过迁移脚本 upgrade_asd_api_20260718.sql 直接补齐为独立列。
+ */
+ public function addRecord($data)
+ {
+ $map = [
+ 'status' => 'test_result',
+ 'device' => 'device_code',
+ 'test_time' => 'test_date',
+ 'project_name' => 'product_model',
+ ];
+ foreach ($map as $from => $to) {
+ if (array_key_exists($from, $data)) {
+ // 仅当目标列尚未显式传入时才用接口值填充,避免覆盖
+ if (!array_key_exists($to, $data) || $data[$to] === '' || $data[$to] === null) {
+ $data[$to] = $data[$from];
+ }
+ unset($data[$from]);
+ }
+ }
+
+ $result = $this->add($data);
+ if ($result) {
+ return $this->db()->lastInsertId();
+ }
+ return false;
+ }
+
+ /**
+ * 根据ID获取测试记录
+ */
+ public function findById($id)
+ {
+ return $this->where(['id = :id'], [':id' => $id])->fetch();
+ }
+
+ /**
+ * 新增测试工步详情
+ */
+ public function addDetail($data)
+ {
+ $sql = "INSERT INTO DGZXY_asd_test_detail
+ (record_id, seq, test_name, test_item, test_units, data_value, lower_limit, upper_limit, test_value, test_limit, test_result)
+ VALUES
+ (:record_id, :seq, :test_name, :test_item, :test_units, :data_value, :lower_limit, :upper_limit, :test_value, :test_limit, :test_result)";
+ $stmt = $this->db()->prepare($sql);
+ return $stmt->execute([
+ ':record_id' => $data['record_id'],
+ ':seq' => $data['seq'] ?? 1,
+ ':test_name' => $data['test_name'] ?? '',
+ ':test_item' => $data['test_item'] ?? '',
+ ':test_units' => $data['test_units'] ?? '',
+ ':data_value' => $data['data_value'] ?? null,
+ ':lower_limit' => $data['lower_limit'] ?? null,
+ ':upper_limit' => $data['upper_limit'] ?? null,
+ ':test_value' => $data['test_value'] ?? '',
+ ':test_limit' => $data['test_limit'] ?? '',
+ ':test_result' => $data['test_result'] ?? 'PASS',
+ ]);
+ }
+
+ /**
+ * 根据记录ID获取所有工步详情
+ */
+ public function getDetails($recordId)
+ {
+ $stmt = $this->db()->prepare("SELECT * FROM DGZXY_asd_test_detail WHERE record_id = :rid ORDER BY seq ASC");
+ $stmt->execute([':rid' => $recordId]);
+ return $stmt->fetchAll(\PDO::FETCH_ASSOC);
+ }
+
+ /**
+ * 删除测试记录(级联删除详情)
+ */
+ public function deleteRecord($id)
+ {
+ // 先删详情(FOREIGN KEY ON DELETE CASCADE 会自动处理,这里显式处理更安全)
+ $this->db()->prepare("DELETE FROM DGZXY_asd_test_detail WHERE record_id = :rid")->execute([':rid' => $id]);
+ // 再删主表
+ return $this->delete($id);
+ }
+
+ /**
+ * 统计记录数
+ */
+ public function countRecords($keyword = '')
+ {
+ if (!empty($keyword)) {
+ $stmt = $this->db()->prepare("SELECT COUNT(*) AS cnt FROM DGZXY_asd_test_record WHERE qr_code LIKE :kw");
+ $stmt->execute([':kw' => '%' . $keyword . '%']);
+ return $stmt->fetch(\PDO::FETCH_ASSOC)['cnt'];
+ }
+ $stmt = $this->db()->prepare("SELECT COUNT(*) AS cnt FROM DGZXY_asd_test_record");
+ $stmt->execute();
+ return $stmt->fetch(\PDO::FETCH_ASSOC)['cnt'];
+ }
+}
diff --git a/app/models/Assembly.php b/app/models/Assembly.php
new file mode 100644
index 0000000..6e03e50
--- /dev/null
+++ b/app/models/Assembly.php
@@ -0,0 +1,68 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ public function addRecord($data)
+ {
+ return $this->add($data);
+ }
+
+ public function findByFinishedSerial($finished_serial)
+ {
+ return $this->where(['finished_serial = :serial'], [':serial' => $finished_serial])->fetch();
+ }
+
+ // 根据产品型号筛选记录(assembly 表无 product_type 列)
+ public function getByTypeAndModel($product_type, $product_model)
+ {
+ if (empty($product_model)) {
+ return [];
+ }
+ return $this->where(['product_model = :pm'], [':pm' => $product_model])->order(['id DESC'])->fetchAll();
+ }
+
+ // 统计当日某操作员的录入数量
+ public function countTodayByOperator($operator)
+ {
+ $sql = "SELECT COUNT(*) as cnt FROM " . $this->getTable() . " WHERE operator = :op AND DATE(assembly_time) = CURDATE()";
+ $result = $this->query($sql, [':op' => $operator]);
+ return $result[0]['cnt'] ?? 0;
+ }
+
+ // 统计总记录数(可按型号筛选)
+ public function countTotal($product_type = '', $product_model = '')
+ {
+ if (!empty($product_model)) {
+ $result = $this->where(['product_model = :pm'], [':pm' => $product_model])->field('COUNT(*) as cnt')->fetch();
+ } else {
+ $sql = "SELECT COUNT(*) as cnt FROM " . $this->getTable();
+ $result = $this->query($sql);
+ }
+ return $result[0]['cnt'] ?? 0;
+ }
+
+ public function batchImport($records)
+ {
+ foreach ($records as $record) {
+ $this->add($record);
+ }
+ return true;
+ }
+}
diff --git a/app/models/Asset.php b/app/models/Asset.php
new file mode 100644
index 0000000..293efd4
--- /dev/null
+++ b/app/models/Asset.php
@@ -0,0 +1,35 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ public function getById($id)
+ {
+ return $this->where(['id = :id'], [':id' => $id])->fetch();
+ }
+
+ public function getCount()
+ {
+ $sql = 'SELECT COUNT(*) AS cnt, SUM(purchase_price * quantity) AS total_value FROM ' . $this->getTable();
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute();
+ return $stmt->fetch();
+ }
+
+ public function getByStatus($status)
+ {
+ return $this->where(['status = :st'], [':st' => $status])->order(['id DESC'])->fetchAll();
+ }
+}
diff --git a/app/models/Battery.php b/app/models/Battery.php
new file mode 100644
index 0000000..12fa282
--- /dev/null
+++ b/app/models/Battery.php
@@ -0,0 +1,88 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ public function addRecord($data)
+ {
+ return $this->add($data);
+ }
+
+ public function findBySerial($serial_no)
+ {
+ return $this->where(['serial_no = :serial'], [':serial' => $serial_no])->fetch();
+ }
+
+ // 根据产品类型和型号筛选记录
+ public function getByTypeAndModel($product_type, $product_model)
+ {
+ if (empty($product_type) && empty($product_model)) {
+ return [];
+ }
+ $conditions = [];
+ $params = [];
+ if (!empty($product_type)) {
+ $conditions[] = 'product_type = :pt';
+ $params[':pt'] = $product_type;
+ }
+ if (!empty($product_model)) {
+ $conditions[] = 'product_model = :pm';
+ $params[':pm'] = $product_model;
+ }
+ return $this->where($conditions, $params)->order(['id DESC'])->fetchAll();
+ }
+
+ // 统计当日某操作员的录入数量
+ public function countTodayByOperator($operator)
+ {
+ $sql = "SELECT COUNT(*) as cnt FROM " . $this->getTable() . " WHERE operator = :op AND DATE(created_at) = CURDATE()";
+ $result = $this->query($sql, [':op' => $operator]);
+ return $result[0]['cnt'] ?? 0;
+ }
+
+ // 统计总记录数(可按型号筛选)
+ public function countTotal($product_type = '', $product_model = '')
+ {
+ $conditions = [];
+ $params = [];
+ if (!empty($product_type)) {
+ $conditions[] = 'product_type = :pt';
+ $params[':pt'] = $product_type;
+ }
+ if (!empty($product_model)) {
+ $conditions[] = 'product_model = :pm';
+ $params[':pm'] = $product_model;
+ }
+ if (!empty($conditions)) {
+ $result = $this->where($conditions, $params)->field('COUNT(*) as cnt')->fetch();
+ } else {
+ $sql = "SELECT COUNT(*) as cnt FROM " . $this->getTable();
+ $result = $this->query($sql);
+ }
+ return $result[0]['cnt'] ?? 0;
+ }
+
+ public function batchImport($records)
+ {
+ foreach ($records as $record) {
+ $this->add($record);
+ }
+ return true;
+ }
+}
diff --git a/app/models/Customer.php b/app/models/Customer.php
new file mode 100644
index 0000000..23c68df
--- /dev/null
+++ b/app/models/Customer.php
@@ -0,0 +1,36 @@
+order(['id ASC'])->fetchAll();
+ }
+
+ // 添加客户
+ public function addCustomer($data)
+ {
+ return $this->add($data);
+ }
+
+ // 更新客户
+ public function updateCustomer($id, $data)
+ {
+ return $this->where(['id = :id'], [':id' => $id])->update($data);
+ }
+
+ // 删除客户
+ public function deleteCustomer($id)
+ {
+ return $this->delete($id);
+ }
+}
diff --git a/app/models/Delivery.php b/app/models/Delivery.php
new file mode 100644
index 0000000..9e35352
--- /dev/null
+++ b/app/models/Delivery.php
@@ -0,0 +1,102 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ public function addRecord($data)
+ {
+ return $this->add($data);
+ }
+
+ public function findBySerial($finished_serial)
+ {
+ return $this->where(['finished_serial = :serial'], [':serial' => $finished_serial])->fetch();
+ }
+
+ /**
+ * 批量添加出货记录
+ * @param array $records 记录数组
+ */
+ public function batchAdd($records)
+ {
+ foreach ($records as $record) {
+ // 跳过已出货的序列号
+ $existing = $this->findBySerial($record['finished_serial']);
+ if ($existing) {
+ continue;
+ }
+ $this->add($record);
+ }
+ return true;
+ }
+
+ /**
+ * 从 warehouse_in 表查询成品序列号对应的产品型号
+ * @param string $finished_serial 成品序列号
+ * @return array|null ['product_model' => ..., 'box_serial' => ...] 或 null
+ */
+ public function lookupFinishedSerial($finished_serial)
+ {
+ $table = self::TABLE_PREFIX . 'warehouse_in';
+ $sql = "SELECT finished_serial, product_model, box_serial FROM {$table} WHERE finished_serial = :serial LIMIT 1";
+ $result = $this->query($sql, [':serial' => $finished_serial]);
+ return !empty($result) ? $result[0] : null;
+ }
+
+ // 根据产品型号筛选记录(delivery 表无 product_type 列)
+ public function getByTypeAndModel($product_type, $product_model)
+ {
+ if (empty($product_model)) {
+ return [];
+ }
+ return $this->where(['product_model = :pm'], [':pm' => $product_model])->order(['id DESC'])->fetchAll();
+ }
+
+ // 统计当日某操作员的录入数量
+ public function countTodayByOperator($operator)
+ {
+ $sql = "SELECT COUNT(*) as cnt FROM " . $this->getTable() . " WHERE operator = :op AND DATE(delivery_time) = CURDATE()";
+ $result = $this->query($sql, [':op' => $operator]);
+ return $result[0]['cnt'] ?? 0;
+ }
+
+ // 统计总记录数(可按型号筛选)
+ public function countTotal($product_type = '', $product_model = '')
+ {
+ if (!empty($product_model)) {
+ $result = $this->where(['product_model = :pm'], [':pm' => $product_model])->field('COUNT(*) as cnt')->fetch();
+ } else {
+ $sql = "SELECT COUNT(*) as cnt FROM " . $this->getTable();
+ $result = $this->query($sql);
+ }
+ return $result[0]['cnt'] ?? 0;
+ }
+
+ /**
+ * 根据箱序列号查询该箱下所有成品
+ * @param string $box_serial 箱序列号
+ * @return array 该箱下所有成品记录数组
+ */
+ public function getBoxItems($box_serial)
+ {
+ $table = self::TABLE_PREFIX . 'warehouse_in';
+ $sql = "SELECT finished_serial, product_model FROM {$table} WHERE box_serial = :box ORDER BY id ASC";
+ return $this->query($sql, [':box' => $box_serial]);
+ }
+}
diff --git a/app/models/Document.php b/app/models/Document.php
new file mode 100644
index 0000000..f862a44
--- /dev/null
+++ b/app/models/Document.php
@@ -0,0 +1,43 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ public function getById($id)
+ {
+ return $this->where(['id = :id'], [':id' => $id])->fetch();
+ }
+
+ public function getByCategory($category)
+ {
+ return $this->where(['category = :cat'], [':cat' => $category])->order(['id DESC'])->fetchAll();
+ }
+
+ public function getCount()
+ {
+ $sql = 'SELECT COUNT(*) AS cnt FROM ' . $this->getTable();
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute();
+ $row = $stmt->fetch();
+ return (int)($row['cnt'] ?? 0);
+ }
+
+ public function getCategories()
+ {
+ $sql = 'SELECT DISTINCT category FROM ' . $this->getTable() . " WHERE category != '' ORDER BY category";
+ $stmt = $this->pdo->query($sql);
+ return $stmt->fetchAll();
+ }
+}
diff --git a/app/models/Employee.php b/app/models/Employee.php
new file mode 100644
index 0000000..bf3b0f8
--- /dev/null
+++ b/app/models/Employee.php
@@ -0,0 +1,176 @@
+where(['emp_no = :emp_no'], [':emp_no' => $emp_no])->fetch();
+ }
+
+ /**
+ * 验证登录(支持 bcrypt 和旧版 MD5 兼容)
+ */
+ public function validateLogin($emp_no, $password)
+ {
+ // 先按工号查找用户
+ $user = $this->where(['emp_no = :emp_no'], [':emp_no' => $emp_no])->fetch();
+ if (!$user) {
+ $ua = isset($_SERVER['HTTP_USER_AGENT']) ? substr($_SERVER['HTTP_USER_AGENT'], 0, 150) : 'unknown';
+ $sess = session_id() ? hash('sha256', session_id()) : 'none';
+ error_log("[LOGIN_DIAG] emp_no_len=" . strlen($emp_no) . " emp_no_raw='" . $emp_no . "' found_user=0 ua={$ua} sess_hash={$sess}");
+ return false;
+ }
+
+ // 判断密码是否为 bcrypt 格式(bcrypt 哈希总是以 $2y$ 开头)
+ $isBcrypt = (substr($user['password'], 0, 4) === '$2y$');
+ $pwdFmt = $isBcrypt ? 'bcrypt' : (strlen($user['password']) === 32 ? 'md5' : 'other');
+ $ua = isset($_SERVER['HTTP_USER_AGENT']) ? substr($_SERVER['HTTP_USER_AGENT'], 0, 150) : 'unknown';
+ $sess = session_id() ? hash('sha256', session_id()) : 'none';
+ $pwdLen = strlen($password);
+ error_log("[LOGIN_DIAG] emp_no_len=" . strlen($emp_no) . " emp_no_raw='" . $emp_no . "' found_user=1 pwd_fmt={$pwdFmt} pwd_len={$pwdLen} ua={$ua} sess_hash={$sess}");
+
+ if ($isBcrypt) {
+ if (password_verify($password, $user['password'])) {
+ // 如果算法需要升级,自动更新哈希
+ if (password_needs_rehash($user['password'], PASSWORD_BCRYPT)) {
+ $this->updatePasswordHash($user['id'], $password);
+ }
+ return $user;
+ }
+ error_log("[LOGIN_DIAG] PWD_VERIFY_FAIL pwd_len={$pwdLen} pwd_hash_len=" . strlen($user['password']) . " ua={$ua} sess_hash={$sess}");
+ return false;
+ }
+
+ // 兼容旧版 MD5 密码
+ if (md5($password) === $user['password']) {
+ // 自动升级为 bcrypt
+ $this->updatePasswordHash($user['id'], $password);
+ return $user;
+ }
+
+ return false;
+ }
+
+ /**
+ * 更新密码哈希为 bcrypt
+ */
+ private function updatePasswordHash($id, $password)
+ {
+ $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
+ $this->where(['id = :id'], [':id' => $id])->update(['password' => $hash]);
+ }
+
+ /**
+ * 生成密码哈希
+ */
+ public function hashPassword($password)
+ {
+ return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
+ }
+
+ /**
+ * 密码复杂度校验(P1:强制强密码策略)
+ * 要求:≥12 位,且同时包含大写字母、小写字母、数字、特殊字符
+ * @throws \RuntimeException 不满足时抛出异常
+ */
+ public function validatePasswordStrength($password)
+ {
+ if (!is_string($password) || $password === '') {
+ throw new \RuntimeException('密码不能为空');
+ }
+ if (mb_strlen($password) < 12) {
+ throw new \RuntimeException('密码长度至少 12 位');
+ }
+ if (!preg_match('/[A-Z]/', $password)) {
+ throw new \RuntimeException('密码必须包含至少一个大写字母');
+ }
+ if (!preg_match('/[a-z]/', $password)) {
+ throw new \RuntimeException('密码必须包含至少一个小写字母');
+ }
+ if (!preg_match('/[0-9]/', $password)) {
+ throw new \RuntimeException('密码必须包含至少一个数字');
+ }
+ if (!preg_match('/[^A-Za-z0-9]/', $password)) {
+ throw new \RuntimeException('密码必须包含至少一个特殊字符(如 !@#$%^&*)');
+ }
+ // 拒绝常见弱密码
+ $weak = ['123456', 'password', 'admin123', 'qwerty', '111111', '12345678', 'abc123'];
+ if (in_array(strtolower($password), $weak, true)) {
+ throw new \RuntimeException('密码过于常见,请更换');
+ }
+ }
+
+ // 重置密码
+ public function resetPassword($id, $new_password)
+ {
+ $this->validatePasswordStrength($new_password);
+ $hash = $this->hashPassword($new_password);
+ return $this->where(['id = :id'], [':id' => $id])->update(['password' => $hash]);
+ }
+
+ // 获取所有员工
+ public function getAll()
+ {
+ return $this->order(['id ASC'])->fetchAll();
+ }
+
+ // 添加员工
+ public function addEmployee($data)
+ {
+ if (!isset($data['password']) || empty(trim($data['password']))) {
+ // 生成随机强密码
+ $randomPwd = bin2hex(random_bytes(8)); // 16位随机密码
+ $data['password'] = $this->hashPassword($randomPwd);
+ // 返回生成的密码供管理员告知用户
+ $data['_generated_password'] = $randomPwd;
+ } else {
+ // P1:密码复杂度校验(≥12位,含大小写+数字+特殊字符)
+ $this->validatePasswordStrength($data['password']);
+ $data['password'] = $this->hashPassword($data['password']);
+ }
+ // 移除 _generated_password 再写入
+ $generatedPwd = $data['_generated_password'] ?? null;
+ unset($data['_generated_password']);
+ $id = $this->add($data);
+ if ($id && $generatedPwd) {
+ // 返回生成的密码
+ $this->_lastGeneratedPwd = $generatedPwd;
+ }
+ return $id;
+ }
+
+ // 获取最后生成的密码
+ public function getLastGeneratedPwd()
+ {
+ return $this->_lastGeneratedPwd ?? null;
+ }
+ private $_lastGeneratedPwd = null;
+
+ // 更新员工
+ public function updateEmployee($id, $data)
+ {
+ unset($data['password']); // 不允许直接更新密码
+ return $this->where(['id = :id'], [':id' => $id])->update($data);
+ }
+
+ // 根据类目获取员工列表
+ public function getByCategory($category)
+ {
+ if ($category === 'all') {
+ return $this->order(['id ASC'])->fetchAll();
+ }
+ return $this->where(['category = :cat OR role = :super'], [
+ ':cat' => $category,
+ ':super' => 'super_admin'
+ ])->order(['id ASC'])->fetchAll();
+ }
+}
diff --git a/app/models/EmployeePermission.php b/app/models/EmployeePermission.php
new file mode 100644
index 0000000..726a337
--- /dev/null
+++ b/app/models/EmployeePermission.php
@@ -0,0 +1,93 @@
+ is_allowed 的键值对)
+ */
+ public function getByEmployee($employeeId)
+ {
+ $rows = $this->where(['employee_id = :eid'], [':eid' => $employeeId])->fetchAll();
+ $permissions = [];
+ foreach ($rows as $row) {
+ $permissions[$row['station_type']] = (int)$row['is_allowed'];
+ }
+ return $permissions;
+ }
+
+ /**
+ * 检查员工是否有某个工位的访问权限
+ * 超级管理员和管理员始终返回 true
+ */
+ public function canAccess($employeeId, $role, $stationType)
+ {
+ // 超级管理员和管理员拥有所有工位权限
+ if ($role === \core\base\Controller::ROLE_SUPER_ADMIN || $role === \core\base\Controller::ROLE_ADMIN) {
+ return true;
+ }
+
+ $row = $this->where([
+ 'employee_id = :eid AND station_type = :stype'
+ ], [
+ ':eid' => $employeeId,
+ ':stype' => $stationType
+ ])->fetch();
+
+ // 如果没配置过权限,默认允许访问
+ if (!$row) {
+ return true;
+ }
+
+ return (int)$row['is_allowed'] === 1;
+ }
+
+ /**
+ * 获取某员工允许访问的工位类型列表
+ */
+ public function getAllowedStations($employeeId)
+ {
+ $rows = $this->where([
+ 'employee_id = :eid AND is_allowed = 1'
+ ], [':eid' => $employeeId])->fetchAll();
+
+ return array_column($rows, 'station_type');
+ }
+
+ /**
+ * 批量设置员工工位权限
+ * @param int $employeeId 员工ID
+ * @param array $permissions 格式:['station_type' => 1/0, ...]
+ */
+ public function savePermissions($employeeId, $permissions)
+ {
+ // 先删除旧权限
+ $this->where(['employee_id = :eid'], [':eid' => $employeeId])->deleteWhere();
+
+ // 批量插入新权限
+ foreach ($permissions as $stationType => $isAllowed) {
+ $this->add([
+ 'employee_id' => $employeeId,
+ 'station_type' => $stationType,
+ 'is_allowed' => $isAllowed ? 1 : 0,
+ ]);
+ }
+
+ return true;
+ }
+
+ /**
+ * 删除某员工所有权限记录
+ */
+ public function deleteByEmployee($employeeId)
+ {
+ return $this->where(['employee_id = :eid'], [':eid' => $employeeId])->deleteWhere();
+ }
+}
diff --git a/app/models/FinanceRecord.php b/app/models/FinanceRecord.php
new file mode 100644
index 0000000..d273bc5
--- /dev/null
+++ b/app/models/FinanceRecord.php
@@ -0,0 +1,56 @@
+order(['record_date DESC', 'id DESC'])->fetchAll();
+ }
+
+ public function getById($id)
+ {
+ return $this->where(['id = :id'], [':id' => $id])->fetch();
+ }
+
+ public function getByDateRange($startDate, $endDate, $type = '')
+ {
+ $where = 'record_date >= :start AND record_date <= :end';
+ $params = [':start' => $startDate, ':end' => $endDate];
+ if ($type) {
+ $where .= ' AND type = :type';
+ $params[':type'] = $type;
+ }
+ return $this->where([$where], $params)->order(['record_date DESC', 'id DESC'])->fetchAll();
+ }
+
+ public function getSummary($startDate, $endDate)
+ {
+ $sql = "SELECT
+ type,
+ SUM(amount) AS total,
+ COUNT(*) AS cnt
+ FROM " . $this->getTable() . "
+ WHERE record_date >= :start AND record_date <= :end
+ GROUP BY type";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute([':start' => $startDate, ':end' => $endDate]);
+ return $stmt->fetchAll();
+ }
+
+ public function getCount()
+ {
+ $sql = 'SELECT COUNT(*) AS cnt FROM ' . $this->getTable();
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute();
+ $row = $stmt->fetch();
+ return (int)($row['cnt'] ?? 0);
+ }
+}
diff --git a/app/models/FinishedTest.php b/app/models/FinishedTest.php
new file mode 100644
index 0000000..8be5101
--- /dev/null
+++ b/app/models/FinishedTest.php
@@ -0,0 +1,68 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ public function addRecord($data)
+ {
+ return $this->add($data);
+ }
+
+ public function findBySerial($finished_serial)
+ {
+ return $this->where(['finished_serial = :serial'], [':serial' => $finished_serial])->fetchAll();
+ }
+
+ // 根据产品型号筛选记录(finished_test 表无 product_type 列)
+ public function getByTypeAndModel($product_type, $product_model)
+ {
+ if (empty($product_model)) {
+ return [];
+ }
+ return $this->where(['product_model = :pm'], [':pm' => $product_model])->order(['id DESC'])->fetchAll();
+ }
+
+ // 统计当日某操作员的录入数量
+ public function countTodayByOperator($operator)
+ {
+ $sql = "SELECT COUNT(*) as cnt FROM " . $this->getTable() . " WHERE operator = :op AND DATE(test_time) = CURDATE()";
+ $result = $this->query($sql, [':op' => $operator]);
+ return $result[0]['cnt'] ?? 0;
+ }
+
+ // 统计总记录数(可按型号筛选)
+ public function countTotal($product_type = '', $product_model = '')
+ {
+ if (!empty($product_model)) {
+ $result = $this->where(['product_model = :pm'], [':pm' => $product_model])->field('COUNT(*) as cnt')->fetch();
+ } else {
+ $sql = "SELECT COUNT(*) as cnt FROM " . $this->getTable();
+ $result = $this->query($sql);
+ }
+ return $result[0]['cnt'] ?? 0;
+ }
+
+ public function batchImport($records)
+ {
+ foreach ($records as $record) {
+ $this->add($record);
+ }
+ return true;
+ }
+}
diff --git a/app/models/HrEmployee.php b/app/models/HrEmployee.php
new file mode 100644
index 0000000..81c2f86
--- /dev/null
+++ b/app/models/HrEmployee.php
@@ -0,0 +1,41 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ public function getById($id)
+ {
+ return $this->where(['id = :id'], [':id' => $id])->fetch();
+ }
+
+ public function getByDepartment($department)
+ {
+ return $this->where(['department = :dept'], [':dept' => $department])->order(['id DESC'])->fetchAll();
+ }
+
+ public function getByStatus($status)
+ {
+ return $this->where(['status = :st'], [':st' => $status])->order(['id DESC'])->fetchAll();
+ }
+
+ public function getCount()
+ {
+ $sql = 'SELECT COUNT(*) AS cnt FROM ' . $this->getTable();
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute();
+ $row = $stmt->fetch();
+ return (int)($row['cnt'] ?? 0);
+ }
+}
diff --git a/app/models/Inbound.php b/app/models/Inbound.php
new file mode 100644
index 0000000..9231751
--- /dev/null
+++ b/app/models/Inbound.php
@@ -0,0 +1,94 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ // 根据产品类型和型号筛选记录
+ public function getByTypeAndModel($product_type, $product_model)
+ {
+ if (empty($product_type) && empty($product_model)) {
+ return [];
+ }
+ $conditions = [];
+ $params = [];
+ if (!empty($product_type)) {
+ $conditions[] = 'product_type = :pt';
+ $params[':pt'] = $product_type;
+ }
+ if (!empty($product_model)) {
+ $conditions[] = 'product_model = :pm';
+ $params[':pm'] = $product_model;
+ }
+ return $this->where($conditions, $params)->order(['id DESC'])->fetchAll();
+ }
+
+ // 统计当日某操作员的录入数量
+ public function countTodayByOperator($operator)
+ {
+ $sql = "SELECT COUNT(*) as cnt FROM " . $this->getTable() . " WHERE operator = :op AND DATE(created_at) = CURDATE()";
+ $result = $this->query($sql, [':op' => $operator]);
+ return $result[0]['cnt'] ?? 0;
+ }
+
+ // 统计总记录数(可按型号筛选)
+ public function countTotal($product_type = '', $product_model = '')
+ {
+ $conditions = [];
+ $params = [];
+ if (!empty($product_type)) {
+ $conditions[] = 'product_type = :pt';
+ $params[':pt'] = $product_type;
+ }
+ if (!empty($product_model)) {
+ $conditions[] = 'product_model = :pm';
+ $params[':pm'] = $product_model;
+ }
+ if (!empty($conditions)) {
+ $result = $this->where($conditions, $params)->field('COUNT(*) as cnt')->fetch();
+ } else {
+ $sql = "SELECT COUNT(*) as cnt FROM " . $this->getTable();
+ $result = $this->query($sql);
+ }
+ return $result[0]['cnt'] ?? 0;
+ }
+
+ // 添加记录
+ public function addRecord($data)
+ {
+ return $this->add($data);
+ }
+
+ // 根据序列号查找
+ public function findBySerial($serial_no)
+ {
+ return $this->where(['serial_no = :serial'], [':serial' => $serial_no])->fetch();
+ }
+
+ // 批量导入
+ public function batchImport($records)
+ {
+ foreach ($records as $record) {
+ $this->add($record);
+ }
+ return true;
+ }
+}
diff --git a/app/models/Inventory.php b/app/models/Inventory.php
new file mode 100644
index 0000000..119b900
--- /dev/null
+++ b/app/models/Inventory.php
@@ -0,0 +1,43 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ public function getById($id)
+ {
+ return $this->where(['id = :id'], [':id' => $id])->fetch();
+ }
+
+ public function getLowStock()
+ {
+ return $this->where(['quantity <= min_stock AND min_stock > 0'])->order(['quantity ASC'])->fetchAll();
+ }
+
+ public function getCount()
+ {
+ $sql = 'SELECT COUNT(*) AS cnt, SUM(quantity) AS total_qty FROM ' . $this->getTable();
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute();
+ return $stmt->fetch();
+ }
+
+ public function search($keyword)
+ {
+ return $this->where(
+ ['product_name LIKE :kw OR product_model LIKE :kw2 OR category LIKE :kw3'],
+ [':kw' => "%{$keyword}%", ':kw2' => "%{$keyword}%", ':kw3' => "%{$keyword}%"]
+ )->order(['id DESC'])->fetchAll();
+ }
+}
diff --git a/app/models/PcbTest.php b/app/models/PcbTest.php
new file mode 100644
index 0000000..ba19ef1
--- /dev/null
+++ b/app/models/PcbTest.php
@@ -0,0 +1,88 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ public function addRecord($data)
+ {
+ return $this->add($data);
+ }
+
+ public function findBySerial($serial_no)
+ {
+ return $this->where(['serial_no = :serial'], [':serial' => $serial_no])->fetch();
+ }
+
+ // 根据产品类型和型号筛选记录
+ public function getByTypeAndModel($product_type, $product_model)
+ {
+ if (empty($product_type) && empty($product_model)) {
+ return [];
+ }
+ $conditions = [];
+ $params = [];
+ if (!empty($product_type)) {
+ $conditions[] = 'product_type = :pt';
+ $params[':pt'] = $product_type;
+ }
+ if (!empty($product_model)) {
+ $conditions[] = 'product_model = :pm';
+ $params[':pm'] = $product_model;
+ }
+ return $this->where($conditions, $params)->order(['id DESC'])->fetchAll();
+ }
+
+ // 统计当日某操作员的录入数量
+ public function countTodayByOperator($operator)
+ {
+ $sql = "SELECT COUNT(*) as cnt FROM " . $this->getTable() . " WHERE operator = :op AND DATE(created_at) = CURDATE()";
+ $result = $this->query($sql, [':op' => $operator]);
+ return $result[0]['cnt'] ?? 0;
+ }
+
+ // 统计总记录数(可按型号筛选)
+ public function countTotal($product_type = '', $product_model = '')
+ {
+ $conditions = [];
+ $params = [];
+ if (!empty($product_type)) {
+ $conditions[] = 'product_type = :pt';
+ $params[':pt'] = $product_type;
+ }
+ if (!empty($product_model)) {
+ $conditions[] = 'product_model = :pm';
+ $params[':pm'] = $product_model;
+ }
+ if (!empty($conditions)) {
+ $result = $this->where($conditions, $params)->field('COUNT(*) as cnt')->fetch();
+ } else {
+ $sql = "SELECT COUNT(*) as cnt FROM " . $this->getTable();
+ $result = $this->query($sql);
+ }
+ return $result[0]['cnt'] ?? 0;
+ }
+
+ public function batchImport($records)
+ {
+ foreach ($records as $record) {
+ $this->add($record);
+ }
+ return true;
+ }
+}
diff --git a/app/models/ProductModel.php b/app/models/ProductModel.php
new file mode 100644
index 0000000..f96c0a6
--- /dev/null
+++ b/app/models/ProductModel.php
@@ -0,0 +1,160 @@
+getTable();
+ try {
+ $this->query("SELECT type FROM {$table} LIMIT 1");
+ self::$typeColumnExists = true;
+ self::$migrated = true;
+ } catch (\Throwable $e) {
+ // 列不存在 → 尝试自动迁移
+ try {
+ $this->execute("ALTER TABLE {$table}
+ ADD COLUMN `type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '类型(成品/半成品/配件)'
+ AFTER `id`");
+ $this->execute("UPDATE {$table} SET `type` = `product_type` WHERE `type` = ''");
+ self::$typeColumnExists = true;
+ } catch (\Throwable $migErr) {
+ error_log('[ProductModel] Auto-migration failed: ' . $migErr->getMessage());
+ self::$typeColumnExists = false;
+ }
+ self::$migrated = true;
+ }
+ return self::$typeColumnExists;
+ }
+
+ // 获取所有产品型号
+ public function getAll()
+ {
+ $this->ensureTypeColumn();
+ return $this->order(['id ASC'])->fetchAll();
+ }
+
+ // 添加产品型号
+ public function addModel($data)
+ {
+ if (!$this->ensureTypeColumn()) {
+ unset($data['type']); // type 列不存在,移除该字段
+ }
+ return $this->add($data);
+ }
+
+ // 更新产品型号
+ public function updateModel($id, $data)
+ {
+ if (!$this->ensureTypeColumn()) {
+ unset($data['type']);
+ }
+ return $this->where(['id = :id'], [':id' => $id])->update($data);
+ }
+
+ // 删除产品型号
+ public function deleteModel($id)
+ {
+ return $this->delete($id);
+ }
+
+ // 根据型号代码查找
+ public function findByCode($model_code)
+ {
+ return $this->where(['model_code = :code'], [':code' => $model_code])->fetch();
+ }
+
+ // 根据类型+产品类型和型号代码联合查找(用于检测同类型下是否重复)
+ public function findByTypeAndCode($type, $product_type, $model_code)
+ {
+ if ($this->ensureTypeColumn()) {
+ return $this->where(
+ ['type = :t', 'product_type = :pt', 'model_code = :mc'],
+ [':t' => $type, ':pt' => $product_type, ':mc' => $model_code]
+ )->fetch();
+ }
+ // Fallback: type 列不存在,仅在 product_type 和 model_code 上查重
+ return $this->where(
+ ['product_type = :pt', 'model_code = :mc'],
+ [':pt' => $product_type, ':mc' => $model_code]
+ )->fetch();
+ }
+
+ // 根据类型获取该类型下的所有型号(用于前端联动)
+ public function getByType($type)
+ {
+ if (!$this->ensureTypeColumn()) {
+ return [];
+ }
+ return $this->where(['type = :t'], [':t' => $type])
+ ->order(['model_code ASC'])
+ ->fetchAll();
+ }
+
+ // 获取类型列表(去重)
+ public function getTypes()
+ {
+ if (!$this->ensureTypeColumn()) {
+ return [];
+ }
+ $sql = 'SELECT DISTINCT type FROM ' . $this->getTable() . ' WHERE type != \'\' ORDER BY type';
+ return $this->query($sql);
+ }
+
+ /**
+ * 获取所有产品类型(product_type 去重列表,用于下拉筛选)
+ * 注意:这是 product_type 字段(自由文本),不是 type(成品/半成品/配件)
+ */
+ public function getProductTypes()
+ {
+ $table = $this->getTable();
+ return $this->query(
+ "SELECT DISTINCT product_type FROM {$table} WHERE product_type != '' ORDER BY product_type"
+ );
+ }
+
+ /**
+ * 根据类型获取产品型号
+ * 优先通过 type 字段查询(大分类:成品/半成品/配件),
+ * 如果 type 查不到则通过 product_type 字段查询(子分类:PCBA/电池/外壳等)
+ * 若 type 列不可用,回退到 product_type 字段查询
+ */
+ public function getByProductType($productType)
+ {
+ if ($this->ensureTypeColumn()) {
+ // 先尝试 type 字段
+ $result = $this->getByType($productType);
+ if (!empty($result)) {
+ return $result;
+ }
+ // type 查不到,回退到 product_type 字段
+ return $this->where(['product_type = :pt'], [':pt' => $productType])
+ ->order(['model_code ASC'])
+ ->fetchAll();
+ }
+ // Fallback: type 列不存在,使用 product_type 字段
+ return $this->where(['product_type = :pt'], [':pt' => $productType])
+ ->order(['model_code ASC'])
+ ->fetchAll();
+ }
+}
diff --git a/app/models/ProductTypeConfig.php b/app/models/ProductTypeConfig.php
new file mode 100644
index 0000000..cc9c3d3
--- /dev/null
+++ b/app/models/ProductTypeConfig.php
@@ -0,0 +1,390 @@
+getMessage());
+ return $default;
+ }
+ }
+
+ /**
+ * 获取所有启用的类型(按 sort_order 排序)
+ */
+ public function getEnabled()
+ {
+ return $this->safeQuery(function () {
+ return $this->where(['enabled = 1'])
+ ->order(['sort_order ASC'])
+ ->fetchAll();
+ }, []);
+ }
+
+ /**
+ * 获取所有类型(含未启用,按 sort_order 排序)
+ */
+ public function getAll()
+ {
+ return $this->safeQuery(function () {
+ return $this->order(['sort_order ASC'])->fetchAll();
+ }, []);
+ }
+
+ /**
+ * 根据类型名称获取配置
+ */
+ public function getByTypeName($typeName)
+ {
+ return $this->safeQuery(function () use ($typeName) {
+ return $this->where(['type_name = :name'], [':name' => $typeName])->fetch();
+ }, null);
+ }
+
+ /**
+ * 获取类型名称列表(用于下拉选择)
+ */
+ public function getTypeNames()
+ {
+ $rows = $this->getEnabled();
+ return array_column($rows, 'type_name');
+ }
+
+ /**
+ * 获取入库工位可见的类型(enabled=1 且 show_in_inbound=1)
+ */
+ public function getInboundVisible()
+ {
+ return $this->safeQuery(function () {
+ return $this->where(['enabled = 1', 'show_in_inbound = 1'])
+ ->order(['sort_order ASC'])
+ ->fetchAll();
+ }, []);
+ }
+
+ /**
+ * 获取类型的 display_columns(解析 JSON)
+ */
+ public function getDisplayColumns($typeName)
+ {
+ $config = $this->getByTypeName($typeName);
+ if ($config && !empty($config['display_columns'])) {
+ $columns = json_decode($config['display_columns'], true);
+ if (is_array($columns)) {
+ return $columns;
+ }
+ }
+ // 默认列
+ return [
+ ['key' => 'type', 'label' => '类型'],
+ ['key' => 'model_code', 'label' => '产品型号'],
+ ['key' => 'model_desc', 'label' => '产品说明'],
+ ];
+ }
+
+ /**
+ * 获取所有类型的 display_columns(keyed by type_name)
+ */
+ public function getAllDisplayColumns()
+ {
+ $all = $this->getAll();
+ $result = [];
+ foreach ($all as $row) {
+ $cols = [];
+ if (!empty($row['display_columns'])) {
+ $cols = json_decode($row['display_columns'], true);
+ }
+ if (!is_array($cols)) {
+ $cols = [
+ ['key' => 'type', 'label' => '类型'],
+ ['key' => 'model_code', 'label' => '产品型号'],
+ ['key' => 'model_desc', 'label' => '产品说明'],
+ ];
+ }
+ $result[$row['type_name']] = $cols;
+ }
+ return $result;
+ }
+
+ /**
+ * 添加类型配置(表不存在时自动创建)
+ */
+ public function addConfig($data)
+ {
+ try {
+ return $this->add($data);
+ } catch (\Throwable $e) {
+ $msg = $e->getMessage();
+ error_log('[ProductTypeConfig] addConfig error: ' . $msg);
+ // 表不存在时自动创建
+ if (stripos($msg, 'exist') !== false || stripos($msg, 'not found') !== false || stripos($msg, '1146') !== false) {
+ if ($this->autoCreateTable()) {
+ // 重试添加
+ try {
+ return $this->add($data);
+ } catch (\Throwable $e2) {
+ error_log('[ProductTypeConfig] addConfig retry error: ' . $e2->getMessage());
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+ }
+
+ /**
+ * 更新类型配置
+ */
+ public function updateConfig($id, $data)
+ {
+ try {
+ return $this->where(['id = :id'], [':id' => $id])->update($data);
+ } catch (\Throwable $e) {
+ $msg = $e->getMessage();
+ error_log('[ProductTypeConfig] updateConfig error: ' . $msg);
+ if (stripos($msg, 'exist') !== false || stripos($msg, 'not found') !== false || stripos($msg, '1146') !== false) {
+ if ($this->autoCreateTable()) {
+ try {
+ return $this->where(['id = :id'], [':id' => $id])->update($data);
+ } catch (\Throwable $e2) {
+ error_log('[ProductTypeConfig] updateConfig retry error: ' . $e2->getMessage());
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+ }
+
+ /**
+ * 删除类型配置
+ */
+ public function deleteConfig($id)
+ {
+ try {
+ return $this->delete($id);
+ } catch (\Throwable $e) {
+ $msg = $e->getMessage();
+ error_log('[ProductTypeConfig] deleteConfig error: ' . $msg);
+ if (stripos($msg, 'exist') !== false || stripos($msg, 'not found') !== false || stripos($msg, '1146') !== false) {
+ if ($this->autoCreateTable()) {
+ try {
+ return $this->delete($id);
+ } catch (\Throwable $e2) {
+ error_log('[ProductTypeConfig] deleteConfig retry error: ' . $e2->getMessage());
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+ }
+
+ /**
+ * 自动创建 product_type_config 表并初始化默认数据
+ */
+ public function autoCreateTable()
+ {
+ try {
+ $tableName = $this->getTable();
+ $sql = "CREATE TABLE IF NOT EXISTS `{$tableName}` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `type_name` VARCHAR(50) NOT NULL COMMENT '类型名称',
+ `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序',
+ `display_columns` TEXT COMMENT '列表页额外列(JSON)',
+ `product_type_state` VARCHAR(10) NOT NULL DEFAULT 'editable' COMMENT '产品类型输入框状态:hidden/readonly/editable',
+ `product_type_label` VARCHAR(50) DEFAULT '产品类型' COMMENT '产品类型输入框标签',
+ `product_type_field` VARCHAR(50) DEFAULT 'product_type' COMMENT '提交字段名',
+ `product_type_placeholder` VARCHAR(100) DEFAULT '' COMMENT '输入框占位提示',
+ `model_code_state` VARCHAR(10) NOT NULL DEFAULT 'editable' COMMENT '产品型号输入框状态:hidden/readonly/editable',
+ `color_state` VARCHAR(10) NOT NULL DEFAULT 'editable' COMMENT '颜色输入框状态:hidden/readonly/editable',
+ `color` VARCHAR(30) DEFAULT 'default' COMMENT '列表页box颜色',
+ `enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
+ `show_in_inbound` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '入库页是否显示',
+ `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品类型配置'";
+ $this->pdo->exec($sql);
+
+ // 检查是否已有数据
+ $count = $this->pdo->query("SELECT COUNT(*) FROM `{$tableName}`")->fetchColumn();
+ if ($count == 0) {
+ $defaults = self::getDefaultConfigs();
+ $insertSql = "INSERT INTO `{$tableName}`
+ (`type_name`, `sort_order`, `display_columns`, `product_type_state`,
+ `product_type_label`, `product_type_field`, `product_type_placeholder`,
+ `model_code_state`, `color_state`, `color`, `enabled`, `show_in_inbound`)
+ VALUES (:type_name, :sort_order, :display_columns, :product_type_state,
+ :product_type_label, :product_type_field, :product_type_placeholder,
+ :model_code_state, :color_state, :color, :enabled, :show_in_inbound)";
+ $stmt = $this->pdo->prepare($insertSql);
+ foreach ($defaults as $row) {
+ $stmt->execute([
+ ':type_name' => $row['type_name'],
+ ':sort_order' => $row['sort_order'],
+ ':display_columns' => $row['display_columns'],
+ ':product_type_state' => $row['product_type_state'],
+ ':product_type_label' => $row['product_type_label'],
+ ':product_type_field' => $row['product_type_field'],
+ ':product_type_placeholder' => $row['product_type_placeholder'],
+ ':model_code_state' => $row['model_code_state'] ?? 'editable',
+ ':color_state' => $row['color_state'] ?? 'editable',
+ ':color' => $row['color'],
+ ':enabled' => $row['enabled'],
+ ':show_in_inbound' => $row['show_in_inbound'] ?? 1,
+ ]);
+ }
+ }
+ error_log('[ProductTypeConfig] Table auto-created successfully');
+ return true;
+ } catch (\Throwable $e) {
+ error_log('[ProductTypeConfig] autoCreateTable error: ' . $e->getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * 获取硬编码默认配置(数据库表不存在时的 fallback)
+ */
+ /**
+ * 三态值常量
+ * hidden: 隐藏输入框
+ * readonly: 显示但只读
+ * editable: 可编辑
+ */
+ const STATE_HIDDEN = 'hidden';
+ const STATE_READONLY = 'readonly';
+ const STATE_EDITABLE = 'editable';
+
+ public static function getDefaultConfigs()
+ {
+ return [
+ [
+ 'type_name' => 'PCBA',
+ 'sort_order' => 0,
+ 'display_columns' => '[{"key":"type","label":"类型"},{"key":"model_code","label":"产品型号"},{"key":"model_desc","label":"产品说明"}]',
+ 'product_type_state' => self::STATE_HIDDEN,
+ 'product_type_label' => '产品类型',
+ 'product_type_field' => 'product_type',
+ 'product_type_placeholder' => '',
+ 'model_code_state' => self::STATE_EDITABLE,
+ 'color_state' => self::STATE_HIDDEN,
+ 'color' => 'primary',
+ 'enabled' => 1,
+ 'show_in_inbound' => 0, // PCBA 默认不在入库页显示
+ ],
+ [
+ 'type_name' => '成品',
+ 'sort_order' => 1,
+ 'display_columns' => '[{"key":"type","label":"类型"},{"key":"model_code","label":"产品型号"},{"key":"model_desc","label":"产品说明"}]',
+ 'product_type_state' => self::STATE_HIDDEN,
+ 'product_type_label' => '产品类型',
+ 'product_type_field' => 'product_type',
+ 'product_type_placeholder' => '',
+ 'model_code_state' => self::STATE_EDITABLE,
+ 'color_state' => self::STATE_HIDDEN,
+ 'color' => 'success',
+ 'enabled' => 1,
+ 'show_in_inbound' => 1,
+ ],
+ [
+ 'type_name' => '电池',
+ 'sort_order' => 2,
+ 'display_columns' => '[{"key":"model_code","label":"产品型号"},{"key":"capacity","label":"容量"},{"key":"model_desc","label":"产品说明"}]',
+ 'product_type_state' => self::STATE_EDITABLE,
+ 'product_type_label' => '容量',
+ 'product_type_field' => 'capacity',
+ 'product_type_placeholder' => '如:2000mAh',
+ 'model_code_state' => self::STATE_EDITABLE,
+ 'color_state' => self::STATE_HIDDEN,
+ 'color' => 'primary',
+ 'enabled' => 1,
+ 'show_in_inbound' => 1,
+ ],
+ [
+ 'type_name' => '外壳',
+ 'sort_order' => 3,
+ 'display_columns' => '[{"key":"model_code","label":"产品型号"},{"key":"color","label":"颜色"},{"key":"model_desc","label":"产品说明"}]',
+ 'product_type_state' => self::STATE_HIDDEN,
+ 'product_type_label' => '产品类型',
+ 'product_type_field' => 'product_type',
+ 'product_type_placeholder' => '',
+ 'model_code_state' => self::STATE_EDITABLE,
+ 'color_state' => self::STATE_EDITABLE,
+ 'color' => 'info',
+ 'enabled' => 1,
+ 'show_in_inbound' => 1,
+ ],
+ [
+ 'type_name' => '外壳颜色',
+ 'sort_order' => 4,
+ 'display_columns' => '[{"key":"color","label":"颜色"},{"key":"model_code","label":"产品型号"},{"key":"model_desc","label":"产品说明"}]',
+ 'product_type_state' => self::STATE_EDITABLE,
+ 'product_type_label' => '颜色',
+ 'product_type_field' => 'color',
+ 'product_type_placeholder' => '如:黑色',
+ 'model_code_state' => self::STATE_HIDDEN,
+ 'color_state' => self::STATE_EDITABLE,
+ 'color' => 'default',
+ 'enabled' => 1,
+ 'show_in_inbound' => 1,
+ ],
+ [
+ 'type_name' => '包装',
+ 'sort_order' => 5,
+ 'display_columns' => '[{"key":"model_code","label":"产品型号"},{"key":"model_desc","label":"产品说明"}]',
+ 'product_type_state' => self::STATE_EDITABLE,
+ 'product_type_label' => '产品类型',
+ 'product_type_field' => 'product_type',
+ 'product_type_placeholder' => '如:通用包装',
+ 'model_code_state' => self::STATE_EDITABLE,
+ 'color_state' => self::STATE_HIDDEN,
+ 'color' => 'warning',
+ 'enabled' => 1,
+ 'show_in_inbound' => 1,
+ ],
+ [
+ 'type_name' => '半成品',
+ 'sort_order' => 6,
+ 'display_columns' => '[{"key":"type","label":"类型"},{"key":"model_code","label":"产品型号"},{"key":"model_desc","label":"产品说明"}]',
+ 'product_type_state' => self::STATE_EDITABLE,
+ 'product_type_label' => '产品类型',
+ 'product_type_field' => 'product_type',
+ 'product_type_placeholder' => '如:电池、外壳',
+ 'model_code_state' => self::STATE_EDITABLE,
+ 'color_state' => self::STATE_HIDDEN,
+ 'color' => 'warning',
+ 'enabled' => 1,
+ 'show_in_inbound' => 1,
+ ],
+ [
+ 'type_name' => '配件',
+ 'sort_order' => 7,
+ 'display_columns' => '[{"key":"type","label":"类型"},{"key":"model_code","label":"产品型号"},{"key":"model_desc","label":"产品说明"}]',
+ 'product_type_state' => self::STATE_EDITABLE,
+ 'product_type_label' => '产品类型',
+ 'product_type_field' => 'product_type',
+ 'product_type_placeholder' => '如:电池、外壳',
+ 'model_code_state' => self::STATE_EDITABLE,
+ 'color_state' => self::STATE_HIDDEN,
+ 'color' => 'info',
+ 'enabled' => 1,
+ 'show_in_inbound' => 1,
+ ],
+ ];
+ }
+}
diff --git a/app/models/PurchaseItem.php b/app/models/PurchaseItem.php
new file mode 100644
index 0000000..0ea22de
--- /dev/null
+++ b/app/models/PurchaseItem.php
@@ -0,0 +1,24 @@
+where(['order_id = :oid'], [':oid' => $orderId])->fetchAll();
+ }
+
+ public function deleteByOrder($orderId)
+ {
+ $sql = 'DELETE FROM ' . $this->getTable() . ' WHERE order_id = :oid';
+ $stmt = $this->pdo->prepare($sql);
+ return $stmt->execute([':oid' => $orderId]);
+ }
+}
diff --git a/app/models/PurchaseOrder.php b/app/models/PurchaseOrder.php
new file mode 100644
index 0000000..5da2f1a
--- /dev/null
+++ b/app/models/PurchaseOrder.php
@@ -0,0 +1,36 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ public function getById($id)
+ {
+ return $this->where(['id = :id'], [':id' => $id])->fetch();
+ }
+
+ public function getBySupplier($supplierId)
+ {
+ return $this->where(['supplier_id = :sid'], [':sid' => $supplierId])->order(['id DESC'])->fetchAll();
+ }
+
+ public function getCount()
+ {
+ $sql = 'SELECT COUNT(*) AS cnt FROM ' . $this->getTable();
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute();
+ $row = $stmt->fetch();
+ return (int)($row['cnt'] ?? 0);
+ }
+}
diff --git a/app/models/ReceivablePayable.php b/app/models/ReceivablePayable.php
new file mode 100644
index 0000000..9be21ac
--- /dev/null
+++ b/app/models/ReceivablePayable.php
@@ -0,0 +1,36 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ public function getById($id)
+ {
+ return $this->where(['id = :id'], [':id' => $id])->fetch();
+ }
+
+ public function getByType($type)
+ {
+ return $this->where(['type = :type'], [':type' => $type])->order(['id DESC'])->fetchAll();
+ }
+
+ public function getCount()
+ {
+ $sql = 'SELECT COUNT(*) AS cnt FROM ' . $this->getTable();
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute();
+ $row = $stmt->fetch();
+ return (int)($row['cnt'] ?? 0);
+ }
+}
diff --git a/app/models/SalesItem.php b/app/models/SalesItem.php
new file mode 100644
index 0000000..ed074be
--- /dev/null
+++ b/app/models/SalesItem.php
@@ -0,0 +1,24 @@
+where(['order_id = :oid'], [':oid' => $orderId])->fetchAll();
+ }
+
+ public function deleteByOrder($orderId)
+ {
+ $sql = 'DELETE FROM ' . $this->getTable() . ' WHERE order_id = :oid';
+ $stmt = $this->pdo->prepare($sql);
+ return $stmt->execute([':oid' => $orderId]);
+ }
+}
diff --git a/app/models/SalesOrder.php b/app/models/SalesOrder.php
new file mode 100644
index 0000000..1979ee0
--- /dev/null
+++ b/app/models/SalesOrder.php
@@ -0,0 +1,31 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ public function getById($id)
+ {
+ return $this->where(['id = :id'], [':id' => $id])->fetch();
+ }
+
+ public function getCount()
+ {
+ $sql = 'SELECT COUNT(*) AS cnt FROM ' . $this->getTable();
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute();
+ $row = $stmt->fetch();
+ return (int)($row['cnt'] ?? 0);
+ }
+}
diff --git a/app/models/StationDefinition.php b/app/models/StationDefinition.php
new file mode 100644
index 0000000..ae23e0d
--- /dev/null
+++ b/app/models/StationDefinition.php
@@ -0,0 +1,603 @@
+where(['station_type = :type'], [':type' => $stationType])->fetch();
+ } catch (\Throwable $e) {
+ error_log('[StationDefinition] getByType(' . $stationType . ') failed: ' . $e->getMessage());
+ // 表不存在,自动创建并初始化
+ if ($this->autoCreateTable()) {
+ try {
+ $row = $this->where(['station_type = :type'], [':type' => $stationType])->fetch();
+ } catch (\Throwable $e2) {
+ error_log('[StationDefinition] getByType retry failed: ' . $e2->getMessage());
+ $row = null;
+ }
+ } else {
+ $row = null;
+ }
+ }
+ if ($row && !empty($row['extra_config'])) {
+ $row['extra'] = json_decode($row['extra_config'], true) ?: [];
+ } elseif ($row) {
+ $row['extra'] = [];
+ }
+ self::$cache[$stationType] = $row ?: null;
+ return self::$cache[$stationType];
+ }
+
+ /**
+ * 获取完整表名(带前缀)
+ */
+ public function getFullTableName($stationType)
+ {
+ $def = $this->getByType($stationType);
+ if (!$def) {
+ // 尝试自动重建
+ $this->autoCreateTable();
+ self::clearCache();
+ $def = $this->getByType($stationType);
+ if (!$def) return null;
+ }
+ return self::TABLE_PREFIX . $def['db_table'];
+ }
+
+ /**
+ * 获取时间列名
+ */
+ public function getTimeColumn($stationType)
+ {
+ $def = $this->getByType($stationType);
+ if (!$def) {
+ $this->autoCreateTable();
+ self::clearCache();
+ $def = $this->getByType($stationType);
+ }
+ return $def ? $def['time_column'] : 'created_at';
+ }
+
+ /**
+ * 获取记录列表(支持按类型和型号筛选)
+ *
+ * 如果 station_definitions 记录缺失,自动尝试重建。
+ */
+ public function getRecords($stationType, $filterType = '', $filterModel = '', $limit = 100)
+ {
+ $def = $this->getByType($stationType);
+ if (!$def) {
+ error_log("[StationDefinition] getRecords: no definition for '{$stationType}', auto-initializing...");
+ $this->autoCreateTable();
+ self::clearCache();
+ $def = $this->getByType($stationType);
+ if (!$def) return [];
+ }
+
+ $table = self::TABLE_PREFIX . $def['db_table'];
+ $conditions = [];
+ $params = [];
+
+ if ($def['has_product_type'] && !empty($filterType)) {
+ $conditions[] = 'product_type = :pt';
+ $params[':pt'] = $filterType;
+ }
+ if ($def['has_product_model'] && !empty($filterModel)) {
+ $conditions[] = 'product_model = :pm';
+ $params[':pm'] = $filterModel;
+ }
+
+ $timeCol = $def['time_column'];
+ $sql = "SELECT * FROM `{$table}`";
+ if (!empty($conditions)) {
+ $sql .= ' WHERE ' . implode(' AND ', $conditions);
+ }
+ $sql .= " ORDER BY `{$timeCol}` DESC LIMIT " . intval($limit);
+
+ try {
+ return $this->query($sql, $params);
+ } catch (\Throwable $e) {
+ error_log("[StationDefinition] getRecords query failed for {$stationType}: " . $e->getMessage());
+ // 表可能不存在,尝试创建
+ $fieldConfigs = $GLOBALS['fieldConfigs'] ?? [];
+ if ($this->ensureTable($stationType, $fieldConfigs)) {
+ try {
+ return $this->query($sql, $params);
+ } catch (\Throwable $e2) {
+ error_log("[StationDefinition] getRecords retry failed for {$stationType}: " . $e2->getMessage());
+ }
+ }
+ return [];
+ }
+ }
+
+ /**
+ * 获取所有记录
+ */
+ public function getAllRecords($stationType, $limit = 100)
+ {
+ return $this->getRecords($stationType, '', '', $limit);
+ }
+
+ /**
+ * 统计今日某操作员的录入数量
+ */
+ public function countTodayByOperator($stationType, $operator)
+ {
+ $def = $this->getByType($stationType);
+ if (!$def) {
+ error_log("[StationDefinition] countTodayByOperator: no definition for '{$stationType}', auto-initializing...");
+ $this->autoCreateTable();
+ self::clearCache();
+ $def = $this->getByType($stationType);
+ if (!$def) return 0;
+ }
+
+ $table = self::TABLE_PREFIX . $def['db_table'];
+ $timeCol = $def['time_column'];
+
+ try {
+ $sql = "SELECT COUNT(*) as cnt FROM `{$table}` WHERE operator = :op AND DATE(`{$timeCol}`) = CURDATE()";
+ $result = $this->query($sql, [':op' => $operator]);
+ return $result[0]['cnt'] ?? 0;
+ } catch (\Throwable $e) {
+ error_log("[StationDefinition] countTodayByOperator failed for {$stationType}: " . $e->getMessage());
+ return 0;
+ }
+ }
+
+ /**
+ * 统计总记录数(可按型号筛选)
+ */
+ public function countTotal($stationType, $filterType = '', $filterModel = '')
+ {
+ $def = $this->getByType($stationType);
+ if (!$def) {
+ error_log("[StationDefinition] countTotal: no definition for '{$stationType}', auto-initializing...");
+ $this->autoCreateTable();
+ self::clearCache();
+ $def = $this->getByType($stationType);
+ if (!$def) return 0;
+ }
+
+ $table = self::TABLE_PREFIX . $def['db_table'];
+ $conditions = [];
+ $params = [];
+
+ if ($def['has_product_type'] && !empty($filterType)) {
+ $conditions[] = 'product_type = :pt';
+ $params[':pt'] = $filterType;
+ }
+ if ($def['has_product_model'] && !empty($filterModel)) {
+ $conditions[] = 'product_model = :pm';
+ $params[':pm'] = $filterModel;
+ }
+
+ try {
+ $sql = "SELECT COUNT(*) as cnt FROM `{$table}`";
+ if (!empty($conditions)) {
+ $sql .= ' WHERE ' . implode(' AND ', $conditions);
+ }
+ $result = $this->query($sql, $params);
+ return $result[0]['cnt'] ?? 0;
+ } catch (\Throwable $e) {
+ error_log("[StationDefinition] countTotal failed for {$stationType}: " . $e->getMessage());
+ return 0;
+ }
+ }
+
+ /**
+ * 添加记录到工位专用表
+ *
+ * 如果 station_definitions 中没有该工位记录,自动尝试重建。
+ * 如果数据表不存在,自动创建后再插入。
+ */
+ public function addRecord($stationType, $data)
+ {
+ $def = $this->getByType($stationType);
+ if (!$def) {
+ // 尝试自动重建 station_definitions 数据
+ error_log("[StationDefinition] addRecord: no definition for '{$stationType}', auto-initializing...");
+ $this->autoCreateTable();
+ self::clearCache();
+ $def = $this->getByType($stationType);
+ if (!$def) {
+ error_log("[StationDefinition] addRecord: still no definition for '{$stationType}' after re-init");
+ return false;
+ }
+ }
+
+ $table = self::TABLE_PREFIX . $def['db_table'];
+
+ // 确保数据表存在(不存在则创建)
+ try {
+ $fields = implode('`, `', array_keys($data));
+ $placeholders = ':' . implode(', :', array_keys($data));
+ $sql = "INSERT INTO `{$table}` (`{$fields}`) VALUES ({$placeholders})";
+ return $this->execute($sql, $data);
+ } catch (\Throwable $e) {
+ $msg = $e->getMessage();
+ error_log("[StationDefinition] addRecord failed for {$stationType}: {$msg}");
+ // 表不存在时自动创建
+ if (stripos($msg, 'exist') !== false || stripos($msg, 'not found') !== false || stripos($msg, '1146') !== false) {
+ // 需要 fieldConfigs 来创建表,从全局获取
+ $fieldConfigs = $GLOBALS['fieldConfigs'] ?? [];
+ if ($this->ensureTable($stationType, $fieldConfigs)) {
+ // 重试插入
+ try {
+ return $this->execute($sql, $data);
+ } catch (\Throwable $e2) {
+ error_log("[StationDefinition] addRecord retry failed for {$stationType}: " . $e2->getMessage());
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+ }
+
+ /**
+ * 检查记录是否已存在(防重复)
+ */
+ public function findByField($stationType, $fieldName, $value)
+ {
+ $def = $this->getByType($stationType);
+ if (!$def) {
+ error_log("[StationDefinition] findByField: no definition for '{$stationType}', auto-initializing...");
+ $this->autoCreateTable();
+ self::clearCache();
+ $def = $this->getByType($stationType);
+ if (!$def) return null;
+ }
+
+ $table = self::TABLE_PREFIX . $def['db_table'];
+ try {
+ $sql = "SELECT * FROM `{$table}` WHERE `{$fieldName}` = :val LIMIT 1";
+ $result = $this->query($sql, [':val' => $value]);
+ return $result[0] ?? null;
+ } catch (\Throwable $e) {
+ error_log("[StationDefinition] findByField failed for {$stationType}: " . $e->getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * 检查工位是否启用筛选
+ */
+ public function isFilterEnabled($stationType)
+ {
+ $def = $this->getByType($stationType);
+ return $def ? (bool)$def['filter_enabled'] : false;
+ }
+
+ /**
+ * 检查是否显示今日计数
+ */
+ public function showTodayCount($stationType)
+ {
+ $def = $this->getByType($stationType);
+ return $def ? (bool)$def['show_today_count'] : false;
+ }
+
+ /**
+ * 检查是否显示总计数
+ */
+ public function showTotalCount($stationType)
+ {
+ $def = $this->getByType($stationType);
+ return $def ? (bool)$def['show_total_count'] : false;
+ }
+
+ /**
+ * 确保工位数据表存在,不存在则自动创建
+ *
+ * 通用工位表结构(参考 station_inbound):
+ * id, product_type, product_model, operator, created_at + 动态字段
+ *
+ * @param string $stationType 工位类型标识
+ * @param array $fieldConfigs 字段配置(从 fields_config 解析)
+ * @return bool 表是否已存在/创建成功
+ */
+ public function ensureTable($stationType, $fieldConfigs = [])
+ {
+ // 先尝试获取工位定义,如果失败则尝试重建 station_definitions 表数据
+ $def = $this->getByType($stationType);
+ if (!$def) {
+ // station_definitions 表中没有该工位的记录,尝试重新插入
+ error_log("[StationDefinition] ensureTable: no definition for '{$stationType}', trying to re-initialize built-in data");
+ $this->autoCreateTable();
+ // 清除缓存后重试
+ self::clearCache();
+ $def = $this->getByType($stationType);
+ if (!$def) {
+ error_log("[StationDefinition] ensureTable: still no definition for '{$stationType}' after re-init");
+ return false;
+ }
+ }
+
+ $fullTable = self::TABLE_PREFIX . $def['db_table'];
+
+ // 检查表是否已存在
+ $exists = $this->query("SHOW TABLES LIKE '{$fullTable}'");
+ if (!empty($exists)) return true;
+
+ // 基础列:所有通用工位表都有的字段
+ $columns = [
+ "`id` INT AUTO_INCREMENT PRIMARY KEY",
+ "`product_type` VARCHAR(100) DEFAULT NULL COMMENT '产品类型'",
+ "`product_model` VARCHAR(100) DEFAULT NULL COMMENT '产品型号'",
+ "`operator` VARCHAR(50) DEFAULT NULL COMMENT '操作人'",
+ "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '录入时间'",
+ ];
+
+ // 索引
+ $indexes = [
+ "KEY `idx_product_type` (`product_type`)",
+ "KEY `idx_product_model` (`product_model`)",
+ "KEY `idx_created_at` (`created_at`)",
+ ];
+
+ // 从 fields_config 派生额外字段(排除已存在的基础字段和隐藏字段)
+ $baseFields = ['product_type', 'product_model', 'operator', 'created_at', 'id'];
+ $addedFields = [];
+ foreach ($fieldConfigs as $field) {
+ $name = $field['name'] ?? '';
+ $type = $field['type'] ?? 'text';
+ if (in_array($name, $baseFields) || empty($name)) continue;
+
+ // 跳过纯虚拟字段(没有实际存储意义的)
+ if (in_array($name, ['_meta'])) continue;
+
+ // 根据字段类型确定 MySQL 列类型
+ $colType = 'VARCHAR(200)';
+ if ($type === 'number') {
+ $colType = 'VARCHAR(50)';
+ } elseif ($type === 'textarea') {
+ $colType = 'TEXT';
+ }
+
+ $label = $field['label'] ?? $name;
+ $comment = addslashes($label);
+ $columns[] = "`{$name}` {$colType} DEFAULT NULL COMMENT '{$comment}'";
+
+ // 为序列号类字段添加索引
+ if (!empty($field['is_scan']) || stripos($name, 'serial') !== false) {
+ $indexes[] = "KEY `idx_{$name}` (`{$name}`)";
+ }
+
+ $addedFields[] = $name;
+ }
+
+ // 从 station_business_helper 的 dataMapping 补充可能缺失的列
+ // 确保 helper 文件已加载
+ if (!function_exists('getStationBusinessConfig')) {
+ @include_once APP_PATH . 'app/helpers/station_business_helper.php';
+ }
+ if (function_exists('getStationBusinessConfig')) {
+ $bizConfig = getStationBusinessConfig($stationType);
+ if ($bizConfig && !empty($bizConfig['dataMapping'])) {
+ foreach ($bizConfig['dataMapping'] as $dbField => $postKey) {
+ if ($postKey === '__operator__') continue;
+ if (in_array($dbField, $baseFields) || in_array($dbField, $addedFields)) continue;
+ $columns[] = "`{$dbField}` VARCHAR(200) DEFAULT NULL COMMENT '{$dbField}'";
+ $addedFields[] = $dbField;
+ }
+ }
+ }
+
+ $allColumns = implode(",\n ", $columns);
+ $allIndexes = !empty($indexes) ? ",\n " . implode(",\n ", $indexes) : '';
+
+ $sql = "CREATE TABLE `{$fullTable}` (\n {$allColumns}{$allIndexes}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='{$stationType} 工位数据表'";
+
+ try {
+ $this->execute($sql);
+ error_log("[StationDefinition] Auto-created table: {$fullTable} for station_type={$stationType}, extra fields: " . implode(', ', $addedFields));
+ return true;
+ } catch (\Throwable $e) {
+ error_log("[StationDefinition] Failed to create table {$fullTable}: " . $e->getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * 确保工位列存在(表已存在时补充缺失的列)
+ * 当 fields_config 新增字段后,自动 ALTER TABLE 添加
+ */
+ public function ensureColumns($stationType, $fieldConfigs = [])
+ {
+ $def = $this->getByType($stationType);
+ if (!$def) return;
+
+ $fullTable = self::TABLE_PREFIX . $def['db_table'];
+
+ // 检查表是否存在
+ $exists = $this->query("SHOW TABLES LIKE '{$fullTable}'");
+ if (empty($exists)) {
+ // 表不存在,走建表流程
+ return $this->ensureTable($stationType, $fieldConfigs);
+ }
+
+ // 获取现有列
+ $existingCols = $this->query("SHOW COLUMNS FROM `{$fullTable}`");
+ $existingNames = [];
+ foreach ($existingCols as $col) {
+ $existingNames[] = strtolower($col['Field']);
+ }
+
+ // 基础字段名
+ $baseFields = ['product_type', 'product_model', 'operator', 'created_at', 'id'];
+
+ foreach ($fieldConfigs as $field) {
+ $name = $field['name'] ?? '';
+ $type = $field['type'] ?? 'text';
+ if (in_array($name, $baseFields) || empty($name)) continue;
+ if (in_array($name, ['_meta'])) continue;
+ if (in_array(strtolower($name), $existingNames)) continue;
+
+ $colType = 'VARCHAR(200)';
+ if ($type === 'number') {
+ $colType = 'VARCHAR(50)';
+ } elseif ($type === 'textarea') {
+ $colType = 'TEXT';
+ }
+
+ $label = $field['label'] ?? $name;
+ $comment = addslashes($label);
+
+ try {
+ $this->execute("ALTER TABLE `{$fullTable}` ADD COLUMN `{$name}` {$colType} DEFAULT NULL COMMENT '{$comment}'");
+ error_log("[StationDefinition] Added column `{$name}` to {$fullTable}");
+ } catch (\Throwable $e) {
+ error_log("[StationDefinition] Failed to add column `{$name}` to {$fullTable}: " . $e->getMessage());
+ }
+ }
+ }
+
+ /**
+ * 自动创建 station_definitions 表并初始化内置工位数据
+ * @return bool 是否成功
+ */
+ /**
+ * 获取所有内置工位的默认定义数据
+ */
+ private function getBuiltinDefaults()
+ {
+ return [
+ ['station_type' => 'inbound', 'db_table' => 'station_inbound', 'time_column' => 'created_at', 'has_product_type' => 1, 'has_product_model' => 1, 'filter_enabled' => 1, 'show_today_count' => 1, 'show_total_count' => 1, 'extra_config' => '{"alt_table":"warehouse_in","alt_time_column":"in_time","alt_condition_field":"product_type","alt_condition_value":"成品"}'],
+ ['station_type' => 'pcb_test', 'db_table' => 'pcb_test', 'time_column' => 'created_at', 'has_product_type' => 1, 'has_product_model' => 1, 'filter_enabled' => 1, 'show_today_count' => 1, 'show_total_count' => 1, 'extra_config' => ''],
+ ['station_type' => 'battery', 'db_table' => 'battery_status', 'time_column' => 'created_at', 'has_product_type' => 1, 'has_product_model' => 1, 'filter_enabled' => 1, 'show_today_count' => 1, 'show_total_count' => 1, 'extra_config' => ''],
+ ['station_type' => 'assembly', 'db_table' => 'product_assembly', 'time_column' => 'assembly_time', 'has_product_type' => 0, 'has_product_model' => 1, 'filter_enabled' => 1, 'show_today_count' => 1, 'show_total_count' => 1, 'extra_config' => ''],
+ ['station_type' => 'finished_test','db_table' => 'finished_test', 'time_column' => 'test_time', 'has_product_type' => 0, 'has_product_model' => 1, 'filter_enabled' => 1, 'show_today_count' => 1, 'show_total_count' => 1, 'extra_config' => ''],
+ ['station_type' => 'delivery', 'db_table' => 'delivery', 'time_column' => 'delivery_time', 'has_product_type' => 0, 'has_product_model' => 1, 'filter_enabled' => 1, 'show_today_count' => 1, 'show_total_count' => 1, 'extra_config' => ''],
+ ['station_type' => 'warehouse', 'db_table' => 'warehouse_in', 'time_column' => 'in_time', 'has_product_type' => 0, 'has_product_model' => 1, 'filter_enabled' => 0, 'show_today_count' => 0, 'show_total_count' => 0, 'extra_config' => ''],
+ ];
+ }
+
+ /**
+ * 插入/更新内置工位默认数据(ON DUPLICATE KEY UPDATE)
+ *
+ * 使用 ON DUPLICATE KEY UPDATE 而非 INSERT IGNORE,确保即使表中有旧数据,
+ * 也能更新为正确的默认值(修复数据损坏/不完整的情况)。
+ *
+ * @return bool
+ */
+ private function insertBuiltinDefaults()
+ {
+ $fullTable = $this->getTable();
+ $defaults = $this->getBuiltinDefaults();
+ $insertSql = "INSERT INTO `{$fullTable}`
+ (`station_type`, `db_table`, `time_column`, `has_product_type`, `has_product_model`,
+ `filter_enabled`, `show_today_count`, `show_total_count`, `extra_config`)
+ VALUES (:st, :dt, :tc, :hpt, :hpm, :fe, :stc, :sttc, :ec)
+ ON DUPLICATE KEY UPDATE
+ `db_table` = VALUES(`db_table`),
+ `time_column` = VALUES(`time_column`),
+ `has_product_type` = VALUES(`has_product_type`),
+ `has_product_model` = VALUES(`has_product_model`),
+ `filter_enabled` = VALUES(`filter_enabled`),
+ `show_today_count` = VALUES(`show_today_count`),
+ `show_total_count` = VALUES(`show_total_count`),
+ `extra_config` = VALUES(`extra_config`)";
+ try {
+ // 使用 $this->execute() 而非直接访问 $this->pdo,
+ // 确保在 Model 构造链完整的情况下执行
+ $affected = 0;
+ foreach ($defaults as $d) {
+ $params = [
+ ':st' => $d['station_type'],
+ ':dt' => $d['db_table'],
+ ':tc' => $d['time_column'],
+ ':hpt' => $d['has_product_type'],
+ ':hpm' => $d['has_product_model'],
+ ':fe' => $d['filter_enabled'],
+ ':stc' => $d['show_today_count'],
+ ':sttc' => $d['show_total_count'],
+ ':ec' => $d['extra_config'],
+ ];
+ // execute() 返回 bool,rowCount() 需要通过 pdo 获取
+ if ($this->execute($insertSql, $params)) {
+ $affected++;
+ }
+ }
+ if ($affected > 0) {
+ error_log('[StationDefinition] Upserted ' . $affected . ' built-in station definitions');
+ }
+ return true;
+ } catch (\Throwable $e) {
+ error_log('[StationDefinition] insertBuiltinDefaults failed: ' . $e->getMessage());
+ return false;
+ }
+ }
+
+ public function autoCreateTable()
+ {
+ $fullTable = $this->getTable();
+ try {
+ // 检查表是否存在
+ $exists = $this->query("SHOW TABLES LIKE '{$fullTable}'");
+ if (empty($exists)) {
+ // 创建表
+ $sql = "CREATE TABLE IF NOT EXISTS `{$fullTable}` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `station_type` VARCHAR(50) NOT NULL UNIQUE COMMENT '工位类型标识',
+ `db_table` VARCHAR(100) NOT NULL COMMENT '数据表名(不含前缀)',
+ `time_column` VARCHAR(50) NOT NULL DEFAULT 'created_at' COMMENT '时间列名',
+ `has_product_type` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否有 product_type 列',
+ `has_product_model` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否有 product_model 列',
+ `filter_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用筛选',
+ `show_today_count` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '显示今日计数',
+ `show_total_count` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '显示总计数',
+ `extra_config` TEXT COMMENT '额外配置(JSON)',
+ `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX `idx_station_type` (`station_type`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工位定义统一配置表'";
+ $this->execute($sql);
+ error_log('[StationDefinition] Auto-created table: ' . $fullTable);
+ }
+
+ // 无论表是否刚创建,确保内置工位数据存在
+ // (表可能已存在但数据不完整,例如之前创建失败只建了表没插入数据)
+ $this->insertBuiltinDefaults();
+
+ return true;
+ } catch (\Throwable $e) {
+ error_log('[StationDefinition] autoCreateTable failed: ' . $e->getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * 清除缓存(数据更新后调用)
+ */
+ public static function clearCache()
+ {
+ self::$cache = [];
+ }
+}
diff --git a/app/models/StationRecord.php b/app/models/StationRecord.php
new file mode 100644
index 0000000..241101f
--- /dev/null
+++ b/app/models/StationRecord.php
@@ -0,0 +1,65 @@
+where(['station_type = :type'], [':type' => $stationType])
+ ->order(['created_at DESC'])
+ ->limit($limit)
+ ->fetchAll();
+ }
+
+ /**
+ * 添加记录
+ * @param string $stationType 工位类型
+ * @param array $data 字段名→值的键值对
+ * @param string $operator 操作人
+ */
+ public function addRecord($stationType, $data, $operator = '')
+ {
+ return $this->add([
+ 'station_type' => $stationType,
+ 'record_data' => json_encode($data, JSON_UNESCAPED_UNICODE),
+ 'operator' => $operator,
+ 'created_at' => date('Y-m-d H:i:s'),
+ ]);
+ }
+
+ /**
+ * 统计今日某操作人的记录数
+ */
+ public function countTodayByOperator($operator, $stationType = null)
+ {
+ $conditions = [];
+ $params = [];
+
+ $conditions[] = 'operator = :operator';
+ $params[':operator'] = $operator;
+
+ $conditions[] = 'DATE(created_at) = CURDATE()';
+
+ if ($stationType) {
+ $conditions[] = 'station_type = :type';
+ $params[':type'] = $stationType;
+ }
+
+ $result = $this->where($conditions, $params)
+ ->field('COUNT(*) as cnt')
+ ->fetch();
+ return $result['cnt'] ?? 0;
+ }
+}
diff --git a/app/models/Supplier.php b/app/models/Supplier.php
new file mode 100644
index 0000000..3e49b7a
--- /dev/null
+++ b/app/models/Supplier.php
@@ -0,0 +1,35 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ public function getActiveList()
+ {
+ return $this->where(['status = 1'])->order(['name ASC'])->fetchAll();
+ }
+
+ public function getById($id)
+ {
+ return $this->where(['id = :id'], [':id' => $id])->fetch();
+ }
+
+ public function search($keyword)
+ {
+ return $this->where(
+ ['name LIKE :kw OR contact_person LIKE :kw2 OR phone LIKE :kw3'],
+ [':kw' => "%{$keyword}%", ':kw2' => "%{$keyword}%", ':kw3' => "%{$keyword}%"]
+ )->order(['id DESC'])->fetchAll();
+ }
+}
diff --git a/app/models/SysConfig.php b/app/models/SysConfig.php
new file mode 100644
index 0000000..4877157
--- /dev/null
+++ b/app/models/SysConfig.php
@@ -0,0 +1,192 @@
+where(['id = :id'], [':id' => 1])->fetch();
+ }
+
+ // 更新配置
+ public function updateConfig($data)
+ {
+ return $this->where(['id = :id'], [':id' => 1])->update($data);
+ }
+
+ /**
+ * 获取代码前缀规则
+ * 格式: ['成品' => 'CP', '电芯' => 'DX', 'PCBA' => 'XLB']
+ */
+ public function getCodePrefixRules()
+ {
+ $config = $this->getConfig();
+ $raw = $config['code_prefix_rules'] ?? '';
+ if (empty($raw)) {
+ // 返回默认规则
+ $rules = $this->getDefaultCodePrefixRules();
+ } else {
+ $rules = json_decode($raw, true);
+ if (!is_array($rules)) {
+ $rules = $this->getDefaultCodePrefixRules();
+ }
+ }
+
+ // 产品类型别名对齐:确保同义类型能匹配到同一前缀规则
+ // 例如配置表中使用「电池」,而前缀规则可能使用旧名「电芯」
+ $aliases = [
+ '电池' => '电芯',
+ '电芯' => '电池',
+ '底壳' => '外壳',
+ '外壳' => '底壳',
+ '半成品' => '半成',
+ '半成' => '半成品',
+ ];
+ foreach ($aliases as $from => $to) {
+ if (!isset($rules[$from]) && isset($rules[$to])) {
+ $rules[$from] = $rules[$to];
+ }
+ }
+ return $rules;
+ }
+
+ /**
+ * 获取默认代码前缀规则
+ */
+ public function getDefaultCodePrefixRules()
+ {
+ return [
+ '成品' => 'CP',
+ '电池' => 'dc',
+ 'PCBA' => 'xlb',
+ '半成品' => '',
+ '外壳' => '',
+ '包装' => '',
+ '配件' => '',
+ ];
+ }
+
+ /**
+ * 根据产品类型获取代码前缀
+ * @param string $productType 产品类型(如:成品、电芯、PCBA)
+ * @return string 前缀,无规则返回空字符串
+ */
+ public function getPrefixByType($productType)
+ {
+ $rules = $this->getCodePrefixRules();
+ $prefix = $rules[$productType] ?? '';
+ // 原则:所有工位序列号都受前缀管控。配置了该类型但前缀为空时,
+ // 自动回退为「产品类型名拼音首字母」,避免无前缀类型被放行或卡死。
+ if ($prefix === '' && isset($rules[$productType])) {
+ $prefix = $this->pinyinInitials($productType);
+ }
+ return $prefix;
+ }
+
+ /**
+ * 取得字符串的拼音首字母(中文取首字拼音首字母,英文/数字原样保留)。
+ * 兼容「外壳」「包装」「半成品」「配件」等常见类型,英文部分(如 PCBA)原样输出。
+ * @param string $str
+ * @return string
+ */
+ public function pinyinInitials($str)
+ {
+ $str = trim($str);
+ if ($str === '') return '';
+ // 常见产品类型精确映射(避免多音字/误判)
+ $map = [
+ '外壳' => 'WK',
+ '底壳' => 'WK',
+ '包装' => 'BZ',
+ '半成品' => 'BCP',
+ '半成' => 'BCP',
+ '配件' => 'PJ',
+ '成品' => 'CP',
+ '电池' => 'DC',
+ '电芯' => 'DC',
+ 'PCBA' => 'XLB',
+ ];
+ if (isset($map[$str])) return $map[$str];
+
+ $first = mb_substr($str, 0, 1, 'UTF-8');
+ // 英文字母/数字:直接原样(大写)
+ if (preg_match('/^[A-Za-z0-9]$/', $first)) {
+ return strtoupper($first) . (mb_strlen($str, 'UTF-8') > 1 ? strtoupper(mb_substr($str, 1, 1, 'UTF-8')) : '');
+ }
+ // 中文:取首字拼音首字母
+ return $this->chineseInitial($first);
+ }
+
+ /**
+ * 单汉字 → 拼音首字母(GB2312 区位区间法,覆盖常用汉字)
+ */
+ private function chineseInitial($char)
+ {
+ $py = 'A';
+ try {
+ $gbk = iconv('UTF-8', 'GB2312//IGNORE', $char);
+ } catch (\Throwable $e) {
+ $gbk = '';
+ }
+ if ($gbk === '' || strlen($gbk) < 2) {
+ return $py; // 生僻字/无法转换时回退
+ }
+ $hi = ord($gbk[0]);
+ $lo = ord($gbk[1]);
+ $code = $hi * 256 + $lo;
+ // GB2312 拼音首字母分区(常用字)
+ $ranges = [
+ [0xB0A1, 0xB0C4, 'A'], [0xB0C5, 0xB2C0, 'B'], [0xB2C1, 0xB4ED, 'C'],
+ [0xB4EE, 0xB6E9, 'D'], [0xB6EA, 0xB7A1, 'E'], [0xB7A2, 0xB8C0, 'F'],
+ [0xB8C1, 0xB9FD, 'G'], [0xB9FE, 0xBBF6, 'H'], [0xBBF7, 0xBFA5, 'J'],
+ [0xBFA6, 0xC0AB, 'K'], [0xC0AC, 0xC2E7, 'L'], [0xC2E8, 0xC4C2, 'M'],
+ [0xC4C3, 0xC5B5, 'N'], [0xC5B6, 0xC5BD, 'O'], [0xC5BE, 0xC6DA, 'P'],
+ [0xC6DB, 0xC8BA, 'Q'], [0xC8BB, 0xC8F5, 'R'], [0xC8F6, 0xCBF0, 'S'],
+ [0xCBF1, 0xCDDA, 'T'], [0xCDDB, 0xCEF3, 'W'], [0xCEF4, 0xD1B8, 'X'],
+ [0xD1B9, 0xD4D0, 'Y'], [0xD4D1, 0xD7F9, 'Z'],
+ ];
+ foreach ($ranges as $r) {
+ if ($code >= $r[0] && $code <= $r[1]) { $py = $r[2]; break; }
+ }
+ return $py;
+ }
+
+ /**
+ * 获取所有已配置的非空前缀规则(用于验证)
+ * @return array [前缀 => 产品类型名]
+ */
+ public function getActivePrefixMap()
+ {
+ $rules = $this->getCodePrefixRules();
+ $active = [];
+ foreach ($rules as $type => $prefix) {
+ if (!empty($prefix)) {
+ $active[$prefix] = $type;
+ }
+ }
+ return $active;
+ }
+
+ /**
+ * 更新代码前缀规则(JSON 存储)
+ * @param array $rules ['成品' => 'CP', '电池' => 'dc', ...]
+ */
+ public function updateCodePrefixRules($rules)
+ {
+ $json = json_encode($rules, JSON_UNESCAPED_UNICODE);
+ // 使用原生 PDO 直写,避免 Model::update 链式调用在二次写入时的潜在问题
+ $pdo = \core\db\Db::pdo();
+ $table = $this->getTable();
+ $stmt = $pdo->prepare("UPDATE {$table} SET code_prefix_rules = :v WHERE id = 1");
+ $stmt->execute([':v' => $json]);
+ return true;
+ }
+}
diff --git a/app/models/WarehouseIn.php b/app/models/WarehouseIn.php
new file mode 100644
index 0000000..1121dc3
--- /dev/null
+++ b/app/models/WarehouseIn.php
@@ -0,0 +1,68 @@
+order(['id DESC'])->fetchAll();
+ }
+
+ public function addRecord($data)
+ {
+ return $this->add($data);
+ }
+
+ public function findBySerial($finished_serial)
+ {
+ return $this->where(['finished_serial = :serial'], [':serial' => $finished_serial])->fetch();
+ }
+
+ // 根据产品型号筛选记录(warehouse_in 表无 product_type 列)
+ public function getByTypeAndModel($product_type, $product_model)
+ {
+ if (empty($product_model)) {
+ return [];
+ }
+ return $this->where(['product_model = :pm'], [':pm' => $product_model])->order(['id DESC'])->fetchAll();
+ }
+
+ // 统计当日某操作员的录入数量
+ public function countTodayByOperator($operator)
+ {
+ $sql = "SELECT COUNT(*) as cnt FROM " . $this->getTable() . " WHERE operator = :op AND DATE(in_time) = CURDATE()";
+ $result = $this->query($sql, [':op' => $operator]);
+ return $result[0]['cnt'] ?? 0;
+ }
+
+ // 统计总记录数(可按型号筛选)
+ public function countTotal($product_type = '', $product_model = '')
+ {
+ if (!empty($product_model)) {
+ $result = $this->where(['product_model = :pm'], [':pm' => $product_model])->field('COUNT(*) as cnt')->fetch();
+ } else {
+ $sql = "SELECT COUNT(*) as cnt FROM " . $this->getTable();
+ $result = $this->query($sql);
+ }
+ return $result[0]['cnt'] ?? 0;
+ }
+
+ public function batchImport($records)
+ {
+ foreach ($records as $record) {
+ $this->add($record);
+ }
+ return true;
+ }
+}
diff --git a/app/models/Workstation.php b/app/models/Workstation.php
new file mode 100644
index 0000000..c838cc6
--- /dev/null
+++ b/app/models/Workstation.php
@@ -0,0 +1,48 @@
+order(['sort_order ASC'])->fetchAll();
+ }
+
+ // 添加工位
+ public function addStation($data)
+ {
+ return $this->add($data);
+ }
+
+ // 更新工位
+ public function updateStation($id, $data)
+ {
+ return $this->where(['id = :id'], [':id' => $id])->update($data);
+ }
+
+ // 删除工位
+ public function deleteStation($id)
+ {
+ return $this->delete($id);
+ }
+
+ // 更新排序
+ public function updateSort($id, $sort_order)
+ {
+ return $this->where(['id = :id'], [':id' => $id])->update(['sort_order' => $sort_order]);
+ }
+
+ // 根据类型获取工位
+ public function getByType($station_type)
+ {
+ return $this->where(['station_type = :type'], [':type' => $station_type])->fetch();
+ }
+}
diff --git a/app/service/AuthService.php b/app/service/AuthService.php
new file mode 100644
index 0000000..9d5e8ce
--- /dev/null
+++ b/app/service/AuthService.php
@@ -0,0 +1,244 @@
+adminModel = new Admin();
+ $this->config = require __DIR__ . '/../config/app.php';
+ }
+
+ /**
+ * 用户登录
+ */
+ public function login($username, $password)
+ {
+ if (empty($username) || empty($password)) {
+ return ['code' => 400, 'msg' => '用户名和密码不能为空'];
+ }
+
+ $admin = $this->adminModel->findByUsername($username);
+
+ if (!$admin) {
+ return ['code' => 400, 'msg' => '用户名或密码错误'];
+ }
+
+ if ($admin['status'] != 1) {
+ return ['code' => 403, 'msg' => '账号已被禁用,请联系管理员'];
+ }
+
+ if (!password_verify($password, $admin['password'])) {
+ return ['code' => 400, 'msg' => '用户名或密码错误'];
+ }
+
+ // 更新登录信息
+ $ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
+ $this->adminModel->updateLoginInfo($admin['id'], $ip);
+
+ // 生成Token
+ $token = $this->generateToken($admin);
+
+ return [
+ 'code' => 200,
+ 'msg' => '登录成功',
+ 'data' => [
+ 'token' => $token,
+ 'userinfo' => [
+ 'id' => $admin['id'],
+ 'username' => $admin['username'],
+ 'nickname' => $admin['nickname'] ?: $admin['username'],
+ 'avatar' => $admin['avatar'] ?: '',
+ 'category' => $admin['category'] ?? 'mes',
+ 'role' => ($admin['category'] === 'all') ? 'super_admin' : 'admin',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * 生成JWT Token
+ */
+ private function generateToken($admin)
+ {
+ $header = base64_encode(json_encode([
+ 'alg' => 'HS256',
+ 'typ' => 'JWT',
+ ]));
+
+ $payload = base64_encode(json_encode([
+ 'id' => $admin['id'],
+ 'username' => $admin['username'],
+ 'iat' => time(),
+ 'exp' => time() + $this->config['token_expire'],
+ ]));
+
+ $signature = base64_encode(hash_hmac(
+ 'sha256',
+ "{$header}.{$payload}",
+ $this->config['token_secret'],
+ true
+ ));
+
+ return "{$header}.{$payload}.{$signature}";
+ }
+
+ /**
+ * 验证Token
+ */
+ public function verifyToken($token)
+ {
+ if (empty($token)) {
+ return false;
+ }
+
+ $parts = explode('.', $token);
+ if (count($parts) !== 3) {
+ return false;
+ }
+
+ [$header, $payload, $signature] = $parts;
+
+ // 验证签名
+ $expectedSig = base64_encode(hash_hmac(
+ 'sha256',
+ "{$header}.{$payload}",
+ $this->config['token_secret'],
+ true
+ ));
+
+ if (!hash_equals($expectedSig, $signature)) {
+ return false;
+ }
+
+ // 解析payload
+ $data = json_decode(base64_decode($payload), true);
+ if (!$data) {
+ return false;
+ }
+
+ // 检查过期
+ if (isset($data['exp']) && $data['exp'] < time()) {
+ return false;
+ }
+
+ return $data;
+ }
+
+ /**
+ * 获取当前登录用户
+ */
+ public function getCurrentUser()
+ {
+ $token = $this->getBearerToken();
+ if (!$token) {
+ return null;
+ }
+
+ $payload = $this->verifyToken($token);
+ if (!$payload) {
+ return null;
+ }
+
+ return $this->adminModel->find($payload['id']);
+ }
+
+ /**
+ * 从请求头获取Token
+ * 优先级: Authorization > X-Auth-Token (Nginx兼容)
+ */
+ public function getBearerToken()
+ {
+ // 1. 标准 Authorization header
+ $headers = $this->getAuthorizationHeader();
+ if ($headers && preg_match('/Bearer\s+(.+)$/i', $headers, $matches)) {
+ return $matches[1];
+ }
+
+ // 2. 自定义 header X-Auth-Token (Nginx 默认不丢弃自定义 header)
+ // 前端发送: X-Auth-Token: Bearer
+ $xAuthToken = $_SERVER['HTTP_X_AUTH_TOKEN'] ?? null;
+ if ($xAuthToken) {
+ if (preg_match('/^Bearer\s+(.+)$/i', $xAuthToken, $m)) {
+ return $m[1];
+ }
+ // 也支持直接传 token 值
+ return $xAuthToken;
+ }
+
+ // 3. URL 参数 token(用于 window.open / 下载等场景)
+ $urlToken = $_GET['token'] ?? null;
+ if ($urlToken) {
+ return $urlToken;
+ }
+
+ return null;
+ }
+
+ /**
+ * 获取Authorization头
+ * 兼容 Apache / Nginx / IIS / PHP-FPM 等多种环境
+ */
+ private function getAuthorizationHeader()
+ {
+ // 1. Apache / Nginx (with pass) 标准
+ if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
+ return $_SERVER['HTTP_AUTHORIZATION'];
+ }
+
+ // 2. Apache rewrite 后
+ if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
+ return $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
+ }
+
+ // 3. Nginx + PHP-FPM: Authorization 可能被放在其他变量中
+ if (isset($_SERVER['AUTHORIZATION'])) {
+ return $_SERVER['AUTHORIZATION'];
+ }
+
+ // 4. apache_request_headers (Apache/FastCGI)
+ if (function_exists('apache_request_headers')) {
+ $headers = apache_request_headers();
+ if (isset($headers['Authorization'])) {
+ return $headers['Authorization'];
+ }
+ // 有些环境下 header 名是小写的
+ if (isset($headers['authorization'])) {
+ return $headers['authorization'];
+ }
+ }
+
+ // 5. Nginx + PHP-FPM 终极兜底: 从原始 HTTP 头中提取
+ // 有些 Nginx 配置下,Authorization 可能根本不在 $_SERVER 中
+ // 尝试从 getallheaders() 获取 (PHP-FPM built-in)
+ if (function_exists('getallheaders')) {
+ $allHeaders = getallheaders();
+ if (isset($allHeaders['Authorization'])) {
+ return $allHeaders['Authorization'];
+ }
+ if (isset($allHeaders['authorization'])) {
+ return $allHeaders['authorization'];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * 生成密码哈希
+ */
+ public function hashPassword($password)
+ {
+ return password_hash($password, PASSWORD_BCRYPT);
+ }
+}
diff --git a/app/views/Admin/asdBatchDelete.php b/app/views/Admin/asdBatchDelete.php
new file mode 100644
index 0000000..78b5e3c
--- /dev/null
+++ b/app/views/Admin/asdBatchDelete.php
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+ - MES系统
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 返回测试数据列表
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/views/Admin/asdDeviceDebug.php b/app/views/Admin/asdDeviceDebug.php
new file mode 100644
index 0000000..de00a42
--- /dev/null
+++ b/app/views/Admin/asdDeviceDebug.php
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
手动连接(IP:端口)
+
+
不依赖数据库记录,直接对任意 IP:端口做 TCP 连通测试。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 设备 | |
+ | 目标地址 | : |
+ | 连接结果 |
+
+
+ 可达
+
+ 不可达
+
+ |
+
+ | 延迟 | ms |
+
+ | 错误信息 | |
+
+
+
+
+
+
+ 设备编号:
+ | 测试记录总数:
+ | 测试数据总数:
+ | 设备总数:
+
+
最近 10 条该设备的测试记录(device_code 匹配):
+
+
该设备暂无测试记录。
+
+
+
+ | ID | 二维码/条码 | 产品型号 | 测试结果 | 测试时间 |
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+ | 目标 | |
+ | 地址 | : |
+ | 连接结果 |
+
+
+ 可达
+
+ 不可达
+
+ |
+
+ | 延迟 | ms |
+
+ | 错误信息 | |
+
+
+
+
+
+
+
+ 数据读写链路正常(探针已写入并回读、清理)
+
+
+
+ | 设备编号 | |
+ | 探针二维码 | |
+ | 写入 ID | |
+ | 回读一致 |
+
+
+ 一致
+
+ 不一致
+
+ |
+
+ | 探针记录已清理 |
+
+
+ 已删除
+
+ 未删除(请检查)
+
+ |
+
+
+ | 错误信息 | |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/asdTestData.php b/app/views/Admin/asdTestData.php
new file mode 100644
index 0000000..348a0f7
--- /dev/null
+++ b/app/views/Admin/asdTestData.php
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ 条码 |
+ 项目名称 |
+ 测试员 |
+ 工位 |
+ 设备 |
+ 状态 |
+ 工步数 |
+ 失败数 |
+ 测试时间 |
+ 操作 |
+
+
+
+
+ | 暂无测试数据 |
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+ PASS
+
+
+
+ |
+ |
+
+ 0): ?>
+
+
+ 0
+
+ |
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/asdTestDataImport.php b/app/views/Admin/asdTestDataImport.php
new file mode 100644
index 0000000..8bab37d
--- /dev/null
+++ b/app/views/Admin/asdTestDataImport.php
@@ -0,0 +1,805 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
设备连接
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 设备编码:
+ -
+
+
+ 设备名称:
+ -
+
+
+ 所属组别:
+ -
+
+
+ 设备IP:
+
+
+
+
+
+ 连接状态:
+
+ 未连接
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 尚未选择设备,请先扫码或从列表选择并连接设备
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
确定要删除该设备的所有测试数据吗?此操作不可撤销!
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/asdTestDataView.php b/app/views/Admin/asdTestDataView.php
new file mode 100644
index 0000000..d4c014d
--- /dev/null
+++ b/app/views/Admin/asdTestDataView.php
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+ | 记录ID |
+ |
+ 条码 |
+ |
+
+
+ | 测试员 |
+ |
+ 项目名称 |
+ |
+
+
+ | 工位 |
+ |
+ 站点 |
+ |
+
+
+ | 单号 |
+ |
+ 工单 |
+ |
+
+
+ | 组别 |
+ |
+ 设备 |
+ |
+
+
+ | 运行时间 |
+ |
+ 测试时间 |
+ |
+
+
+ | 整体状态 |
+
+
+ PASS
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ | 序号 |
+ 工步名称 |
+ 测试项目 |
+ 测试值 |
+ 单位 |
+ 下限 |
+ 上限 |
+ 字符串值 |
+ 结果 |
+
+
+
+
+ | 无工步数据 |
+
+
+
+ |
+ |
+ |
+
+
+
+
+ -
+
+ |
+ |
+ |
+ |
+ |
+
+
+ PASS
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/config.php b/app/views/Admin/config.php
new file mode 100644
index 0000000..bbdb5cf
--- /dev/null
+++ b/app/views/Admin/config.php
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 设置各产品类型的序列号前缀。工位录入时,序列号必须匹配对应前缀,否则将报错。
+ 留空表示不检查前缀。
+
+
+
+
+
+
+
+
+
+
+
+
+ - 公司名称
+
+ - 系统名称
+
+ - 前缀规则
+ -
+
+
+ | 产品类型 | 前缀 |
+
+ $prefixVal): ?>
+
+ |
+
+
+
+
+ 无限制
+
+ |
+
+
+
+
+
+ 未配置
+
+
+ - PHP 版本
+
+
+
+
+
+
+
+ 已抽离为 external JS,并以 nonce 加载,配合 CSP script-src 'nonce-***' ?>
+
+
+
diff --git a/app/views/Admin/customer.php b/app/views/Admin/customer.php
new file mode 100644
index 0000000..44cc67d
--- /dev/null
+++ b/app/views/Admin/customer.php
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+ | 序号 |
+ 公司名称 |
+ 联系人1 |
+ 联系方式1 |
+ 联系人2 |
+ 联系方式2 |
+ 操作人 |
+ 操作 |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+ 编辑
+
+ |
+
+
+
+ | 暂无客户数据 |
+
+
+
+
+
+
+
diff --git a/app/views/Admin/customerAdd.php b/app/views/Admin/customerAdd.php
new file mode 100644
index 0000000..7abd043
--- /dev/null
+++ b/app/views/Admin/customerAdd.php
@@ -0,0 +1,45 @@
+
+
+
+
+
diff --git a/app/views/Admin/customerEdit.php b/app/views/Admin/customerEdit.php
new file mode 100644
index 0000000..cbd95e4
--- /dev/null
+++ b/app/views/Admin/customerEdit.php
@@ -0,0 +1,45 @@
+
+
+
+
+
diff --git a/app/views/Admin/database.php b/app/views/Admin/database.php
new file mode 100644
index 0000000..0d5fec4
--- /dev/null
+++ b/app/views/Admin/database.php
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+ | 序号 |
+ 表名 |
+ 记录数 |
+ 操作 |
+
+
+
+ $tbl): ?>
+
+ |
+
+
+
+ |
+ |
+
+
+ 查看/编辑
+
+ |
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/databaseBackup.php b/app/views/Admin/databaseBackup.php
new file mode 100644
index 0000000..2a273e3
--- /dev/null
+++ b/app/views/Admin/databaseBackup.php
@@ -0,0 +1,130 @@
+
+
+
+
+
+
操作结果
+
+
+
+
+
+
+
+
+
+
+
将当前数据库导出为 SQL 文件,保存到服务器 backups/ 目录。
+
数据库:
+
主机:
+
+
+
+
+
+
+
+
+
+ - 备份:将数据库完整导出为 .sql 文件保存到服务器本地
+ - 恢复:选择一个备份文件,将其数据恢复到当前数据库
+ - 下载:将备份文件下载到本地电脑保存
+ - 删除:删除服务器上的备份文件
+
+
+
注意
+
恢复操作会覆盖当前数据库中已存在的同名表!请在恢复前确认已做好最新备份。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 文件名 |
+ 大小 |
+ 备份时间 |
+ 操作 |
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/databaseEdit.php b/app/views/Admin/databaseEdit.php
new file mode 100644
index 0000000..27351fd
--- /dev/null
+++ b/app/views/Admin/databaseEdit.php
@@ -0,0 +1,74 @@
+
+
+
+
+
diff --git a/app/views/Admin/databaseUpgrade.php b/app/views/Admin/databaseUpgrade.php
new file mode 100644
index 0000000..805b3c9
--- /dev/null
+++ b/app/views/Admin/databaseUpgrade.php
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
使用说明
+
+ 1. 系统会自动扫描 sql/ 目录下的所有 .sql 文件。
+ 2. 已执行过的脚本会标记为 已执行,不会重复执行。
+ 3. 每个脚本在事务中执行,失败会自动回滚。
+ 4. 点击文件名可展开查看 SQL 内容预览。
+
+
+
+
+
+
+
sql/ 目录下没有找到 .sql 迁移文件
+
+
+
+
+
+
+ | # |
+ 文件名 |
+ 状态 |
+ 执行时间 |
+ 操作 |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+ 已执行
+
+ 执行失败
+
+
+ 待执行
+
+ |
+
+
+
+
+ —
+
+ |
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/databaseView.php b/app/views/Admin/databaseView.php
new file mode 100644
index 0000000..730ef91
--- /dev/null
+++ b/app/views/Admin/databaseView.php
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ |
+
+ 操作 |
+
+
+
+
+
+
+ |
+ NULL';
+ } elseif ($val === '') {
+ echo '-';
+ } else {
+ echo htmlspecialchars($val);
+ }
+ ?>
+ |
+
+
+ 编辑
+
+ |
+
+
+
+
+ |
+ 暂无数据
+ |
+
+
+
+
+
+ 1): ?>
+
+
+
+
+
+
+
diff --git a/app/views/Admin/employee.php b/app/views/Admin/employee.php
new file mode 100644
index 0000000..15015e8
--- /dev/null
+++ b/app/views/Admin/employee.php
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+ | 序号 |
+ 员工编号 |
+ 姓名 |
+ 权限 |
+ 管理类目 |
+ 操作人 |
+ 操作 |
+
+
+
+
+
+
+ |
+ |
+ |
+
+ 'danger',
+ \core\base\Controller::ROLE_ADMIN => 'warning',
+ \core\base\Controller::ROLE_OPERATOR => 'info',
+ ];
+ $cls = $roleClass[$emp['role']] ?? 'default';
+ ?>
+
+
+
+ |
+
+ 全部类目';
+ } else {
+ echo '' . htmlspecialchars($catNames[$cat] ?? $cat) . '';
+ }
+ ?>
+ |
+ |
+
+
+
+ 工位权限
+ 编辑
+ 重置密码
+
+
+ 无操作权限
+
+ |
+
+
+
+ | 暂无员工数据 |
+
+
+
+
+
+
+
diff --git a/app/views/Admin/employeeAdd.php b/app/views/Admin/employeeAdd.php
new file mode 100644
index 0000000..1715d37
--- /dev/null
+++ b/app/views/Admin/employeeAdd.php
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/employeeEdit.php b/app/views/Admin/employeeEdit.php
new file mode 100644
index 0000000..d033e94
--- /dev/null
+++ b/app/views/Admin/employeeEdit.php
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/employeePermission.php b/app/views/Admin/employeePermission.php
new file mode 100644
index 0000000..d7334a9
--- /dev/null
+++ b/app/views/Admin/employeePermission.php
@@ -0,0 +1,129 @@
+
+
+
+
+
+
保存成功!
+ 员工 的工位权限已更新。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/employeeResetPwd.php b/app/views/Admin/employeeResetPwd.php
new file mode 100644
index 0000000..08c3e21
--- /dev/null
+++ b/app/views/Admin/employeeResetPwd.php
@@ -0,0 +1,41 @@
+
+
+
+
+
diff --git a/app/views/Admin/employee_backup.php b/app/views/Admin/employee_backup.php
new file mode 100644
index 0000000..cd80f1e
--- /dev/null
+++ b/app/views/Admin/employee_backup.php
@@ -0,0 +1,77 @@
+
+
+
+
+ - MES
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 序号 |
+ 员工编号 |
+ 姓名 |
+ 权限 |
+ 操作人 |
+ 操作 |
+
+
+
+ $emp): ?>
+
+ |
+ |
+ |
+
+
+
+
+ |
+ |
+
+ 编辑
+ 重置密码
+ 删除
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/footer.php b/app/views/Admin/footer.php
new file mode 100644
index 0000000..121a678
--- /dev/null
+++ b/app/views/Admin/footer.php
@@ -0,0 +1,8 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/views/Admin/header.php b/app/views/Admin/header.php
new file mode 100644
index 0000000..78e2bd7
--- /dev/null
+++ b/app/views/Admin/header.php
@@ -0,0 +1,39 @@
+
+
+
\ No newline at end of file
diff --git a/app/views/Admin/import.php b/app/views/Admin/import.php
new file mode 100644
index 0000000..86b30c7
--- /dev/null
+++ b/app/views/Admin/import.php
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ '员工数据模板',
+ 'customer' => '客户数据模板',
+ 'product_model' => '产品型号模板',
+ 'inbound' => '入库工位数据模板',
+ 'pcb_test' => 'PCB 测试数据模板',
+ 'battery' => '电池状态数据模板',
+ 'assembly' => '产品组装数据模板',
+ 'finished_test' => '成品测试数据模板',
+ 'warehouse' => '入库数据模板',
+ 'delivery' => '出货登记数据模板',
+ ];
+ foreach ($templates as $key => $label):
+ ?>
+
+ (CSV)
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/index.php b/app/views/Admin/index.php
new file mode 100644
index 0000000..ff13d08
--- /dev/null
+++ b/app/views/Admin/index.php
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+ - 公司名称
+ - 系统名称
+ - 当前用户
+ - 用户角色
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/productModel.php b/app/views/Admin/productModel.php
new file mode 100644
index 0000000..3b9d053
--- /dev/null
+++ b/app/views/Admin/productModel.php
@@ -0,0 +1,158 @@
+'id','label'=>'序号','width'=>'60px']);
+ }
+ // 补充 width 字段
+ foreach ($cols as &$c) {
+ if (!isset($c['width'])) $c['width'] = '';
+ }
+ unset($c);
+ $typeColumns[$cfg['type_name']] = $cols;
+}
+
+// 通用 fallback 列(所有类型都可用的字段集合)
+$defaultColumns = [
+ ['key'=>'id','label'=>'序号','width'=>'60px'],
+ ['key'=>'type','label'=>'类型','width'=>'80px'],
+ ['key'=>'model_code','label'=>'产品型号','width'=>''],
+ ['key'=>'model_desc','label'=>'产品说明','width'=>''],
+];
+
+function renderModelTableRow($model, $columns, $typeName, $idx, $csrfToken) {
+ $html = '';
+ foreach ($columns as $col) {
+ $key = $col['key'];
+ $val = '';
+ if ($key === 'id') {
+ $val = $idx;
+ } elseif ($key === 'type') {
+ $val = htmlspecialchars($typeName);
+ } elseif (isset($model[$key])) {
+ $val = htmlspecialchars($model[$key]);
+ } else {
+ $val = '-';
+ }
+ $html .= '| ' . $val . ' | ';
+ }
+ $html .= '
+ 编辑
+
+ | ';
+ $html .= '
';
+ return $html;
+}
+?>
+
+
+
+
+
+
+
+
+ $models): ?>
+
+
+
+
+
+
+
+
+ | > |
+
+ 操作 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/productModelAdd.php b/app/views/Admin/productModelAdd.php
new file mode 100644
index 0000000..2144a4a
--- /dev/null
+++ b/app/views/Admin/productModelAdd.php
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/productModelEdit.php b/app/views/Admin/productModelEdit.php
new file mode 100644
index 0000000..a02745d
--- /dev/null
+++ b/app/views/Admin/productModelEdit.php
@@ -0,0 +1,162 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/productSearch.php b/app/views/Admin/productSearch.php
new file mode 100644
index 0000000..ebc7bd0
--- /dev/null
+++ b/app/views/Admin/productSearch.php
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
未找到匹配记录
+
关键词 "" 未匹配到任何产品记录,请检查输入。
+
+
+
+
+
+ | # |
+ 成品序列号 |
+ 产品型号 |
+ 电池序列号 |
+ PCB序列号 |
+ 操作员 |
+ 组装时间 |
+ 电池电压平台 |
+ PCB测试结果 |
+ 成品测试结果 |
+ 入库时间 |
+ 出货客户 |
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+ -
+
+ |
+
+
+
+
+
+
+ -
+
+ |
+
+ 未入库'; ?>
+ |
+
+ 未出货'; ?>
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/productTypeConfig.php b/app/views/Admin/productTypeConfig.php
new file mode 100644
index 0000000..4a79d4e
--- /dev/null
+++ b/app/views/Admin/productTypeConfig.php
@@ -0,0 +1,568 @@
+ 'id', 'label' => '序号', 'width' => '60px']);
+ }
+ $configsWithParsed[] = $cfg;
+ }
+}
+?>
+
+
+
+
+
+
+
+
+ | 排序 |
+ 类型名称 |
+ 列表显示列 |
+ 产品类型输入框 |
+ 输入框标签 |
+ 提交字段 |
+ 颜色 |
+ 启用 |
+ 入库显示 |
+ 操作 |
+
+
+
+
+
+
+ |
+ |
+
+
+ |
+
+
+ 隐藏
+
+ 只读
+
+ 可编辑
+
+ |
+ |
+ |
+ |
+ 启用' : '禁用'; ?> |
+ 显示' : '隐藏'; ?> |
+
+
+
+ |
+
+
+
+
+ | 暂无配置,请先执行 SQL 初始化或点击"添加类型" |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/workstation.php b/app/views/Admin/workstation.php
new file mode 100644
index 0000000..3ad4a35
--- /dev/null
+++ b/app/views/Admin/workstation.php
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+ | 序号 |
+ 工位名称 |
+ 工位编码 |
+ 工位类型 |
+ 排序 |
+ 状态 |
+ 操作 |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+ |
+
+ 编辑
+
+ |
+
+
+
+ | 暂无工位数据 |
+
+
+
+
+
+
+
diff --git a/app/views/Admin/workstationAdd.php b/app/views/Admin/workstationAdd.php
new file mode 100644
index 0000000..cf92fc7
--- /dev/null
+++ b/app/views/Admin/workstationAdd.php
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Admin/workstationEdit.php b/app/views/Admin/workstationEdit.php
new file mode 100644
index 0000000..58b93b2
--- /dev/null
+++ b/app/views/Admin/workstationEdit.php
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Dashboard/index.php b/app/views/Dashboard/index.php
new file mode 100644
index 0000000..e6b395e
--- /dev/null
+++ b/app/views/Dashboard/index.php
@@ -0,0 +1,319 @@
+
+
+
+
+
+ 前台工位看板 -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 'fa-sign-in',
+ 'pcb_test' => 'fa-microchip',
+ 'battery' => 'fa-battery-full',
+ 'assembly' => 'fa-cogs',
+ 'finished_test' => 'fa-check-circle',
+ 'warehouse' => 'fa-warehouse',
+ 'delivery' => 'fa-truck',
+ ];
+ foreach ($stations as $station):
+ $type = $station['station_type'];
+ $icon = $icons[$type] ?? 'fa-circle-o';
+ $stat = $stats[$type] ?? ['count' => 0, 'name' => $station['station_name']];
+ ?>
+
>
+
+
+
今日处理量
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Finance/accounts.php b/app/views/Finance/accounts.php
new file mode 100644
index 0000000..c9cb19e
--- /dev/null
+++ b/app/views/Finance/accounts.php
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+ | 序号 |
+ 凭证号 |
+ 类型 |
+ 类别 |
+ 金额 |
+ 日期 |
+ 摘要 |
+ 往来单位 |
+ 支付方式 |
+ 操作人 |
+ 操作 |
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+ |
+ |
+ ¥ |
+ |
+ |
+ |
+ |
+ |
+
+ 编辑
+
+ |
+
+
+
+ | 暂无账务记录 |
+
+
+
+
+
+
+
diff --git a/app/views/Finance/accountsAdd.php b/app/views/Finance/accountsAdd.php
new file mode 100644
index 0000000..59740d0
--- /dev/null
+++ b/app/views/Finance/accountsAdd.php
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Finance/accountsEdit.php b/app/views/Finance/accountsEdit.php
new file mode 100644
index 0000000..65d4be4
--- /dev/null
+++ b/app/views/Finance/accountsEdit.php
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Finance/index.php b/app/views/Finance/index.php
new file mode 100644
index 0000000..40124cb
--- /dev/null
+++ b/app/views/Finance/index.php
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Finance/payable.php b/app/views/Finance/payable.php
new file mode 100644
index 0000000..7a5d255
--- /dev/null
+++ b/app/views/Finance/payable.php
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+ | 序号 |
+ 往来单位 |
+ 应付金额 |
+ 已付金额 |
+ 未付金额 |
+ 到期日期 |
+ 状态 |
+ 操作人 |
+ 操作 |
+
+
+
+
+
+
+
+ |
+ |
+ ¥ |
+ ¥ |
+
+
+ ¥
+
+ |
+ |
+
+ '待付','partial'=>'部分付','paid'=>'已付','overdue'=>'逾期'];
+ $statusColors = ['pending'=>'warning','partial'=>'info','paid'=>'success','overdue'=>'danger'];
+ $st = $r['status'] ?? 'pending';
+ ?>
+
+
+
+ |
+ |
+
+ 编辑
+
+ |
+
+
+
+ | 暂无应付账款 |
+
+
+
+
+
+
+
diff --git a/app/views/Finance/payableAdd.php b/app/views/Finance/payableAdd.php
new file mode 100644
index 0000000..f1ff4c6
--- /dev/null
+++ b/app/views/Finance/payableAdd.php
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Finance/payableEdit.php b/app/views/Finance/payableEdit.php
new file mode 100644
index 0000000..5802819
--- /dev/null
+++ b/app/views/Finance/payableEdit.php
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Finance/receivable.php b/app/views/Finance/receivable.php
new file mode 100644
index 0000000..3d1b382
--- /dev/null
+++ b/app/views/Finance/receivable.php
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+ | 序号 |
+ 往来单位 |
+ 应收金额 |
+ 已收金额 |
+ 未收金额 |
+ 到期日期 |
+ 状态 |
+ 操作人 |
+ 操作 |
+
+
+
+
+
+
+
+ |
+ |
+ ¥ |
+ ¥ |
+
+
+ ¥
+
+ |
+ |
+
+ '待收','partial'=>'部分收','paid'=>'已收','overdue'=>'逾期'];
+ $statusColors = ['pending'=>'warning','partial'=>'info','paid'=>'success','overdue'=>'danger'];
+ $st = $r['status'] ?? 'pending';
+ ?>
+
+
+
+ |
+ |
+
+ 编辑
+
+ |
+
+
+
+ | 暂无应收账款 |
+
+
+
+
+
+
+
diff --git a/app/views/Finance/receivableAdd.php b/app/views/Finance/receivableAdd.php
new file mode 100644
index 0000000..5445f33
--- /dev/null
+++ b/app/views/Finance/receivableAdd.php
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Finance/receivableEdit.php b/app/views/Finance/receivableEdit.php
new file mode 100644
index 0000000..6f8d242
--- /dev/null
+++ b/app/views/Finance/receivableEdit.php
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Finance/report.php b/app/views/Finance/report.php
new file mode 100644
index 0000000..c7a6c8a
--- /dev/null
+++ b/app/views/Finance/report.php
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
费用类别明细
+
+
+
+ | 类别 |
+ 收入 |
+ 支出 |
+ 小计 |
+
+
+
+ $vals): ?>
+
+ |
+ ¥ |
+ ¥ |
+ ¥ |
+
+
+
+
+
+
该月份暂无财务数据
+
+
+
+
+
+
+
diff --git a/app/views/Front/index.php b/app/views/Front/index.php
new file mode 100644
index 0000000..eadbcd1
--- /dev/null
+++ b/app/views/Front/index.php
@@ -0,0 +1,75 @@
+getConfig();
+}
+include APP_PATH . 'app/views/layouts/station_header.php';
+?>
+
+
+
+
+
+ 当前登录:
+ | 角色:
+ | 选择下方工位开始操作
+
+
+
+
+
+
+
+
+
+
+ 'fa-sign-in',
+ 'pcb_test' => 'fa-microchip',
+ 'battery' => 'fa-battery-full',
+ 'assembly' => 'fa-cogs',
+ 'finished_test' => 'fa-check-circle',
+ 'warehouse' => 'fa-warehouse',
+ 'delivery' => 'fa-truck',
+ 'document_manage' => 'fa-folder-open',
+ ];
+ $iconColors = [
+ 'inbound' => ['#10ac84', 'rgba(16,172,132,0.2)'],
+ 'pcb_test' => ['#0abde3', 'rgba(10,189,227,0.2)'],
+ 'battery' => ['#feca57', 'rgba(254,202,87,0.2)'],
+ 'assembly' => ['#a29bfe', 'rgba(162,155,254,0.2)'],
+ 'finished_test' => ['#55efc4', 'rgba(85,239,196,0.2)'],
+ 'warehouse' => ['#fd79a8', 'rgba(253,121,168,0.2)'],
+ 'delivery' => ['#74b9ff', 'rgba(116,185,255,0.2)'],
+ 'document_manage' => ['#e17055', 'rgba(225,112,85,0.2)'],
+ ];
+
+ foreach ($stations as $station):
+ $type = $station['station_type'];
+ $icon = $stationIcons[$type] ?? 'fa-circle-o';
+ $color = $iconColors[$type] ?? ['#0abde3', 'rgba(10,189,227,0.2)'];
+ $disabled = $station['status'] == 0;
+ ?>
+
>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Front/noPermission.php b/app/views/Front/noPermission.php
new file mode 100644
index 0000000..cca7fce
--- /dev/null
+++ b/app/views/Front/noPermission.php
@@ -0,0 +1,30 @@
+getConfig();
+}
+include APP_PATH . 'app/views/layouts/station_header.php';
+?>
+
+
+
+
+
+
抱歉,您没有访问该工位的权限
+
+ 工位类型:
+
+
+
+
+
请联系管理员为您开通对应工位的访问权限。
+
+
+
+ 返回工位首页
+
+
+
+
+
+
diff --git a/app/views/Front/station_document_manage.php b/app/views/Front/station_document_manage.php
new file mode 100644
index 0000000..c4841b3
--- /dev/null
+++ b/app/views/Front/station_document_manage.php
@@ -0,0 +1,350 @@
+
+
+
+
返回工位选择
+
+
+
+
文件管理
+
+
+
+ 共: 个文件
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 文件名称 |
+ 分类 |
+ 版本 |
+ 大小 |
+ 上传人 |
+ 上传时间 |
+ 操作 |
+
+
+
+
+ 1048576) $sizeStr = round($fs / 1048576, 1) . ' MB';
+ elseif ($fs > 1024) $sizeStr = round($fs / 1024, 1) . ' KB';
+ else $sizeStr = $fs . ' B';
+ $fileExt = strtolower($doc['file_type'] ?? '');
+ $fileIcon = 'fa-file-o';
+ if (in_array($fileExt, ['pdf'])) $fileIcon = 'fa-file-pdf-o';
+ elseif (in_array($fileExt, ['doc','docx'])) $fileIcon = 'fa-file-word-o';
+ elseif (in_array($fileExt, ['xls','xlsx','csv'])) $fileIcon = 'fa-file-excel-o';
+ elseif (in_array($fileExt, ['ppt','pptx'])) $fileIcon = 'fa-file-powerpoint-o';
+ elseif (in_array($fileExt, ['jpg','jpeg','png','gif'])) $fileIcon = 'fa-file-image-o';
+ elseif (in_array($fileExt, ['zip','rar'])) $fileIcon = 'fa-file-archive-o';
+ elseif (in_array($fileExt, ['txt'])) $fileIcon = 'fa-file-text-o';
+ ?>
+
+
+
+
+
+ 60 ? '...' : ''; ?>
+
+ |
+
+
+
+
+ -
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+ 打开
+
+
+ 下载
+
+
+ 无文件
+
+
+
+
+ |
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Front/station_generic.php b/app/views/Front/station_generic.php
new file mode 100644
index 0000000..2caea8c
--- /dev/null
+++ b/app/views/Front/station_generic.php
@@ -0,0 +1,131 @@
+ $stationType,
+ 'pageTitle' => $stationName,
+ 'pageIcon' => 'cube',
+ 'cardTitle' => '快速录入',
+ 'tableTitle' => '操作记录',
+ 'dataSources' => [
+ 'types' => $types ?? [],
+ 'models' => $models ?? [],
+ 'customers' => $customers ?? [],
+ ],
+ 'submitMode' => 'auto',
+ 'codeFields' => ['serial_no'],
+ 'labelFields' => [],
+ 'emptyText' => '暂无记录,请在扫描框中录入第一条数据',
+ 'reloadAfterSubmit' => true,
+ 'enableFilter' => false,
+ ], $bizConfig['view'] ?? []);
+
+ // 注入全局变量
+ $GLOBALS['records'] = $parsedRecords;
+ $GLOBALS['fieldConfigs'] = $fieldConfigs;
+ $GLOBALS['types'] = $types ?? [];
+ $GLOBALS['models'] = $models ?? [];
+ $GLOBALS['customers'] = $customers ?? [];
+ $GLOBALS['user'] = $user;
+ $GLOBALS['sysConfig'] = $sysConfig;
+ $GLOBALS['stationType'] = $stationType;
+ $GLOBALS['bizConfig'] = $bizConfig;
+ $GLOBALS['filterType'] = $filterType ?? '';
+ $GLOBALS['filterModel'] = $filterModel ?? '';
+ $GLOBALS['todayCount'] = $todayCount ?? 0;
+ $GLOBALS['totalCount'] = $totalCount ?? 0;
+ $GLOBALS['shellColors'] = $shellColors ?? [];
+ $GLOBALS['csrfToken'] = $csrfToken;
+ $GLOBALS['stationError'] = $stationError ?? '';
+
+ renderStationTemplate($stationConfig);
+
+} else {
+ // ========== 动态工位:使用 station_records 表(JSON 存储) ==========
+
+ // 解析记录中的 JSON 数据
+ $parsedRecords = [];
+ if (!empty($records)) {
+ foreach ($records as $rec) {
+ $row = ['id' => $rec['id'], 'operator' => $rec['operator'], 'created_at' => $rec['created_at']];
+ $jsonData = json_decode($rec['record_data'], true);
+ if (is_array($jsonData)) {
+ $row = array_merge($row, $jsonData);
+ }
+ $parsedRecords[] = $row;
+ }
+ }
+
+ // 配置工位模板
+ $stationConfig = [
+ 'stationKey' => $stationType,
+ 'pageTitle' => $stationName,
+ 'pageIcon' => 'cube',
+ 'cardTitle' => '快速录入',
+ 'tableTitle' => '操作记录',
+ 'dataSources' => [
+ 'types' => $types ?? [],
+ 'models' => $models ?? [],
+ 'customers' => $customers ?? [],
+ ],
+ 'submitMode' => 'auto', // 扫描回车直接提交
+ 'codeFields' => ['serial_no'], // 序列号类字段 code 样式
+ 'labelFields' => [],
+ 'emptyText' => '暂无记录,请在扫描框中录入第一条数据',
+ 'reloadAfterSubmit' => true, // 提交后刷新页面
+ 'enableFilter' => false, // 不启用联动筛选
+ ];
+
+ // 将解析后的记录和字段配置注入全局,供 renderStationTemplate 使用
+ $GLOBALS['records'] = $parsedRecords;
+ $GLOBALS['fieldConfigs'] = $fieldConfigs;
+ $GLOBALS['types'] = $types ?? [];
+ $GLOBALS['models'] = $models ?? [];
+ $GLOBALS['customers'] = $customers ?? [];
+ $GLOBALS['user'] = $user;
+ $GLOBALS['sysConfig'] = $sysConfig;
+ $GLOBALS['stationType'] = $stationType;
+ $GLOBALS['bizConfig'] = null;
+ $GLOBALS['filterType'] = '';
+ $GLOBALS['filterModel'] = '';
+ $GLOBALS['todayCount'] = 0;
+ $GLOBALS['totalCount'] = $totalCount ?? count($parsedRecords);
+ $GLOBALS['shellColors'] = [];
+ $GLOBALS['csrfToken'] = $csrfToken;
+ $GLOBALS['stationError'] = '';
+
+ renderStationTemplate($stationConfig);
+}
diff --git a/app/views/General/asset.php b/app/views/General/asset.php
new file mode 100644
index 0000000..00cbccb
--- /dev/null
+++ b/app/views/General/asset.php
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+ | 序号 |
+ 资产编号 |
+ 资产名称 |
+ 类别 |
+ 规格型号 |
+ 数量 |
+ 购置价格 |
+ 使用部门 |
+ 使用人 |
+ 状态 |
+ 操作 |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ ¥ |
+ |
+ |
+
+ '正常','scrapped'=>'报废','repairing'=>'维修中','borrowed'=>'借用中'];
+ $statusColors = ['normal'=>'success','scrapped'=>'default','repairing'=>'warning','borrowed'=>'info'];
+ $st = $a['status'] ?? 'normal';
+ ?>
+
+
+
+ |
+
+ 编辑
+
+ |
+
+
+
+ | 暂无资产数据 |
+
+
+
+
+
+
+
diff --git a/app/views/General/assetAdd.php b/app/views/General/assetAdd.php
new file mode 100644
index 0000000..77aa6a8
--- /dev/null
+++ b/app/views/General/assetAdd.php
@@ -0,0 +1,77 @@
+
+
+
+
+
diff --git a/app/views/General/assetEdit.php b/app/views/General/assetEdit.php
new file mode 100644
index 0000000..7adeb80
--- /dev/null
+++ b/app/views/General/assetEdit.php
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/General/document.php b/app/views/General/document.php
new file mode 100644
index 0000000..197a4d0
--- /dev/null
+++ b/app/views/General/document.php
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+ | 序号 |
+ 文档编号 |
+ 标题 |
+ 分类 |
+ 版本 |
+ 作者 |
+ 部门 |
+ 状态 |
+ 创建时间 |
+ 操作 |
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+ '启用','archived'=>'归档','obsolete'=>'作废'];
+ $statusColors = ['active'=>'success','archived'=>'info','obsolete'=>'default'];
+ $st = $d['status'] ?? 'active';
+ ?>
+
+
+
+ |
+ |
+
+ 编辑
+
+ |
+
+
+
+ | 暂无文档数据 |
+
+
+
+
+
+
+
diff --git a/app/views/General/documentAdd.php b/app/views/General/documentAdd.php
new file mode 100644
index 0000000..aa76856
--- /dev/null
+++ b/app/views/General/documentAdd.php
@@ -0,0 +1,60 @@
+
+
+
+
+
diff --git a/app/views/General/documentEdit.php b/app/views/General/documentEdit.php
new file mode 100644
index 0000000..4f5f5c7
--- /dev/null
+++ b/app/views/General/documentEdit.php
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/General/hr.php b/app/views/General/hr.php
new file mode 100644
index 0000000..e257080
--- /dev/null
+++ b/app/views/General/hr.php
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+ | 序号 |
+ 工号 |
+ 姓名 |
+ 性别 |
+ 部门 |
+ 职位 |
+ 手机号 |
+ 入职日期 |
+ 状态 |
+ 操作 |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+ '在职','resigned'=>'离职','suspended'=>'停职'];
+ $statusColors = ['active'=>'success','resigned'=>'default','suspended'=>'warning'];
+ $st = $e['status'] ?? 'active';
+ ?>
+
+
+
+ |
+
+ 编辑
+
+ |
+
+
+
+ | 暂无员工数据 |
+
+
+
+
+
+
+
diff --git a/app/views/General/hrAdd.php b/app/views/General/hrAdd.php
new file mode 100644
index 0000000..5563863
--- /dev/null
+++ b/app/views/General/hrAdd.php
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/General/hrEdit.php b/app/views/General/hrEdit.php
new file mode 100644
index 0000000..e1cda80
--- /dev/null
+++ b/app/views/General/hrEdit.php
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/General/index.php b/app/views/General/index.php
new file mode 100644
index 0000000..d047b8a
--- /dev/null
+++ b/app/views/General/index.php
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Inventory/index.php b/app/views/Inventory/index.php
new file mode 100644
index 0000000..2136070
--- /dev/null
+++ b/app/views/Inventory/index.php
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 产品名称 |
+ 规格型号 |
+ 当前库存 |
+ 最低库存 |
+ 仓库 |
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Inventory/purchase.php b/app/views/Inventory/purchase.php
new file mode 100644
index 0000000..32c0bb7
--- /dev/null
+++ b/app/views/Inventory/purchase.php
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+ | 序号 |
+ 采购单号 |
+ 供应商 |
+ 采购日期 |
+ 总金额 |
+ 状态 |
+ 操作人 |
+ 操作 |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ ¥ |
+
+ '待确认','confirmed'=>'已确认','received'=>'已收货','cancelled'=>'已取消'];
+ $statusColors = ['pending'=>'warning','confirmed'=>'info','received'=>'success','cancelled'=>'default'];
+ $st = $o['status'] ?? 'pending';
+ ?>
+
+
+
+ |
+ |
+
+ 详情
+ 编辑
+
+ |
+
+
+
+ | 暂无采购订单 |
+
+
+
+
+
+
+
diff --git a/app/views/Inventory/purchaseAdd.php b/app/views/Inventory/purchaseAdd.php
new file mode 100644
index 0000000..99f7b72
--- /dev/null
+++ b/app/views/Inventory/purchaseAdd.php
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Inventory/purchaseDetail.php b/app/views/Inventory/purchaseDetail.php
new file mode 100644
index 0000000..047b14d
--- /dev/null
+++ b/app/views/Inventory/purchaseDetail.php
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+ - 采购单号
+ - 供应商
+ - 采购日期
+ - 总金额
- ¥
+ - 状态
+ - 操作人
+ - 备注
+
+
+
采购明细
+
+
+
+ | 产品名称 |
+ 规格型号 |
+ 数量 |
+ 单位 |
+ 单价 |
+ 金额 |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ ¥ |
+ ¥ |
+
+
+
+ | 无明细数据 |
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Inventory/purchaseEdit.php b/app/views/Inventory/purchaseEdit.php
new file mode 100644
index 0000000..117c1d0
--- /dev/null
+++ b/app/views/Inventory/purchaseEdit.php
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Inventory/sales.php b/app/views/Inventory/sales.php
new file mode 100644
index 0000000..19fc9bd
--- /dev/null
+++ b/app/views/Inventory/sales.php
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+ | 序号 |
+ 销售单号 |
+ 客户名称 |
+ 销售日期 |
+ 总金额 |
+ 状态 |
+ 操作人 |
+ 操作 |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ ¥ |
+
+ '待发货','shipped'=>'已发货','completed'=>'已完成','cancelled'=>'已取消'];
+ $statusColors = ['pending'=>'warning','shipped'=>'info','completed'=>'success','cancelled'=>'default'];
+ $st = $o['status'] ?? 'pending';
+ ?>
+
+
+
+ |
+ |
+
+ 详情
+ 编辑
+
+ |
+
+
+
+ | 暂无销售订单 |
+
+
+
+
+
+
+
diff --git a/app/views/Inventory/salesAdd.php b/app/views/Inventory/salesAdd.php
new file mode 100644
index 0000000..d439bd2
--- /dev/null
+++ b/app/views/Inventory/salesAdd.php
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Inventory/salesDetail.php b/app/views/Inventory/salesDetail.php
new file mode 100644
index 0000000..1e59bb2
--- /dev/null
+++ b/app/views/Inventory/salesDetail.php
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+ - 销售单号
+ - 客户名称
+ - 销售日期
+ - 总金额
- ¥
+ - 状态
+ - 操作人
+ - 备注
+
+
+
销售明细
+
+
+
+ | 产品名称 |
+ 规格型号 |
+ 数量 |
+ 单位 |
+ 单价 |
+ 金额 |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ ¥ |
+ ¥ |
+
+
+
+ | 无明细数据 |
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Inventory/salesEdit.php b/app/views/Inventory/salesEdit.php
new file mode 100644
index 0000000..9292897
--- /dev/null
+++ b/app/views/Inventory/salesEdit.php
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Inventory/stock.php b/app/views/Inventory/stock.php
new file mode 100644
index 0000000..796126d
--- /dev/null
+++ b/app/views/Inventory/stock.php
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+ | 序号 |
+ 产品名称 |
+ 规格型号 |
+ 物料分类 |
+ 库存数量 |
+ 单位 |
+ 参考单价 |
+ 最低库存 |
+ 仓库 |
+ 库位 |
+ 操作 |
+
+
+
+
+
+ 0 && $s['quantity'] <= $s['min_stock'];
+ ?>
+
+ |
+ |
+ |
+ |
+
+
+
+
+ |
+ |
+ ¥ |
+ |
+ |
+ |
+
+ 编辑
+
+ |
+
+
+
+ | 暂无库存数据 |
+
+
+
+
+
+
+
diff --git a/app/views/Inventory/stockAdd.php b/app/views/Inventory/stockAdd.php
new file mode 100644
index 0000000..c6d50f2
--- /dev/null
+++ b/app/views/Inventory/stockAdd.php
@@ -0,0 +1,94 @@
+
+
+
+
+
diff --git a/app/views/Inventory/stockEdit.php b/app/views/Inventory/stockEdit.php
new file mode 100644
index 0000000..f866fc3
--- /dev/null
+++ b/app/views/Inventory/stockEdit.php
@@ -0,0 +1,94 @@
+
+
+
+
+
diff --git a/app/views/Inventory/supplier.php b/app/views/Inventory/supplier.php
new file mode 100644
index 0000000..04e9cd5
--- /dev/null
+++ b/app/views/Inventory/supplier.php
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+ | 序号 |
+ 供应商名称 |
+ 联系人 |
+ 联系电话 |
+ 地址 |
+ 状态 |
+ 备注 |
+ 操作 |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+ |
+ |
+
+ 编辑
+
+ |
+
+
+
+ | 暂无供应商数据 |
+
+
+
+
+
+
+
diff --git a/app/views/Inventory/supplierAdd.php b/app/views/Inventory/supplierAdd.php
new file mode 100644
index 0000000..8de0325
--- /dev/null
+++ b/app/views/Inventory/supplierAdd.php
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Inventory/supplierEdit.php b/app/views/Inventory/supplierEdit.php
new file mode 100644
index 0000000..9af5f6e
--- /dev/null
+++ b/app/views/Inventory/supplierEdit.php
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/Login/index.php b/app/views/Login/index.php
new file mode 100644
index 0000000..541f939
--- /dev/null
+++ b/app/views/Login/index.php
@@ -0,0 +1,78 @@
+
+
+
+
+
+ MES 制造执行系统 - 登录
+
+
+
+
+
+
+
+
+
+ MES 制造执行系统
+
+
+
请使用员工账号登录
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/layouts/admin_footer.php b/app/views/layouts/admin_footer.php
new file mode 100644
index 0000000..723a8ba
--- /dev/null
+++ b/app/views/layouts/admin_footer.php
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+