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

1887 lines
81 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 DELIVERY_SLIP_DEFAULTS = {
companyName: '安庆市山峡化工有限公司',
receivingUnit: '安庆市山峡化工有限公司',
companyAddress: '安庆市高新区霞虹路5号',
companyPhone: '0556-5999710',
contactPhone: '方总13395560066'
} as const
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 DELIVERY_SLIP_DOC_MAX_WIDTH = '960px'
const DOC_WRAP_EXTRA = `max-width:${DELIVERY_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>`
/**
* 物料表列宽:单据编号 + 加宽编号/名称,收窄规格/单位;前 5 列合计供条款区使用。
* 单据编号 / 物料编号 / 物料名称 / 规格 / 单位 / 数量 / 产品规格 / 实收数量
*/
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;`
/** 条款单元格:行高随内容,避免被右侧印章区 min-height 撑出底部空白 */
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;`
const TERMS_TEXT_STYLE = 'line-height:1.35;'
const SEAL_BLOCK_LINE_STYLE = 'line-height:1.35;'
/** 送(提)货单印章:public/sxshzyz.png,定位在地址单元格右上角(相对原位置向上 66px) */
const DELIVERY_SLIP_SEAL_IMAGE_SRC = '/sxshzyz.png'
const DELIVERY_SLIP_SEAL_TOP = -66
const DELIVERY_SLIP_SEAL_IMAGE_STYLE = `position:absolute;right:4px;top:${DELIVERY_SLIP_SEAL_TOP}px;width:140px;height:140px;opacity:0.85;z-index:10;`
const HANDWRITE_BLANK_INNER = '&nbsp;&nbsp;&nbsp;&nbsp;'
const HANDWRITE_BLANK = `<span class="delivery-slip-handwrite-blank">(${HANDWRITE_BLANK_INNER})</span>`
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 buildDeliverySlipSignItem(label: string, valueContent: string, minWidth: string): string {
return `<span class="delivery-slip-sign-item"><span class="delivery-slip-sign-label">${label}</span><span class="delivery-slip-sign-value" style="min-width:${minWidth};">${valueContent}</span></span>`
}
function buildDeliverySlipSignRowHtml(): string {
return `<tr class="delivery-slip-sign-row">
<td colspan="8" class="delivery-slip-sign-row-cell" style="border:none !important;padding:${TABLE_ROW_PADDING};text-align:center;${FONT_SIGN}background:transparent !important;">
<div class="delivery-slip-sign-inner">
${buildDeliverySlipSignItem('仓库:', '&nbsp;', '80px')}
${buildDeliverySlipSignItem('审核:', '&nbsp;', '80px')}
${buildDeliverySlipSignItem('客户签收:', '&nbsp;', '80px')}
${buildDeliverySlipSignItem('司机/送货人(附车牌):', '&nbsp;', '120px')}
</div>
</td>
</tr>`
}
export interface DeliverySlipRenderContext {
/** CRM 产品 id -> 物料编码 productNo */
productCodeById?: Record<string, string>
/** 单位字典 code -> 展示文案 */
getUnitLabel?: (unitCode: string) => string
}
function resolveSlipProductCode(product: PlanProduct, ctx?: DeliverySlipRenderContext): 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?: DeliverySlipRenderContext): string {
const code = String(unitCode || '').trim()
if (!code) return ''
return ctx?.getUnitLabel?.(code) || code
}
export function canPrintDeliverySlip(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 buildReceivingUnit(plan: Plan): string {
return plan.customerName?.trim() || plan.destinationName?.trim() || ''
}
function formatDeliverySlipProductGridCellContent(content: string): string {
const plain = content
.trim()
.replace(/<div class="delivery-slip-product-cell-inner"[^>]*>([\s\S]*?)<\/div>/gi, '$1')
.replace(/<div class="delivery-slip-pdf-grid-inner"[^>]*>([\s\S]*?)<\/div>/gi, '$1')
.replace(/<span class="delivery-slip-pdf-grid-text"[^>]*>([\s\S]*?)<\/span>/gi, '$1')
.replace(/<span class="delivery-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?: DeliverySlipRenderContext
): string {
if (!products.length) {
return `<tr><td class="delivery-slip-product-cell" colspan="8" style="${PRODUCT_CELL}">暂无产品信息</td></tr>`
}
const dataRows = products
.map((product) => {
const rowPlanNo = (product as SlipPlanProduct).slipPlanNo || planNo
const planNoText = formatDeliverySlipProductGridCellContent(rowPlanNo)
const qty = product.outboundQty ?? product.outboundEstimateQty ?? product.quantity ?? ''
return `<tr>
<td class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${planNoText}</td>
<td class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatDeliverySlipProductGridCellContent(resolveSlipProductCode(product, ctx))}</td>
<td class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatDeliverySlipProductGridCellContent(product.productName || '')}</td>
<td class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatDeliverySlipProductGridCellContent(product.model || '')}</td>
<td class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatDeliverySlipProductGridCellContent(resolveSlipUnitLabel(product.unit, ctx))}</td>
<td class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatDeliverySlipProductGridCellContent(formatQty(qty))}</td>
<td class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatDeliverySlipProductGridCellContent(product.packageSpec || '')}</td>
<td class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">&nbsp;</td>
</tr>`
})
.join('')
return dataRows
}
export const DELIVERY_SLIP_PRINT_CSS = `
.delivery-slip-doc {
padding: ${DOC_PADDING} !important;
max-width: ${DELIVERY_SLIP_DOC_MAX_WIDTH} !important;
margin-left: auto !important;
margin-right: auto !important;
box-sizing: border-box !important;
}
.delivery-slip-doc .delivery-slip-title-heading {
font-weight: 700 !important;
line-height: 1.35 !important;
}
.delivery-slip-doc .delivery-slip-title-heading + .delivery-slip-title-heading {
margin-top: 2px !important;
}
.delivery-slip-doc .delivery-slip-info-row {
display: flex !important;
align-items: flex-end !important;
width: 100% !important;
box-sizing: border-box !important;
}
.delivery-slip-doc .delivery-slip-type-check-wrap {
margin-top: -8px !important;
margin-bottom: 20px !important;
}
.delivery-slip-doc .delivery-slip-type-check-spacer {
visibility: hidden !important;
}
.delivery-slip-doc .delivery-slip-type-check-row {
flex: 1 !important;
min-width: 0 !important;
display: flex !important;
flex-wrap: nowrap !important;
justify-content: flex-start !important;
align-items: center !important;
gap: 10px 12px !important;
box-sizing: border-box !important;
font-size: 20px !important;
line-height: 1.35 !important;
white-space: nowrap !important;
}
.delivery-slip-doc .delivery-slip-type-check-item {
display: inline-flex !important;
align-items: center !important;
gap: 4px !important;
white-space: nowrap !important;
cursor: pointer !important;
user-select: none !important;
-webkit-user-modify: read-only !important;
}
.delivery-slip-doc .delivery-slip-type-check-input {
width: 16px !important;
height: 16px !important;
margin: 0 !important;
flex-shrink: 0 !important;
vertical-align: middle !important;
cursor: pointer !important;
pointer-events: auto !important;
appearance: none !important;
-webkit-appearance: none !important;
background: #fff !important;
border: 1px solid #000 !important;
border-radius: 0 !important;
box-sizing: border-box !important;
print-color-adjust: exact !important;
-webkit-print-color-adjust: exact !important;
position: relative !important;
}
.delivery-slip-doc .delivery-slip-type-check-input:checked::after {
content: '' !important;
position: absolute !important;
left: 4px !important;
top: 1px !important;
width: 5px !important;
height: 10px !important;
border: solid #000 !important;
border-width: 0 2.5px 2.5px 0 !important;
transform: rotate(45deg) !important;
box-sizing: border-box !important;
}
.delivery-slip-doc .delivery-slip-type-check-item-pdf {
display: inline-block !important;
white-space: nowrap !important;
font-family: SimSun, 'Songti SC', serif !important;
font-size: 20px !important;
line-height: 20px !important;
height: 20px !important;
vertical-align: middle !important;
margin-right: 18px !important;
}
.delivery-slip-doc .delivery-slip-type-check-item-pdf:last-child {
margin-right: 0 !important;
}
.delivery-slip-doc .delivery-slip-type-check-img-pdf {
display: inline-block !important;
width: 16px !important;
height: 16px !important;
margin: 0 4px 0 0 !important;
padding: 0 !important;
border: none !important;
vertical-align: middle !important;
}
.delivery-slip-doc .delivery-slip-type-check-text-pdf {
display: inline-block !important;
font-size: 20px !important;
line-height: 20px !important;
vertical-align: middle !important;
position: relative !important;
top: -12px !important;
}
.delivery-slip-doc .delivery-slip-type-check-wrap-pdf {
display: table !important;
width: 100% !important;
table-layout: auto !important;
border-collapse: collapse !important;
}
.delivery-slip-doc .delivery-slip-type-check-spacer-pdf {
display: table-cell !important;
visibility: hidden !important;
white-space: nowrap !important;
vertical-align: middle !important;
padding: 0 !important;
border: none !important;
}
.delivery-slip-doc .delivery-slip-type-check-row-pdf {
display: table-cell !important;
vertical-align: middle !important;
line-height: 20px !important;
font-size: 20px !important;
}
.delivery-slip-doc .delivery-slip-info-label {
white-space: nowrap !important;
flex-shrink: 0 !important;
}
.delivery-slip-doc table.delivery-slip-layout-table tr.delivery-slip-gap-before-product td.delivery-slip-gap-before-product-cell,
.delivery-slip-doc .delivery-slip-section-gap.delivery-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;
}
.delivery-slip-doc table.delivery-slip-layout-table tr.delivery-slip-gap-before-sign td.delivery-slip-gap-before-sign-cell,
.delivery-slip-doc .delivery-slip-section-gap.delivery-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;
}
.delivery-slip-doc .delivery-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;
}
.delivery-slip-doc .delivery-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;
}
.delivery-slip-doc .delivery-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;
}
.delivery-slip-doc .delivery-slip-sign-label {
line-height: 1.35 !important;
vertical-align: baseline !important;
white-space: nowrap !important;
}
.delivery-slip-doc table.delivery-slip-outer-table {
table-layout: fixed !important;
width: 100% !important;
border-collapse: collapse !important;
}
.delivery-slip-doc td.delivery-slip-main-col {
overflow: hidden !important;
box-sizing: border-box !important;
vertical-align: top !important;
}
.delivery-slip-doc td.delivery-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;
}
.delivery-slip-doc .delivery-slip-sign-row-cell,
.delivery-slip-doc .delivery-slip-sign-inner,
.delivery-slip-doc .delivery-slip-sign-item {
font-size: 14px !important;
}
.delivery-slip-doc .delivery-slip-sign-row-cell {
overflow: hidden !important;
max-width: 100% !important;
box-sizing: border-box !important;
}
.delivery-slip-doc .delivery-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;
}
.delivery-slip-doc .delivery-slip-sign-item {
display: inline !important;
white-space: nowrap !important;
line-height: 1.35 !important;
vertical-align: baseline !important;
}
.delivery-slip-doc .delivery-slip-terms-text {
line-height: 1.35 !important;
margin: 0 !important;
padding: 0 !important;
}
.delivery-slip-doc td.delivery-slip-terms-cell {
padding: 10px 6px 14px 6px !important;
line-height: 1.35 !important;
vertical-align: top !important;
}
.delivery-slip-doc .delivery-slip-handwrite-blank {
display: inline-block !important;
min-width: 3.6em !important;
text-align: center !important;
letter-spacing: 0.08em !important;
}
.delivery-slip-doc .delivery-slip-seal-block {
position: relative !important;
line-height: 1.35 !important;
min-height: 0 !important;
height: auto !important;
}
.delivery-slip-doc .delivery-slip-seal-block img[alt="印章"] {
position: absolute !important;
right: 4px !important;
top: ${DELIVERY_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;
}
.delivery-slip-doc .delivery-slip-seal-block .delivery-slip-seal-lines {
position: relative !important;
z-index: 1 !important;
line-height: 1.35 !important;
margin: 0 !important;
padding: 0 !important;
}
.delivery-slip-doc table.delivery-slip-outer-table > tbody > tr > td,
.delivery-slip-doc table.delivery-slip-outer-table > tbody > tr > th,
.delivery-slip-doc table.delivery-slip-layout-table td:not(.delivery-slip-product-cell):not(.delivery-slip-gap-before-product-cell):not(.delivery-slip-gap-before-sign-cell),
.delivery-slip-doc table.delivery-slip-layout-table th:not(.delivery-slip-product-cell),
.delivery-slip-doc table.delivery-slip-info-table td,
.delivery-slip-doc table.delivery-slip-info-table th,
.delivery-slip-doc table.delivery-slip-sign-table td,
.delivery-slip-doc table.delivery-slip-sign-table th {
border: none !important;
background: transparent !important;
padding: ${TABLE_ROW_PADDING} !important;
}
.delivery-slip-doc table.delivery-slip-layout-table,
.delivery-slip-doc table.delivery-slip-info-table,
.delivery-slip-doc table.delivery-slip-product-table,
.delivery-slip-doc table.delivery-slip-footer-table,
.delivery-slip-doc table.delivery-slip-sign-table {
border-collapse: collapse !important;
border-spacing: 0 !important;
table-layout: fixed !important;
width: 100% !important;
}
.delivery-slip-doc .delivery-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;
}
.delivery-slip-doc th.delivery-slip-product-grid-cell {
padding: ${PRODUCT_GRID_TH_PADDING};
font-weight: bold !important;
}
.delivery-slip-doc .delivery-slip-product-grid-cell > p {
margin: 0 !important;
padding: 0 !important;
text-align: center !important;
line-height: 1.35;
}
.delivery-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;
}
.delivery-slip-doc .delivery-slip-product-cell:not(.delivery-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;
}
.delivery-slip-doc .delivery-slip-seal-block,
.delivery-slip-doc .delivery-slip-seal-lines,
.delivery-slip-doc .delivery-slip-terms-text {
margin: 0 !important;
padding: 0 !important;
line-height: 1.35 !important;
}
.delivery-slip-doc table.delivery-slip-side-labels {
border: none !important;
border-collapse: collapse !important;
width: auto !important;
margin: 0 auto !important;
}
.delivery-slip-doc td.delivery-slip-side-labels-col,
.delivery-slip-doc table.delivery-slip-side-labels td,
.delivery-slip-doc table.delivery-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 DELIVERY_SLIP_STYLE_BLOCK = `<style type="text/css">${DELIVERY_SLIP_PRINT_CSS}</style>`
/** 生成公司印章 HTML(地址/电话单元格右上角) */
export function buildDeliverySlipCompanySealHtml(): string {
return `<img src="${DELIVERY_SLIP_SEAL_IMAGE_SRC}" alt="印章" style="${DELIVERY_SLIP_SEAL_IMAGE_STYLE}" />`
}
const SEAL_BLOCK = `<div class="delivery-slip-seal-block" style="position:relative;padding:0;${FONT_BODY}${SEAL_BLOCK_LINE_STYLE}">
<!-- 印章图片 -->
{{companySeal}}
<div class="delivery-slip-seal-lines" style="position:relative;z-index:1;${SEAL_BLOCK_LINE_STYLE}margin:0;padding:0;">
地址:{{companyAddress}}<br />
送货单位固话:{{companyPhone}}<br />
联系人电话:{{contactPhone}}
</div>
</div>`
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;`
const DELIVERY_SLIP_TYPE_CHECK_WRAP_STYLE = `${INFO_ROW}margin-top:-8px;margin-bottom:20px;`
const DELIVERY_SLIP_TYPE_CHECK_INNER_STYLE =
'flex:1;min-width:0;display:flex;flex-wrap:nowrap;justify-content:flex-start;align-items:center;gap:10px 12px;box-sizing:border-box;font-size:20px;line-height:1.35;white-space:nowrap;'
function buildDeliverySlipTypeCheckboxesHtml(): string {
const item = (label: string) =>
`<label class="delivery-slip-type-check-item" contenteditable="false"><input type="checkbox" class="delivery-slip-type-check-input" />${label}</label>`
return `<div class="delivery-slip-info-row delivery-slip-type-check-wrap" style="${DELIVERY_SLIP_TYPE_CHECK_WRAP_STYLE}">
<span class="delivery-slip-info-label delivery-slip-type-check-spacer" style="${INFO_LABEL}visibility:hidden;">日&nbsp;期:</span>
<div class="delivery-slip-type-check-row" style="${DELIVERY_SLIP_TYPE_CHECK_INNER_STYLE}">${item('自提')}${item('出库')}${item('直销')}</div>
</div>`
}
function buildDeliverySlipInfoTableHtml(): string {
return `<table class="delivery-slip-info-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${INFO_COLGROUP}
<tr>
<td colspan="2" style="${INFO_CELL_LEFT}">
<div class="delivery-slip-info-row" style="${INFO_ROW}">
<span class="delivery-slip-info-label" style="${INFO_LABEL}">收货单位:</span>
<span class="delivery-slip-info-value-left" style="${INFO_VALUE_INLINE_LEFT}">{{receivingUnit}}&nbsp;</span>
</div>
</td>
<td colspan="2" style="${INFO_CELL_LEFT}">
<div class="delivery-slip-info-row" style="${INFO_ROW}">
<span class="delivery-slip-info-label" style="${INFO_LABEL}">收货联系方式:</span>
<span class="delivery-slip-info-value-left" style="${INFO_VALUE_INLINE_LEFT}">{{receiveContact}}&nbsp;</span>
</div>
</td>
<td colspan="3" style="${INFO_CELL_RIGHT}">
${buildDeliverySlipTypeCheckboxesHtml()}
<div class="delivery-slip-info-row" style="${INFO_ROW}">
<span class="delivery-slip-info-label" style="${INFO_LABEL}">日&nbsp;期:</span>
<span class="delivery-slip-info-value-center" style="${INFO_VALUE_INLINE_CENTER}">{{planDateChinese}}&nbsp;</span>
</div>
</td>
</tr>
</table>`
}
function buildDeliverySlipTermsHtml(): string {
return `<div class="delivery-slip-terms-text" style="${TERMS_TEXT_STYLE}">
1.本送货单如购销双方未签订合同,视为有效;<br />
2.本次送货含桶${HANDWRITE_BLANK}个,回收${HANDWRITE_BLANK}个,结存${HANDWRITE_BLANK}个;
</div>`
}
/** 右侧联次:单列纵向,带圈序号 + 每字一行依次向下排列 */
export function buildDeliverySlipSideLabelsHtml(): string {
return `<table class="delivery-slip-side-labels" style="border:none !important;border-collapse:collapse;margin:0 auto;width:auto;">
<tr>
<td class="delivery-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 generateDeliverySlipTemplate(_plan?: Plan): string {
return `${DELIVERY_SLIP_STYLE_BLOCK}<div class="delivery-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="delivery-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="delivery-slip-title-heading" style="${TITLE_STYLE}">{{companyName}}</div>
<div class="delivery-slip-title-heading" style="${TITLE_SUB_STYLE}">送&nbsp;(提)&nbsp;货&nbsp;单</div>
</div>
</div>
<!-- 下部分:内容区(左正文 + 右侧联次) -->
<table class="delivery-slip-outer-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
<tr>
<td class="delivery-slip-main-col" style="vertical-align:top;padding-right:6px;border:none !important;background:transparent !important;">
<!-- 1. 基本信息(独立表,不可拖宽高) -->
${buildDeliverySlipInfoTableHtml()}
<div class="delivery-slip-section-gap delivery-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>
<!-- 2. 物料明细表(含备注、条款+印章) -->
<table class="delivery-slip-product-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${PRODUCT_COLGROUP}
<tr class="delivery-slip-product-header">
<th class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_TH}">单据编号</th>
<th class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_TH}">物料编号</th>
<th class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_TH}">物料名称</th>
<th class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_TH}">规格</th>
<th class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_TH}">单位</th>
<th class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_TH}">数量</th>
<th class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_TH}">产品规格</th>
<th class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_TH}">实收数量</th>
</tr>
{{productTable}}
<tr>
<td colspan="8" class="delivery-slip-product-cell" style="${PRODUCT_TABLE_CELL_LEFT}">
备注:{{remark}}
</td>
</tr>
<tr>
<td colspan="5" class="delivery-slip-product-cell delivery-slip-terms-cell" style="${PRODUCT_TERMS_CELL_LEFT}">
${buildDeliverySlipTermsHtml()}
</td>
<td colspan="3" class="delivery-slip-product-cell" style="${PRODUCT_TABLE_CELL_LEFT}">
${SEAL_BLOCK}
</td>
</tr>
</table>
<div class="delivery-slip-section-gap delivery-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>
<!-- 3. 签字区(独立表) -->
<table class="delivery-slip-sign-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${buildDeliverySlipSignRowHtml()}
</table>
</td>
<!-- 最右侧纵向联次 -->
<td class="delivery-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 normalizeDeliverySlipSealPlaceholder(html: string): string {
if (!html.includes('delivery-slip-doc')) return html
let result = html.replace(/<img[^>]*alt="印章"[^>]*>/gi, '{{companySeal}}')
if (result.includes('delivery-slip-seal-block') && !result.includes('{{companySeal}}')) {
result = result.replace(
/(<div class="delivery-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 patchDeliverySlipProductGridCellAttrs(attrs: string): string {
let next = attrs.includes('class="')
? attrs.replace(/class="([^"]*)"/, (_, cls: string) => {
let updated = cls
if (!updated.includes('delivery-slip-product-cell')) updated += ' delivery-slip-product-cell'
if (!updated.includes('delivery-slip-product-grid-cell')) updated += ' delivery-slip-product-grid-cell'
return `class="${updated.trim()}"`
})
: `${attrs} class="delivery-slip-product-cell delivery-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 normalizeDeliverySlipProductGridCell(html: string): string {
if (!html.includes('delivery-slip-doc') || !html.includes('delivery-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('delivery-slip-product-header')
? rowMatch
: rowMatch.replace('<tr', '<tr class="delivery-slip-product-header"')
return row.replace(/<(th)([^>]*)>([\s\S]*?)<\/th>/g, (_m, tag, attrs, content) => {
return `<${tag}${patchDeliverySlipProductGridCellAttrs(attrs)}>${formatDeliverySlipProductGridCellContent(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}${patchDeliverySlipProductGridCellAttrs(attrs)}>${formatDeliverySlipProductGridCellContent(content)}</${tag}>`
})
})
return normalizeDeliverySlipGridCellWrappers(result)
}
/** 去掉 TinyMCE / 历史包裹层,单元格内保留纯文本(对齐合同 PDF) */
function normalizeDeliverySlipGridCellWrappers(html: string): string {
if (!html.includes('delivery-slip-product-grid-cell')) return html
const unwrap = (tag: string, attrs: string, content: string) =>
`<${tag}${attrs}>${formatDeliverySlipProductGridCellContent(content)}</${tag}>`
let result = html.replace(
/<(td|th)([^>]*delivery-slip-product-grid-cell[^>]*)>\s*<span class="delivery-slip-product-grid-align"[^>]*>([\s\S]*?)<\/span>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(
/<(td|th)([^>]*delivery-slip-product-grid-cell[^>]*)>\s*<span class="delivery-slip-pdf-grid-text"[^>]*>([\s\S]*?)<\/span>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(
/<(td|th)([^>]*delivery-slip-product-grid-cell[^>]*)>\s*<div class="delivery-slip-pdf-grid-inner"[^>]*>([\s\S]*?)<\/div>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(
/<(td|th)([^>]*delivery-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)([^>]*delivery-slip-product-grid-cell[^>]*)>\s*<div(?![^>]*class="delivery-slip)[^>]*>([\s\S]*?)<\/div>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(/<(td|th)([^>]*delivery-slip-product-grid-cell[^>]*)\s+height="[^"]*"/gi, '<$1$2')
result = result.replace(/<(tr)([^>]*class="[^"]*delivery-slip-product[^"]*"[^>]*)\s+height="[^"]*"/gi, '<$1$2')
return result
}
function extractDeliverySlipGridCellContent(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('delivery-slip-pdf-grid-inner') ||
onlyChild.classList.contains('delivery-slip-pdf-grid-text')
) {
content = onlyChild.innerHTML.trim()
}
}
return formatDeliverySlipProductGridCellContent(content)
}
function applyDeliverySlipPdfGridCellStyles(cell: HTMLTableCellElement, isHeader: boolean): void {
const content = extractDeliverySlipGridCellContent(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 applyDeliverySlipPdfFooterCellStyles(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 unwrapDeliverySlipPdfCellParagraph(cell: HTMLTableCellElement): void {
const onlyChild = cell.firstElementChild
if (onlyChild?.tagName === 'P' && cell.childElementCount === 1) {
cell.innerHTML = onlyChild.innerHTML.trim() || '&nbsp;'
}
}
function prepareDeliverySlipPdfRemarkCell(cell: HTMLTableCellElement): void {
unwrapDeliverySlipPdfCellParagraph(cell)
applyDeliverySlipPdfFooterCellStyles(cell)
}
function prepareDeliverySlipPdfTermsCell(cell: HTMLTableCellElement): void {
applyDeliverySlipPdfFooterCellStyles(cell)
const terms = cell.querySelector('.delivery-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 prepareDeliverySlipPdfSealCell(cell: HTMLTableCellElement): void {
applyDeliverySlipPdfFooterCellStyles(cell)
const block = cell.querySelector('.delivery-slip-seal-block') as HTMLElement | null
const lines = cell.querySelector('.delivery-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 prepareDeliverySlipPdfSignValues(root: HTMLElement): void {
root.querySelectorAll('.delivery-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 截图前:将原生 checkbox 替换为 canvas 方框图 + 文字(html2canvas 对 flex/SVG 对齐不稳定) */
const deliverySlipPdfCheckboxImgCache: { empty?: string; checked?: string } = {}
function buildDeliverySlipPdfCheckboxImgDataUrl(checked: boolean): string {
const key = checked ? 'checked' : 'empty'
if (deliverySlipPdfCheckboxImgCache[key]) return deliverySlipPdfCheckboxImgCache[key]!
const size = 32
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
if (!ctx) return ''
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, size, size)
ctx.strokeStyle = '#000000'
ctx.lineWidth = 2
ctx.strokeRect(1, 1, size - 2, size - 2)
if (checked) {
ctx.beginPath()
ctx.moveTo(8, 17)
ctx.lineTo(14, 23)
ctx.lineTo(24, 8)
ctx.strokeStyle = '#000000'
ctx.lineWidth = 3.5
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.stroke()
}
const url = canvas.toDataURL('image/png')
deliverySlipPdfCheckboxImgCache[key] = url
return url
}
function buildDeliverySlipPdfTypeCheckItemHtml(checked: boolean, labelText: string): string {
const imgSrc = buildDeliverySlipPdfCheckboxImgDataUrl(checked)
if (!imgSrc) return `<span class="delivery-slip-type-check-item-pdf">${labelText}</span>`
return `<span class="delivery-slip-type-check-item-pdf"><img class="delivery-slip-type-check-img-pdf" src="${imgSrc}" width="16" height="16" alt="" /><span class="delivery-slip-type-check-text-pdf">${labelText}</span></span>`
}
function applyDeliverySlipPdfTypeCheckboxLayout(root: HTMLElement): void {
root.querySelectorAll('.delivery-slip-type-check-wrap').forEach((node) => {
const wrap = node as HTMLElement
wrap.classList.add('delivery-slip-type-check-wrap-pdf')
wrap.style.setProperty('display', 'table', 'important')
wrap.style.setProperty('width', '100%', 'important')
wrap.style.setProperty('table-layout', 'auto', 'important')
wrap.style.setProperty('border-collapse', 'collapse', 'important')
wrap.style.setProperty('align-items', 'unset', 'important')
})
root.querySelectorAll('.delivery-slip-type-check-spacer').forEach((node) => {
const spacer = node as HTMLElement
spacer.classList.add('delivery-slip-type-check-spacer-pdf')
spacer.style.setProperty('display', 'table-cell', 'important')
spacer.style.setProperty('visibility', 'hidden', 'important')
spacer.style.setProperty('white-space', 'nowrap', 'important')
spacer.style.setProperty('vertical-align', 'middle', 'important')
spacer.style.setProperty('padding', '0', 'important')
spacer.style.setProperty('border', 'none', 'important')
})
root.querySelectorAll('.delivery-slip-type-check-row').forEach((node) => {
const row = node as HTMLElement
row.classList.add('delivery-slip-type-check-row-pdf')
row.style.setProperty('display', 'table-cell', 'important')
row.style.setProperty('vertical-align', 'middle', 'important')
row.style.setProperty('line-height', '20px', 'important')
row.style.setProperty('font-size', '20px', 'important')
row.style.setProperty('flex', 'unset', 'important')
row.style.removeProperty('gap')
row.style.removeProperty('align-items')
row.style.removeProperty('justify-content')
})
root.querySelectorAll('.delivery-slip-type-check-item-pdf').forEach((node) => {
const item = node as HTMLElement
item.style.setProperty('display', 'inline-block', 'important')
item.style.setProperty('white-space', 'nowrap', 'important')
item.style.setProperty('font-family', "SimSun, 'Songti SC', serif", 'important')
item.style.setProperty('font-size', '20px', 'important')
item.style.setProperty('line-height', '20px', 'important')
item.style.setProperty('height', '20px', 'important')
item.style.setProperty('margin-right', '18px', 'important')
item.style.setProperty('vertical-align', 'middle', 'important')
const img = item.querySelector('img.delivery-slip-type-check-img-pdf') as HTMLImageElement | null
if (img) {
img.style.setProperty('display', 'inline-block', 'important')
img.style.setProperty('width', '16px', 'important')
img.style.setProperty('height', '16px', 'important')
img.style.setProperty('margin', '0 4px 0 0', 'important')
img.style.setProperty('padding', '0', 'important')
img.style.setProperty('border', 'none', 'important')
img.style.setProperty('vertical-align', 'middle', 'important')
}
const text = item.querySelector('.delivery-slip-type-check-text-pdf') as HTMLElement | null
if (text) {
text.style.setProperty('display', 'inline-block', 'important')
text.style.setProperty('font-size', '20px', 'important')
text.style.setProperty('line-height', '20px', 'important')
text.style.setProperty('vertical-align', 'middle', 'important')
text.style.setProperty('position', 'relative', 'important')
text.style.setProperty('top', '-12px', 'important')
}
})
}
function prepareDeliverySlipPdfTypeCheckboxes(root: HTMLElement): void {
root.querySelectorAll('label.delivery-slip-type-check-item').forEach((node) => {
const label = node as HTMLElement
const input = label.querySelector('input.delivery-slip-type-check-input') as HTMLInputElement | null
const checked = input?.checked ?? label.querySelector('.delivery-slip-type-check-box')?.textContent?.includes('✓') ?? false
const labelText = (label.textContent || '').replace(/[✓☑□☐]/g, '').trim()
const html = buildDeliverySlipPdfTypeCheckItemHtml(checked, labelText)
const wrapper = document.createElement('div')
wrapper.innerHTML = html.trim()
const replacement = wrapper.firstElementChild
if (replacement) {
label.replaceWith(replacement)
}
})
applyDeliverySlipPdfTypeCheckboxLayout(root)
}
/** PDF 截图前:物料网格 + 备注/条款/地址行 + 签字区统一样式 */
export function prepareDeliverySlipPdfGridCells(root: HTMLElement): void {
root.querySelectorAll('td.delivery-slip-product-grid-cell, th.delivery-slip-product-grid-cell').forEach((node) => {
applyDeliverySlipPdfGridCellStyles(node as HTMLTableCellElement, node.tagName === 'TH')
})
root.querySelectorAll('td.delivery-slip-terms-cell').forEach((node) => {
prepareDeliverySlipPdfTermsCell(node as HTMLTableCellElement)
})
root.querySelectorAll('td.delivery-slip-product-cell:not(.delivery-slip-product-grid-cell)').forEach((node) => {
const cell = node as HTMLTableCellElement
if (cell.classList.contains('delivery-slip-terms-cell')) return
if (cell.querySelector('.delivery-slip-seal-block')) {
prepareDeliverySlipPdfSealCell(cell)
return
}
if (/备注\s*[::]/.test(cell.textContent || '')) {
prepareDeliverySlipPdfRemarkCell(cell)
}
})
root.querySelectorAll('tr').forEach((row) => {
if (!row.querySelector('.delivery-slip-product-cell')) return
row.removeAttribute('height')
row.style.removeProperty('height')
row.style.removeProperty('min-height')
})
prepareDeliverySlipPdfSignValues(root)
prepareDeliverySlipPdfTypeCheckboxes(root)
// 亚像素 border 在 Windows DPI 下易变粗;截图前改为 1px 背景单线
applyPdfStableTableCellBorders(root.querySelectorAll('.delivery-slip-product-cell'))
}
/** 备注、条款、印章行:统一 0.3px 全边框(兼容已保存模板,与物料网格衔接) */
function normalizeDeliverySlipProductTableFooter(html: string): string {
if (!html.includes('delivery-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,
'delivery-slip-product-cell',
PRODUCT_TABLE_CELL_LEFT
)
return `<td${patched}>${space}备注:`
})
result = result.replace(
/<td([^>]*colspan="[456]"[^>]*)>\s*(<div class="delivery-slip-terms-text"|\s*1\.本送货单)/g,
(_match, attrs, contentStart) => {
const withSpan = String(attrs).replace(/colspan="[456]"/, 'colspan="5"')
const patched = patchProductFooterCellAttrs(
withSpan,
'delivery-slip-product-cell delivery-slip-terms-cell',
PRODUCT_TERMS_CELL_LEFT
)
return `<td${patched}>\n ${contentStart}`
}
)
result = result.replace(
/<td([^>]*colspan="[23]"[^>]*)>\s*<div class="delivery-slip-seal-block"/g,
(_match, attrs) => {
const withSpan = String(attrs).replace(/colspan="[23]"/, 'colspan="3"')
const patched = patchProductFooterCellAttrs(withSpan, 'delivery-slip-product-cell', PRODUCT_TABLE_CELL_LEFT)
return `<td${patched}>\n <div class="delivery-slip-seal-block"`
}
)
return result
}
/** 条款单元格行高随内容(兼容已保存模板) */
function normalizeDeliverySlipTermsCell(html: string): string {
if (!html.includes('delivery-slip-doc')) return html
const patchTermsTd = (attrs: string) => {
let nextAttrs = attrs
if (nextAttrs.includes('class="')) {
nextAttrs = nextAttrs.replace(/class="([^"]*)"/, (_, cls: string) =>
cls.includes('delivery-slip-terms-cell') ? `class="${cls}"` : `class="${cls} delivery-slip-terms-cell"`
)
} else {
nextAttrs = `${nextAttrs} class="delivery-slip-product-cell delivery-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="[456]"([^>]*)>\s*<div class="delivery-slip-terms-text"/g,
(match, attrs) => {
if (attrs.includes('delivery-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="delivery-slip-terms-text"`
}
)
result = result.replace(
/<div class="delivery-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="delivery-slip-seal-block" style="${nextStyle}"`
}
)
return result
}
/** 物料表列宽 + 条款区跨列(兼容已保存模板) */
function normalizeDeliverySlipProductColWidths(html: string): string {
if (!html.includes('delivery-slip-product-table')) return html
return html.replace(
/(<table class="delivery-slip-product-table"[^>]*>\s*)<colgroup>[\s\S]*?<\/colgroup>/i,
`$1${PRODUCT_COLGROUP}`
)
}
/** 条款区两行间距与括号内手写空白(兼容已保存模板) */
function normalizeDeliverySlipTermsText(html: string): string {
if (!html.includes('delivery-slip-doc') || !html.includes('1.本送货单')) {
return html
}
const termsHtml = buildDeliverySlipTermsHtml()
if (html.includes('delivery-slip-terms-text')) {
return html.replace(/<div class="delivery-slip-terms-text"[\s\S]*?<\/div>/, termsHtml.trim())
}
return html.replace(
/1\.本送货单如购销双方未签订合同,视为有效;[\s\S]*?结存\([^)]*\)个;/,
termsHtml.trim()
)
}
/** 刷新已有间距行的 inline 样式(TinyMCE 保存后可能丢失) */
function refreshDeliverySlipGapBeforeProduct(html: string): string {
return html
.replace(
/<tr class="delivery-slip-gap-before-product">[\s\S]*?<\/tr>/,
`<tr class="delivery-slip-gap-before-product">\n <td colspan="8" class="delivery-slip-gap-before-product-cell" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</td>\n </tr>`
)
.replace(
/<div class="delivery-slip-section-gap delivery-slip-gap-before-product"[^>]*>[\s\S]*?<\/div>/,
`<div class="delivery-slip-section-gap delivery-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>`
)
}
/** 在收货联系方式行与物料表之间插入间距行(兼容已保存模板) */
function normalizeDeliverySlipGapBeforeProduct(html: string): string {
if (!html.includes('delivery-slip-doc')) {
return html
}
if (html.includes('delivery-slip-gap-before-product')) {
return refreshDeliverySlipGapBeforeProduct(html)
}
const gapRow = `<tr class="delivery-slip-gap-before-product"><td colspan="8" class="delivery-slip-gap-before-product-cell" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</td></tr>\n `
const productHeader = `(?:单据编号|物料编号)`
const withComment = html.replace(
new RegExp(
`(收货联系方式:[\\s\\S]*?<\\/tr>\\s*)(<!--\\s*中:产品明细表[\\s\\S]*?<tr[\\s\\S]*?<th[^>]*>${productHeader})`
),
`$1${gapRow}$2`
)
if (withComment !== html) return withComment
const withHeader = html.replace(
new RegExp(`(收货联系方式:[\\s\\S]*?<\\/tr>\\s*)(<tr[\\s\\S]*?<th[^>]*>${productHeader})`),
`$1${gapRow}$2`
)
if (withHeader !== html) return withHeader
return html.replace(
new RegExp(
`(<span class="delivery-slip-info-label"[^>]*>日(?:&nbsp;)?期:<\\/span>[\\s\\S]*?<\\/tr>\\s*)(<!--\\s*中:产品明细表[\\s\\S]*?<tr[\\s\\S]*?<th[^>]*>${productHeader}|<tr[\\s\\S]*?<th[^>]*>${productHeader})`
),
`$1${gapRow}$2`
)
}
/** 刷新签字区上方间距行 inline 样式 */
function refreshDeliverySlipGapBeforeSign(html: string): string {
return html
.replace(
/<tr class="delivery-slip-gap-before-sign">[\s\S]*?<\/tr>/,
`<tr class="delivery-slip-gap-before-sign">\n <td colspan="8" class="delivery-slip-gap-before-sign-cell" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</td>\n </tr>`
)
.replace(
/<div class="delivery-slip-section-gap delivery-slip-gap-before-sign"[^>]*>[\s\S]*?<\/div>/,
`<div class="delivery-slip-section-gap delivery-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>`
)
}
/** 在物料表与签字区之间插入间距行(兼容已保存模板) */
function normalizeDeliverySlipGapBeforeSign(html: string): string {
if (!html.includes('delivery-slip-doc')) return html
if (html.includes('delivery-slip-gap-before-sign')) {
return refreshDeliverySlipGapBeforeSign(html)
}
const gapRow = `<tr class="delivery-slip-gap-before-sign"><td colspan="8" class="delivery-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(
/(delivery-slip-seal-block[\s\S]*?<\/tr>\s*)(<tr>\s*<td colspan="[78]"[^>]*>\s*仓库:)/,
`$1${gapRow}$2`
)
}
/** 签字区标签/下划线 baseline 对齐(兼容已保存模板) */
function normalizeDeliverySlipSignLabelMarkup(html: string): string {
if (!html.includes('delivery-slip-sign-value')) return html
let result = html.replace(
/<span class="delivery-slip-sign-item">(仓库:|审核:|客户签收:|司机\/送货人\(附车牌\):)\s*(<span class="delivery-slip-sign-value")/g,
'<span class="delivery-slip-sign-item"><span class="delivery-slip-sign-label">$1</span>$2'
)
result = result.replace(/<span class="delivery-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="delivery-slip-sign-value" style="${nextStyle}"` : `<span class="delivery-slip-sign-value"`
})
return result
}
/** 签字区 flex 布局(兼容已保存模板,避免打印预览溢出到右侧联次列) */
function normalizeDeliverySlipSignRow(html: string): string {
if (!html.includes('delivery-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('delivery-slip-sign-value') || content.includes('delivery-slip-sign-inner')) {
return match
}
const wrappedContent = content
.replace(/&nbsp;&nbsp;/g, '')
.replace(
/(仓库:|审核:|客户签收:|司机\/送货人\(附车牌\):)\s*(<span class="delivery-slip-sign-value"[\s\S]*?<\/span>)/g,
'<span class="delivery-slip-sign-item"><span class="delivery-slip-sign-label">$1</span>$2</span>'
)
.replace(
/<span class="delivery-slip-sign-item">(仓库:|审核:|客户签收:|司机\/送货人\(附车牌\):)\s*(<span class="delivery-slip-sign-value")/g,
'<span class="delivery-slip-sign-item"><span class="delivery-slip-sign-label">$1</span>$2'
)
const trClassAttr = trAttrs.includes('class="')
? trAttrs.replace(/class="([^"]*)"/, (_, cls: string) =>
cls.includes('delivery-slip-sign-row') ? `class="${cls}"` : `class="${cls} delivery-slip-sign-row"`
)
: `${trAttrs} class="delivery-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('delivery-slip-sign-row-cell') ? `class="${cls}"` : `class="${cls} delivery-slip-sign-row-cell"`
)
} else {
nextTdAttrs = `${nextTdAttrs} class="delivery-slip-sign-row-cell"`
}
return `<tr${trClassAttr}>\n <td colspan="8"${nextTdAttrs}>\n <div class="delivery-slip-sign-inner">${wrappedContent.trim()}</div>\n </td>\n </tr>`
}
)
result = result.replace(
/<td(?![^>]*class="[^"]*delivery-slip-main-col)([^>]*style="[^"]*vertical-align:top;padding-right:6px)/g,
'<td class="delivery-slip-main-col"$1'
)
return normalizeDeliverySlipSignLabelMarkup(result)
}
/** 信息区:收货单位 / 收货联系方式 / 日期 同一行(兼容旧多行布局) */
function normalizeDeliverySlipInfoLayout(html: string): string {
if (!html.includes('delivery-slip-info-table')) return html
const infoMatch = html.match(/<table class="delivery-slip-info-table"[^>]*>[\s\S]*?<\/table>/i)
if (!infoMatch) return html
const infoHtml = infoMatch[0]
const rowCount = (infoHtml.match(/<tr[\s>]/g) || []).length
const hasPlanNoInInfo = infoHtml.includes('单据编号:')
const dateColWideEnough = /<td[^>]*colspan="3"[^>]*>[\s\S]*?日(?:&nbsp;)?期:/.test(infoHtml)
const oneRowThreeFields =
rowCount === 1 &&
infoHtml.includes('收货单位:') &&
infoHtml.includes('收货联系方式:') &&
/日(?:&nbsp;)?期:/.test(infoHtml) &&
!hasPlanNoInInfo &&
dateColWideEnough
if (oneRowThreeFields) return html
return html.replace(infoMatch[0], buildDeliverySlipInfoTableHtml())
}
/** 物料表:表头补「单据编号」列,并把旧 7 列数据行升为 8 列(兼容已保存模板) */
function normalizeDeliverySlipProductPlanNoColumn(html: string): string {
if (!html.includes('delivery-slip-product-table')) return html
let result = html
if (!/>单据编号<\/th>/.test(result)) {
result = result.replace(
/(<tr[^>]*class="[^"]*delivery-slip-product-header[^"]*"[^>]*>\s*)(<th[^>]*>物料编号<\/th>)/,
`$1<th class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_TH}">单据编号</th>\n $2`
)
result = result.replace(
/(<tr>\s*)(<th[^>]*>物料编号<\/th>)/,
`$1<th class="delivery-slip-product-cell delivery-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="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">{{planNo}}</td>\n ${cells}</tr>`
}
)
// 仅升级物料表内备注等跨列,条款区保持 5 列、印章区 3 列
result = result.replace(
/(<table class="delivery-slip-product-table"[^>]*>)([\s\S]*?)(<\/table>)/i,
(_m, open, inner, close) => {
let nextInner = String(inner).replace(/colspan="7"/g, 'colspan="8"')
nextInner = nextInner.replace(
/(<td[^>]*colspan=")6("[^>]*>\s*(?:<div class="delivery-slip-terms-text"|[\s]*1\.本送货单))/g,
'$15$2'
)
nextInner = nextInner.replace(
/(<td[^>]*colspan=")2("[^>]*>\s*<div class="delivery-slip-seal-block")/g,
'$13$2'
)
return `${open}${nextInner}${close}`
}
)
result = result.replace(
/(<table class="delivery-slip-sign-table"[^>]*>)([\s\S]*?)(<\/table>)/i,
(_m, open, inner, close) => `${open}${String(inner).replace(/colspan="7"/g, 'colspan="8"')}${close}`
)
return result
}
/**
* 旧版一体 layout-table / 曾拆出的 footer-table → 信息表 + 物料表(含备注条款) + 签字表。
*/
function migrateDeliverySlipSplitTables(html: string): string {
if (!html.includes('delivery-slip-doc')) return html
if (
html.includes('delivery-slip-product-table') &&
!html.includes('delivery-slip-footer-table') &&
/delivery-slip-product-table[\s\S]*?备注:/.test(html)
) {
return html
}
if (html.includes('delivery-slip-footer-table') && html.includes('delivery-slip-product-table')) {
return html.replace(
/(<\/tr>\s*)<\/table>\s*<table class="delivery-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('delivery-slip-layout-table')) return html
return html.replace(
/<table class="delivery-slip-layout-table"[^>]*>([\s\S]*?)<\/table>/i,
(_full, inner: string) => {
const headerMatch =
inner.match(/<tr[^>]*class="[^"]*delivery-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="delivery-slip-gap-before-product">[\s\S]*?<\/tr>/i, '')
const afterHeader = inner.slice(headerIdx + headerMatch[0].length)
const signMatch =
afterHeader.match(/<tr[^>]*class="[^"]*delivery-slip-sign-row[^"]*"[\s\S]*?<\/tr>/i) ||
afterHeader.match(/<tr>\s*<td[^>]*>[\s\S]*?仓库:[\s\S]*?<\/tr>/i)
let productAndFooter = afterHeader
let signPart = buildDeliverySlipSignRowHtml()
if (signMatch) {
const signAt = afterHeader.indexOf(signMatch[0])
productAndFooter = afterHeader.slice(0, signAt)
signPart = signMatch[0]
}
productAndFooter = productAndFooter.replace(
/<tr class="delivery-slip-gap-before-sign">[\s\S]*?<\/tr>/i,
''
)
return `<table class="delivery-slip-info-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${INFO_COLGROUP}
${before.trim()}
</table>
<div class="delivery-slip-section-gap delivery-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>
<table class="delivery-slip-product-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${PRODUCT_COLGROUP}
${headerMatch[0]}
${productAndFooter.trim()}
</table>
<div class="delivery-slip-section-gap delivery-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>
<table class="delivery-slip-sign-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${signPart.trim()}
</table>`
}
)
}
/** 统一单据左右 10px 间距、内容区宽度(兼容已保存模板) */
function normalizeDeliverySlipDocLayout(html: string): string {
let result = migrateDeliverySlipSplitTables(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:${DELIVERY_SLIP_DOC_MAX_WIDTH}`)
.replace(/max-width:760px/g, `max-width:${DELIVERY_SLIP_DOC_MAX_WIDTH}`)
.replace(/max-width:794px/g, `max-width:${DELIVERY_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:${DELIVERY_SLIP_SEAL_TOP}px !important;`)
.replace(/top:4px;/g, `top:${DELIVERY_SLIP_SEAL_TOP}px;`)
.replace(/top:-96px !important;/g, `top:${DELIVERY_SLIP_SEAL_TOP}px !important;`)
.replace(/top:-96px;/g, `top:${DELIVERY_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="delivery-slip-sign-value") style="/g,
'$1<span class="delivery-slip-sign-value" style="'
)
.replace(
/(<span class="delivery-slip-sign-value" style="[^"]*)border-bottom:2px solid #000 !important;/g,
`$1border-bottom:${SIGN_UNDERLINE} !important;`
)
.replace(/class="delivery-slip-info-before-product"/g, '')
.replace(/padding-bottom:12px;/g, '')
.replace(/padding-bottom:24px;/g, '')
if (result.includes('delivery-slip-doc') && !result.includes('max-width:')) {
result = result.replace(
/(<div class="delivery-slip-doc" style=")([^"]*)"/,
`$1$2;${DOC_WRAP_EXTRA}"`
)
}
if (result.includes('delivery-slip-doc')) {
result = result.replace(/<style type="text\/css">[\s\S]*?<\/style>/, DELIVERY_SLIP_STYLE_BLOCK)
result = normalizeDeliverySlipInfoLayout(result)
result = normalizeDeliverySlipProductPlanNoColumn(result)
result = normalizeDeliverySlipSignLabelMarkup(result)
result = normalizeDeliverySlipProductGridCell(result)
result = normalizeDeliverySlipProductTableFooter(result)
result = normalizeDeliverySlipTypeCheckRow(result)
}
return result
}
/** 单据编号上方:自提/出库/直销多选(兼容已保存模板) */
function refreshDeliverySlipTypeCheckRowStyle(html: string): string {
if (!html.includes('delivery-slip-type-check-row')) return html
let result = html
if (!result.includes('delivery-slip-type-check-wrap')) {
result = result.replace(
/<div class="delivery-slip-type-check-row"[^>]*>([\s\S]*?)<\/div>/,
(_match, inner) =>
`<div class="delivery-slip-info-row delivery-slip-type-check-wrap" style="${DELIVERY_SLIP_TYPE_CHECK_WRAP_STYLE}"><span class="delivery-slip-info-label delivery-slip-type-check-spacer" style="${INFO_LABEL}visibility:hidden;">日&nbsp;期:</span><div class="delivery-slip-type-check-row" style="${DELIVERY_SLIP_TYPE_CHECK_INNER_STYLE}">${inner}</div></div>`
)
}
result = result.replace(
/<div class="delivery-slip-info-row delivery-slip-type-check-wrap"[^>]*>/g,
`<div class="delivery-slip-info-row delivery-slip-type-check-wrap" style="${DELIVERY_SLIP_TYPE_CHECK_WRAP_STYLE}">`
)
result = result.replace(
/<span class="delivery-slip-info-label delivery-slip-type-check-spacer"[^>]*>[\s\S]*?<\/span>/g,
`<span class="delivery-slip-info-label delivery-slip-type-check-spacer" style="${INFO_LABEL}visibility:hidden;">日&nbsp;期:</span>`
)
result = result.replace(
/<div class="delivery-slip-type-check-row"[^>]*>/g,
`<div class="delivery-slip-type-check-row" style="${DELIVERY_SLIP_TYPE_CHECK_INNER_STYLE}">`
)
result = result.replace(
/<label class="delivery-slip-type-check-item"(?![^>]*contenteditable=)/g,
'<label class="delivery-slip-type-check-item" contenteditable="false"'
)
return result
}
function normalizeDeliverySlipTypeCheckRow(html: string): string {
if (!html.includes('delivery-slip-doc')) return html
let result = html
if (!result.includes('delivery-slip-type-check-row')) {
const checkRowHtml = buildDeliverySlipTypeCheckboxesHtml()
const withClass = result.replace(
/(<td colspan="3"[^>]*style="[^"]*"[^>]*>\s*)(<div class="delivery-slip-info-row"[^>]*>\s*<span class="delivery-slip-info-label"[^>]*>日(?:&nbsp;)?期:)/,
`$1${checkRowHtml}\n $2`
)
if (withClass !== result) {
result = withClass
} else {
result = result.replace(
/(<td colspan="3"[^>]*>\s*)(<div class="delivery-slip-info-row"[^>]*>\s*<span class="delivery-slip-info-label"[^>]*>日(?:&nbsp;)?期:)/,
`$1${checkRowHtml}\n $2`
)
}
// 兼容旧模板:日期在 colspan=2 单元格时仍插多选
if (!result.includes('delivery-slip-type-check-row')) {
result = result.replace(
/(<td colspan="2"[^>]*>\s*)(<div class="delivery-slip-info-row"[^>]*>\s*<span class="delivery-slip-info-label"[^>]*>日(?:&nbsp;)?期:)/,
`$1${checkRowHtml}\n $2`
)
}
// 兼容更旧模板:单据编号位置仍插多选,随后由 infoLayout 迁移
if (!result.includes('delivery-slip-type-check-row')) {
result = result.replace(
/(<td colspan="3"[^>]*>\s*)(<div class="delivery-slip-info-row"[^>]*>\s*<span class="delivery-slip-info-label"[^>]*>单据编号:)/,
`$1${checkRowHtml}\n $2`
)
}
}
return refreshDeliverySlipTypeCheckRowStyle(result)
}
const DELIVERY_SLIP_CHECK_BOUND_ATTR = 'data-delivery-slip-check-bound'
function syncDeliverySlipTypeCheckboxInput(input: HTMLInputElement): void {
if (input.checked) {
input.setAttribute('checked', 'checked')
} else {
input.removeAttribute('checked')
}
}
/** 富文本编辑器内:绑定自提/出库/直销多选点击(TinyMCE 默认可编辑区会拦截原生 checkbox) */
export function bindDeliverySlipTypeCheckboxInteraction(
root: HTMLElement | null | undefined,
onChange?: () => void
): void {
if (!root) return
root.querySelectorAll('label.delivery-slip-type-check-item').forEach((node) => {
const label = node as HTMLElement
label.setAttribute('contenteditable', 'false')
const input = label.querySelector('input.delivery-slip-type-check-input') as HTMLInputElement | null
if (!input) return
syncDeliverySlipTypeCheckboxInput(input)
if (label.getAttribute(DELIVERY_SLIP_CHECK_BOUND_ATTR) === '1') return
label.setAttribute(DELIVERY_SLIP_CHECK_BOUND_ATTR, '1')
const toggle = (e: Event) => {
if (root.isContentEditable === false) return
e.preventDefault()
e.stopPropagation()
input.checked = !input.checked
syncDeliverySlipTypeCheckboxInput(input)
onChange?.()
}
label.addEventListener('mousedown', toggle, true)
input.addEventListener('mousedown', (e) => {
e.preventDefault()
e.stopPropagation()
}, true)
input.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
}, true)
})
}
/** 送(提)货单 TinyMCE:初始化与内容变更时绑定多选项交互 */
export function setupDeliverySlipEditorTypeCheckboxes(editor: {
getBody: () => HTMLElement
on: (event: string, callback: () => void) => void
fire: (event: string) => void
}): void {
const bind = () => {
bindDeliverySlipTypeCheckboxInteraction(editor.getBody(), () => editor.fire('change'))
}
editor.on('init', () => {
window.setTimeout(bind, 0)
})
editor.on('SetContent', () => {
window.setTimeout(bind, 0)
})
editor.on('focus', () => {
window.setTimeout(bind, 0)
})
}
/** 将旧版右侧联次 HTML 统一还原为占位符,便于每次打开注入最新纵向布局 */
function normalizeDeliverySlipSideLabelsPlaceholder(html: string): string {
if (html.includes('{{sideLabels}}')) return html
let result = html
result = result.replace(/<table class="delivery-slip-side-labels"[\s\S]*?<\/table>/, '{{sideLabels}}')
result = result.replace(/<div class="delivery-slip-side-labels"[\s\S]*?<\/div>/, '{{sideLabels}}')
result = result.replace(
/(<!--\s*最右侧纵向联次\s*-->\s*<td[^>]*>)[\s\S]*?(<\/td>)/,
'$1{{sideLabels}}$2'
)
return result
}
/** 将占位符替换为计划最新数据 */
export function replaceDeliverySlipPlaceholders(
htmlContent: string,
plan: Plan,
ctx?: DeliverySlipRenderContext
): string {
if (!htmlContent || !plan) return htmlContent
const replacements: Record<string, string> = {
'{{companyName}}': DELIVERY_SLIP_DEFAULTS.companyName,
'{{receivingUnit}}': buildReceivingUnit(plan),
'{{planNo}}': plan.planNo || '',
'{{receiveContact}}': '',
'{{driverInfo}}': '',
'{{planDateChinese}}': formatPlanDateChinese(plan.planDate),
'{{productTable}}': buildProductRows(plan.products, plan.planNo || '', ctx),
'{{remark}}': formatRemarkForSlipHtml(plan.remark),
'{{companyAddress}}': DELIVERY_SLIP_DEFAULTS.companyAddress,
'{{companyPhone}}': DELIVERY_SLIP_DEFAULTS.companyPhone,
'{{contactPhone}}': DELIVERY_SLIP_DEFAULTS.contactPhone,
'{{companySeal}}': buildDeliverySlipCompanySealHtml(),
'{{sideLabels}}': buildDeliverySlipSideLabelsHtml()
}
let result = normalizeDeliverySlipDocLayout(
normalizeDeliverySlipSealPlaceholder(
normalizeDeliverySlipSignRow(
normalizeDeliverySlipTermsCell(
normalizeDeliverySlipTermsText(
normalizeDeliverySlipGapBeforeSign(
normalizeDeliverySlipProductTableFooter(
normalizeDeliverySlipProductColWidths(
normalizeDeliverySlipProductGridCell(
normalizeDeliverySlipProductPlanNoColumn(
normalizeDeliverySlipInfoLayout(
normalizeDeliverySlipGapBeforeProduct(
normalizeDeliverySlipTypeCheckRow(normalizeDeliverySlipSideLabelsPlaceholder(htmlContent))
)
)
)
)
)
)
)
)
)
)
)
)
for (const [placeholder, value] of Object.entries(replacements)) {
result = result.split(placeholder).join(value)
}
return result
}
/** 保存模板前:将动态字段还原为占位符 */
export function restoreDeliverySlipPlaceholders(template: string, plan: Plan): string {
let result = template
const dynamicReplacements: Record<string, string> = {
'{{companyName}}': DELIVERY_SLIP_DEFAULTS.companyName,
'{{receivingUnit}}': buildReceivingUnit(plan),
'{{planNo}}': plan.planNo || '',
'{{planDateChinese}}': formatPlanDateChinese(plan.planDate),
'{{remark}}': plan.remark || '',
'{{companyAddress}}': DELIVERY_SLIP_DEFAULTS.companyAddress,
'{{companyPhone}}': DELIVERY_SLIP_DEFAULTS.companyPhone,
'{{contactPhone}}': DELIVERY_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)
}
// 产品明细行还原为占位符(表头「实收数量」之后、页脚「备注」之前;兼容分表与旧一体表)
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="delivery-slip-side-labels"[\s\S]*?<\/table>/, '{{sideLabels}}')
result = result.replace(/<div class="delivery-slip-side-labels"[\s\S]*?<\/div>/, '{{sideLabels}}')
// 印章还原为占位符(对齐合同管理打印弹窗)
result = result.replace(/<img[^>]*alt="印章"[^>]*>/gi, '')
if (result.includes('delivery-slip-seal-block') && !result.includes('{{companySeal}}')) {
result = result.replace(
/(<div class="delivery-slip-seal-block"[^>]*>)/,
'$1\n <!-- 印章图片 -->\n {{companySeal}}\n '
)
}
result = result.replace(/(\{\{companySeal\}\}\s*){2,}/g, '{{companySeal}}\n')
return result
}