feat: 新增人工费用工资测算表

按 8 月工资表落地 61 列表头与 34 个实时计算公式;不导入工资明细。
This commit is contained in:
chenxi 2026-10-26 15:05:49 +08:00
parent b25ae15a4b
commit 93cbc83bb0
4 changed files with 972 additions and 0 deletions

View File

@ -4594,6 +4594,7 @@ class Pages extends Controller
'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
'product-costs' => $this->contentForProductCosts(),
'financial-analysis' => $this->contentForFinancialAnalysis(),
'labor-expenses' => $this->contentForLaborExpenses(),
'courier-surcharge' => $this->contentForCourierSurcharge($tab),
'courier-surcharge-settlement' => $this->contentForCourierSurchargeSettlementPage(),
'freight-difference', 'other-surcharge-summary', 'return-fee' => $this->contentForCourierPayableSummary($page),
@ -4622,6 +4623,14 @@ class Pages extends Controller
};
}
protected function contentForLaborExpenses(): array
{
return [
'view' => 'business.pages.financial-operations.labor-expenses',
'laborExpenseTable' => config('labor_expense_table'),
];
}
/**
* 税金预测表逻辑配置:规则清单、数据源状态和运行参数。
*/

View File

@ -0,0 +1,76 @@
<?php
return [
'source_label' => '8月工资.xlsx',
'groups' => [
['key' => 'base', 'label' => '基础信息', 'span' => 5],
['key' => 'original', 'label' => '原数据', 'span' => 9],
['key' => 'fund', 'label' => '提高公积金基数方案', 'span' => 12],
['key' => 'salary_parttime', 'label' => '工资+兼职方案', 'span' => 22],
['key' => 'settlement', 'label' => '汇算清缴', 'span' => 12],
['key' => 'recommendation', 'label' => '', 'span' => 1],
],
'fields' => [
['key' => 'A', 'label' => '序号', 'group' => 'base', 'kind' => 'sequence'],
['key' => 'B', 'label' => '花名', 'group' => 'base', 'kind' => 'text'],
['key' => 'C', 'label' => '姓名', 'group' => 'base', 'kind' => 'text'],
['key' => 'D', 'label' => '中心', 'group' => 'base', 'kind' => 'text'],
['key' => 'E', 'label' => '职位', 'group' => 'base', 'kind' => 'text'],
['key' => 'F', 'label' => '8月应发工资', 'group' => 'original', 'kind' => 'number'],
['key' => 'G', 'label' => '原社保个人扣款', 'group' => 'original', 'kind' => 'number'],
['key' => 'H', 'label' => '原公积金个人扣款', 'group' => 'original', 'kind' => 'number'],
['key' => 'I', 'label' => '扣除额', 'group' => 'original', 'kind' => 'number'],
['key' => 'J', 'label' => '原需报个税基数', 'group' => 'original', 'kind' => 'formula_number', 'formula' => '=F#-G#-H#-I#'],
['key' => 'K', 'label' => '原个税比列', 'group' => 'original', 'kind' => 'percent'],
['key' => 'L', 'label' => '原速算扣除数', 'group' => 'original', 'kind' => 'number'],
['key' => 'M', 'label' => '原需交个税金额', 'group' => 'original', 'kind' => 'number'],
['key' => 'N', 'label' => '原需年交个税金额', 'group' => 'original', 'kind' => 'formula_number', 'formula' => '=M#*12'],
['key' => 'O', 'label' => '新公积金缴存基数', 'group' => 'fund', 'kind' => 'number'],
['key' => 'P', 'label' => '新单位公积金月缴存12%', 'group' => 'fund', 'kind' => 'number'],
['key' => 'Q', 'label' => '新个人公积金月缴存12%', 'group' => 'fund', 'kind' => 'number'],
['key' => 'R', 'label' => '超额代扣缴', 'group' => 'fund', 'kind' => 'number'],
['key' => 'S', 'label' => '新月度应纳税所得额', 'group' => 'fund', 'kind' => 'formula_number', 'formula' => '=F#-G#-I#-P#-R#'],
['key' => 'T', 'label' => '新月度个税比列', 'group' => 'fund', 'kind' => 'percent'],
['key' => 'U', 'label' => '新月度新速算扣除数', 'group' => 'fund', 'kind' => 'number'],
['key' => 'V', 'label' => '新月度个税金额', 'group' => 'fund', 'kind' => 'number'],
['key' => 'W', 'label' => '全年应纳税所得额', 'group' => 'fund', 'kind' => 'formula_number', 'formula' => '=S#*12'],
['key' => 'X', 'label' => '年度税率', 'group' => 'fund', 'kind' => 'formula_percent', 'formula' => '=IF(S#=0,"",IF(W#<=0,0,IF(W#<=36000,0.03,IF(W#<=144000,0.1,IF(W#<=300000,0.2,IF(W#<=420000,0.25,IF(W#<=660000,0.3,IF(W#<=960000,0.35,0.45))))))))'],
['key' => 'Y', 'label' => '年度速算扣除数', 'group' => 'fund', 'kind' => 'formula_number', 'formula' => '=IF(W#=0,"",IF(W#<=0,0,IF(W#<=36000,0,IF(W#<=144000,2520,IF(W#<=300000,16920,IF(W#<=420000,31920,IF(W#<=660000,52920,IF(W#<=960000,85920,181920))))))))'],
['key' => 'Z', 'label' => '全年个税', 'group' => 'fund', 'kind' => 'formula_number', 'formula' => '=W#*X#-Y#'],
['key' => 'AA', 'label' => '单月应纳税所得额区间', 'group' => 'salary_parttime', 'kind' => 'number'],
['key' => 'AB', 'label' => '福利费', 'group' => 'salary_parttime', 'kind' => 'number'],
['key' => 'AC', 'label' => '现金', 'group' => 'salary_parttime', 'kind' => 'number'],
['key' => 'AD', 'label' => '兼职工资合计', 'group' => 'salary_parttime', 'kind' => 'formula_number', 'formula' => '=AB#+AC#'],
['key' => 'AE', 'label' => '新8月工资应发', 'group' => 'salary_parttime', 'kind' => 'formula_number', 'formula' => '=F#-AD#'],
['key' => 'AF', 'label' => '新月度应纳税所得额', 'group' => 'salary_parttime', 'kind' => 'formula_number', 'formula' => '=AE#-G#-I#-P#-R#'],
['key' => 'AG', 'label' => '新月度个税比列', 'group' => 'salary_parttime', 'kind' => 'percent'],
['key' => 'AH', 'label' => '新月度新速算扣除数', 'group' => 'salary_parttime', 'kind' => 'number'],
['key' => 'AI', 'label' => '新月度个税金额', 'group' => 'salary_parttime', 'kind' => 'number'],
['key' => 'AJ', 'label' => '全年应纳税所得额', 'group' => 'salary_parttime', 'kind' => 'formula_number', 'formula' => '=AF#*12'],
['key' => 'AK', 'label' => '年度税率', 'group' => 'salary_parttime', 'kind' => 'formula_percent', 'formula' => '=IF(AJ#=0,"",IF(AJ#<=0,0,IF(AJ#<=36000,0.03,IF(AJ#<=144000,0.1,IF(AJ#<=300000,0.2,IF(AJ#<=420000,0.25,IF(AJ#<=660000,0.3,IF(AJ#<=960000,0.35,0.45))))))))'],
['key' => 'AL', 'label' => '年度速算扣除数', 'group' => 'salary_parttime', 'kind' => 'formula_number', 'formula' => '=IF(AJ#=0,"",IF(AJ#<=0,0,IF(AJ#<=36000,0,IF(AJ#<=144000,2520,IF(AJ#<=300000,16920,IF(AJ#<=420000,31920,IF(AJ#<=660000,52920,IF(AJ#<=960000,85920,181920))))))))'],
['key' => 'AM', 'label' => '全年工资个税', 'group' => 'salary_parttime', 'kind' => 'formula_number', 'formula' => '=AJ#*AK#-AL#'],
['key' => 'AN', 'label' => '月度兼职工资', 'group' => 'salary_parttime', 'kind' => 'number'],
['key' => 'AO', 'label' => '月扣除额', 'group' => 'salary_parttime', 'kind' => 'formula_number', 'formula' => '=IF(AN#<=0,0,IF(AN#<=4000,800,AN#*0.2))'],
['key' => 'AP', 'label' => '月应纳税所得额', 'group' => 'salary_parttime', 'kind' => 'formula_number', 'formula' => '=AN#-AO#'],
['key' => 'AQ', 'label' => '月个税预扣率', 'group' => 'salary_parttime', 'kind' => 'formula_percent', 'formula' => '=IF(AN#<=0,0,IF(AP#<=20000,0.2,IF(AP#<=50000,0.3,0.4)))'],
['key' => 'AR', 'label' => '月速算扣除数', 'group' => 'salary_parttime', 'kind' => 'formula_number', 'formula' => '=IF(AN#<=0,0,IF(AP#<=20000,0,IF(AP#<=50000,2000,7000)))'],
['key' => 'AS', 'label' => '月兼职需交个税', 'group' => 'salary_parttime', 'kind' => 'formula_number', 'formula' => '=AP#*AQ#-AR#'],
['key' => 'AT', 'label' => '月度工资+兼职个税合计', 'group' => 'salary_parttime', 'kind' => 'formula_number', 'formula' => '=AI#+AS#'],
['key' => 'AU', 'label' => '年度兼职工资个税', 'group' => 'salary_parttime', 'kind' => 'formula_number', 'formula' => '=AS#*12'],
['key' => 'AV', 'label' => '年度工资+兼职个税合计', 'group' => 'salary_parttime', 'kind' => 'formula_number', 'formula' => '=AM#+AU#'],
['key' => 'AW', 'label' => '汇算 工资薪金收入额', 'group' => 'settlement', 'kind' => 'formula_number', 'formula' => '=AE#*12'],
['key' => 'AX', 'label' => '汇算 劳务报酬收入额(×80%)', 'group' => 'settlement', 'kind' => 'formula_number', 'formula' => '=AN#*12*0.8'],
['key' => 'AY', 'label' => '汇算 收入额合计', 'group' => 'settlement', 'kind' => 'formula_number', 'formula' => '=AW#+AX#'],
['key' => 'AZ', 'label' => '汇算 减基本减除费用', 'group' => 'settlement', 'kind' => 'formula_number', 'formula' => '=60000'],
['key' => 'BA', 'label' => '汇算 减社保公积金', 'group' => 'settlement', 'kind' => 'formula_number', 'formula' => '=(G#+P#+R#)*12'],
['key' => 'BB', 'label' => '汇算 全年应纳税所得额', 'group' => 'settlement', 'kind' => 'formula_number', 'formula' => '=AY#-AZ#-BA#'],
['key' => 'BC', 'label' => '汇算 适用税率', 'group' => 'settlement', 'kind' => 'formula_percent', 'formula' => '=IF(BB#<=0,"",IF(BB#<=0,0,IF(BB#<=36000,0.03,IF(BB#<=144000,0.1,IF(BB#<=300000,0.2,IF(BB#<=420000,0.25,IF(BB#<=660000,0.3,IF(BB#<=960000,0.35,0.45))))))))'],
['key' => 'BD', 'label' => '汇算 速算扣除数', 'group' => 'settlement', 'kind' => 'formula_integer', 'formula' => '=IF(BB#<=0,"",IF(BB#<=0,0,IF(BB#<=36000,0,IF(BB#<=144000,2520,IF(BB#<=300000,16920,IF(BB#<=420000,31920,IF(BB#<=660000,52920,IF(BB#<=960000,85920,181920))))))))'],
['key' => 'BE', 'label' => '汇算 全年应纳税额', 'group' => 'settlement', 'kind' => 'formula_number', 'formula' => '=IF(AN#<=0,"",IF(BB#<=0,0,IF(BB#<=36000,BB#*0.03,IF(BB#<=144000,BB#*0.1-2520,IF(BB#<=300000,BB#*0.2-16920,IF(BB#<=420000,BB#*0.25-31920,IF(BB#<=660000,BB#*0.3-52920,IF(BB#<=960000,BB#*0.35-85920,BB#*0.45-181920))))))))'],
['key' => 'BF', 'label' => '汇算 月应纳税额', 'group' => 'settlement', 'kind' => 'formula_number', 'formula' => '=BE#/12'],
['key' => 'BG', 'label' => '汇算 应退金额(正=退/负=补)', 'group' => 'settlement', 'kind' => 'formula_number', 'formula' => '=IF(AN#<=0,"",AV#-BE#)'],
['key' => 'BH', 'label' => '全年真实节税', 'group' => 'settlement', 'kind' => 'formula_number', 'formula' => '=IF(AN#<=0,"",Z#-BE#)'],
['key' => 'BI', 'label' => '推荐方案', 'group' => 'recommendation', 'kind' => 'text'],
],
];

