Files
MES/app/controllers/FrontController.php
T
2026-08-08 18:28:49 +08:00

1270 lines
54 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\controllers;
use core\base\Controller;
use app\models\StationDefinition;
use app\models\ProductModel;
use app\models\ProductTypeConfig;
use app\models\Customer;
use app\models\Workstation;
use app\models\EmployeePermission;
use app\models\SysConfig;
use app\models\StationRecord;
use app\models\Document;
require_once APP_PATH . 'app/helpers/field_helper.php';
require_once APP_PATH . 'app/helpers/station_business_helper.php';
class FrontController extends Controller
{
/**
* 根据工位类型获取字段配置
*/
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'] === \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 '<!DOCTYPE html><html><head><meta charset="utf-8"><title>入库单</title>';
echo '<style>body{font-family:Arial,sans-serif;padding:20px;}';
echo 'table{border-collapse:collapse;width:100%;}';
echo 'th,td{border:1px solid #333;padding:8px;text-align:left;}';
echo 'th{background:#3c8dbc;color:white;}';
echo 'h1{text-align:center;color:#333;}</style></head><body>';
echo '<h1>入库单</h1>';
echo '<p>操作人:' . htmlspecialchars($user['emp_name']) . '</p>';
echo '<p>生成时间:' . date('Y-m-d H:i:s') . '</p>';
echo '<table><tr><th>序号</th><th>成品序列号</th><th>箱序列号</th><th>入库时间</th><th>产品型号</th><th>操作人</th></tr>';
foreach ($records as $i => $record) {
echo '<tr><td>' . ($i + 1) . '</td>';
echo '<td>' . htmlspecialchars($record['finished_serial']) . '</td>';
echo '<td>' . htmlspecialchars($record['box_serial'] ?? '') . '</td>';
echo '<td>' . htmlspecialchars($record['in_time']) . '</td>';
echo '<td>' . htmlspecialchars($record['product_model']) . '</td>';
echo '<td>' . htmlspecialchars($record['operator']) . '</td></tr>';
}
echo '</table></body></html>';
}
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 '<!DOCTYPE html><html><head><meta charset="utf-8"><title>出库单</title>';
echo '<style>body{font-family:Arial,sans-serif;padding:20px;}';
echo 'table{border-collapse:collapse;width:100%;}';
echo 'th,td{border:1px solid #333;padding:8px;text-align:left;}';
echo 'th{background:#3c8dbc;color:white;}';
echo 'h1{text-align:center;color:#333;}</style></head><body>';
echo '<h1>出库单</h1>';
echo '<p>操作人:' . htmlspecialchars($user['emp_name']) . '</p>';
echo '<p>生成时间:' . date('Y-m-d H:i:s') . '</p>';
echo '<table><tr><th>序号</th><th>产品型号</th><th>数量</th><th>客户名称</th></tr>';
$idx = 1; $totalQty = 0;
foreach ($summary as $item) {
echo '<tr><td>' . $idx . '</td>';
echo '<td>' . htmlspecialchars($item['product_model']) . '</td>';
echo '<td>' . $item['count'] . '</td>';
echo '<td>' . htmlspecialchars($item['customer_name']) . '</td></tr>';
$idx++; $totalQty += $item['count'];
}
echo '<tr style="font-weight:bold;background:#f0f0f0;"><td colspan="2" style="text-align:right;">合计</td><td>' . $totalQty . '</td><td></td></tr>';
echo '</table></body></html>';
}
// ========== 文件管理工位 ==========
/**
* 渲染文件管理工位视图
*/
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()]);
}
}
}