PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
));
// SQLite 外键
$pdo->exec('PRAGMA foreign_keys = ON;');
trace_db_migrate($pdo);
return $pdo;
}
/**
* @param PDO $pdo
*/
function trace_db_migrate($pdo)
{
$pdo->exec("
CREATE TABLE IF NOT EXISTS raw_material_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_no TEXT NOT NULL UNIQUE,
supplier_code TEXT NOT NULL DEFAULT '',
supplier_name TEXT NOT NULL DEFAULT '',
material_name TEXT NOT NULL DEFAULT '',
inbound_date TEXT NOT NULL,
quantity REAL NOT NULL,
unit TEXT NOT NULL DEFAULT 'kg',
inbound_order_no TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
");
trace_db_ensure_column($pdo, 'raw_material_batches', 'inbound_order_no', "TEXT NOT NULL DEFAULT ''");
$pdo->exec("
CREATE TABLE IF NOT EXISTS production_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_no TEXT NOT NULL UNIQUE,
line_type TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
");
trace_db_ensure_column($pdo, 'production_batches', 'line_type', "TEXT NOT NULL DEFAULT ''");
$pdo->exec("
CREATE TABLE IF NOT EXISTS production_consumption (
id INTEGER PRIMARY KEY AUTOINCREMENT,
production_batch_no TEXT NOT NULL,
material_name TEXT NOT NULL,
raw_batch_no TEXT NOT NULL,
quantity REAL NOT NULL,
unit TEXT NOT NULL DEFAULT 'kg',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (production_batch_no) REFERENCES production_batches(batch_no) ON DELETE CASCADE,
FOREIGN KEY (raw_batch_no) REFERENCES raw_material_batches(batch_no) ON DELETE RESTRICT
);
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS finished_goods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_no TEXT NOT NULL UNIQUE,
product_name TEXT NOT NULL,
quantity REAL NOT NULL,
unit TEXT NOT NULL DEFAULT 'kg',
production_date TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (batch_no) REFERENCES production_batches(batch_no) ON DELETE RESTRICT
);
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS sales_outbound (
id INTEGER PRIMARY KEY AUTOINCREMENT,
outbound_order_no TEXT NOT NULL,
finished_batch_no TEXT NOT NULL,
customer TEXT NOT NULL,
quantity REAL NOT NULL,
unit TEXT NOT NULL DEFAULT 'kg',
outbound_date TEXT NOT NULL,
outbound_type TEXT NOT NULL DEFAULT '出库',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (finished_batch_no) REFERENCES finished_goods(batch_no) ON DELETE RESTRICT
);
");
trace_db_ensure_column($pdo, 'sales_outbound', 'outbound_type', "TEXT NOT NULL DEFAULT '出库'");
$pdo->exec("
CREATE TABLE IF NOT EXISTS small_package_specs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
spec_code TEXT NOT NULL DEFAULT '',
spec_name TEXT NOT NULL,
unit TEXT NOT NULL DEFAULT '袋',
fg_qty_per_package REAL NOT NULL DEFAULT 0,
fg_qty_per_package_unit TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
");
trace_db_ensure_column($pdo, 'small_package_specs', 'fg_qty_per_package', 'REAL NOT NULL DEFAULT 0');
trace_db_ensure_column($pdo, 'small_package_specs', 'fg_qty_per_package_unit', "TEXT NOT NULL DEFAULT ''");
$pdo->exec("
CREATE TABLE IF NOT EXISTS small_package_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
package_batch_no TEXT NOT NULL UNIQUE,
spec_id INTEGER NOT NULL,
quantity REAL NOT NULL,
unit TEXT NOT NULL DEFAULT '袋',
inbound_date TEXT NOT NULL,
inbound_type TEXT NOT NULL DEFAULT '采购入库',
source_outbound_order_no TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (spec_id) REFERENCES small_package_specs(id) ON DELETE RESTRICT
);
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS small_package_outbound (
id INTEGER PRIMARY KEY AUTOINCREMENT,
outbound_order_no TEXT NOT NULL,
package_batch_no TEXT NOT NULL,
spec_id INTEGER NOT NULL,
quantity REAL NOT NULL,
unit TEXT NOT NULL DEFAULT '袋',
customer TEXT NOT NULL,
outbound_date TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (package_batch_no) REFERENCES small_package_batches(package_batch_no) ON DELETE RESTRICT,
FOREIGN KEY (spec_id) REFERENCES small_package_specs(id) ON DELETE RESTRICT
);
");
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_sp_batch_spec ON small_package_batches(spec_id);');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_sp_out_batch ON small_package_outbound(package_batch_no);');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_sp_source_order ON small_package_batches(source_outbound_order_no);');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_consume_prod ON production_consumption(production_batch_no);');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_consume_raw ON production_consumption(raw_batch_no);');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_sales_fg ON sales_outbound(finished_batch_no);');
// 历史数据:出库类型与入库类型命名统一
$pdo->exec("UPDATE sales_outbound SET outbound_type = '转小包装' WHERE outbound_type = '再加工'");
$pdo->exec("UPDATE small_package_batches SET inbound_type = '销售转入' WHERE inbound_type = '再加工转入'");
}
/**
* 旧库升级:列不存在时执行 ALTER TABLE ADD COLUMN(SQLite)
*
* @param PDO $pdo
* @param string $table
* @param string $column
* @param string $sqlType e.g. "TEXT NOT NULL DEFAULT ''"
*/
function trace_db_ensure_column($pdo, $table, $column, $sqlType)
{
$info = $pdo->query('PRAGMA table_info(' . $table . ')')->fetchAll();
foreach ($info as $col) {
if (isset($col['name']) && strcasecmp($col['name'], $column) === 0) {
return;
}
}
$pdo->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $column . ' ' . $sqlType);
}
批号中用到的英文段,须为字母数字
*
* @return array
*/
function trace_line_types()
{
return array(
'原粉发酵' => 'YPFJ',
'水剂发酵' => 'SJFJ',
'粉剂填料' => 'FJTL',
'水剂填料' => 'SJTL',
);
}
/**
* 允许「成品入库」下拉出现的线别(填料类)
*
* @return array
*/
function trace_finished_goods_line_types()
{
return array('粉剂填料', '水剂填料');
}
/**
* @param string $label
* @return string|null
*/
function trace_line_type_segment($label)
{
$map = trace_line_types();
return isset($map[$label]) ? $map[$label] : null;
}
/**
* @param mixed $s
* @return string
*/
function h($s)
{
return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* 原料批次:P-YYYYMMDD-供应商代码-当日序号(两位)
* 若供应商代码为空则用 PYYYYMMDD-序号
*
* @param PDO $pdo
* @param string $inboundDate YYYY-MM-DD
* @param string $supplierCode
* @return string
*/
function generate_raw_batch_no($pdo, $inboundDate, $supplierCode)
{
$d = preg_replace('/[^0-9]/', '', $inboundDate);
if (strlen($d) !== 8) {
$d = date('Ymd');
}
$code = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', $supplierCode));
if ($code === '') {
$prefix = 'P' . $d . '-';
} else {
$prefix = 'P-' . $d . '-' . $code . '-';
}
$stmt = $pdo->prepare(
'SELECT batch_no FROM raw_material_batches WHERE batch_no LIKE ? ORDER BY batch_no DESC LIMIT 1'
);
$stmt->execute(array($prefix . '%'));
$row = $stmt->fetch();
$next = 1;
if ($row && isset($row['batch_no'])) {
if (preg_match('/-(\d{2})$/', $row['batch_no'], $m)) {
$next = (int) $m[1] + 1;
}
}
if ($next > 99) {
$next = 1;
$prefix .= 'x';
}
return $prefix . str_pad((string) $next, 2, '0', STR_PAD_LEFT);
}
/**
* 原料批次当前剩余可领数量(入库量 − 已记入领料表的合计)。
* 单位一致时才准确;系统不做单位换算。
*
* @param PDO $pdo
* @param string $rawBatchNo
* @return float|null 批次不存在返回 null
*/
function trace_raw_available_qty($pdo, $rawBatchNo)
{
$st = $pdo->prepare('SELECT quantity FROM raw_material_batches WHERE batch_no = ? LIMIT 1');
$st->execute(array($rawBatchNo));
$row = $st->fetch();
if (!$row) {
return null;
}
$inbound = (float) $row['quantity'];
$st2 = $pdo->prepare(
'SELECT COALESCE(SUM(quantity), 0) FROM production_consumption WHERE raw_batch_no = ?'
);
$st2->execute(array($rawBatchNo));
$used = (float) $st2->fetchColumn();
return $inbound - $used;
}
/**
* 生产批号:YYYYMMDD-线别段-当日序号 例 20260415-FJTL-01(线别段为 trace_line_types 映射值)
*
* @param PDO $pdo
* @param string $lineCode 英文段,如 YPFJ、FJTL
* @param string|null $prodDate YYYY-MM-DD,默认今天
* @return string
*/
function generate_production_batch_no($pdo, $lineCode, $prodDate = null)
{
$line = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $lineCode));
if ($line === '') {
$line = 'YPFJ';
}
if ($prodDate === null || $prodDate === '') {
$prodDate = date('Y-m-d');
}
$d = preg_replace('/[^0-9]/', '', $prodDate);
if (strlen($d) !== 8) {
$d = date('Ymd');
}
$prefix = $d . '-' . $line . '-';
$stmt = $pdo->prepare(
'SELECT batch_no FROM production_batches WHERE batch_no LIKE ? ORDER BY batch_no DESC LIMIT 1'
);
$stmt->execute(array($prefix . '%'));
$row = $stmt->fetch();
$next = 1;
if ($row && isset($row['batch_no'])) {
if (preg_match('/-(\d{2})$/', $row['batch_no'], $m)) {
$next = (int) $m[1] + 1;
}
}
return $prefix . str_pad((string) $next, 2, '0', STR_PAD_LEFT);
}
/**
* 销售出库单号:O-YYYYMMDD-当日序号(两位)
*
* @param PDO $pdo
* @param string|null $outboundDate YYYY-MM-DD
* @return string
*/
function generate_outbound_order_no($pdo, $outboundDate = null)
{
if ($outboundDate === null || $outboundDate === '') {
$outboundDate = date('Y-m-d');
}
$d = preg_replace('/[^0-9]/', '', $outboundDate);
if (strlen($d) !== 8) {
$d = date('Ymd');
}
$prefix = 'O-' . $d . '-';
$stmt = $pdo->prepare(
'SELECT outbound_order_no FROM sales_outbound WHERE outbound_order_no LIKE ? ORDER BY outbound_order_no DESC LIMIT 1'
);
$stmt->execute(array($prefix . '%'));
$row = $stmt->fetch();
$next = 1;
if ($row && !empty($row['outbound_order_no'])) {
if (preg_match('/-(\d{2})$/', $row['outbound_order_no'], $m)) {
$next = (int) $m[1] + 1;
}
}
if ($next > 99) {
$next = 1;
$prefix .= 'x';
}
return $prefix . str_pad((string) $next, 2, '0', STR_PAD_LEFT);
}
/**
* 销售界面大类 => production_batches.line_type
*
* @return array
*/
function trace_sales_fg_categories()
{
return array(
'粉剂' => '粉剂填料',
'水剂' => '水剂填料',
);
}
/**
* 出库业务类型
*
* @return array
*/
function trace_sales_outbound_types()
{
return array('出库', '转小包装');
}
/**
* 是否「成品转出并计入小包装库存」类出库(兼容旧库中的「再加工」)
*
* @param string $outboundType
* @return bool
*/
function trace_sales_is_transfer_small_pack($outboundType)
{
return $outboundType === '转小包装' || $outboundType === '再加工';
}
/**
* 某成品批次剩余可出数量(成品入库量 − 已销售出库合计)
*
* @param PDO $pdo
* @param string $batchNo
* @return array|null array('remaining' => float, 'unit' => string)
*/
function trace_finished_goods_remaining($pdo, $batchNo)
{
$st = $pdo->prepare('SELECT quantity, unit FROM finished_goods WHERE batch_no = ? LIMIT 1');
$st->execute(array($batchNo));
$row = $st->fetch();
if (!$row) {
return null;
}
$inbound = (float) $row['quantity'];
$st2 = $pdo->prepare(
'SELECT COALESCE(SUM(quantity), 0) FROM sales_outbound WHERE finished_batch_no = ?'
);
$st2->execute(array($batchNo));
$sold = (float) $st2->fetchColumn();
return array(
'remaining' => $inbound - $sold,
'unit' => $row['unit'],
);
}
/**
* 按 FIFO(生产日期早、同日期则批次号序)列出某线别仍有库存的成品批次
*
* @param PDO $pdo
* @param string $lineType 粉剂填料|水剂填料
* @return array
*/
function trace_sales_fifo_batches($pdo, $lineType)
{
$sql = "
SELECT x.id, x.batch_no, x.product_name, x.production_date, x.unit, x.inbound_qty, x.remaining
FROM (
SELECT f.id, f.batch_no, f.product_name, f.production_date, f.unit, f.quantity AS inbound_qty,
(f.quantity - COALESCE((SELECT SUM(s.quantity) FROM sales_outbound s WHERE s.finished_batch_no = f.batch_no), 0)) AS remaining
FROM finished_goods f
INNER JOIN production_batches p ON p.batch_no = f.batch_no
WHERE p.line_type = ?
) x
WHERE x.remaining > 0.0000001
ORDER BY x.production_date ASC, x.id ASC
";
$st = $pdo->prepare($sql);
$st->execute(array($lineType));
return $st->fetchAll();
}
/**
* FIFO 分配出库数量;每行对应一个成品批次,可追溯
*
* @param PDO $pdo
* @param string $lineType
* @param float $needQty
* @return array|false|null 分配列表 [['batch_no','quantity','unit'],...];null 库存不足;false 单位不一致
*/
function trace_sales_fifo_allocate($pdo, $lineType, $needQty)
{
if ($needQty <= 0) {
return array();
}
$batches = trace_sales_fifo_batches($pdo, $lineType);
if (empty($batches)) {
return null;
}
$units = array();
foreach ($batches as $b) {
$units[$b['unit']] = true;
}
if (count($units) > 1) {
return false;
}
$left = (float) $needQty;
$alloc = array();
foreach ($batches as $b) {
if ($left <= 1e-9) {
break;
}
$rem = (float) $b['remaining'];
if ($rem <= 0) {
continue;
}
$take = $rem < $left ? $rem : $left;
if ($take <= 0) {
continue;
}
$alloc[] = array(
'batch_no' => $b['batch_no'],
'quantity' => $take,
'unit' => $b['unit'],
);
$left -= $take;
}
if ($left > 1e-9) {
return null;
}
return $alloc;
}
/**
* 小包装批次号:SP-YYYYMMDD-三位序号
*
* @param PDO $pdo
* @param string|null $inboundDate YYYY-MM-DD
* @return string
*/
function generate_small_package_batch_no($pdo, $inboundDate = null)
{
if ($inboundDate === null || $inboundDate === '') {
$inboundDate = date('Y-m-d');
}
$d = preg_replace('/[^0-9]/', '', $inboundDate);
if (strlen($d) !== 8) {
$d = date('Ymd');
}
$prefix = 'SP-' . $d . '-';
$stmt = $pdo->prepare(
'SELECT package_batch_no FROM small_package_batches WHERE package_batch_no LIKE ? ORDER BY package_batch_no DESC LIMIT 1'
);
$stmt->execute(array($prefix . '%'));
$row = $stmt->fetch();
$next = 1;
if ($row && !empty($row['package_batch_no'])) {
if (preg_match('/-(\d{3})$/', $row['package_batch_no'], $m)) {
$next = (int) $m[1] + 1;
}
}
if ($next > 999) {
$next = 1;
$prefix .= 'x';
}
return $prefix . str_pad((string) $next, 3, '0', STR_PAD_LEFT);
}
/**
* 小包装销售出库单号:SPO-YYYYMMDD-两位序号
*
* @param PDO $pdo
* @param string|null $outboundDate
* @return string
*/
function generate_small_package_outbound_order_no($pdo, $outboundDate = null)
{
if ($outboundDate === null || $outboundDate === '') {
$outboundDate = date('Y-m-d');
}
$d = preg_replace('/[^0-9]/', '', $outboundDate);
if (strlen($d) !== 8) {
$d = date('Ymd');
}
$prefix = 'SPO-' . $d . '-';
$stmt = $pdo->prepare(
'SELECT outbound_order_no FROM small_package_outbound WHERE outbound_order_no LIKE ? ORDER BY outbound_order_no DESC LIMIT 1'
);
$stmt->execute(array($prefix . '%'));
$row = $stmt->fetch();
$next = 1;
if ($row && !empty($row['outbound_order_no'])) {
if (preg_match('/-(\d{2})$/', $row['outbound_order_no'], $m)) {
$next = (int) $m[1] + 1;
}
}
if ($next > 99) {
$next = 1;
$prefix .= 'x';
}
return $prefix . str_pad((string) $next, 2, '0', STR_PAD_LEFT);
}
/**
* 小包装入库类型(仅历史数据;新数据均由销售出库「转小包装」写入「销售转入」)
*
* @return array
*/
function trace_small_package_manual_inbound_types()
{
return array('采购入库', '其他入库', '销售转入');
}
/**
* 某规格下仍有库存的小包装批次(FIFO:入库日期早优先)
*
* @param PDO $pdo
* @param int $specId
* @return array
*/
function trace_sp_fifo_batches($pdo, $specId)
{
$sql = "
SELECT x.id, x.package_batch_no, x.spec_id, x.inbound_date, x.unit, x.quantity AS inbound_qty, x.remaining,
s.spec_name, s.spec_code
FROM (
SELECT b.id, b.package_batch_no, b.spec_id, b.inbound_date, b.unit, b.quantity,
(b.quantity - COALESCE((SELECT SUM(o.quantity) FROM small_package_outbound o WHERE o.package_batch_no = b.package_batch_no), 0)) AS remaining
FROM small_package_batches b
WHERE b.spec_id = ?
) x
INNER JOIN small_package_specs s ON s.id = x.spec_id
WHERE x.remaining > 0.0000001
ORDER BY x.inbound_date ASC, x.id ASC
";
$st = $pdo->prepare($sql);
$st->execute(array((int) $specId));
return $st->fetchAll();
}
/**
* 小包装按规格 FIFO 分配出库
*
* @param PDO $pdo
* @param int $specId
* @param float $needQty
* @return array|false|null
*/
function trace_sp_fifo_allocate($pdo, $specId, $needQty)
{
if ($needQty <= 0) {
return array();
}
$batches = trace_sp_fifo_batches($pdo, $specId);
if (empty($batches)) {
return null;
}
$units = array();
foreach ($batches as $b) {
$units[$b['unit']] = true;
}
if (count($units) > 1) {
return false;
}
$left = (float) $needQty;
$alloc = array();
foreach ($batches as $b) {
if ($left <= 1e-9) {
break;
}
$rem = (float) $b['remaining'];
if ($rem <= 0) {
continue;
}
$take = $rem < $left ? $rem : $left;
if ($take <= 0) {
continue;
}
$alloc[] = array(
'package_batch_no' => $b['package_batch_no'],
'quantity' => $take,
'unit' => $b['unit'],
'spec_id' => (int) $b['spec_id'],
);
$left -= $take;
}
if ($left > 1e-9) {
return null;
}
return $alloc;
}
/**
* @param string $msg
* @param string $type success|error|info
*/
function flash_set($msg, $type = 'info')
{
if (!isset($_SESSION)) {
return;
}
$_SESSION['_flash'] = array('msg' => $msg, 'type' => $type);
}
/**
* @return array|null
*/
function flash_get()
{
if (!isset($_SESSION)) {
return null;
}
if (empty($_SESSION['_flash'])) {
return null;
}
$f = $_SESSION['_flash'];
unset($_SESSION['_flash']);
return $f;
}
prepare('SELECT line_type FROM production_batches WHERE batch_no = ? LIMIT 1');
$chk->execute(array($batchNo));
$prow = $chk->fetch();
if (!$prow) {
flash_set('该批次号在生产任务中不存在,请先创建生产批号。', 'error');
} elseif (!in_array($prow['line_type'], $fgLineTypes, true)) {
flash_set('仅「粉剂填料」「水剂填料」线别的生产批号可成品入库。', 'error');
} else {
try {
$stmt = $pdo->prepare(
'INSERT INTO finished_goods (batch_no, product_name, quantity, unit, production_date) VALUES (?,?,?,?,?)'
);
$stmt->execute(array(
$batchNo,
$productName,
(float) $quantity,
$unit !== '' ? $unit : 'kg',
$productionDate,
));
flash_set('成品已入库:' . $batchNo, 'success');
header('Location: finished_goods.php');
exit;
} catch (PDOException $e) {
if (strpos($e->getMessage(), 'UNIQUE') !== false) {
flash_set('该成品批次号已入库,不可重复。', 'error');
} else {
flash_set('保存失败:' . $e->getMessage(), 'error');
}
}
}
}
}
$list = $pdo->query(
'SELECT * FROM finished_goods ORDER BY production_date DESC, id DESC LIMIT 200'
)->fetchAll();
$ph = implode(',', array_fill(0, count($fgLineTypes), '?'));
$stOpt = $pdo->prepare(
"SELECT p.batch_no, p.line_type,
CASE WHEN f.batch_no IS NOT NULL THEN 1 ELSE 0 END AS fg_done
FROM production_batches p
LEFT JOIN finished_goods f ON f.batch_no = p.batch_no
WHERE p.line_type IN ($ph)
ORDER BY p.id DESC
LIMIT 300"
);
$stOpt->execute($fgLineTypes);
$prodOptions = $stOpt->fetchAll();
require __DIR__ . '/layout.php';
?>
成品入库
成品批次号与生产批号一致。下拉仅显示粉剂填料、水剂填料的生产批号;若该批号已做过成品入库,选择后会提示。数量默认单位为 kg。
最近记录
库存与追溯
流程:原料入库 → 生产领料 → 成品入库 → 销售出库(直出或转小包装)→ 小包装出库。全链路数量默认 kg,各页可改单位。
小包装库存仅通过销售出库类型转小包装增加(箱数 × 规格「每箱 kg」扣成品)。统一在 追溯查询 按成品批次号或小包装批次号溯源。
查询统计(筛选导出友好)
' . h($flash['msg']) . '';
}
?>
prepare('INSERT INTO production_batches (batch_no, line_type, note) VALUES (?,?,?)');
$stmt->execute(array($batchNo, $lineType, $note));
flash_set('已创建生产批号:' . $batchNo . '(' . $lineType . ')', 'success');
} catch (PDOException $e) {
if (strpos($e->getMessage(), 'UNIQUE') !== false) {
flash_set('该生产批号已存在。', 'error');
} else {
flash_set('创建失败:' . $e->getMessage(), 'error');
}
}
}
header('Location: production.php');
exit;
}
// 添加领料(多行一次保存,校验每批原料剩余库存)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'add_consumption') {
$prodBatch = isset($_POST['production_batch_no']) ? trim($_POST['production_batch_no']) : '';
$names = isset($_POST['material_name']) && is_array($_POST['material_name']) ? $_POST['material_name'] : array();
$raws = isset($_POST['raw_batch_no']) && is_array($_POST['raw_batch_no']) ? $_POST['raw_batch_no'] : array();
$qtys = isset($_POST['quantity']) && is_array($_POST['quantity']) ? $_POST['quantity'] : array();
$units = isset($_POST['unit']) && is_array($_POST['unit']) ? $_POST['unit'] : array();
if ($prodBatch === '') {
flash_set('请选择生产批号。', 'error');
} else {
$check = $pdo->prepare('SELECT 1 FROM production_batches WHERE batch_no = ? LIMIT 1');
$check->execute(array($prodBatch));
if (!$check->fetch()) {
flash_set('生产批号不存在,请先创建生产批号。', 'error');
} else {
$n = max(count($names), count($raws), count($qtys));
$pending = array();
$err = '';
$toInsert = array();
$chkMat = $pdo->prepare('SELECT material_name FROM raw_material_batches WHERE batch_no = ? LIMIT 1');
for ($i = 0; $i < $n; $i++) {
$mn = isset($names[$i]) ? trim($names[$i]) : '';
$rb = isset($raws[$i]) ? trim($raws[$i]) : '';
$q = isset($qtys[$i]) ? $qtys[$i] : '';
$u = isset($units[$i]) && trim($units[$i]) !== '' ? trim($units[$i]) : 'kg';
if ($mn === '' && $rb === '' && (string) $q === '') {
continue;
}
if ($mn === '' || $rb === '' || !is_numeric($q) || (float) $q <= 0) {
$err = '每一行需选择原料、原料批次并填写有效用量。';
break;
}
$chkMat->execute(array($rb));
$rm = $chkMat->fetch();
if (!$rm) {
$err = '原料批次不存在:' . $rb;
break;
}
if (trim((string) $rm['material_name']) !== $mn) {
$err = '第 ' . ($i + 1) . ' 行:原料名称与所选原料批次不匹配。';
break;
}
$need = (float) $q;
$availBase = trace_raw_available_qty($pdo, $rb);
if ($availBase === null) {
$err = '原料批次不存在:' . $rb;
break;
}
$alreadyPending = isset($pending[$rb]) ? $pending[$rb] : 0;
$avail = $availBase - $alreadyPending;
if ($need > $avail + 1e-9) {
$err = '库存不足:批次 ' . $rb . ' 剩余 ' . $availBase . '(本单已分配 ' . $alreadyPending . '),不能领用 ' . $need . '。';
break;
}
$pending[$rb] = $alreadyPending + $need;
$toInsert[] = array($mn, $rb, $need, $u);
}
if ($err !== '') {
flash_set($err, 'error');
} elseif (empty($toInsert)) {
flash_set('请至少填写一行有效领料数据。', 'error');
} else {
$pdo->beginTransaction();
try {
$ins = $pdo->prepare(
'INSERT INTO production_consumption (production_batch_no, material_name, raw_batch_no, quantity, unit) VALUES (?,?,?,?,?)'
);
foreach ($toInsert as $row) {
$ins->execute(array($prodBatch, $row[0], $row[1], $row[2], $row[3]));
}
$pdo->commit();
flash_set('已保存生产批号 ' . $prodBatch . ' 的领料共 ' . count($toInsert) . ' 项。', 'success');
} catch (PDOException $e) {
$pdo->rollBack();
flash_set('保存失败:' . $e->getMessage(), 'error');
}
}
}
}
header('Location: production.php');
exit;
}
$batches = $pdo->query(
'SELECT batch_no, line_type, note, created_at FROM production_batches ORDER BY id DESC LIMIT 200'
)->fetchAll();
$consumeCountByBatch = array();
$ccRows = $pdo->query(
'SELECT production_batch_no, COUNT(*) AS n FROM production_consumption GROUP BY production_batch_no'
)->fetchAll();
foreach ($ccRows as $cr) {
$consumeCountByBatch[$cr['production_batch_no']] = (int) $cr['n'];
}
$rawOptions = $pdo->query(
"SELECT r.batch_no, r.material_name, r.unit, r.inbound_order_no,
(r.quantity - COALESCE((SELECT SUM(c.quantity) FROM production_consumption c WHERE c.raw_batch_no = r.batch_no), 0)) AS remaining
FROM raw_material_batches r
WHERE (r.quantity - COALESCE((SELECT SUM(c.quantity) FROM production_consumption c WHERE c.raw_batch_no = r.batch_no), 0)) > 0
ORDER BY r.inbound_date DESC, r.id DESC
LIMIT 500"
)->fetchAll();
$materialChoices = array();
foreach ($rawOptions as $o) {
$materialChoices[$o['material_name']] = true;
}
$materialChoices = array_keys($materialChoices);
natcasesort($materialChoices);
$materialChoices = array_values($materialChoices);
$rawForJs = array();
foreach ($rawOptions as $o) {
$rawForJs[] = array(
'batch_no' => $o['batch_no'],
'material_name' => $o['material_name'],
'remaining' => (float) $o['remaining'],
'unit' => $o['unit'],
'inbound_order_no' => isset($o['inbound_order_no']) ? $o['inbound_order_no'] : '',
);
}
$batchKeys = $pdo->query(
'SELECT production_batch_no FROM production_consumption GROUP BY production_batch_no ORDER BY MAX(id) DESC LIMIT 80'
)->fetchAll(PDO::FETCH_COLUMN);
$lineTypeByBatch = array();
foreach ($pdo->query('SELECT batch_no, line_type FROM production_batches')->fetchAll() as $br) {
$lineTypeByBatch[$br['batch_no']] = isset($br['line_type']) ? $br['line_type'] : '';
}
$consumptionByBatch = array();
if (!empty($batchKeys)) {
$placeholders = implode(',', array_fill(0, count($batchKeys), '?'));
$st = $pdo->prepare(
'SELECT c.production_batch_no, c.material_name, c.raw_batch_no, c.quantity, c.unit, c.id,
COALESCE(r.inbound_order_no, \'\') AS inbound_order_no
FROM production_consumption c
LEFT JOIN raw_material_batches r ON r.batch_no = c.raw_batch_no
WHERE c.production_batch_no IN (' . $placeholders . ')
ORDER BY c.production_batch_no, c.id'
);
$st->execute($batchKeys);
while ($row = $st->fetch()) {
$pb = $row['production_batch_no'];
if (!isset($consumptionByBatch[$pb])) {
$consumptionByBatch[$pb] = array();
}
$consumptionByBatch[$pb][] = $row;
}
}
require __DIR__ . '/layout.php';
?>
生产领料(追溯核心)
领料用量默认 kg;与原料入库单位一致时 FIFO 剩余量才准确。
领料记录(每生产批号一行,点开看原料明细)
$sc, 'supplier_name' => $sn, 'material_name' => $mn, 'quantity' => (float) $q, 'unit' => $u, 'batch_no' => $bn);
}
if ($lines === null) {
// flash already set
} elseif (empty($lines)) {
flash_set('请至少填写一行入库明细(供应商/原料/数量)。', 'error');
} else {
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'INSERT INTO raw_material_batches (batch_no, supplier_code, supplier_name, material_name, inbound_date, quantity, unit, inbound_order_no)
VALUES (?,?,?,?,?,?,?,?)'
);
$saved = 0;
foreach ($lines as $line) {
if ($auto || $line['batch_no'] === '') {
$batchNo = generate_raw_batch_no($pdo, $inboundDate, $line['supplier_code']);
} else {
$batchNo = $line['batch_no'];
}
$stmt->execute(array(
$batchNo,
$line['supplier_code'],
$line['supplier_name'],
$line['material_name'],
$inboundDate,
$line['quantity'],
$line['unit'],
$inboundOrderNo,
));
$saved++;
}
$pdo->commit();
flash_set('已入库 ' . $saved . ' 笔原料批次。', 'success');
header('Location: raw_inbound.php');
exit;
} catch (PDOException $e) {
$pdo->rollBack();
if (strpos($e->getMessage(), 'UNIQUE') !== false) {
flash_set('存在重复的原料批次号,请检查手工批次号或分批保存。', 'error');
} else {
flash_set('保存失败:' . $e->getMessage(), 'error');
}
}
}
}
$list = $pdo->query(
"SELECT r.*,
(r.quantity - COALESCE((SELECT SUM(c.quantity) FROM production_consumption c WHERE c.raw_batch_no = r.batch_no), 0)) AS remaining
FROM raw_material_batches r
ORDER BY r.inbound_date DESC, r.id DESC
LIMIT 200"
)->fetchAll();
require __DIR__ . '/layout.php';
?>
原料采购入库
数量默认单位为 kg(单位列可改)。同一笔到货可填一个入库单号,本单内多行共用;每行独立原料批次号。
最近记录
| 入库单号 |
原料批次号 |
供应商 |
原料名称 |
入库日期 |
入库数量 |
剩余可领 |
| 暂无数据 |
|
|
|
|
|
|
|
= ?';
$params[] = $df;
}
if ($dt !== '') {
$where[] = 'f.production_date <= ?';
$params[] = $dt;
}
if ($product !== '') {
$where[] = 'f.product_name LIKE ?';
$params[] = '%' . $product . '%';
}
if ($batch !== '') {
$where[] = 'f.batch_no LIKE ?';
$params[] = '%' . $batch . '%';
}
$sql = 'SELECT f.*, p.line_type
FROM finished_goods f
LEFT JOIN production_batches p ON p.batch_no = f.batch_no
WHERE ' . implode(' AND ', $where) . '
ORDER BY f.production_date DESC, f.id DESC
LIMIT 800';
$st = $pdo->prepare($sql);
$st->execute($params);
$rows = $st->fetchAll();
$sum = 0.0;
foreach ($rows as $r) {
$sum += (float) $r['quantity'];
}
require __DIR__ . '/layout.php';
?>
成品入库查询统计
默认数量 kg;可与线别对照。
共 行;数量合计 (单位混用时请人工辨别)。
| 成品批次号 |
线别 |
成品名称 |
数量 |
生产日期 |
| 无匹配数据 |
|
|
|
|
|
= ?";
$params[] = $df;
}
if ($dt !== '') {
$where[] = "substr(p.created_at, 1, 10) <= ?";
$params[] = $dt;
}
if ($lineType !== '') {
$where[] = 'p.line_type = ?';
$params[] = $lineType;
}
if ($batch !== '') {
$where[] = 'p.batch_no LIKE ?';
$params[] = '%' . $batch . '%';
}
if ($rawBatch !== '') {
$where[] = 'c.raw_batch_no LIKE ?';
$params[] = '%' . $rawBatch . '%';
}
$sql = 'SELECT p.batch_no, p.line_type, p.created_at, p.note,
c.id AS cid, c.material_name, c.raw_batch_no, c.quantity, c.unit
FROM production_batches p
LEFT JOIN production_consumption c ON c.production_batch_no = p.batch_no
WHERE ' . implode(' AND ', $where) . '
ORDER BY p.id DESC, c.id ASC LIMIT 2000';
$st = $pdo->prepare($sql);
$st->execute($params);
$rows = $st->fetchAll();
$lineTypes = array_keys(trace_line_types());
require __DIR__ . '/layout.php';
?>
生产领料查询统计
按生产批号与领料行展开;领料用量默认 kg。
共 行(含无领料的生产批,领料列为空)。
| 生产批号 |
线别 |
创建时间 |
备注 |
原料名称 |
原料批次 |
领用量 |
| 无匹配数据 |
|
|
|
|
|
|
|
= ?';
$params[] = $df;
}
if ($dt !== '') {
$where[] = 'r.inbound_date <= ?';
$params[] = $dt;
}
if ($material !== '') {
$where[] = 'r.material_name LIKE ?';
$params[] = '%' . $material . '%';
}
if ($supplier !== '') {
$where[] = '(r.supplier_code LIKE ? OR r.supplier_name LIKE ?)';
$params[] = '%' . $supplier . '%';
$params[] = '%' . $supplier . '%';
}
if ($batch !== '') {
$where[] = 'r.batch_no LIKE ?';
$params[] = '%' . $batch . '%';
}
if ($orderNo !== '') {
$where[] = 'r.inbound_order_no LIKE ?';
$params[] = '%' . $orderNo . '%';
}
$sql = 'SELECT r.*,
(r.quantity - COALESCE((SELECT SUM(c.quantity) FROM production_consumption c WHERE c.raw_batch_no = r.batch_no), 0)) AS remaining
FROM raw_material_batches r
WHERE ' . implode(' AND ', $where) . '
ORDER BY r.inbound_date DESC, r.id DESC
LIMIT 800';
$st = $pdo->prepare($sql);
$st->execute($params);
$rows = $st->fetchAll();
$sumQty = 0.0;
$sumRem = 0.0;
foreach ($rows as $r) {
$sumQty += (float) $r['quantity'];
$sumRem += (float) $r['remaining'];
}
require __DIR__ . '/layout.php';
?>
原料查询统计
按条件筛选原料入库明细;默认数量单位为 kg。
本页共 行;入库数量合计 (按行相加,单位可能混用时请人工辨别);剩余可领合计 。
| 批次号 |
入库单号 |
供应商 |
原料 |
入库日期 |
入库量 |
剩余可领 |
| 无匹配数据 |
|
|
|
|
|
|
|
= ?';
$params[] = $df;
}
if ($dt !== '') {
$where[] = 's.outbound_date <= ?';
$params[] = $dt;
}
if ($customer !== '') {
$where[] = 's.customer LIKE ?';
$params[] = '%' . $customer . '%';
}
if ($orderNo !== '') {
$where[] = 's.outbound_order_no LIKE ?';
$params[] = '%' . $orderNo . '%';
}
if ($otype !== '') {
$where[] = 'COALESCE(s.outbound_type, \'出库\') = ?';
$params[] = $otype;
}
if ($fgBatch !== '') {
$where[] = 's.finished_batch_no LIKE ?';
$params[] = '%' . $fgBatch . '%';
}
$sql = 'SELECT s.*, p.line_type
FROM sales_outbound s
LEFT JOIN finished_goods f ON f.batch_no = s.finished_batch_no
LEFT JOIN production_batches p ON p.batch_no = s.finished_batch_no
WHERE ' . implode(' AND ', $where) . '
ORDER BY s.outbound_date DESC, s.outbound_order_no DESC, s.id DESC
LIMIT 1200';
$st = $pdo->prepare($sql);
$st->execute($params);
$rows = $st->fetchAll();
$sum = 0.0;
foreach ($rows as $r) {
$sum += (float) $r['quantity'];
}
require __DIR__ . '/layout.php';
?>
销售出库查询统计
按明细行展示(一单可对应多成品批次);默认 kg。
共 行;出库数量合计 。
| 出库单号 |
类型 |
品类 |
成品批次 |
客户 |
数量 |
日期 |
| 无匹配数据 |
|
|
|
|
|
|
|
= ?';
$params[] = $df;
}
if ($dt !== '') {
$where[] = 'b.inbound_date <= ?';
$params[] = $dt;
}
if ($specId > 0) {
$where[] = 'b.spec_id = ?';
$params[] = $specId;
}
if ($inType !== '') {
$where[] = 'b.inbound_type = ?';
$params[] = $inType;
}
if ($pkg !== '') {
$where[] = 'b.package_batch_no LIKE ?';
$params[] = '%' . $pkg . '%';
}
if ($srcOrder !== '') {
$where[] = 'b.source_outbound_order_no LIKE ?';
$params[] = '%' . $srcOrder . '%';
}
$sql = 'SELECT b.*, s.spec_name,
(b.quantity - COALESCE((SELECT SUM(o.quantity) FROM small_package_outbound o WHERE o.package_batch_no = b.package_batch_no), 0)) AS remaining
FROM small_package_batches b
INNER JOIN small_package_specs s ON s.id = b.spec_id
WHERE ' . implode(' AND ', $where) . '
ORDER BY b.inbound_date DESC, b.id DESC
LIMIT 1000';
$st = $pdo->prepare($sql);
$st->execute($params);
$rows = $st->fetchAll();
$specs = $pdo->query('SELECT id, spec_name FROM small_package_specs ORDER BY spec_name')->fetchAll();
$inTypesRows = $pdo->query('SELECT DISTINCT inbound_type FROM small_package_batches ORDER BY inbound_type')->fetchAll();
$inTypeOpts = array();
foreach ($inTypesRows as $ir) {
$inTypeOpts[] = $ir['inbound_type'];
}
require __DIR__ . '/layout.php';
?>
小包装查询统计
入库批次与剩余可出;溯源请用 追溯查询 输入小包装批次号。
共 行。
| 小包装批次 |
规格 |
入库类型 |
来源出库单 |
入库量 |
剩余 |
日期 |
追溯 |
| 无匹配数据 |
|
|
|
|
|
|
|
打开 |
= ?';
$params[] = $df;
}
if ($dt !== '') {
$where[] = 'o.outbound_date <= ?';
$params[] = $dt;
}
if ($customer !== '') {
$where[] = 'o.customer LIKE ?';
$params[] = '%' . $customer . '%';
}
if ($orderNo !== '') {
$where[] = 'o.outbound_order_no LIKE ?';
$params[] = '%' . $orderNo . '%';
}
if ($pkg !== '') {
$where[] = 'o.package_batch_no LIKE ?';
$params[] = '%' . $pkg . '%';
}
if ($specId > 0) {
$where[] = 'o.spec_id = ?';
$params[] = $specId;
}
$sql = 'SELECT o.*, s.spec_name
FROM small_package_outbound o
INNER JOIN small_package_specs s ON s.id = o.spec_id
WHERE ' . implode(' AND ', $where) . '
ORDER BY o.outbound_date DESC, o.outbound_order_no DESC, o.id DESC
LIMIT 1200';
$st = $pdo->prepare($sql);
$st->execute($params);
$rows = $st->fetchAll();
$sum = 0.0;
foreach ($rows as $r) {
$sum += (float) $r['quantity'];
}
$specs = $pdo->query('SELECT id, spec_name FROM small_package_specs ORDER BY spec_name')->fetchAll();
require __DIR__ . '/layout.php';
?>
小包装出库查询统计
小包装对外发货明细;批次溯源见 追溯查询。
共 行;出库数量合计 。
| 出库单号 |
小包装批次 |
规格 |
客户 |
数量 |
日期 |
追溯 |
| 无匹配数据 |
|
|
|
|
|
|
打开 |
query(
'SELECT id, spec_code, spec_name, unit, fg_qty_per_package FROM small_package_specs ORDER BY spec_name'
)->fetchAll();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$fgCat = isset($_POST['fg_category']) ? trim($_POST['fg_category']) : '';
$outboundType = isset($_POST['outbound_type']) ? trim($_POST['outbound_type']) : '';
if ($outboundType === '再加工') {
$outboundType = '转小包装';
}
$customer = isset($_POST['customer']) ? trim($_POST['customer']) : '';
$quantity = isset($_POST['quantity']) ? $_POST['quantity'] : '';
$outboundDate = isset($_POST['outbound_date']) ? trim($_POST['outbound_date']) : '';
$spSpecIds = isset($_POST['sp_spec_id']) && is_array($_POST['sp_spec_id']) ? $_POST['sp_spec_id'] : array();
$spQtys = isset($_POST['sp_quantity']) && is_array($_POST['sp_quantity']) ? $_POST['sp_quantity'] : array();
$repackLines = array();
$need = null;
if ($outboundDate === '') {
$outboundDate = date('Y-m-d');
}
$lineType = isset($fgCats[$fgCat]) ? $fgCats[$fgCat] : null;
if ($lineType === null) {
flash_set('请选择成品类型(粉剂 / 水剂)。', 'error');
} elseif (!in_array($outboundType, $outTypes, true)) {
flash_set('请选择有效的出库类型。', 'error');
} elseif ($customer === '') {
flash_set('请填写客户(转小包装可填内部单位或仓库)。', 'error');
} elseif (trace_sales_is_transfer_small_pack($outboundType)) {
$n = max(count($spSpecIds), count($spQtys));
for ($i = 0; $i < $n; $i++) {
$sid = isset($spSpecIds[$i]) ? (int) $spSpecIds[$i] : 0;
$sq = isset($spQtys[$i]) ? $spQtys[$i] : '';
if ($sid <= 0 && (string) $sq === '') {
continue;
}
if ($sid <= 0 || !is_numeric($sq) || (float) $sq <= 0) {
flash_set('转小包装:每一行有数据时须选择规格并填写大于 0 的箱数(或规格单位数量)。', 'error');
$repackLines = null;
break;
}
$stsp = $pdo->prepare('SELECT id, unit FROM small_package_specs WHERE id = ? LIMIT 1');
$stsp->execute(array($sid));
$spRow = $stsp->fetch();
if (!$spRow) {
flash_set('转小包装:规格不存在(ID ' . $sid . ')。', 'error');
$repackLines = null;
break;
}
$repackLines[] = array(
'spec_id' => $sid,
'qty' => (float) $sq,
'unit' => $spRow['unit'],
);
}
if ($repackLines !== null && empty($repackLines)) {
flash_set('转小包装须至少填写一行(规格 + 箱数)。', 'error');
$repackLines = null;
} elseif ($repackLines !== null) {
$stSpec = $pdo->prepare(
'SELECT spec_name, fg_qty_per_package FROM small_package_specs WHERE id = ? LIMIT 1'
);
$needCalc = 0.0;
foreach ($repackLines as $rl) {
$stSpec->execute(array($rl['spec_id']));
$sr = $stSpec->fetch();
if (!$sr || (float) $sr['fg_qty_per_package'] <= 0) {
flash_set(
'转小包装:规格「' . ($sr ? $sr['spec_name'] : '') . '」未设置「每箱重量(kg)」或值为 0。请在「小包装规格」中维护。',
'error'
);
$repackLines = null;
break;
}
$needCalc += $rl['qty'] * (float) $sr['fg_qty_per_package'];
}
if ($repackLines !== null) {
if ($needCalc <= 0) {
flash_set('转小包装:折合成品出库量(箱数×每箱 kg)无效。', 'error');
$repackLines = null;
} else {
$need = $needCalc;
}
}
}
} else {
if (!is_numeric($quantity) || (float) $quantity <= 0) {
flash_set('成品出库数量须为大于 0 的数字(默认单位为 kg,不是箱)。', 'error');
} else {
$need = (float) $quantity;
}
}
if ($lineType !== null && in_array($outboundType, $outTypes, true) && $customer !== ''
&& $need !== null && $need > 0 && $repackLines !== null
&& (!trace_sales_is_transfer_small_pack($outboundType) || !empty($repackLines))
) {
$alloc = trace_sales_fifo_allocate($pdo, $lineType, $need);
if ($alloc === false) {
flash_set('该类别下各成品批次单位不一致,无法按总量合并出库。请统一单位或分次出库。', 'error');
} elseif ($alloc === null) {
flash_set('库存不足:当前「' . $fgCat . '」可出数量小于 ' . $need . '。', 'error');
} else {
$orderNo = generate_outbound_order_no($pdo, $outboundDate);
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'INSERT INTO sales_outbound (outbound_order_no, finished_batch_no, customer, quantity, unit, outbound_date, outbound_type)
VALUES (?,?,?,?,?,?,?)'
);
foreach ($alloc as $a) {
$stmt->execute(array(
$orderNo,
$a['batch_no'],
$customer,
$a['quantity'],
$a['unit'],
$outboundDate,
$outboundType,
));
}
$spMsgs = array();
if (trace_sales_is_transfer_small_pack($outboundType) && !empty($repackLines)) {
$insPb = $pdo->prepare(
'INSERT INTO small_package_batches (package_batch_no, spec_id, quantity, unit, inbound_date, inbound_type, source_outbound_order_no, note)
VALUES (?,?,?,?,?,?,?,?)'
);
foreach ($repackLines as $rl) {
$pbn = generate_small_package_batch_no($pdo, $outboundDate);
$insPb->execute(array(
$pbn,
$rl['spec_id'],
$rl['qty'],
$rl['unit'],
$outboundDate,
'销售转入',
$orderNo,
'',
));
$spMsgs[] = $pbn . ' × ' . $rl['qty'] . $rl['unit'];
}
}
$pdo->commit();
$parts = array();
foreach ($alloc as $a) {
$parts[] = $a['batch_no'] . ' × ' . $a['quantity'] . $a['unit'];
}
$msg = '出库已记录,单号 ' . $orderNo . '(' . $outboundType . ')。成品 FIFO:' . implode(';', $parts);
if (!empty($spMsgs)) {
$msg .= '。小包装已入库:' . implode(';', $spMsgs);
}
flash_set($msg, 'success');
header('Location: sales.php');
exit;
} catch (PDOException $e) {
$pdo->rollBack();
if (strpos($e->getMessage(), 'UNIQUE') !== false) {
flash_set('单号冲突,请重试保存。', 'error');
} else {
flash_set('保存失败:' . $e->getMessage(), 'error');
}
}
}
}
}
$list = $pdo->query(
'SELECT s.*, p.line_type
FROM sales_outbound s
INNER JOIN finished_goods f ON f.batch_no = s.finished_batch_no
LEFT JOIN production_batches p ON p.batch_no = f.batch_no
ORDER BY s.outbound_date DESC, s.outbound_order_no DESC, s.id DESC
LIMIT 200'
)->fetchAll();
$stockPayload = array();
foreach ($fgCats as $label => $lt) {
$lines = trace_sales_fifo_batches($pdo, $lt);
$total = 0.0;
$units = array();
foreach ($lines as $L) {
$total += (float) $L['remaining'];
$units[$L['unit']] = true;
}
$stockPayload[$label] = array(
'lines' => $lines,
'total' => $total,
'unit' => count($units) === 1 && !empty($lines) ? $lines[0]['unit'] : (count($units) === 0 ? 'kg' : '—'),
'unit_conflict' => count($units) > 1,
);
}
$specFactorsForJs = array();
foreach ($spSpecsList as $sp) {
$specFactorsForJs[(string) (int) $sp['id']] = array(
'per' => isset($sp['fg_qty_per_package']) ? (float) $sp['fg_qty_per_package'] : 0.0,
'spUnit' => isset($sp['unit']) ? $sp['unit'] : '',
);
}
require __DIR__ . '/layout.php';
?>
销售出库
全链路数量默认单位为 kg(各页可改填其他单位)。本页扣成品(粉/水剂)库存;选转小包装时按「箱数 × 小包装规格中的每箱 kg」折算成品出库量,并直接写入小包装库存(不再使用单独的「小包装入库」页)。小包装对外发货请走小包装出库。
出库:手工填写成品 kg 数。转小包装:填写各行箱数,成品扣减 = Σ(箱数 × 每箱 kg),详见「小包装规格」。
最近记录
| 出库单号 |
类型 |
品类 |
成品批次号 |
客户 |
数量 |
出库日期 |
| 暂无数据 |
|
|
|
|
|
|
|
prepare('SELECT id, unit FROM small_package_specs WHERE id = ? LIMIT 1');
for ($i = 0; $i < $n; $i++) {
$sid = isset($specIds[$i]) ? (int) $specIds[$i] : 0;
$q = isset($qtys[$i]) ? $qtys[$i] : '';
$uIn = isset($units[$i]) ? trim($units[$i]) : '';
$lnote = isset($notes[$i]) ? trim($notes[$i]) : '';
if ($sid <= 0 && (string) $q === '') {
continue;
}
if ($sid <= 0 || !is_numeric($q) || (float) $q <= 0) {
flash_set('每一行有数据时须选择规格并填写大于 0 的数量。', 'error');
$lines = null;
break;
}
$stsp->execute(array($sid));
$sp = $stsp->fetch();
if (!$sp) {
flash_set('存在无效的规格。', 'error');
$lines = null;
break;
}
$u = $uIn !== '' ? $uIn : $sp['unit'];
$lines[] = array('spec_id' => $sid, 'qty' => (float) $q, 'unit' => $u, 'note' => $lnote);
}
if ($lines === null) {
// flash set above
} elseif (empty($lines)) {
flash_set('请至少填写一行(规格 + 数量)。', 'error');
} else {
$pdo->beginTransaction();
try {
$ins = $pdo->prepare(
'INSERT INTO small_package_batches (package_batch_no, spec_id, quantity, unit, inbound_date, inbound_type, source_outbound_order_no, note)
VALUES (?,?,?,?,?,?,?,?)'
);
$saved = array();
foreach ($lines as $line) {
$pb = generate_small_package_batch_no($pdo, $inDate);
$ins->execute(array(
$pb,
$line['spec_id'],
$line['qty'],
$line['unit'],
$inDate,
$inType,
'',
$line['note'],
));
$saved[] = $pb;
}
$pdo->commit();
flash_set('已入库 ' . count($saved) . ' 笔小包装批次:' . implode('、', $saved), 'success');
header('Location: small_inbound.php');
exit;
} catch (PDOException $e) {
$pdo->rollBack();
flash_set('保存失败:' . $e->getMessage(), 'error');
}
}
}
}
$specs = $pdo->query('SELECT id, spec_code, spec_name, unit FROM small_package_specs ORDER BY spec_name')->fetchAll();
$list = $pdo->query(
'SELECT b.*, s.spec_name, s.spec_code
FROM small_package_batches b
INNER JOIN small_package_specs s ON s.id = b.spec_id
ORDER BY b.inbound_date DESC, b.id DESC
LIMIT 150'
)->fetchAll();
require __DIR__ . '/layout.php';
?>
小包装入库(采购 / 其他)
再加工产生的小包装由「销售出库」自动入库。此处可一次录入多行,每行可为不同规格;入库日期与入库类型本单共用,每行生成独立小包装批次号。此类库存出库请使用小包装出库(「销售出库」只扣成品,不会动小包装)。批次溯源见小包装溯源。
请先在「小包装规格」中维护规格。
最近入库记录
| 小包装批次号 |
规格 |
入库类型 |
数量 |
入库日期 |
来源成品出库单 |
溯源 |
| 暂无数据 |
|
|
|
|
|
|
查看 |
query('SELECT id, spec_code, spec_name, unit FROM small_package_specs ORDER BY spec_name')->fetchAll();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$customer = isset($_POST['customer']) ? trim($_POST['customer']) : '';
$outDate = isset($_POST['outbound_date']) ? trim($_POST['outbound_date']) : '';
$specIds = isset($_POST['spec_id']) && is_array($_POST['spec_id']) ? $_POST['spec_id'] : array();
$qtys = isset($_POST['quantity']) && is_array($_POST['quantity']) ? $_POST['quantity'] : array();
if ($outDate === '') {
$outDate = date('Y-m-d');
}
if ($customer === '') {
flash_set('请填写客户。', 'error');
} else {
$n = max(count($specIds), count($qtys));
$lines = array();
$specNameById = array();
foreach ($specs as $sx) {
$specNameById[(int) $sx['id']] = $sx['spec_name'];
}
for ($i = 0; $i < $n; $i++) {
$sid = isset($specIds[$i]) ? (int) $specIds[$i] : 0;
$q = isset($qtys[$i]) ? $qtys[$i] : '';
if ($sid <= 0 && (string) $q === '') {
continue;
}
if ($sid <= 0 || !is_numeric($q) || (float) $q <= 0) {
flash_set('每一行有数据时须选择规格并填写大于 0 的数量。', 'error');
$lines = null;
break;
}
if (!isset($specNameById[$sid])) {
flash_set('存在无效的规格。', 'error');
$lines = null;
break;
}
$lines[] = array('spec_id' => $sid, 'qty' => (float) $q);
}
if ($lines === null) {
// flash above
} elseif (empty($lines)) {
flash_set('请至少填写一行(规格 + 出库数量)。', 'error');
} else {
$pdo->beginTransaction();
try {
$orderNo = generate_small_package_outbound_order_no($pdo, $outDate);
$ins = $pdo->prepare(
'INSERT INTO small_package_outbound (outbound_order_no, package_batch_no, spec_id, quantity, unit, customer, outbound_date)
VALUES (?,?,?,?,?,?,?)'
);
$summaryParts = array();
foreach ($lines as $idx => $line) {
$alloc = trace_sp_fifo_allocate($pdo, $line['spec_id'], $line['qty']);
$sname = $specNameById[$line['spec_id']];
if ($alloc === false) {
throw new RuntimeException('规格「' . $sname . '」下各批次单位不一致,无法出库。');
}
if ($alloc === null) {
throw new RuntimeException('规格「' . $sname . '」库存不足,需要 ' . $line['qty'] . '。');
}
foreach ($alloc as $a) {
$ins->execute(array(
$orderNo,
$a['package_batch_no'],
$a['spec_id'],
$a['quantity'],
$a['unit'],
$customer,
$outDate,
));
}
$sub = array();
foreach ($alloc as $a) {
$sub[] = $a['package_batch_no'] . '×' . $a['quantity'] . $a['unit'];
}
$summaryParts[] = $sname . ':' . implode(';', $sub);
}
$pdo->commit();
flash_set('小包装出库已记录,单号 ' . $orderNo . '。' . implode(' | ', $summaryParts), 'success');
header('Location: small_outbound.php');
exit;
} catch (RuntimeException $e) {
$pdo->rollBack();
flash_set($e->getMessage(), 'error');
} catch (PDOException $e) {
$pdo->rollBack();
flash_set('保存失败:' . $e->getMessage(), 'error');
}
}
}
}
$list = $pdo->query(
'SELECT o.*, s.spec_name
FROM small_package_outbound o
INNER JOIN small_package_specs s ON s.id = o.spec_id
ORDER BY o.outbound_date DESC, o.outbound_order_no DESC, o.id DESC
LIMIT 200'
)->fetchAll();
$stockBySpec = array();
foreach ($specs as $s) {
$lines = trace_sp_fifo_batches($pdo, (int) $s['id']);
$total = 0.0;
$units = array();
foreach ($lines as $L) {
$total += (float) $L['remaining'];
$units[$L['unit']] = true;
}
$stockBySpec[(string) $s['id']] = array(
'spec_name' => $s['spec_name'],
'unit' => $s['unit'],
'lines' => $lines,
'total' => $total,
'unit_conflict' => count($units) > 1,
);
}
require __DIR__ . '/layout.php';
?>
小包装出库
可一次多行:每行选规格并填本行出库数量;系统按行依次FIFO扣减库存。同一出库单号(SPO-…)下多行明细。扣减后在追溯查询输入小包装批次号查看记录。
最近出库记录
| 出库单号 |
规格 |
小包装批次号 |
客户 |
数量 |
日期 |
| 暂无数据 |
|
|
|
|
|
|
prepare(
'INSERT INTO small_package_specs (spec_code, spec_name, unit, fg_qty_per_package, fg_qty_per_package_unit, note) VALUES (?,?,?,?,?,?)'
);
$st->execute(array(
$code,
$name,
$unit !== '' ? $unit : '箱',
is_numeric($kgPerBox) ? (float) $kgPerBox : 0,
'kg',
$note,
));
flash_set('规格已添加。', 'success');
} catch (PDOException $e) {
flash_set('保存失败:' . $e->getMessage(), 'error');
}
}
header('Location: small_spec.php');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'update_convert') {
$id = isset($_POST['id']) ? (int) $_POST['id'] : 0;
$kgPerBox = isset($_POST['kg_per_box']) ? $_POST['kg_per_box'] : '0';
if ($id <= 0) {
flash_set('无效记录。', 'error');
} else {
try {
$st = $pdo->prepare(
'UPDATE small_package_specs SET fg_qty_per_package = ?, fg_qty_per_package_unit = ? WHERE id = ?'
);
$st->execute(array(is_numeric($kgPerBox) ? (float) $kgPerBox : 0, 'kg', $id));
flash_set('每箱 kg 已更新。', 'success');
} catch (PDOException $e) {
flash_set('更新失败:' . $e->getMessage(), 'error');
}
}
header('Location: small_spec.php');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'delete') {
$id = isset($_POST['id']) ? (int) $_POST['id'] : 0;
if ($id > 0) {
try {
$pdo->prepare('DELETE FROM small_package_specs WHERE id = ?')->execute(array($id));
flash_set('已删除。', 'success');
} catch (PDOException $e) {
flash_set('删除失败(可能已有小包装批次引用该规格)。', 'error');
}
}
header('Location: small_spec.php');
exit;
}
$list = $pdo->query('SELECT * FROM small_package_specs ORDER BY spec_name')->fetchAll();
require __DIR__ . '/layout.php';
?>
小包装规格
点数单位一般为箱。请维护每箱对应的成品重量(kg):销售出库选「转小包装」时,成品扣减 kg = 箱数 × 每箱 kg。全系统成品/原料默认 kg。
规格列表
| ID |
规格名称 |
单位 |
每箱 kg |
备注 |
|
| 暂无规格,请先新增。 |
|
|
|
|
|
|
query(
'SELECT b.package_batch_no, b.spec_id, s.spec_name, s.spec_code, b.quantity AS inbound_qty, b.unit,
b.inbound_date, b.inbound_type, b.source_outbound_order_no,
(b.quantity - COALESCE((SELECT SUM(o.quantity) FROM small_package_outbound o WHERE o.package_batch_no = b.package_batch_no), 0)) AS remaining
FROM small_package_batches b
INNER JOIN small_package_specs s ON s.id = b.spec_id
ORDER BY s.spec_name, b.inbound_date ASC, b.id ASC'
)->fetchAll();
$bySpec = array();
foreach ($rows as $r) {
$rem = (float) $r['remaining'];
if ($rem <= 1e-9) {
continue;
}
$sid = (int) $r['spec_id'];
if (!isset($bySpec[$sid])) {
$bySpec[$sid] = array(
'name' => $r['spec_name'],
'unit' => $r['unit'],
'total' => 0.0,
'lines' => array(),
);
}
$bySpec[$sid]['total'] += $rem;
$r['remaining'] = $rem;
$bySpec[$sid]['lines'][] = $r;
}
require __DIR__ . '/layout.php';
?>
小包装库存(按规格汇总)
仅列出剩余可出 > 0的批次。新数据由销售出库 → 转小包装写入。批次溯源在追溯查询输入小包装批次号。
当前无在库小包装。请通过销售出库「转小包装」生成库存(历史「采购入库」数据仍可查)。
$grp): ?>
· 可出合计
| 小包装批次号 |
入库类型 |
入库日期 |
入库数量 |
剩余可出 |
来源成品出库单 |
溯源 |
|
|
|
|
|
|
追溯 |
prepare(
'SELECT b.*, s.spec_name, s.spec_code, s.unit AS spec_unit,
s.fg_qty_per_package, s.fg_qty_per_package_unit
FROM small_package_batches b
INNER JOIN small_package_specs s ON s.id = b.spec_id
WHERE b.package_batch_no = ?
LIMIT 1'
);
$st->execute(array($pkg));
$batch = $st->fetch();
if ($batch) {
$stO = $pdo->prepare(
'SELECT o.outbound_order_no, o.customer, o.quantity, o.unit, o.outbound_date,
s.spec_name
FROM small_package_outbound o
INNER JOIN small_package_specs s ON s.id = o.spec_id
WHERE o.package_batch_no = ?
ORDER BY o.outbound_date DESC, o.id DESC'
);
$stO->execute(array($pkg));
$outbounds = $stO->fetchAll();
$src = isset($batch['source_outbound_order_no']) ? trim($batch['source_outbound_order_no']) : '';
if ($src !== '') {
$stS = $pdo->prepare(
'SELECT outbound_order_no, finished_batch_no, customer, quantity, unit, outbound_date, outbound_type
FROM sales_outbound
WHERE outbound_order_no = ?
ORDER BY id'
);
$stS->execute(array($src));
$salesFromSource = $stS->fetchAll();
}
$stRem = $pdo->prepare(
'SELECT (b.quantity - COALESCE((SELECT SUM(o.quantity) FROM small_package_outbound o WHERE o.package_batch_no = b.package_batch_no), 0)) AS rem
FROM small_package_batches b WHERE b.package_batch_no = ? LIMIT 1'
);
$stRem->execute(array($pkg));
$rrow = $stRem->fetch();
if ($rrow) {
$remaining = (float) $rrow['rem'];
}
}
}
require __DIR__ . '/layout.php';
?>
小包装溯源
按小包装批次号查看入库信息、关联的成品出库单(再加工来源)及小包装出库记录。采购/其他入库的小包装须在「小包装出库」扣账。
未找到该小包装批次。请从「小包装库存」或入库记录中复制批次号。
小包装入库
| 小包装批次号 | |
| 规格 | () |
| 入库数量 | |
| 入库类型 | |
| 入库日期 | |
| 备注 | |
| 来源成品出库单 |
(再加工自成品出库)
—(采购/其他入库无关联成品出库单)
|
0): ?>
| 规格折算(参考) | 每 1 ≈ 成品 |
| 当前剩余可出 | |
关联成品出库明细(同一单号)
再加工时,小包装批次会挂到对应的成品出库单号;可点击成品批次号在「追溯查询」中继续向上游查原料。
小包装出库记录
尚无小包装出库记录;库存仍按入库减已出库计算。
prepare('SELECT * FROM finished_goods WHERE batch_no = ? LIMIT 1');
$st->execute(array($q));
$fg = $st->fetch();
$st2 = $pdo->prepare(
'SELECT c.material_name, c.raw_batch_no, c.quantity, c.unit,
COALESCE(r.inbound_order_no, \'\') AS inbound_order_no
FROM production_consumption c
LEFT JOIN raw_material_batches r ON r.batch_no = c.raw_batch_no
WHERE c.production_batch_no = ?
ORDER BY c.id'
);
$st2->execute(array($q));
$consumption = $st2->fetchAll();
$rawNos = array();
foreach ($consumption as $c) {
if (!empty($c['raw_batch_no'])) {
$rawNos[$c['raw_batch_no']] = true;
}
}
if (!empty($rawNos)) {
$placeholders = implode(',', array_fill(0, count($rawNos), '?'));
$st3 = $pdo->prepare(
'SELECT batch_no, supplier_code, supplier_name, material_name, inbound_date, quantity, unit, inbound_order_no
FROM raw_material_batches WHERE batch_no IN (' . $placeholders . ')'
);
$st3->execute(array_keys($rawNos));
while ($row = $st3->fetch()) {
$rawDetails[$row['batch_no']] = $row;
}
}
$st4 = $pdo->prepare(
'SELECT outbound_order_no, customer, quantity, unit, outbound_date,
COALESCE(outbound_type, \'出库\') AS outbound_type
FROM sales_outbound WHERE finished_batch_no = ? ORDER BY id'
);
$st4->execute(array($q));
$sales = $st4->fetchAll();
$types = array('转小包装', '再加工');
$ph = implode(',', array_fill(0, count($types), '?'));
$ordStmt = $pdo->prepare(
"SELECT DISTINCT outbound_order_no FROM sales_outbound WHERE finished_batch_no = ? AND outbound_type IN ($ph)"
);
$ordStmt->execute(array_merge(array($q), $types));
$transferOrderNos = $ordStmt->fetchAll(PDO::FETCH_COLUMN);
if (!empty($transferOrderNos)) {
$ph2 = implode(',', array_fill(0, count($transferOrderNos), '?'));
$spStmt = $pdo->prepare(
'SELECT b.package_batch_no, b.quantity, b.unit, b.inbound_date, b.inbound_type, b.source_outbound_order_no, s.spec_name
FROM small_package_batches b
INNER JOIN small_package_specs s ON s.id = b.spec_id
WHERE b.source_outbound_order_no IN (' . $ph2 . ')
ORDER BY b.source_outbound_order_no, b.package_batch_no'
);
$spStmt->execute($transferOrderNos);
$smallPackFromTransfer = $spStmt->fetchAll();
}
}
if ($pkg !== '') {
$st = $pdo->prepare(
'SELECT b.*, s.spec_name, s.spec_code, s.unit AS spec_unit,
s.fg_qty_per_package
FROM small_package_batches b
INNER JOIN small_package_specs s ON s.id = b.spec_id
WHERE b.package_batch_no = ?
LIMIT 1'
);
$st->execute(array($pkg));
$spBatch = $st->fetch();
if ($spBatch) {
$stO = $pdo->prepare(
'SELECT o.outbound_order_no, o.customer, o.quantity, o.unit, o.outbound_date, s.spec_name
FROM small_package_outbound o
INNER JOIN small_package_specs s ON s.id = o.spec_id
WHERE o.package_batch_no = ?
ORDER BY o.outbound_date DESC, o.id DESC'
);
$stO->execute(array($pkg));
$spOutbounds = $stO->fetchAll();
$src = isset($spBatch['source_outbound_order_no']) ? trim($spBatch['source_outbound_order_no']) : '';
if ($src !== '') {
$stS = $pdo->prepare(
'SELECT outbound_order_no, finished_batch_no, customer, quantity, unit, outbound_date, outbound_type
FROM sales_outbound WHERE outbound_order_no = ? ORDER BY id'
);
$stS->execute(array($src));
$spSalesFromSource = $stS->fetchAll();
}
$stRem = $pdo->prepare(
'SELECT (b.quantity - COALESCE((SELECT SUM(o.quantity) FROM small_package_outbound o WHERE o.package_batch_no = b.package_batch_no), 0)) AS rem
FROM small_package_batches b WHERE b.package_batch_no = ? LIMIT 1'
);
$stRem->execute(array($pkg));
$rrow = $stRem->fetch();
if ($rrow) {
$spRemaining = (float) $rrow['rem'];
}
}
}
require __DIR__ . '/layout.php';
?>
追溯查询
默认数量单位为 kg。可按成品批次号查原料→生产→销售→小包装;或按小包装批次号查入库与小包装出库、关联成品出库单。
未找到该小包装批次。
小包装入库
| 小包装批次号 | |
| 规格 | () |
| 入库数量 | |
| 入库类型 | |
| 入库日期 | |
| 备注 | |
| 来源成品出库单 |
(销售出库「转小包装」)
—
|
0): ?>
| 规格:每箱 kg(参考) | kg / |
| 当前剩余可出 | |
未找到该成品批次入库记录。若仅有生产未入库,仍可查看下方领料。
成品入库
| 成品批次号 / 生产批号 | |
| 成品名称 | |
| 数量 | |
| 生产日期 | |
原料批次 → 采购
无领料行。
原料批次在库中无匹配记录。
| 入库单号 |
原料批次号 |
供应商 |
原料名称 |
入库日期 |
入库数量 |
|
|
|
|
|
|
转小包装 → 生成的小包装批次
无「转小包装」出库或未生成小包装批次。