Files
shanxia2.0/web-admin/src/utils/pl-inbound-slip-template.ts
T

1457 lines
64 KiB
TypeScript
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.
import type { Plan, PlanProduct } from '@/api/pl'
import type { SlipPlanProduct } from '@/utils/pl-batch-print'
import { formatRemarkForSlipHtml } from '@/utils/pl-batch-print'
import { applyPdfStableTableCellBorders } from '@/utils/pl-slip-pdf-render'
/** 入库单打印默认公司信息(可在富文本中修改) */
export const INBOUND_SLIP_DEFAULTS = {
companyName: '安庆市山峡化工有限公司',
companyAddress: '安庆市高新区霞虹路5号',
companyPhone: '0556-5999710',
contactPhone: '方总13395560066'
} as const
/** 旧模板曾把送货单位写死为本公司名,保存还原时一并识别 */
const LEGACY_RECEIVING_UNIT = '安庆市山峡化工有限公司'
const DOC_FONT = "SimSun, 'Songti SC', serif"
const FONT_TITLE = 'font-size:26px;font-weight:bold;'
const FONT_BODY = 'font-size:20px;'
const FONT_SIGN = 'font-size:14px;'
const FONT_SIDE = 'font-size:18px;'
const DOC_PADDING = '8px 10px 24px'
/** 单据内容区最大宽度(编辑预览居中,略窄于 A4 像素宽) */
export const INBOUND_SLIP_DOC_MAX_WIDTH = '960px'
const DOC_WRAP_EXTRA = `max-width:${INBOUND_SLIP_DOC_MAX_WIDTH};margin:0 auto;box-sizing:border-box;`
const LOGO_HEIGHT = 36
const LOGO_LEFT = 50
const TITLE_STYLE = `${FONT_TITLE}font-weight:700 !important;letter-spacing:6px;line-height:1.35;`
const TITLE_SUB_STYLE = `${TITLE_STYLE}margin-top:2px;letter-spacing:10px;`
const TABLE_ROW_PADDING = '4px'
/** 信息表等宽列 */
const INFO_COLGROUP = `<colgroup>
<col span="7" style="width:14.28%;" />
</colgroup>`
/**
* 物料表列宽:单据编号 + 加宽编号/名称,收窄规格/单位。
* 单据编号 / 物料编号 / 物料名称 / 规格 / 单位 / 数量 / 产品规格 / 实收数量
*/
const PRODUCT_COLGROUP = `<colgroup>
<col style="width:14%;" />
<col style="width:14%;" />
<col style="width:20%;" />
<col style="width:8%;" />
<col style="width:6%;" />
<col style="width:10%;" />
<col style="width:14%;" />
<col style="width:14%;" />
</colgroup>`
/** 物料网格 padding(编辑器预览) */
const PRODUCT_GRID_CELL_PADDING = '8px 6px'
const PRODUCT_GRID_TH_PADDING = '10px 6px'
/** PDF 专用:padding 直接写在 td/th(html2canvas 对 flex/vertical-align 支持差,底部多留白) */
const PRODUCT_GRID_PDF_TD_PADDING = '8px 6px 12px 6px'
const PRODUCT_GRID_PDF_TH_PADDING = '10px 6px 16px 6px'
const PRODUCT_GRID_PDF_LINE_HEIGHT = '24px'
/** PDF 专用:备注/条款/地址等左对齐合并单元格 */
const PRODUCT_FOOTER_PDF_PADDING = '10px 6px 16px 6px'
const PRODUCT_FOOTER_PDF_LINE_HEIGHT = '1.35'
/** 物料表格边框线宽(备注/条款等合并单元格,与网格一致便于 PDF 衔接) */
const PRODUCT_TABLE_BORDER = '0.3px solid #000'
/** 物料明细网格线(与合同 PDF 一致:全边框 inline + 0.3px,html2canvas 渲染稳定) */
const PRODUCT_GRID_BORDER = '0.3px solid #000'
/** 物料表格单元格:长文本自动换行 */
const PRODUCT_CELL_WRAP = 'white-space:normal;word-wrap:break-word;overflow-wrap:break-word;word-break:break-word;'
const PRODUCT_GRID_CELL = `padding:${PRODUCT_GRID_CELL_PADDING};box-sizing:border-box;border:${PRODUCT_GRID_BORDER} !important;text-align:center;${FONT_BODY}line-height:normal;background-color:#fff !important;${PRODUCT_CELL_WRAP}`
const PRODUCT_CELL = `padding:4px 6px;border:${PRODUCT_TABLE_BORDER} !important;text-align:center;${FONT_BODY}line-height:1.35;background-color:#fff !important;${PRODUCT_CELL_WRAP}`
const PRODUCT_TH = `padding:${PRODUCT_GRID_TH_PADDING};box-sizing:border-box;border:${PRODUCT_GRID_BORDER} !important;text-align:center;${FONT_BODY}line-height:normal;font-weight:bold;background-color:#fff !important;${PRODUCT_CELL_WRAP}`
const PRODUCT_TABLE_CELL_LEFT = `padding:10px 6px 14px 6px;border:${PRODUCT_TABLE_BORDER} !important;text-align:left;${FONT_BODY}line-height:1.35;vertical-align:top;background-color:#fff !important;`
/** 条款单元格样式(兼容已保存模板归一化) */
const PRODUCT_TERMS_CELL_LEFT = `padding:10px 6px 14px 6px;border:${PRODUCT_TABLE_BORDER} !important;text-align:left;${FONT_BODY}line-height:1.35;vertical-align:top;background-color:#fff !important;`
/** 入库单印章:public/sxshzyz.png(兼容已保存模板的红章开关) */
const INBOUND_SLIP_SEAL_IMAGE_SRC = '/sxshzyz.png'
const INBOUND_SLIP_SEAL_TOP = -66
const INBOUND_SLIP_SEAL_IMAGE_STYLE = `position:absolute;right:4px;top:${INBOUND_SLIP_SEAL_TOP}px;width:140px;height:140px;opacity:0.85;z-index:10;`
const PLAIN_CELL = `border:none !important;padding:${TABLE_ROW_PADDING};${FONT_BODY}line-height:1.8;vertical-align:top;background:transparent !important;`
const NO_BORDER = `${PLAIN_CELL} vertical-align:bottom;`
/** 基本信息:label 与值同一行,值区域占满剩余宽度并带下划线 */
const INFO_ROW = `display:flex;align-items:flex-end;width:100%;box-sizing:border-box;`
const INFO_LABEL = `white-space:nowrap;flex-shrink:0;padding-right:2px;`
const INFO_UNDERLINE = '1px solid #000'
const SIGN_UNDERLINE = '1px solid #000'
/** 签字值下划线与文字间距(底部 padding 把 border-bottom 推离字模) */
const SIGN_VALUE_PADDING = '0 2px 6px 2px'
const SIGN_VALUE_PDF_PADDING = '0 2px 8px 2px'
const INFO_VALUE_INLINE_LEFT = `flex:1;min-width:0;box-sizing:border-box;border-bottom:${INFO_UNDERLINE} !important;padding:0 2px 2px;text-align:left;min-height:1.3em;`
const INFO_VALUE_INLINE_CENTER = `flex:1;min-width:0;box-sizing:border-box;border-bottom:${INFO_UNDERLINE} !important;padding:0 2px 2px;text-align:center;min-height:1.3em;`
const INFO_CELL_LEFT = `${NO_BORDER}text-align:left;`
const INFO_CELL_RIGHT = `${NO_BORDER}text-align:left;`
const INFO_GAP_BEFORE_PRODUCT = 12
const GAP_BEFORE_PRODUCT_CELL = `border:none !important;padding:0 !important;line-height:0 !important;font-size:0 !important;background:transparent !important;height:${INFO_GAP_BEFORE_PRODUCT}px !important;min-height:${INFO_GAP_BEFORE_PRODUCT}px !important;`
const INFO_GAP_BEFORE_SIGN = 12
const GAP_BEFORE_SIGN_CELL = `border:none !important;padding:0 !important;line-height:0 !important;font-size:0 !important;background:transparent !important;height:${INFO_GAP_BEFORE_SIGN}px !important;min-height:${INFO_GAP_BEFORE_SIGN}px !important;`
const SIDE_LABELS_COL_WIDTH = 36
function buildInboundSlipSignItem(label: string, valueContent: string, minWidth: string): string {
return `<span class="inbound-slip-sign-item"><span class="inbound-slip-sign-label">${label}</span><span class="inbound-slip-sign-value" style="min-width:${minWidth};">${valueContent}</span></span>`
}
function buildInboundSlipSignRowHtml(): string {
return `<tr class="inbound-slip-sign-row">
<td colspan="8" class="inbound-slip-sign-row-cell" style="border:none !important;padding:${TABLE_ROW_PADDING};text-align:center;${FONT_SIGN}background:transparent !important;">
<div class="inbound-slip-sign-inner">
${buildInboundSlipSignItem('仓库:', '&nbsp;', '80px')}
${buildInboundSlipSignItem('审核:', '&nbsp;', '80px')}
${buildInboundSlipSignItem('客户签收:', '&nbsp;', '80px')}
${buildInboundSlipSignItem('司机/送货人(附车牌):', '&nbsp;', '120px')}
</div>
</td>
</tr>`
}
export interface InboundSlipRenderContext {
/** CRM 产品 id -> 物料编码 productNo */
productCodeById?: Record<string, string>
/** 单位字典 code -> 展示文案 */
getUnitLabel?: (unitCode: string) => string
}
function resolveSlipProductCode(product: PlanProduct, ctx?: InboundSlipRenderContext): string {
const productId = String(product.productId || '').trim()
const storedNo = String(product.productNo || '').trim()
const mappedNo = productId ? String(ctx?.productCodeById?.[productId] || '').trim() : ''
if (mappedNo) return mappedNo
if (storedNo && productId && storedNo !== productId) return storedNo
return storedNo
}
function resolveSlipUnitLabel(unitCode: string | undefined, ctx?: InboundSlipRenderContext): string {
const code = String(unitCode || '').trim()
if (!code) return ''
return ctx?.getUnitLabel?.(code) || code
}
export function canPrintInboundSlip(plan?: Pick<Plan, 'planType' | 'transportStatus'> | null): boolean {
return plan?.planType === 'inbound' && plan?.transportStatus === 'arranged'
}
function formatPlanDateChinese(planDate?: string): string {
if (!planDate) return ''
const date = new Date(planDate)
if (Number.isNaN(date.getTime())) return planDate
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
}
function formatQty(value?: number | string | null): string {
const num = parseFloat(String(value ?? ''))
if (Number.isNaN(num)) return ''
return num.toFixed(3)
}
function buildDeliveryUnit(plan: Plan): string {
return plan.customerName?.trim() || plan.destinationName?.trim() || ''
}
function formatInboundSlipProductGridCellContent(content: string): string {
const plain = content
.trim()
.replace(/<div class="inbound-slip-product-cell-inner"[^>]*>([\s\S]*?)<\/div>/gi, '$1')
.replace(/<div class="inbound-slip-pdf-grid-inner"[^>]*>([\s\S]*?)<\/div>/gi, '$1')
.replace(/<span class="inbound-slip-pdf-grid-text"[^>]*>([\s\S]*?)<\/span>/gi, '$1')
.replace(/<span class="inbound-slip-product-grid-align"[^>]*>([\s\S]*?)<\/span>/gi, '$1')
.replace(/^<p[^>]*>([\s\S]*?)<\/p>$/i, '$1')
.trim()
return plain || '&nbsp;'
}
function buildProductRows(
products: PlanProduct[] = [],
planNo = '',
ctx?: InboundSlipRenderContext
): string {
if (!products.length) {
return `<tr><td class="inbound-slip-product-cell" colspan="8" style="${PRODUCT_CELL}">暂无产品信息</td></tr>`
}
const dataRows = products
.map((product) => {
const rowPlanNo = (product as SlipPlanProduct).slipPlanNo || planNo
const planNoText = formatInboundSlipProductGridCellContent(rowPlanNo)
const qty = product.inboundQty ?? product.inboundEstimateQty ?? product.quantity ?? ''
return `<tr>
<td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${planNoText}</td>
<td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatInboundSlipProductGridCellContent(resolveSlipProductCode(product, ctx))}</td>
<td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatInboundSlipProductGridCellContent(product.productName || '')}</td>
<td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatInboundSlipProductGridCellContent(product.model || '')}</td>
<td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatInboundSlipProductGridCellContent(resolveSlipUnitLabel(product.unit, ctx))}</td>
<td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatInboundSlipProductGridCellContent(formatQty(qty))}</td>
<td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatInboundSlipProductGridCellContent(product.packageSpec || '')}</td>
<td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">&nbsp;</td>
</tr>`
})
.join('')
return dataRows
}
export const INBOUND_SLIP_PRINT_CSS = `
.inbound-slip-doc {
padding: ${DOC_PADDING} !important;
max-width: ${INBOUND_SLIP_DOC_MAX_WIDTH} !important;
margin-left: auto !important;
margin-right: auto !important;
box-sizing: border-box !important;
}
.inbound-slip-doc .inbound-slip-title-heading {
font-weight: 700 !important;
line-height: 1.35 !important;
}
.inbound-slip-doc .inbound-slip-title-heading + .inbound-slip-title-heading {
margin-top: 2px !important;
}
.inbound-slip-doc .inbound-slip-info-row {
display: flex !important;
align-items: flex-end !important;
width: 100% !important;
box-sizing: border-box !important;
}
.inbound-slip-doc .inbound-slip-info-label {
white-space: nowrap !important;
flex-shrink: 0 !important;
}
.inbound-slip-doc table.inbound-slip-layout-table tr.inbound-slip-gap-before-product td.inbound-slip-gap-before-product-cell,
.inbound-slip-doc .inbound-slip-section-gap.inbound-slip-gap-before-product {
border: none !important;
background: transparent !important;
padding: 0 !important;
line-height: 0 !important;
font-size: 0 !important;
height: ${INFO_GAP_BEFORE_PRODUCT}px !important;
min-height: ${INFO_GAP_BEFORE_PRODUCT}px !important;
}
.inbound-slip-doc table.inbound-slip-layout-table tr.inbound-slip-gap-before-sign td.inbound-slip-gap-before-sign-cell,
.inbound-slip-doc .inbound-slip-section-gap.inbound-slip-gap-before-sign {
border: none !important;
background: transparent !important;
padding: 0 !important;
line-height: 0 !important;
font-size: 0 !important;
height: ${INFO_GAP_BEFORE_SIGN}px !important;
min-height: ${INFO_GAP_BEFORE_SIGN}px !important;
}
.inbound-slip-doc .inbound-slip-info-value-left {
flex: 1 !important;
min-width: 0 !important;
box-sizing: border-box !important;
text-align: left !important;
border-bottom: ${INFO_UNDERLINE} !important;
min-height: 1.3em !important;
}
.inbound-slip-doc .inbound-slip-info-value-center {
flex: 1 !important;
min-width: 0 !important;
box-sizing: border-box !important;
text-align: center !important;
border-bottom: ${INFO_UNDERLINE} !important;
min-height: 1.3em !important;
}
.inbound-slip-doc .inbound-slip-sign-value {
display: inline-block !important;
line-height: 1.35 !important;
padding: ${SIGN_VALUE_PADDING} !important;
margin: 0 !important;
vertical-align: baseline !important;
border: none !important;
border-bottom: ${SIGN_UNDERLINE} !important;
box-sizing: border-box !important;
}
.inbound-slip-doc .inbound-slip-sign-label {
line-height: 1.35 !important;
vertical-align: baseline !important;
white-space: nowrap !important;
}
.inbound-slip-doc table.inbound-slip-outer-table {
table-layout: fixed !important;
width: 100% !important;
border-collapse: collapse !important;
}
.inbound-slip-doc td.inbound-slip-main-col {
overflow: hidden !important;
box-sizing: border-box !important;
vertical-align: top !important;
}
.inbound-slip-doc td.inbound-slip-side-labels-col {
width: ${SIDE_LABELS_COL_WIDTH}px !important;
min-width: ${SIDE_LABELS_COL_WIDTH}px !important;
max-width: ${SIDE_LABELS_COL_WIDTH}px !important;
}
.inbound-slip-doc .inbound-slip-sign-row-cell,
.inbound-slip-doc .inbound-slip-sign-inner,
.inbound-slip-doc .inbound-slip-sign-item {
font-size: 14px !important;
}
.inbound-slip-doc .inbound-slip-sign-row-cell {
overflow: hidden !important;
max-width: 100% !important;
box-sizing: border-box !important;
}
.inbound-slip-doc .inbound-slip-sign-inner {
display: flex !important;
flex-wrap: wrap !important;
justify-content: center !important;
align-items: baseline !important;
column-gap: 10px !important;
row-gap: 6px !important;
max-width: 100% !important;
box-sizing: border-box !important;
margin: 0 auto !important;
white-space: normal !important;
line-height: 1.35 !important;
}
.inbound-slip-doc .inbound-slip-sign-item {
display: inline !important;
white-space: nowrap !important;
line-height: 1.35 !important;
vertical-align: baseline !important;
}
.inbound-slip-doc .inbound-slip-terms-text {
line-height: 1.35 !important;
margin: 0 !important;
padding: 0 !important;
}
.inbound-slip-doc td.inbound-slip-terms-cell {
padding: 10px 6px 14px 6px !important;
line-height: 1.35 !important;
vertical-align: top !important;
}
.inbound-slip-doc .inbound-slip-handwrite-blank {
display: inline-block !important;
min-width: 3.6em !important;
text-align: center !important;
letter-spacing: 0.08em !important;
}
.inbound-slip-doc .inbound-slip-seal-block {
position: relative !important;
line-height: 1.35 !important;
min-height: 0 !important;
height: auto !important;
}
.inbound-slip-doc .inbound-slip-seal-block img[alt="印章"] {
position: absolute !important;
right: 4px !important;
top: ${INBOUND_SLIP_SEAL_TOP}px !important;
left: auto !important;
transform: none !important;
width: 140px !important;
height: 140px !important;
opacity: 0.85 !important;
z-index: 10 !important;
}
.inbound-slip-doc .inbound-slip-seal-block .inbound-slip-seal-lines {
position: relative !important;
z-index: 1 !important;
line-height: 1.35 !important;
margin: 0 !important;
padding: 0 !important;
}
.inbound-slip-doc table.inbound-slip-outer-table > tbody > tr > td,
.inbound-slip-doc table.inbound-slip-outer-table > tbody > tr > th,
.inbound-slip-doc table.inbound-slip-layout-table td:not(.inbound-slip-product-cell):not(.inbound-slip-gap-before-product-cell):not(.inbound-slip-gap-before-sign-cell),
.inbound-slip-doc table.inbound-slip-layout-table th:not(.inbound-slip-product-cell),
.inbound-slip-doc table.inbound-slip-info-table td,
.inbound-slip-doc table.inbound-slip-info-table th,
.inbound-slip-doc table.inbound-slip-sign-table td,
.inbound-slip-doc table.inbound-slip-sign-table th {
border: none !important;
background: transparent !important;
padding: ${TABLE_ROW_PADDING} !important;
}
.inbound-slip-doc table.inbound-slip-layout-table,
.inbound-slip-doc table.inbound-slip-info-table,
.inbound-slip-doc table.inbound-slip-product-table,
.inbound-slip-doc table.inbound-slip-footer-table,
.inbound-slip-doc table.inbound-slip-sign-table {
border-collapse: collapse !important;
border-spacing: 0 !important;
table-layout: fixed !important;
width: 100% !important;
}
.inbound-slip-doc .inbound-slip-product-grid-cell {
border: ${PRODUCT_GRID_BORDER} !important;
text-align: center !important;
vertical-align: middle !important;
/* padding/line-height 勿 !important:与合同一致,允许 tr 行高拖拽生效 */
padding: ${PRODUCT_GRID_CELL_PADDING};
box-sizing: border-box !important;
line-height: normal;
overflow: visible !important;
white-space: normal !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
word-break: break-word !important;
background-color: #fff !important;
}
.inbound-slip-doc th.inbound-slip-product-grid-cell {
padding: ${PRODUCT_GRID_TH_PADDING};
font-weight: bold !important;
}
.inbound-slip-doc .inbound-slip-product-grid-cell > p {
margin: 0 !important;
padding: 0 !important;
text-align: center !important;
line-height: 1.35;
}
.inbound-slip-doc .sx-excel-row-spacer {
display: block !important;
float: left !important;
width: 0 !important;
max-width: 0 !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
pointer-events: none !important;
line-height: 0 !important;
font-size: 0 !important;
}
.inbound-slip-doc .inbound-slip-product-cell:not(.inbound-slip-product-grid-cell) {
border: ${PRODUCT_GRID_BORDER} !important;
background-color: #fff !important;
padding: 10px 6px 14px 6px !important;
line-height: 1.35 !important;
vertical-align: top !important;
white-space: normal !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
word-break: break-word !important;
}
.inbound-slip-doc .inbound-slip-seal-block,
.inbound-slip-doc .inbound-slip-seal-lines,
.inbound-slip-doc .inbound-slip-terms-text {
margin: 0 !important;
padding: 0 !important;
line-height: 1.35 !important;
}
.inbound-slip-doc table.inbound-slip-side-labels {
border: none !important;
border-collapse: collapse !important;
width: auto !important;
margin: 0 auto !important;
}
.inbound-slip-doc td.inbound-slip-side-labels-col,
.inbound-slip-doc table.inbound-slip-side-labels td,
.inbound-slip-doc table.inbound-slip-side-labels th {
border: none !important;
background: transparent !important;
padding: 0 !important;
vertical-align: top !important;
text-align: center !important;
font-size: 18px !important;
line-height: 1.35 !important;
letter-spacing: 0 !important;
white-space: normal !important;
}
`
export const INBOUND_SLIP_STYLE_BLOCK = `<style type="text/css">${INBOUND_SLIP_PRINT_CSS}</style>`
/** 生成公司印章 HTML(地址/电话单元格右上角) */
export function buildInboundSlipCompanySealHtml(): string {
return `<img src="${INBOUND_SLIP_SEAL_IMAGE_SRC}" alt="印章" style="${INBOUND_SLIP_SEAL_IMAGE_STYLE}" />`
}
const SIDE_LABEL_CELL = `border:none !important;padding:0 !important;vertical-align:top;text-align:center;${FONT_SIDE}line-height:1.35;background:transparent !important;`
/** 右侧联次:单列纵向,带圈序号 + 每字一行依次向下排列 */
export function buildInboundSlipSideLabelsHtml(): string {
return `<table class="inbound-slip-side-labels" style="border:none !important;border-collapse:collapse;margin:0 auto;width:auto;">
<tr>
<td class="inbound-slip-side-label-item" style="${SIDE_LABEL_CELL}">①<br />白<br />留<br />存<br />②<br />绿<br />仓<br />库<br />③<br />红<br />客<br />户<br />④<br />黄<br />财<br />务<br />/<br />销<br />售</td>
</tr>
</table>`
}
/** 生成带占位符的入库单 HTML 模板(方案A:信息 / 物料 / 页脚+签字 分表) */
export function generateInboundSlipTemplate(_plan?: Plan): string {
return `${INBOUND_SLIP_STYLE_BLOCK}<div class="inbound-slip-doc" style="font-family:${DOC_FONT};${FONT_BODY}color:#000;line-height:1.5;background:#fff;padding:${DOC_PADDING};${DOC_WRAP_EXTRA}">
<!-- 上部分:标题区 -->
<div class="inbound-slip-title-section" style="position:relative;min-height:56px;margin-bottom:14px;">
<img src="/logo.gif" alt="logo" style="position:absolute;left:${LOGO_LEFT}px;top:50%;transform:translateY(-50%);height:${LOGO_HEIGHT}px;width:auto;" />
<div style="text-align:center;padding:4px 60px 0 100px;">
<div class="inbound-slip-title-heading" style="${TITLE_STYLE}">{{companyName}}</div>
<div class="inbound-slip-title-heading" style="${TITLE_SUB_STYLE}">入&nbsp;&nbsp;库&nbsp;&nbsp;单</div>
</div>
</div>
<!-- 下部分:内容区(左正文 + 右侧联次) -->
<table class="inbound-slip-outer-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
<tr>
<td class="inbound-slip-main-col" style="vertical-align:top;padding-right:6px;border:none !important;background:transparent !important;">
<!-- 1. 基本信息(独立表,不可拖宽高) -->
<table class="inbound-slip-info-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${INFO_COLGROUP}
<tr>
<td colspan="4" style="${INFO_CELL_LEFT}">
<div class="inbound-slip-info-row" style="${INFO_ROW}">
<span class="inbound-slip-info-label" style="${INFO_LABEL}">送货单位:</span>
<span class="inbound-slip-info-value-left" style="${INFO_VALUE_INLINE_LEFT}">{{receivingUnit}}&nbsp;</span>
</div>
</td>
<td colspan="3" style="${INFO_CELL_RIGHT}">
<div class="inbound-slip-info-row" style="${INFO_ROW}">
<span class="inbound-slip-info-label" style="${INFO_LABEL}">日&nbsp;期:</span>
<span class="inbound-slip-info-value-center" style="${INFO_VALUE_INLINE_CENTER}">{{planDateChinese}}&nbsp;</span>
</div>
</td>
</tr>
</table>
<div class="inbound-slip-section-gap inbound-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>
<!-- 2. 物料明细表(含备注;可拖表头/明细列宽行高) -->
<table class="inbound-slip-product-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${PRODUCT_COLGROUP}
<tr class="inbound-slip-product-header">
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">单据编号</th>
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">物料编号</th>
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">物料名称</th>
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">规格</th>
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">单位</th>
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">数量</th>
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">产品规格</th>
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">实收数量</th>
</tr>
{{productTable}}
<tr>
<td colspan="8" class="inbound-slip-product-cell" style="${PRODUCT_TABLE_CELL_LEFT}">
备注:{{remark}}
</td>
</tr>
</table>
<div class="inbound-slip-section-gap inbound-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>
<!-- 3. 签字区(独立表) -->
<table class="inbound-slip-sign-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${buildInboundSlipSignRowHtml()}
</table>
</td>
<!-- 最右侧纵向联次 -->
<td class="inbound-slip-side-labels-col" style="width:36px;min-width:36px;vertical-align:top;text-align:center;color:#333;padding:0 !important;border:none !important;background:transparent !important;">
{{sideLabels}}
</td>
</tr>
</table>
</div>`
}
/** 印章占位符与样式(兼容已保存模板,打开时还原为右上角定位) */
function normalizeInboundSlipSealPlaceholder(html: string): string {
if (!html.includes('inbound-slip-doc')) return html
let result = html.replace(/<img[^>]*alt="印章"[^>]*>/gi, '{{companySeal}}')
if (result.includes('inbound-slip-seal-block') && !result.includes('{{companySeal}}')) {
result = result.replace(
/(<div class="inbound-slip-seal-block"[^>]*>)/,
'$1\n <!-- 印章图片 -->\n {{companySeal}}\n '
)
}
return result.replace(/min-height:76px;/g, 'min-height:0;')
}
function applyProductGridBorderToStyle(style: string): string {
// 保留拖拽产生的 height / min-height / line-height,避免打开模板时被抹掉
const keptHeight = style.match(/height:\s*[^;]+;?/gi)?.join('') || ''
const keptMinHeight = style.match(/min-height:\s*[^;]+;?/gi)?.join('') || ''
const keptLineHeight = style.match(/line-height:\s*[^;]+;?/gi)?.join('') || ''
let next = style
.replace(/border:[^;]*;?/gi, '')
.replace(/border-right:[^;]*;?/gi, '')
.replace(/border-bottom:[^;]*;?/gi, '')
.replace(/border-left:[^;]*;?/gi, '')
.replace(/border-top:[^;]*;?/gi, '')
.replace(/height:\s*[^;]+;?/gi, '')
.replace(/min-height:\s*[^;]+;?/gi, '')
.replace(/line-height:\s*[^;]+;?/gi, '')
.replace(/text-align:left;/g, 'text-align:center;')
.replace(/vertical-align:top;/g, '')
.trim()
const lineHeight = keptLineHeight || 'line-height:normal;'
return `border:${PRODUCT_GRID_BORDER} !important;text-align:center;padding:${PRODUCT_GRID_CELL_PADDING};box-sizing:border-box;${lineHeight}${keptHeight}${keptMinHeight}${next}`
}
function patchProductFooterCellAttrs(attrs: string, classes: string, style: string): string {
let next = attrs
if (next.includes('class="')) {
next = next.replace(/class="([^"]*)"/, (_, cls: string) => {
let updated = cls.trim()
for (const item of classes.split(/\s+/)) {
if (item && !updated.includes(item)) updated += ` ${item}`
}
return `class="${updated.trim()}"`
})
} else {
next = `${next} class="${classes.trim()}"`
}
if (next.includes('style="')) {
next = next.replace(/style="[^"]*"/, `style="${style}"`)
} else {
next = `${next} style="${style}"`
}
return next
}
/** 物料明细网格:全边框 inline + 原生居中(兼容已保存模板,对齐合同 PDF) */
function patchInboundSlipProductGridCellAttrs(attrs: string): string {
let next = attrs.includes('class="')
? attrs.replace(/class="([^"]*)"/, (_, cls: string) => {
let updated = cls
if (!updated.includes('inbound-slip-product-cell')) updated += ' inbound-slip-product-cell'
if (!updated.includes('inbound-slip-product-grid-cell')) updated += ' inbound-slip-product-grid-cell'
return `class="${updated.trim()}"`
})
: `${attrs} class="inbound-slip-product-cell inbound-slip-product-grid-cell"`
if (next.includes('style="')) {
next = next.replace(/style="([^"]*)"/, (_, style: string) => `style="${applyProductGridBorderToStyle(style)}"`)
} else {
next = `${next} style="${PRODUCT_GRID_CELL}"`
}
return next
}
function normalizeInboundSlipProductGridCell(html: string): string {
if (!html.includes('inbound-slip-doc') || !html.includes('inbound-slip-product-cell')) return html
let result = html.replace(/<tr([^>]*)>\s*(<th[\s\S]*?<\/th>\s*){8}\s*<\/tr>/g, (rowMatch) => {
if (!rowMatch.includes('物料编号') || rowMatch.includes('colspan')) return rowMatch
let row = rowMatch.includes('inbound-slip-product-header')
? rowMatch
: rowMatch.replace('<tr', '<tr class="inbound-slip-product-header"')
return row.replace(/<(th)([^>]*)>([\s\S]*?)<\/th>/g, (_m, tag, attrs, content) => {
return `<${tag}${patchInboundSlipProductGridCellAttrs(attrs)}>${formatInboundSlipProductGridCellContent(content)}</${tag}>`
})
})
result = result.replace(/<tr([^>]*)>\s*(<td(?![^>]*colspan)[^>]*>[\s\S]*?<\/td>\s*){8}\s*<\/tr>/g, (rowMatch) => {
if (
rowMatch.includes('备注') ||
rowMatch.includes('1.本送货单') ||
rowMatch.includes('仓库:') ||
rowMatch.includes('暂无产品信息')
) {
return rowMatch
}
return rowMatch.replace(/<(td)([^>]*)>([\s\S]*?)<\/td>/g, (_m, tag, attrs, content) => {
if (attrs.includes('colspan')) return `<${tag}${attrs}>${content}</td>`
return `<${tag}${patchInboundSlipProductGridCellAttrs(attrs)}>${formatInboundSlipProductGridCellContent(content)}</${tag}>`
})
})
return normalizeInboundSlipGridCellWrappers(result)
}
/** 去掉 TinyMCE / 历史包裹层,单元格内保留纯文本(对齐合同 PDF) */
function normalizeInboundSlipGridCellWrappers(html: string): string {
if (!html.includes('inbound-slip-product-grid-cell')) return html
const unwrap = (tag: string, attrs: string, content: string) =>
`<${tag}${attrs}>${formatInboundSlipProductGridCellContent(content)}</${tag}>`
let result = html.replace(
/<(td|th)([^>]*inbound-slip-product-grid-cell[^>]*)>\s*<span class="inbound-slip-product-grid-align"[^>]*>([\s\S]*?)<\/span>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(
/<(td|th)([^>]*inbound-slip-product-grid-cell[^>]*)>\s*<span class="inbound-slip-pdf-grid-text"[^>]*>([\s\S]*?)<\/span>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(
/<(td|th)([^>]*inbound-slip-product-grid-cell[^>]*)>\s*<div class="inbound-slip-pdf-grid-inner"[^>]*>([\s\S]*?)<\/div>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(
/<(td|th)([^>]*inbound-slip-product-grid-cell[^>]*)>\s*<p[^>]*>([\s\S]*?)<\/p>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(
/<(td|th)([^>]*inbound-slip-product-grid-cell[^>]*)>\s*<div(?![^>]*class="inbound-slip)[^>]*>([\s\S]*?)<\/div>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(/<(td|th)([^>]*inbound-slip-product-grid-cell[^>]*)\s+height="[^"]*"/gi, '<$1$2')
result = result.replace(/<(tr)([^>]*class="[^"]*inbound-slip-product[^"]*"[^>]*)\s+height="[^"]*"/gi, '<$1$2')
return result
}
function extractInboundSlipGridCellContent(cell: HTMLTableCellElement): string {
let content = cell.innerHTML.trim()
const onlyChild = cell.firstElementChild
if (onlyChild && cell.childElementCount === 1) {
const tag = onlyChild.tagName
if (
tag === 'P' ||
tag === 'DIV' ||
tag === 'SPAN' ||
onlyChild.classList.contains('inbound-slip-pdf-grid-inner') ||
onlyChild.classList.contains('inbound-slip-pdf-grid-text')
) {
content = onlyChild.innerHTML.trim()
}
}
return formatInboundSlipProductGridCellContent(content)
}
function applyInboundSlipPdfGridCellStyles(cell: HTMLTableCellElement, isHeader: boolean): void {
const content = extractInboundSlipGridCellContent(cell)
cell.innerHTML = content || '&nbsp;'
cell.removeAttribute('height')
cell.style.removeProperty('height')
cell.style.removeProperty('min-height')
cell.style.setProperty('padding', isHeader ? PRODUCT_GRID_PDF_TH_PADDING : PRODUCT_GRID_PDF_TD_PADDING, 'important')
cell.style.setProperty('text-align', 'center', 'important')
cell.style.setProperty('font-size', '20px', 'important')
cell.style.setProperty('line-height', PRODUCT_GRID_PDF_LINE_HEIGHT, 'important')
cell.style.setProperty('box-sizing', 'border-box', 'important')
cell.style.setProperty('overflow', 'visible', 'important')
cell.style.setProperty('vertical-align', 'middle', 'important')
if (isHeader) {
cell.style.setProperty('font-weight', 'bold', 'important')
}
}
function applyInboundSlipPdfFooterCellStyles(cell: HTMLTableCellElement): void {
cell.removeAttribute('height')
cell.style.removeProperty('height')
cell.style.removeProperty('min-height')
cell.style.setProperty('padding', PRODUCT_FOOTER_PDF_PADDING, 'important')
cell.style.setProperty('font-size', '20px', 'important')
cell.style.setProperty('line-height', PRODUCT_FOOTER_PDF_LINE_HEIGHT, 'important')
cell.style.setProperty('box-sizing', 'border-box', 'important')
cell.style.setProperty('overflow', 'visible', 'important')
cell.style.setProperty('vertical-align', 'top', 'important')
cell.style.setProperty('text-align', 'left', 'important')
}
function unwrapInboundSlipPdfCellParagraph(cell: HTMLTableCellElement): void {
const onlyChild = cell.firstElementChild
if (onlyChild?.tagName === 'P' && cell.childElementCount === 1) {
cell.innerHTML = onlyChild.innerHTML.trim() || '&nbsp;'
}
}
function prepareInboundSlipPdfRemarkCell(cell: HTMLTableCellElement): void {
unwrapInboundSlipPdfCellParagraph(cell)
applyInboundSlipPdfFooterCellStyles(cell)
}
function prepareInboundSlipPdfTermsCell(cell: HTMLTableCellElement): void {
applyInboundSlipPdfFooterCellStyles(cell)
const terms = cell.querySelector('.inbound-slip-terms-text') as HTMLElement | null
if (terms) {
terms.style.setProperty('margin', '0', 'important')
terms.style.setProperty('padding', '0', 'important')
terms.style.setProperty('line-height', PRODUCT_FOOTER_PDF_LINE_HEIGHT, 'important')
}
}
function prepareInboundSlipPdfSealCell(cell: HTMLTableCellElement): void {
applyInboundSlipPdfFooterCellStyles(cell)
const block = cell.querySelector('.inbound-slip-seal-block') as HTMLElement | null
const lines = cell.querySelector('.inbound-slip-seal-lines') as HTMLElement | null
if (block) {
block.style.setProperty('padding', '0', 'important')
block.style.setProperty('margin', '0', 'important')
block.style.setProperty('position', 'relative', 'important')
}
if (lines) {
lines.style.setProperty('margin', '0', 'important')
lines.style.setProperty('padding', '0', 'important')
lines.style.setProperty('line-height', PRODUCT_FOOTER_PDF_LINE_HEIGHT, 'important')
}
}
function prepareInboundSlipPdfSignValues(root: HTMLElement): void {
root.querySelectorAll('.inbound-slip-sign-value').forEach((node) => {
const el = node as HTMLElement
el.style.setProperty('display', 'inline-block', 'important')
el.style.setProperty('padding', SIGN_VALUE_PDF_PADDING, 'important')
el.style.setProperty('line-height', '1.35', 'important')
el.style.setProperty('vertical-align', 'baseline', 'important')
el.style.setProperty('border', 'none', 'important')
el.style.setProperty('border-bottom', SIGN_UNDERLINE, 'important')
el.style.setProperty('box-sizing', 'border-box', 'important')
})
}
/** PDF 截图前:物料网格 + 备注/条款/地址行 + 签字区统一样式 */
export function prepareInboundSlipPdfGridCells(root: HTMLElement): void {
root.querySelectorAll('td.inbound-slip-product-grid-cell, th.inbound-slip-product-grid-cell').forEach((node) => {
applyInboundSlipPdfGridCellStyles(node as HTMLTableCellElement, node.tagName === 'TH')
})
root.querySelectorAll('td.inbound-slip-terms-cell').forEach((node) => {
prepareInboundSlipPdfTermsCell(node as HTMLTableCellElement)
})
root.querySelectorAll('td.inbound-slip-product-cell:not(.inbound-slip-product-grid-cell)').forEach((node) => {
const cell = node as HTMLTableCellElement
if (cell.classList.contains('inbound-slip-terms-cell')) return
if (cell.querySelector('.inbound-slip-seal-block')) {
prepareInboundSlipPdfSealCell(cell)
return
}
if (/备注\s*[::]/.test(cell.textContent || '')) {
prepareInboundSlipPdfRemarkCell(cell)
}
})
root.querySelectorAll('tr').forEach((row) => {
if (!row.querySelector('.inbound-slip-product-cell')) return
row.removeAttribute('height')
row.style.removeProperty('height')
row.style.removeProperty('min-height')
})
prepareInboundSlipPdfSignValues(root)
// 亚像素 border 在 Windows DPI 下易变粗;截图前改为 1px 背景单线
applyPdfStableTableCellBorders(root.querySelectorAll('.inbound-slip-product-cell'))
}
/** 备注、条款、印章行:统一 0.3px 全边框(兼容已保存模板,与物料网格衔接) */
function normalizeInboundSlipProductTableFooter(html: string): string {
if (!html.includes('inbound-slip-doc')) return html
let result = html
result = result.replace(/<td([^>]*colspan="[78]"[^>]*)>(\s*)备注:/g, (_match, attrs, space) => {
const withSpan = String(attrs).replace(/colspan="[78]"/, 'colspan="8"')
const patched = patchProductFooterCellAttrs(
withSpan,
'inbound-slip-product-cell',
PRODUCT_TABLE_CELL_LEFT
)
return `<td${patched}>${space}备注:`
})
result = result.replace(
/<td([^>]*colspan="[45]"[^>]*)>\s*(<div class="inbound-slip-terms-text"|\s*1\.本送货单)/g,
(_match, attrs, contentStart) => {
const withSpan = String(attrs).replace(/colspan="[45]"/, 'colspan="5"')
const patched = patchProductFooterCellAttrs(
withSpan,
'inbound-slip-product-cell inbound-slip-terms-cell',
PRODUCT_TERMS_CELL_LEFT
)
return `<td${patched}>\n ${contentStart}`
}
)
result = result.replace(
/<td([^>]*colspan="[23]"[^>]*)>\s*<div class="inbound-slip-seal-block"/g,
(_match, attrs) => {
const withSpan = String(attrs).replace(/colspan="[23]"/, 'colspan="2"')
const patched = patchProductFooterCellAttrs(withSpan, 'inbound-slip-product-cell', PRODUCT_TABLE_CELL_LEFT)
return `<td${patched}>\n <div class="inbound-slip-seal-block"`
}
)
return result
}
/** 条款单元格行高随内容(兼容已保存模板) */
function normalizeInboundSlipTermsCell(html: string): string {
if (!html.includes('inbound-slip-doc')) return html
const patchTermsTd = (attrs: string) => {
let nextAttrs = attrs
if (nextAttrs.includes('class="')) {
nextAttrs = nextAttrs.replace(/class="([^"]*)"/, (_, cls: string) =>
cls.includes('inbound-slip-terms-cell') ? `class="${cls}"` : `class="${cls} inbound-slip-terms-cell"`
)
} else {
nextAttrs = `${nextAttrs} class="inbound-slip-product-cell inbound-slip-terms-cell"`
}
if (nextAttrs.includes('style="')) {
nextAttrs = nextAttrs.replace(/style="[^"]*"/, `style="${PRODUCT_TERMS_CELL_LEFT}"`)
} else {
nextAttrs = `${nextAttrs} style="${PRODUCT_TERMS_CELL_LEFT}"`
}
return nextAttrs
}
let result = html.replace(
/<td colspan="[45]"([^>]*)>\s*<div class="inbound-slip-terms-text"/g,
(match, attrs) => {
if (attrs.includes('inbound-slip-terms-cell') && attrs.includes('border:0.3px solid #000') && /colspan="5"/.test(match)) {
return match
}
return `<td colspan="5"${patchTermsTd(attrs)}>\n <div class="inbound-slip-terms-text"`
}
)
result = result.replace(
/<div class="inbound-slip-seal-block" style="([^"]*)"/g,
(match, style) => {
if (style.includes('padding:0;') && !style.includes('min-height')) return match
let nextStyle = style.replace(/min-height:\d+px;/g, '').replace(/padding:[^;]*;/g, '')
nextStyle = `padding:0;${nextStyle}`
return `<div class="inbound-slip-seal-block" style="${nextStyle}"`
}
)
return result
}
/** 物料表列宽 + 条款区跨列(兼容已保存模板) */
function normalizeInboundSlipProductColWidths(html: string): string {
if (!html.includes('inbound-slip-product-table')) return html
return html.replace(
/(<table class="inbound-slip-product-table"[^>]*>\s*)<colgroup>[\s\S]*?<\/colgroup>/i,
`$1${PRODUCT_COLGROUP}`
)
}
/** 移除条款+地址印章行(兼容已保存模板) */
function normalizeInboundSlipTermsText(html: string): string {
if (!html.includes('inbound-slip-doc')) return html
if (
!html.includes('1.本送货单') &&
!html.includes('inbound-slip-terms-text') &&
!html.includes('inbound-slip-seal-block')
) {
return html
}
// 条款+印章整行删除(同一 tr 内左右两格)
let result = html.replace(
/<tr[^>]*>\s*<td[^>]*colspan="[45]"[^>]*>[\s\S]*?(?:inbound-slip-terms-text|1\.本送货单)[\s\S]*?<\/tr>\s*/gi,
''
)
// 兜底:独立印章行
result = result.replace(
/<tr[^>]*>\s*<td[^>]*>[\s\S]*?inbound-slip-seal-block[\s\S]*?<\/tr>\s*/gi,
''
)
return result
}
/** 刷新已有间距行的 inline 样式(TinyMCE 保存后可能丢失) */
function refreshInboundSlipGapBeforeProduct(html: string): string {
return html
.replace(
/<tr class="inbound-slip-gap-before-product">[\s\S]*?<\/tr>/,
`<tr class="inbound-slip-gap-before-product">\n <td colspan="8" class="inbound-slip-gap-before-product-cell" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</td>\n </tr>`
)
.replace(
/<div class="inbound-slip-section-gap inbound-slip-gap-before-product"[^>]*>[\s\S]*?<\/div>/,
`<div class="inbound-slip-section-gap inbound-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>`
)
}
/** 在信息区与物料表之间插入间距(兼容已保存模板) */
function normalizeInboundSlipGapBeforeProduct(html: string): string {
if (!html.includes('inbound-slip-doc')) {
return html
}
if (html.includes('inbound-slip-gap-before-product')) {
return refreshInboundSlipGapBeforeProduct(html)
}
const gapRow = `<tr class="inbound-slip-gap-before-product"><td colspan="8" class="inbound-slip-gap-before-product-cell" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</td></tr>\n `
const productHeader = `(?:单据编号|物料编号)`
const withComment = html.replace(
new RegExp(
`(<span class="inbound-slip-info-label"[^>]*>日(?:&nbsp;)?期:</span>[\\s\\S]*?</tr>\\s*)(<!--\\s*中:产品明细表[\\s\\S]*?<tr>\\s*<th[^>]*>${productHeader})`
),
`$1${gapRow}$2`
)
if (withComment !== html) return withComment
const withHeader = html.replace(
new RegExp(
`(<span class="inbound-slip-info-label"[^>]*>日(?:&nbsp;)?期:</span>[\\s\\S]*?</tr>\\s*)(<tr>\\s*<th[^>]*>${productHeader})`
),
`$1${gapRow}$2`
)
if (withHeader !== html) return withHeader
return html.replace(
/(收货联系方式:[\s\S]*?<\/tr>\s*)(<!--\s*中:产品明细表[\s\S]*?<tr>\s*<th[^>]*>物料编号|<tr>\s*<th[^>]*>物料编号)/,
`$1${gapRow}$2`
)
}
/** 刷新签字区上方间距行 inline 样式 */
function refreshInboundSlipGapBeforeSign(html: string): string {
return html
.replace(
/<tr class="inbound-slip-gap-before-sign">[\s\S]*?<\/tr>/,
`<tr class="inbound-slip-gap-before-sign">\n <td colspan="8" class="inbound-slip-gap-before-sign-cell" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</td>\n </tr>`
)
.replace(
/<div class="inbound-slip-section-gap inbound-slip-gap-before-sign"[^>]*>[\s\S]*?<\/div>/,
`<div class="inbound-slip-section-gap inbound-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>`
)
}
/** 在物料表与签字区之间插入间距行(兼容已保存模板) */
function normalizeInboundSlipGapBeforeSign(html: string): string {
if (!html.includes('inbound-slip-doc')) return html
if (html.includes('inbound-slip-gap-before-sign')) {
return refreshInboundSlipGapBeforeSign(html)
}
const gapRow = `<tr class="inbound-slip-gap-before-sign"><td colspan="8" class="inbound-slip-gap-before-sign-cell" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</td></tr>\n\n `
const withComment = html.replace(
/(<\/tr>\s*)(<!--\s*下:签字区[\s\S]*?<tr>\s*<td colspan="[78]"[^>]*>\s*仓库:)/,
`$1${gapRow}$2`
)
if (withComment !== html) return withComment
return html.replace(
/(inbound-slip-seal-block[\s\S]*?<\/tr>\s*)(<tr>\s*<td colspan="[78]"[^>]*>\s*仓库:)/,
`$1${gapRow}$2`
)
}
/** 签字区标签/下划线 baseline 对齐(兼容已保存模板) */
function normalizeInboundSlipSignLabelMarkup(html: string): string {
if (!html.includes('inbound-slip-sign-value')) return html
let result = html.replace(
/<span class="inbound-slip-sign-item">(仓库:|审核:|客户签收:|司机\/送货人\(附车牌\):)\s*(<span class="inbound-slip-sign-value")/g,
'<span class="inbound-slip-sign-item"><span class="inbound-slip-sign-label">$1</span>$2'
)
result = result.replace(/<span class="inbound-slip-sign-value" style="([^"]*)"/g, (match, style) => {
const nextStyle = style
.replace(/display:inline-block;?/g, '')
.replace(/padding:[^;]*;?/g, '')
.replace(/vertical-align:[^;]*;?/g, '')
.replace(/border:none !important;?/g, '')
.replace(/border-bottom:[^;]*;?/g, '')
.replace(/;\s*;/g, ';')
.replace(/^;|;$/g, '')
.trim()
return nextStyle ? `<span class="inbound-slip-sign-value" style="${nextStyle}"` : `<span class="inbound-slip-sign-value"`
})
return result
}
/** 签字区 flex 布局(兼容已保存模板,避免打印预览溢出到右侧联次列) */
function normalizeInboundSlipSignRow(html: string): string {
if (!html.includes('inbound-slip-doc') || !html.includes('仓库:')) return html
let result = html.replace(
/<tr([^>]*)>\s*<td colspan="[78]"([^>]*)>([\s\S]*?仓库:[\s\S]*?司机\/送货人\(附车牌\)[^<]*[\s\S]*?)<\/td>\s*<\/tr>/g,
(match, trAttrs, tdAttrs, content) => {
if (!content.includes('inbound-slip-sign-value') || content.includes('inbound-slip-sign-inner')) {
return match
}
const wrappedContent = content
.replace(/&nbsp;&nbsp;/g, '')
.replace(
/(仓库:|审核:|客户签收:|司机\/送货人\(附车牌\):)\s*(<span class="inbound-slip-sign-value"[\s\S]*?<\/span>)/g,
'<span class="inbound-slip-sign-item"><span class="inbound-slip-sign-label">$1</span>$2</span>'
)
.replace(
/<span class="inbound-slip-sign-item">(仓库:|审核:|客户签收:|司机\/送货人\(附车牌\):)\s*(<span class="inbound-slip-sign-value")/g,
'<span class="inbound-slip-sign-item"><span class="inbound-slip-sign-label">$1</span>$2'
)
const trClassAttr = trAttrs.includes('class="')
? trAttrs.replace(/class="([^"]*)"/, (_, cls: string) =>
cls.includes('inbound-slip-sign-row') ? `class="${cls}"` : `class="${cls} inbound-slip-sign-row"`
)
: `${trAttrs} class="inbound-slip-sign-row"`
let nextTdAttrs = tdAttrs.replace(/white-space:nowrap;?/g, '').replace(/font-size:16px;?/g, 'font-size:14px;')
if (nextTdAttrs.includes('class="')) {
nextTdAttrs = nextTdAttrs.replace(/class="([^"]*)"/, (_, cls: string) =>
cls.includes('inbound-slip-sign-row-cell') ? `class="${cls}"` : `class="${cls} inbound-slip-sign-row-cell"`
)
} else {
nextTdAttrs = `${nextTdAttrs} class="inbound-slip-sign-row-cell"`
}
return `<tr${trClassAttr}>\n <td colspan="8"${nextTdAttrs}>\n <div class="inbound-slip-sign-inner">${wrappedContent.trim()}</div>\n </td>\n </tr>`
}
)
result = result.replace(
/<td(?![^>]*class="[^"]*inbound-slip-main-col)([^>]*style="[^"]*vertical-align:top;padding-right:6px)/g,
'<td class="inbound-slip-main-col"$1'
)
return normalizeInboundSlipSignLabelMarkup(result)
}
/** 信息区:送货单位 + 日期(兼容旧「收货单位/单据编号/收货联系方式」布局) */
function normalizeInboundSlipInfoLayout(html: string): string {
if (!html.includes('inbound-slip-info-table')) return html
const infoMatch = html.match(/<table class="inbound-slip-info-table"[^>]*>[\s\S]*?<\/table>/i)
if (!infoMatch) return html
const infoHtml = infoMatch[0]
const needsMigrate =
infoHtml.includes('收货单位:') ||
infoHtml.includes('收货联系方式:') ||
infoHtml.includes('单据编号:') ||
!infoHtml.includes('送货单位:') ||
(infoHtml.match(/<tr[\s>]/g) || []).length > 1
if (!needsMigrate) return html
const newInfoTable = `<table class="inbound-slip-info-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${INFO_COLGROUP}
<tr>
<td colspan="4" style="${INFO_CELL_LEFT}">
<div class="inbound-slip-info-row" style="${INFO_ROW}">
<span class="inbound-slip-info-label" style="${INFO_LABEL}">送货单位:</span>
<span class="inbound-slip-info-value-left" style="${INFO_VALUE_INLINE_LEFT}">{{receivingUnit}}&nbsp;</span>
</div>
</td>
<td colspan="3" style="${INFO_CELL_RIGHT}">
<div class="inbound-slip-info-row" style="${INFO_ROW}">
<span class="inbound-slip-info-label" style="${INFO_LABEL}">日&nbsp;期:</span>
<span class="inbound-slip-info-value-center" style="${INFO_VALUE_INLINE_CENTER}">{{planDateChinese}}&nbsp;</span>
</div>
</td>
</tr>
</table>`
return html.replace(infoMatch[0], newInfoTable)
}
/** 物料表:表头补「单据编号」列,并把旧 7 列数据行升为 8 列(兼容已保存模板) */
function normalizeInboundSlipProductPlanNoColumn(html: string): string {
if (!html.includes('inbound-slip-product-table')) return html
let result = html
if (!/>单据编号<\/th>/.test(result)) {
result = result.replace(
/(<tr[^>]*class="[^"]*inbound-slip-product-header[^"]*"[^>]*>\s*)(<th[^>]*>物料编号<\/th>)/,
`$1<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">单据编号</th>\n $2`
)
result = result.replace(
/(<tr>\s*)(<th[^>]*>物料编号<\/th>)/,
`$1<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">单据编号</th>\n $2`
)
}
// 旧 7 列明细行:在首格前插入单据编号占位
result = result.replace(
/<tr([^>]*)>\s*((?:<td(?![^>]*colspan)[^>]*>[\s\S]*?<\/td>\s*){7})<\/tr>/g,
(rowMatch, trAttrs, cells) => {
if (
rowMatch.includes('备注') ||
rowMatch.includes('1.本送货单') ||
rowMatch.includes('仓库:') ||
rowMatch.includes('暂无产品信息') ||
rowMatch.includes('单据编号')
) {
return rowMatch
}
return `<tr${trAttrs}>\n <td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">{{planNo}}</td>\n ${cells}</tr>`
}
)
result = result.replace(/colspan="7"/g, 'colspan="8"')
return result
}
/**
* 旧版一体 layout-table / 曾拆出的 footer-table → 方案A:
* 信息表 + 物料表(含备注条款) + 签字表。
*/
function migrateInboundSlipSplitTables(html: string): string {
if (!html.includes('inbound-slip-doc')) return html
// 已正确:物料表内含备注,且无独立 footer-table
if (
html.includes('inbound-slip-product-table') &&
!html.includes('inbound-slip-footer-table') &&
/inbound-slip-product-table[\s\S]*?备注:/.test(html)
) {
return html
}
// 把独立 footer-table 并回 product-table
if (html.includes('inbound-slip-footer-table') && html.includes('inbound-slip-product-table')) {
return html.replace(
/(<\/tr>\s*)<\/table>\s*<table class="inbound-slip-footer-table"[^>]*>\s*(?:<colgroup>[\s\S]*?<\/colgroup>\s*)?([\s\S]*?)<\/table>/i,
(_m, beforeClose, footerInner) => {
const rows = footerInner.replace(/^\s*<colgroup>[\s\S]*?<\/colgroup>\s*/i, '').trim()
return `${beforeClose}${rows}\n </table>`
}
)
}
if (!html.includes('inbound-slip-layout-table')) return html
return html.replace(
/<table class="inbound-slip-layout-table"[^>]*>([\s\S]*?)<\/table>/i,
(_full, inner: string) => {
const headerMatch =
inner.match(/<tr[^>]*class="[^"]*inbound-slip-product-header[^"]*"[\s\S]*?<\/tr>/i) ||
inner.match(/<tr>\s*<th[^>]*>单据编号[\s\S]*?<\/tr>/i) ||
inner.match(/<tr>\s*<th[^>]*>物料编号[\s\S]*?<\/tr>/i)
if (!headerMatch) return _full
const headerIdx = inner.indexOf(headerMatch[0])
let before = inner.slice(0, headerIdx)
before = before.replace(/<colgroup>[\s\S]*?<\/colgroup>/i, '')
before = before.replace(/<tr class="inbound-slip-gap-before-product">[\s\S]*?<\/tr>/i, '')
const afterHeader = inner.slice(headerIdx + headerMatch[0].length)
const signMatch =
afterHeader.match(/<tr[^>]*class="[^"]*inbound-slip-sign-row[^"]*"[\s\S]*?<\/tr>/i) ||
afterHeader.match(/<tr>\s*<td[^>]*>[\s\S]*?仓库:[\s\S]*?<\/tr>/i)
let productAndFooter = afterHeader
let signPart = buildInboundSlipSignRowHtml()
if (signMatch) {
const signAt = afterHeader.indexOf(signMatch[0])
productAndFooter = afterHeader.slice(0, signAt)
signPart = signMatch[0]
}
productAndFooter = productAndFooter.replace(
/<tr class="inbound-slip-gap-before-sign">[\s\S]*?<\/tr>/i,
''
)
return `<table class="inbound-slip-info-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${INFO_COLGROUP}
${before.trim()}
</table>
<div class="inbound-slip-section-gap inbound-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>
<table class="inbound-slip-product-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${PRODUCT_COLGROUP}
${headerMatch[0]}
${productAndFooter.trim()}
</table>
<div class="inbound-slip-section-gap inbound-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>
<table class="inbound-slip-sign-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${signPart.trim()}
</table>`
}
)
}
/** 统一单据左右 10px 间距、内容区宽度(兼容已保存模板) */
function normalizeInboundSlipDocLayout(html: string): string {
let result = migrateInboundSlipSplitTables(html)
.replace(/padding:8px 12px 24px 4px;/g, `padding:${DOC_PADDING};`)
.replace(/padding:8px 12px 24px;/g, `padding:${DOC_PADDING};`)
.replace(/padding-left: 4px !important;/g, `padding: ${DOC_PADDING} !important;`)
.replace(/left:0;top:50%/g, `left:${LOGO_LEFT}px;top:50%`)
.replace(/left:20px;/g, `left:${LOGO_LEFT}px;`)
.replace(/padding:4px 80px 0 120px;/g, 'padding:4px 60px 0 100px;')
.replace(/padding:4px 72px 0 88px;/g, 'padding:4px 60px 0 100px;')
.replace(/max-width:720px/g, `max-width:${INBOUND_SLIP_DOC_MAX_WIDTH}`)
.replace(/max-width:760px/g, `max-width:${INBOUND_SLIP_DOC_MAX_WIDTH}`)
.replace(/max-width:794px/g, `max-width:${INBOUND_SLIP_DOC_MAX_WIDTH}`)
.replace(/height:46px;/g, `height:${LOGO_HEIGHT}px;`)
.replace(/height:23px;/g, `height:${LOGO_HEIGHT}px;`)
.replace(/min-height:72px;/g, 'min-height:56px;')
.replace(/min-height:48px;/g, 'min-height:56px;')
.replace(/padding:4px 60px 0 68px;/g, 'padding:4px 60px 0 100px;')
.replace(/padding:4px 60px 0 52px;/g, 'padding:4px 60px 0 100px;')
.replace(/padding:1px 6px;/g, `padding:${TABLE_ROW_PADDING};`)
.replace(/padding:4px 6px;/g, `padding:${TABLE_ROW_PADDING};`)
.replace(/padding-bottom:5px;/g, '')
.replace(/padding-bottom:10px;/g, '')
.replace(/padding:18px 6px 8px;/g, `padding:${TABLE_ROW_PADDING};`)
.replace(/margin-top:6px;/g, 'margin-top:2px;')
.replace(/line-height:1\.6;/g, 'line-height:1.35;')
.replace(/line-height:1\.9;/g, 'line-height:1.35;')
.replace(/min-height:88px;/g, 'min-height:0;')
.replace(/min-height:36px;/g, '')
.replace(/min-height:76px;/g, 'min-height:0;')
.replace(/min-height:120px;/g, 'min-height:0;')
.replace(/top:4px !important;/g, `top:${INBOUND_SLIP_SEAL_TOP}px !important;`)
.replace(/top:4px;/g, `top:${INBOUND_SLIP_SEAL_TOP}px;`)
.replace(/top:-96px !important;/g, `top:${INBOUND_SLIP_SEAL_TOP}px !important;`)
.replace(/top:-96px;/g, `top:${INBOUND_SLIP_SEAL_TOP}px;`)
.replace(/>日期:</g, '>日&nbsp;期:<')
.replace(/border-bottom:2px solid #000 !important;/g, `border-bottom:${INFO_UNDERLINE} !important;`)
.replace(/border-bottom:1px solid #000 !important;/g, `border-bottom:${INFO_UNDERLINE} !important;`)
.replace(/border:2px solid #000 !important;/g, `border:${PRODUCT_GRID_BORDER} !important;`)
.replace(/border:1px solid #000 !important;/g, `border:${PRODUCT_GRID_BORDER} !important;`)
.replace(
/display:inline-block;(?:min-width:\d+px;)?padding:0 2px 1px;vertical-align:bottom;border:none !important;/g,
''
)
.replace(
/display:inline-block;padding:0 2px 2px;vertical-align:bottom;border:none !important;border-bottom:1px solid #000 !important;/g,
''
)
.replace(
/(仓库:|审核:|客户签收:|司机\/送货人\(附车牌\):)<span(?![^>]*class="inbound-slip-sign-value") style="/g,
'$1<span class="inbound-slip-sign-value" style="'
)
.replace(
/(<span class="inbound-slip-sign-value" style="[^"]*)border-bottom:2px solid #000 !important;/g,
`$1border-bottom:${SIGN_UNDERLINE} !important;`
)
.replace(/class="inbound-slip-info-before-product"/g, '')
.replace(/padding-bottom:12px;/g, '')
.replace(/padding-bottom:24px;/g, '')
if (result.includes('inbound-slip-doc') && !result.includes('max-width:')) {
result = result.replace(
/(<div class="inbound-slip-doc" style=")([^"]*)"/,
`$1$2;${DOC_WRAP_EXTRA}"`
)
}
if (result.includes('inbound-slip-doc')) {
result = result.replace(/<style type="text\/css">[\s\S]*?<\/style>/, INBOUND_SLIP_STYLE_BLOCK)
result = normalizeInboundSlipSignLabelMarkup(result)
result = normalizeInboundSlipProductGridCell(result)
result = normalizeInboundSlipProductTableFooter(result)
}
return result
}
/** 将旧版右侧联次 HTML 统一还原为占位符,便于每次打开注入最新纵向布局 */
function normalizeInboundSlipSideLabelsPlaceholder(html: string): string {
if (html.includes('{{sideLabels}}')) return html
let result = html
result = result.replace(/<table class="inbound-slip-side-labels"[\s\S]*?<\/table>/, '{{sideLabels}}')
result = result.replace(/<div class="inbound-slip-side-labels"[\s\S]*?<\/div>/, '{{sideLabels}}')
result = result.replace(
/(<!--\s*最右侧纵向联次\s*-->\s*<td[^>]*>)[\s\S]*?(<\/td>)/,
'$1{{sideLabels}}$2'
)
return result
}
/** 将占位符替换为计划最新数据 */
export function replaceInboundSlipPlaceholders(
htmlContent: string,
plan: Plan,
ctx?: InboundSlipRenderContext
): string {
if (!htmlContent || !plan) return htmlContent
const replacements: Record<string, string> = {
'{{companyName}}': INBOUND_SLIP_DEFAULTS.companyName,
'{{receivingUnit}}': buildDeliveryUnit(plan),
'{{planNo}}': plan.planNo || '',
'{{receiveContact}}': '',
'{{driverInfo}}': '',
'{{planDateChinese}}': formatPlanDateChinese(plan.planDate),
'{{productTable}}': buildProductRows(plan.products, plan.planNo || '', ctx),
'{{remark}}': formatRemarkForSlipHtml(plan.remark),
'{{companyAddress}}': INBOUND_SLIP_DEFAULTS.companyAddress,
'{{companyPhone}}': INBOUND_SLIP_DEFAULTS.companyPhone,
'{{contactPhone}}': INBOUND_SLIP_DEFAULTS.contactPhone,
'{{companySeal}}': buildInboundSlipCompanySealHtml(),
'{{sideLabels}}': buildInboundSlipSideLabelsHtml()
}
let result = normalizeInboundSlipDocLayout(
normalizeInboundSlipSealPlaceholder(
normalizeInboundSlipSignRow(
normalizeInboundSlipTermsCell(
normalizeInboundSlipTermsText(
normalizeInboundSlipGapBeforeSign(
normalizeInboundSlipProductTableFooter(
normalizeInboundSlipProductColWidths(
normalizeInboundSlipProductGridCell(
normalizeInboundSlipProductPlanNoColumn(
normalizeInboundSlipInfoLayout(
normalizeInboundSlipGapBeforeProduct(
normalizeInboundSlipSideLabelsPlaceholder(htmlContent)
)
)
)
)
)
)
)
)
)
)
)
)
for (const [placeholder, value] of Object.entries(replacements)) {
result = result.split(placeholder).join(value)
}
return result
}
/** 保存模板前:将动态字段还原为占位符 */
export function restoreInboundSlipPlaceholders(template: string, plan: Plan): string {
let result = template
const deliveryUnit = buildDeliveryUnit(plan)
const dynamicReplacements: Record<string, string> = {
'{{companyName}}': INBOUND_SLIP_DEFAULTS.companyName,
'{{receivingUnit}}': deliveryUnit,
'{{planNo}}': plan.planNo || '',
'{{planDateChinese}}': formatPlanDateChinese(plan.planDate),
'{{remark}}': plan.remark || '',
'{{companyAddress}}': INBOUND_SLIP_DEFAULTS.companyAddress,
'{{companyPhone}}': INBOUND_SLIP_DEFAULTS.companyPhone,
'{{contactPhone}}': INBOUND_SLIP_DEFAULTS.contactPhone
}
for (const [placeholder, currentValue] of Object.entries(dynamicReplacements)) {
if (!currentValue) continue
const escaped = currentValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
result = result.replace(new RegExp(escaped, 'g'), placeholder)
}
// 旧模板写死的本公司名 → 送货单位占位符
if (deliveryUnit !== LEGACY_RECEIVING_UNIT) {
result = result.replace(new RegExp(LEGACY_RECEIVING_UNIT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '{{receivingUnit}}')
}
// 产品明细行还原为占位符(表头「实收数量」与「备注」行之间)
result = result.replace(
/(<th[^>]*>实收数量<\/th>\s*<\/tr>\s*)([\s\S]*?)(\s*<tr>\s*<td colspan="[78]"[^>]*>\s*备注:)/,
'$1{{productTable}}$3'
)
// 右侧联次还原为占位符
result = result.replace(/<table class="inbound-slip-side-labels"[\s\S]*?<\/table>/, '{{sideLabels}}')
result = result.replace(/<div class="inbound-slip-side-labels"[\s\S]*?<\/div>/, '{{sideLabels}}')
// 印章还原为占位符(对齐合同管理打印弹窗)
result = result.replace(/<img[^>]*alt="印章"[^>]*>/gi, '')
if (result.includes('inbound-slip-seal-block') && !result.includes('{{companySeal}}')) {
result = result.replace(
/(<div class="inbound-slip-seal-block"[^>]*>)/,
'$1\n <!-- 印章图片 -->\n {{companySeal}}\n '
)
}
result = result.replace(/(\{\{companySeal\}\}\s*){2,}/g, '{{companySeal}}\n')
return result
}