feat: 新增人工费用工资测算表
按 8 月工资表落地 61 列表头与 34 个实时计算公式;不导入工资明细。
This commit is contained in:
parent
b25ae15a4b
commit
93cbc83bb0
@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 税金预测表逻辑配置:规则清单、数据源状态和运行参数。
|
||||
*/
|
||||
|
||||
76
config/labor_expense_table.php
Normal file
76
config/labor_expense_table.php
Normal 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'],
|
||||
],
|
||||
];
|
||||
@ -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>
|
||||
@ -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#<=0,0,IF(AN#<=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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user