初始化

This commit is contained in:
2026-08-08 18:28:49 +08:00
parent 9bef4420e0
commit f082037a6e
854 changed files with 217171 additions and 11 deletions
File diff suppressed because it is too large Load Diff
+918
View File
@@ -0,0 +1,918 @@
<?php
namespace app\controllers;
use core\base\Controller;
use app\models\AsdTestData;
use app\models\AsdDevice;
/**
* 昂盛达上位机 MES 接口 API
* 接口文档版本:1.5
*
* 工作流:UserInfoVerification → GetWorkInfo → CheckFlow → UpDataInfo
*
* 接口地址:http://host/Api/{method}
* 协议:HTTP POST
* 数据格式:JSON
*/
class ApiController extends Controller
{
/**
* 构造函数:所有 API 响应统一输出 JSON。
* 注意:框架 Core::route() 不会自动调用 init(),因此此处设置 Content-Type。
*/
public function __construct($controller, $action)
{
parent::__construct($controller, $action);
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
}
// 注册 API 专用异常处理器:PDO 处于 ERRMODE_EXCEPTION 模式,
// 任何未捕获异常(如缺列、连接异常)都会冒泡成 500 显示「系统内部错误」。
// 这里覆盖为 JSON 响应,既避免 500,又把真实错误暴露给调用方便于排查。
set_exception_handler(function ($e) {
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
}
http_response_code(500);
$msg = '服务器处理异常';
if (APP_DEBUG && $e instanceof \Throwable) {
$msg .= ': ' . $e->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());
}
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
/**
* 前台工位看板 - 独立单页(不嵌入 AdminLTE 框架)
* 显示所有工位的实时生产状态
*/
namespace app\controllers;
use core\base\Controller;
use app\models\Workstation;
use app\models\StationDefinition;
class DashboardController extends Controller
{
public function index()
{
$this->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();
}
}
+474
View File
@@ -0,0 +1,474 @@
<?php
namespace app\controllers;
use core\base\Controller;
use app\models\FinanceRecord;
use app\models\ReceivablePayable;
/**
* 财务管理控制器
* 访问权限:超级管理员 或 财务类目管理员
*/
class FinanceController extends Controller
{
private function checkCategoryAccess()
{
$this->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();
}
}
File diff suppressed because it is too large Load Diff
+540
View File
@@ -0,0 +1,540 @@
<?php
namespace app\controllers;
use core\base\Controller;
use app\models\HrEmployee;
use app\models\Asset;
use app\models\Document;
/**
* 综合管理控制器
* 访问权限:超级管理员 或 综合管理类目管理员
*/
class GeneralController extends Controller
{
private function checkCategoryAccess()
{
$this->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;
}
}
+726
View File
@@ -0,0 +1,726 @@
<?php
namespace app\controllers;
use core\base\Controller;
use app\models\Supplier;
use app\models\PurchaseOrder;
use app\models\PurchaseItem;
use app\models\Inventory;
use app\models\SalesOrder;
use app\models\SalesItem;
/**
* 进销存管理控制器
* 访问权限:超级管理员 或 进销存类目管理员
*/
class InventoryController extends Controller
{
private function checkCategoryAccess()
{
$this->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;
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace app\controllers;
use core\base\Controller;
use app\models\Employee;
class LoginController extends Controller
{
// 暴力破解防护配置
const MAX_LOGIN_ATTEMPTS = 5; // 最大尝试次数
const LOCKOUT_DURATION = 900; // 锁定时间(秒),15分钟
const ATTEMPT_WINDOW = 300; // 计数窗口(秒),5分钟
public function index()
{
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 登录页不强制 CSRF 验证,原因:
// 1. 部分设备/浏览器 Session Cookie 不稳定(SameSite=Lax、隐私模式等)
// 导致每次请求创建新 SessionCSRF Token 永远对不上
// 2. 登录本身无敏感副作用(不修改数据),攻击者 CSRF 登录成功
// 也无法获知密码,且暴力破解防护仍在生效
// 3. 登录成功后的所有操作仍受 CSRF 保护
$emp_no = isset($_POST['emp_no']) ? trim($_POST['emp_no']) : '';
$password = isset($_POST['password']) ? $_POST['password'] : '';
if (empty($emp_no) || empty($password)) {
$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] POST_EMPTY emp_no_len=" . strlen($emp_no) . " pwd_len=" . strlen($password) . " ua={$ua} sess_hash={$sess}");
$error = '请输入员工编号和密码';
} else {
// 暴力破解检查
$lockError = $this->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;
}
}
File diff suppressed because it is too large Load Diff