View File

@ -0,0 +1,855 @@
@php
$laborFields = $laborExpenseTable['fields'] ?? [];
$laborGroups = $laborExpenseTable['groups'] ?? [];
$laborFormulaFields = collect($laborFields)->filter(
static fn (array $field): bool => filled($field['formula'] ?? null)
);
$laborFormulaCount = $laborFormulaFields->count();
@endphp
<div class="labor-expense-page" data-labor-expense data-finance-table-surface data-directory-page="labor-expenses">
<style>
.labor-expense-page {
color: var(--vip-content-ink, #171717);
font-family: "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace;
font-size: 0.8125rem;
}
.labor-expense-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.875rem;
}
.labor-expense-head strong {
display: block;
color: var(--vip-content-ink, #171717);
font-size: 0.875rem;
font-weight: 600;
}
.labor-expense-head p {
max-width: 76rem;
margin: 0.35rem 0 0;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
line-height: 1.55;
}
.labor-expense-badge {
flex: 0 0 auto;
padding: 0.25rem 0.55rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 999px;
background: var(--vip-content-accent-soft, #F7F0DE);
color: var(--vip-content-accent-dark, #856632);
font-size: 0.7rem;
font-weight: 600;
white-space: nowrap;
}
.labor-expense-summary {
display: grid;
grid-template-columns: repeat(4, minmax(10rem, 1fr));
gap: 0.75rem;
margin-bottom: 0.875rem;
}
.labor-expense-summary__item {
min-width: 0;
padding: 0.75rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.5rem;
background: #fffdf8;
}
.labor-expense-summary__item span {
display: block;
color: var(--vip-content-muted, #595959);
font-size: 0.7rem;
}
.labor-expense-summary__item strong {
display: block;
margin-top: 0.25rem;
overflow: hidden;
color: var(--vip-content-ink, #171717);
font-size: 1rem;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
.labor-expense-note {
display: flex;
align-items: flex-start;
gap: 0.45rem;
margin-bottom: 0.75rem;
padding: 0.65rem 0.75rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.5rem;
background: #fbfaf7;
color: var(--vip-content-muted, #595959);
font-size: 0.7rem;
line-height: 1.55;
}
.labor-expense-note .material-icons-outlined {
flex: 0 0 auto;
margin-top: 0.05rem;
color: var(--vip-content-accent-dark, #856632);
font-size: 1rem;
}
.labor-expense-toolbar {
margin-bottom: 0.75rem;
}
.labor-expense-toolbar .labor-expense-toolbar__legend {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-left: auto;
color: var(--vip-content-muted, #595959);
font-size: 0.7rem;
white-space: nowrap;
}
.labor-expense-legend-swatch {
width: 0.75rem;
height: 0.75rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.15rem;
background: #fff5cc;
}
.labor-expense-legend-swatch--formula {
background: #f7f0de;
}
.labor-expense-table-wrap {
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.5rem;
background: #ffffff;
}
.labor-expense-table {
width: max-content;
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 0.75rem;
}
.labor-expense-table th,
.labor-expense-table td {
min-width: 7.25rem;
padding: 0.5rem 0.55rem;
border-right: 1px solid var(--vip-border, #D8D2C7);
border-bottom: 1px solid var(--vip-border, #D8D2C7);
vertical-align: middle;
}
.labor-expense-table thead th {
text-align: center;
white-space: normal;
line-height: 1.35;
}
.labor-expense-table thead tr:first-child th {
height: 2rem;
background: #eadfca;
color: var(--vip-content-ink, #171717);
font-weight: 700;
}
.labor-expense-table thead tr:last-child th {
min-height: 3.25rem;
background: var(--vip-content-accent-soft, #F7F0DE);
color: var(--vip-content-ink, #171717);
font-weight: 600;
}
.labor-expense-table thead th[data-labor-column="A"],
.labor-expense-table tbody td[data-labor-column="A"] {
min-width: 4rem;
width: 4rem;
}
.labor-expense-table thead th[data-labor-column="B"],
.labor-expense-table thead th[data-labor-column="C"],
.labor-expense-table thead th[data-labor-column="D"],
.labor-expense-table thead th[data-labor-column="E"],
.labor-expense-table tbody td[data-labor-column="B"],
.labor-expense-table tbody td[data-labor-column="C"],
.labor-expense-table tbody td[data-labor-column="D"],
.labor-expense-table tbody td[data-labor-column="E"] {
min-width: 7rem;
}
.labor-expense-table thead th[data-labor-column="BI"],
.labor-expense-table tbody td[data-labor-column="BI"] {
min-width: 15rem;
}
.labor-expense-table thead th[data-finance-column-key="__action__"],
.labor-expense-table tbody td[data-finance-column-key="__action__"] {
min-width: 4.75rem;
width: 4.75rem;
text-align: center;
}
.labor-expense-table tbody td {
background: #ffffff;
}
.labor-expense-table tbody tr:nth-child(even) td {
background: #fdfcf9;
}
.labor-expense-table tbody tr:hover td {
background: #fbf7ec;
}
.labor-expense-table tbody td.is-input-cell {
background: #fffaf0;
}
.labor-expense-table tbody td.is-formula-cell {
background: #faf7f0;
font-variant-numeric: tabular-nums;
text-align: right;
}
.labor-expense-table tbody td.is-percent-cell {
text-align: right;
}
.labor-expense-input,
.labor-expense-percent {
width: 100%;
min-width: 5.75rem;
height: 2rem;
padding: 0 0.4rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.35rem;
background: #ffffff;
color: var(--vip-content-ink, #171717);
font: inherit;
font-variant-numeric: tabular-nums;
}
.labor-expense-input--text {
min-width: 6.5rem;
text-align: left;
}
.labor-expense-input--number {
text-align: right;
}
.labor-expense-percent-wrap {
position: relative;
display: block;
}
.labor-expense-percent-wrap .labor-expense-percent {
padding-right: 1.5rem;
text-align: right;
}
.labor-expense-percent-wrap span {
position: absolute;
top: 50%;
right: 0.55rem;
color: var(--vip-content-muted, #595959);
transform: translateY(-50%);
pointer-events: none;
}
.labor-expense-input:focus,
.labor-expense-percent:focus {
border-color: var(--vip-content-accent-dark, #856632);
outline: none;
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2);
}
.labor-expense-output {
display: block;
min-width: 5.5rem;
color: var(--vip-content-ink, #171717);
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
.labor-expense-output[data-empty="true"] {
color: var(--vip-content-muted, #595959);
}
.labor-expense-remove {
display: inline-flex;
height: 1.85rem;
align-items: center;
justify-content: center;
padding: 0 0.45rem;
border: 1px solid #e5c7c2;
border-radius: 0.35rem;
background: #fff8f7;
color: var(--vip-danger, #B80000);
font: inherit;
cursor: pointer;
}
.labor-expense-remove:hover,
.labor-expense-remove:focus-visible {
border-color: var(--vip-danger, #B80000);
outline: none;
box-shadow: 0 0 0 3px rgba(184, 0, 0, 0.12);
}
.labor-expense-formulas {
margin-top: 0.875rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.5rem;
background: #ffffff;
}
.labor-expense-formulas > summary {
padding: 0.7rem 0.8rem;
color: var(--vip-content-accent-dark, #856632);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
}
.labor-expense-formulas__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
border-top: 1px solid var(--vip-border, #D8D2C7);
background: var(--vip-border, #D8D2C7);
}
.labor-expense-formula {
min-width: 0;
padding: 0.7rem 0.8rem;
background: #fffdf8;
}
.labor-expense-formula strong {
display: block;
margin-bottom: 0.25rem;
color: var(--vip-content-ink, #171717);
font-size: 0.72rem;
}
.labor-expense-formula code {
display: block;
overflow-wrap: anywhere;
color: var(--vip-content-muted, #595959);
font-family: inherit;
font-size: 0.68rem;
line-height: 1.5;
white-space: pre-wrap;
}
.labor-expense-empty {
padding: 4rem 1rem;
color: var(--vip-content-muted, #595959);
text-align: center;
}
@media (max-width: 900px) {
.labor-expense-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.labor-expense-formulas__grid { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 560px) {
.labor-expense-head { flex-direction: column; }
.labor-expense-summary { grid-template-columns: minmax(0, 1fr); }
.labor-expense-toolbar .labor-expense-toolbar__legend { width: 100%; margin-left: 0; }
}
</style>
<div class="labor-expense-head">
<div>
<strong>人工费用测算表</strong>
<p>表头与公式依据 {{ $laborExpenseTable['source_label'] ?? '工资表' }}。黄色字段用于录入,浅金列按公式实时计算;公式中的 # 表示当前行。</p>
</div>
<span class="labor-expense-badge">{{ count($laborFields) }} 个业务列 · {{ $laborFormulaCount }} 个公式</span>
</div>
<div class="labor-expense-summary" aria-label="人工费用测算汇总">
<div class="labor-expense-summary__item">
<span>测算行数</span>
<strong data-labor-summary="rows">1</strong>
</div>
<div class="labor-expense-summary__item">
<span>月实发工资合计(元)</span>
<strong data-labor-summary="salary">—</strong>
</div>
<div class="labor-expense-summary__item">
<span>月度工资+兼职个税合计(元)</span>
<strong data-labor-summary="monthlyTax">—</strong>
</div>
<div class="labor-expense-summary__item">
<span>汇算应退/补合计(元)</span>
<strong data-labor-summary="settlement">—</strong>
</div>
</div>
<div class="labor-expense-note" role="note">
<span class="material-icons-outlined" aria-hidden="true">info</span>
<div>页面不预置源表人员、花名或金额,录入值只在当前页面参与计算;可使用表格上方的导出按钮保存当前结果。</div>
</div>
<div class="finance-table-controls labor-expense-toolbar" data-finance-controls>
<button type="button" class="finance-table-controls__button finance-table-controls__button--primary" data-labor-add>
<span class="material-icons-outlined" aria-hidden="true">person_add</span>
新增人员
</button>
<button type="button" class="finance-table-controls__button" data-labor-reset>
<span class="material-icons-outlined" aria-hidden="true">restart_alt</span>
重置表格
</button>
<button type="button" class="finance-table-controls__button" data-directory-export>
<span class="material-icons-outlined" aria-hidden="true">download</span>
导出当前显示列
</button>
<button type="button" class="finance-table-controls__button" data-finance-export-all-fields>
<span class="material-icons-outlined" aria-hidden="true">table_view</span>
导出全部字段
</button>
<span class="labor-expense-toolbar__legend">
<i class="labor-expense-legend-swatch" aria-hidden="true"></i>可录入
<i class="labor-expense-legend-swatch labor-expense-legend-swatch--formula" aria-hidden="true"></i>公式
</span>
</div>
<div class="finance-table-scroll-owner labor-expense-table-wrap">
<table class="biz-table labor-expense-table" data-finance-table data-labor-expense-table aria-label="人工费用测算表">
<thead>
<tr>
@foreach ($laborGroups as $group)
<th colspan="{{ $group['span'] }}" @if ($group['label'] === '') aria-label="推荐方案" @endif>{{ $group['label'] }}</th>
@endforeach
<th>操作</th>
</tr>
<tr>
@foreach ($laborFields as $field)
<th
data-finance-column-key="{{ $field['key'] }}"
data-labor-column="{{ $field['key'] }}"
data-labor-header="{{ $field['key'] }}"
@if (in_array($field['key'], ['A', 'B', 'C'], true)) data-finance-fixed="true" @endif
@if (!empty($field['formula'])) title="公式:{{ $field['formula'] }}" @endif
>{{ $field['label'] }}</th>
@endforeach
<th data-finance-column-key="__action__" data-finance-fixed="end">操作</th>
</tr>
</thead>
<tbody data-labor-rows>
<tr class="is-empty">
<td colspan="{{ count($laborFields) + 1 }}">
<div class="labor-expense-empty">正在加载人工费用测算表…</div>
</td>
</tr>
</tbody>
</table>
</div>
<details class="labor-expense-formulas">
<summary>查看全部 {{ $laborFormulaCount }} 个公式</summary>
<div class="labor-expense-formulas__grid">
@foreach ($laborFormulaFields as $field)
<div class="labor-expense-formula">
<strong>{{ $field['key'] }} · {{ $field['label'] }}</strong>
<code>{{ $field['formula'] }}</code>
</div>
@endforeach
</div>
</details>
<script>
(() => {
const root = document.currentScript.closest('[data-labor-expense]');
if (!root) return;
const fields = @json($laborFields);
const fieldMap = Object.fromEntries(fields.map((field) => [field.key, field]));
const table = root.querySelector('[data-labor-expense-table]');
const tbody = root.querySelector('[data-labor-rows]');
const formulaFields = fields.filter((field) => field.formula);
const numericKinds = new Set(['number', 'percent']);
let rows = [{}];
const moneyFormatter = new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const integerFormatter = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 0,
});
const percentFormatter = new Intl.NumberFormat('zh-CN', {
style: 'percent',
maximumFractionDigits: 0,
});
const numeric = (value) => {
if (value === '' || value === null || value === undefined) return 0;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
const annualBracket = (income) => {
if (income <= 0) return { rate: 0, deduction: 0 };
if (income <= 36000) return { rate: 0.03, deduction: 0 };
if (income <= 144000) return { rate: 0.1, deduction: 2520 };
if (income <= 300000) return { rate: 0.2, deduction: 16920 };
if (income <= 420000) return { rate: 0.25, deduction: 31920 };
if (income <= 660000) return { rate: 0.3, deduction: 52920 };
if (income <= 960000) return { rate: 0.35, deduction: 85920 };
return { rate: 0.45, deduction: 181920 };
};
const calculate = (row) => {
const hasNumericInput = fields.some((field) => {
if (!numericKinds.has(field.kind)) return false;
const value = row[field.key];
return value !== '' && value !== null && value !== undefined && Number.isFinite(Number(value));
});
const result = {};
if (!hasNumericInput) {
formulaFields.forEach((field) => { result[field.key] = null; });
return result;
}
const v = (key) => numeric(row[key]);
const originalTaxable = v('F') - v('G') - v('H') - v('I');
const originalAnnualTax = v('M') * 12;
const newMonthlyTaxable = v('F') - v('G') - v('I') - v('P') - v('R');
const newAnnualTaxable = newMonthlyTaxable * 12;
const newAnnualBracket = annualBracket(newAnnualTaxable);
const originalRate = newMonthlyTaxable === 0 ? null : newAnnualBracket.rate;
const originalDeduction = newAnnualTaxable === 0 ? null : newAnnualBracket.deduction;
const originalAnnualTaxAmount = originalRate === null || originalDeduction === null
? null
: newAnnualTaxable * originalRate - originalDeduction;
const parttimeSalary = v('AB') + v('AC');
const newSalary = v('F') - parttimeSalary;
const salaryTaxable = newSalary - v('G') - v('I') - v('P') - v('R');
const salaryAnnualTaxable = salaryTaxable * 12;
const salaryBracket = annualBracket(salaryAnnualTaxable);
const salaryRate = salaryAnnualTaxable === 0 ? null : salaryBracket.rate;
const salaryDeduction = salaryAnnualTaxable === 0 ? null : salaryBracket.deduction;
const salaryAnnualTax = salaryRate === null || salaryDeduction === null
? null
: salaryAnnualTaxable * salaryRate - salaryDeduction;
const monthlyParttime = v('AN');
const parttimeDeduction = monthlyParttime <= 0
? 0
: (monthlyParttime <= 4000 ? 800 : monthlyParttime * 0.2);
const parttimeTaxable = monthlyParttime - parttimeDeduction;
const parttimeRate = monthlyParttime <= 0
? 0
: (parttimeTaxable <= 20000 ? 0.2 : (parttimeTaxable <= 50000 ? 0.3 : 0.4));
const parttimeDeductionAmount = monthlyParttime <= 0
? 0
: (parttimeTaxable <= 20000 ? 0 : (parttimeTaxable <= 50000 ? 2000 : 7000));
const parttimeMonthlyTax = parttimeTaxable * parttimeRate - parttimeDeductionAmount;
const monthlyTaxTotal = v('AI') + parttimeMonthlyTax;
const annualParttimeTax = parttimeMonthlyTax * 12;
const annualTotalTax = salaryAnnualTax === null ? null : salaryAnnualTax + annualParttimeTax;
const salaryIncome = newSalary * 12;
const parttimeIncome = monthlyParttime * 12 * 0.8;
const totalIncome = salaryIncome + parttimeIncome;
const basicDeduction = 60000;
const socialFundDeduction = (v('G') + v('P') + v('R')) * 12;
const settlementTaxable = totalIncome - basicDeduction - socialFundDeduction;
const settlementBracket = annualBracket(settlementTaxable);
const settlementRate = settlementTaxable <= 0 ? null : settlementBracket.rate;
const settlementDeduction = settlementTaxable <= 0 ? null : settlementBracket.deduction;
const settlementTax = monthlyParttime <= 0
? null
: (settlementTaxable <= 0
? 0
: settlementTaxable * settlementBracket.rate - settlementBracket.deduction);
const settlementMonthlyTax = settlementTax === null ? null : settlementTax / 12;
const settlementDifference = monthlyParttime <= 0 || settlementTax === null || annualTotalTax === null
? null
: annualTotalTax - settlementTax;
const realSaving = monthlyParttime <= 0 || settlementTax === null || originalAnnualTaxAmount === null
? null
: originalAnnualTaxAmount - settlementTax;
Object.assign(result, {
J: originalTaxable,
N: originalAnnualTax,
S: newMonthlyTaxable,
W: newAnnualTaxable,
X: originalRate,
Y: originalDeduction,
Z: originalAnnualTaxAmount,
AD: parttimeSalary,
AE: newSalary,
AF: salaryTaxable,
AJ: salaryAnnualTaxable,
AK: salaryRate,
AL: salaryDeduction,
AM: salaryAnnualTax,
AO: parttimeDeduction,
AP: parttimeTaxable,
AQ: parttimeRate,
AR: parttimeDeductionAmount,
AS: parttimeMonthlyTax,
AT: monthlyTaxTotal,
AU: annualParttimeTax,
AV: annualTotalTax,
AW: salaryIncome,
AX: parttimeIncome,
AY: totalIncome,
AZ: basicDeduction,
BA: socialFundDeduction,
BB: settlementTaxable,
BC: settlementRate,
BD: settlementDeduction,
BE: settlementTax,
BF: settlementMonthlyTax,
BG: settlementDifference,
BH: realSaving,
});
return result;
};
const formatValue = (field, value) => {
if (value === null || value === undefined || value === '') return '—';
if (field.kind === 'formula_percent') return percentFormatter.format(value);
if (field.kind === 'formula_integer') return integerFormatter.format(value);
return moneyFormatter.format(value);
};
const headerKeys = () => {
const headerRow = table.tHead?.rows?.[table.tHead.rows.length - 1];
return [...(headerRow?.cells || [])]
.map((cell) => cell.dataset.financeColumnKey)
.filter(Boolean);
};
const orderCells = (cellMap) => {
const keys = headerKeys();
const ordered = keys.map((key) => cellMap[key]).filter(Boolean);
const known = new Set(keys);
Object.entries(cellMap).forEach(([key, cell]) => {
if (!known.has(key)) ordered.push(cell);
});
return ordered;
};
const cellFor = (field, index) => {
const cell = document.createElement('td');
cell.dataset.financeColumnKey = field.key;
cell.dataset.laborColumn = field.key;
if (['A', 'B', 'C'].includes(field.key)) cell.dataset.financeFixed = 'true';
const header = table.querySelector(`thead th[data-finance-column-key="${field.key}"]`);
if (header?.classList.contains('is-column-hidden')) cell.classList.add('is-column-hidden');
if (field.kind === 'sequence') {
cell.textContent = String(index + 1);
cell.dataset.exportValue = String(index + 1);
cell.style.textAlign = 'center';
return cell;
}
if (field.formula) {
cell.classList.add('is-formula-cell');
if (field.kind === 'formula_percent') cell.classList.add('is-percent-cell');
const output = document.createElement('output');
output.className = 'labor-expense-output';
output.dataset.laborOutput = field.key;
output.dataset.empty = 'true';
output.textContent = '—';
output.title = `公式:${field.formula}`;
cell.appendChild(output);
return cell;
}
cell.classList.add('is-input-cell');
cell.dataset.exportValue = '';
if (field.kind === 'percent') {
cell.classList.add('is-percent-cell');
const wrap = document.createElement('label');
wrap.className = 'labor-expense-percent-wrap';
const input = document.createElement('input');
input.className = 'labor-expense-input labor-expense-percent';
input.type = 'number';
input.step = 'any';
input.inputMode = 'decimal';
input.placeholder = '例如 30';
input.dataset.laborField = field.key;
input.setAttribute('aria-label', `${field.label},第 ${index + 1} 行,按百分数输入`);
const storedValue = rows[index]?.[field.key];
if (storedValue !== '' && storedValue !== null && storedValue !== undefined) {
const percentValue = Number(storedValue) * 100;
if (Number.isFinite(percentValue)) {
input.value = String(percentValue);
cell.dataset.exportValue = `${input.value}%`;
}
}
const suffix = document.createElement('span');
suffix.textContent = '%';
wrap.append(input, suffix);
cell.appendChild(wrap);
return cell;
}
const input = document.createElement('input');
input.className = 'labor-expense-input labor-expense-input--text';
input.type = 'text';
input.maxLength = field.kind === 'text' ? 120 : 40;
input.placeholder = field.label;
input.dataset.laborField = field.key;
input.setAttribute('aria-label', `${field.label},第 ${index + 1} 行`);
if (field.kind === 'number') {
input.type = 'number';
input.step = 'any';
input.inputMode = 'decimal';
input.classList.remove('labor-expense-input--text');
input.classList.add('labor-expense-input--number');
input.placeholder = '0.00';
}
const storedValue = rows[index]?.[field.key];
if (storedValue !== '' && storedValue !== null && storedValue !== undefined) {
input.value = String(storedValue);
cell.dataset.exportValue = String(storedValue);
}
cell.appendChild(input);
return cell;
};
const updateOutputs = (rowElement, index) => {
const values = calculate(rows[index] || {});
formulaFields.forEach((field) => {
const output = rowElement.querySelector(`[data-labor-output="${field.key}"]`);
if (!output) return;
const text = formatValue(field, values[field.key]);
output.textContent = text;
output.dataset.empty = text === '—' ? 'true' : 'false';
output.closest('td').dataset.exportValue = text === '—' ? '' : text;
});
};
const buildRow = (row, index) => {
const tr = document.createElement('tr');
tr.dataset.laborRow = String(index);
const cellMap = {};
fields.forEach((field) => { cellMap[field.key] = cellFor(field, index); });
const action = document.createElement('td');
action.dataset.financeColumnKey = '__action__';
action.dataset.financeFixed = 'end';
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'labor-expense-remove';
remove.dataset.laborRemove = String(index);
remove.setAttribute('aria-label', `删除第 ${index + 1} 行`);
remove.title = '删除本行';
remove.innerHTML = '<span class="material-icons-outlined" aria-hidden="true" style="font-size:1rem">delete_outline</span>';
action.appendChild(remove);
cellMap.__action__ = action;
orderCells(cellMap).forEach((cell) => tr.appendChild(cell));
return tr;
};
const renderRows = () => {
tbody.replaceChildren(...rows.map(buildRow));
if (!rows.length) {
const tr = document.createElement('tr');
tr.className = 'is-empty';
const td = document.createElement('td');
td.colSpan = fields.length + 1;
const empty = document.createElement('div');
empty.className = 'labor-expense-empty';
empty.textContent = '暂无测算人员,请点击“新增人员”开始录入。';
td.appendChild(empty);
tr.appendChild(td);
tbody.appendChild(tr);
}
[...tbody.querySelectorAll('tr[data-labor-row]')].forEach((rowElement, index) => updateOutputs(rowElement, index));
updateSummary();
};
const sumCalculated = (key) => {
const values = rows
.map(calculate)
.map((result) => result[key])
.filter((value) => Number.isFinite(value));
if (!values.length) return null;
return values.reduce((total, value) => total + value, 0);
};
const updateSummary = () => {
const summary = {
rows: integerFormatter.format(rows.length),
salary: formatValue({ kind: 'formula_number' }, sumCalculated('AE')),
monthlyTax: formatValue({ kind: 'formula_number' }, sumCalculated('AT')),
settlement: formatValue({ kind: 'formula_number' }, sumCalculated('BG')),
};
Object.entries(summary).forEach(([key, value]) => {
const target = root.querySelector(`[data-labor-summary="${key}"]`);
if (target) target.textContent = value;
});
};
const inputValue = (field, input) => {
const value = input.value.trim();
if (value === '') return '';
if (field.kind === 'percent') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed / 100 : '';
}
return value;
};
tbody.addEventListener('input', (event) => {
const input = event.target.closest('[data-labor-field]');
if (!input) return;
const rowElement = input.closest('tr[data-labor-row]');
const index = Number(rowElement?.dataset.laborRow);
const field = fieldMap[input.dataset.laborField];
if (!Number.isInteger(index) || !rows[index] || !field) return;
const value = inputValue(field, input);
rows[index][field.key] = value;
const exportValue = value === '' ? '' : (field.kind === 'percent' ? `${input.value.trim()}%` : value);
input.closest('td').dataset.exportValue = exportValue;
updateOutputs(rowElement, index);
updateSummary();
});
tbody.addEventListener('click', (event) => {
const remove = event.target.closest('[data-labor-remove]');
if (!remove) return;
const index = Number(remove.dataset.laborRemove);
if (!Number.isInteger(index)) return;
rows.splice(index, 1);
renderRows();
});
root.querySelector('[data-labor-add]')?.addEventListener('click', () => {
rows.push({});
renderRows();
const lastRow = tbody.querySelector(`tr[data-labor-row="${rows.length - 1}"]`);
lastRow?.querySelector('[data-labor-field="B"]')?.focus();
});
root.querySelector('[data-labor-reset]')?.addEventListener('click', () => {
rows = [{}];
renderRows();
tbody.querySelector('[data-labor-field="B"]')?.focus();
});
renderRows();
})();
</script>
</div>

View File

@ -60,6 +60,38 @@ class LaborExpensePageAccessTest extends FeatureTestCase
->assertForbidden();
}
public function test_page_contains_the_workbook_headers_and_formulas_without_payroll_rows(): void
{
$chenxi = $this->account('晨曦');
$fields = config('labor_expense_table.fields');
$formulaFields = collect($fields)->filter(
static fn (array $field): bool => filled($field['formula'] ?? null)
);
$this->assertCount(61, $fields);
$this->assertCount(34, $formulaFields);
$response = $this->loginAs($chenxi)->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'labor-expenses',
]))->assertOk()
->assertSeeText('人工费用测算表')
->assertSeeText('基础信息')
->assertSeeText('原数据')
->assertSeeText('提高公积金基数方案')
->assertSeeText('工资+兼职方案')
->assertSeeText('汇算清缴')
->assertSee('data-labor-expense', false)
->assertSee('data-labor-expense-table', false)
->assertSee('=F#-G#-H#-I#', false)
->assertSee('=IF(AN#&lt;=0,0,IF(AN#&lt;=4000,800,AN#*0.2))', false)
->assertDontSee('data-labor-row="0"', false);
$html = $response->getContent();
$this->assertSame(61, substr_count($html, 'data-labor-header="'));
$this->assertSame(34, substr_count($html, 'class="labor-expense-formula"'));
}
private function pageIsVisible(User $user): bool
{
$financialOperations = collect(SidebarPermissions::navigationFor($user))